kingkont 0.15.1 → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/chatSession.js +17 -0
- package/lib/jobsHub.js +57 -0
- package/lib/providers.js +23 -5
- package/lib/wsHub.js +69 -0
- package/package.json +3 -2
- package/renderer/board.js +25 -0
- package/renderer/chat.js +167 -53
- package/renderer/state.js +13 -3
- package/renderer/styles.css +39 -5
- package/server.js +31 -0
package/lib/chatSession.js
CHANGED
|
@@ -32,6 +32,19 @@ const fs = require('node:fs');
|
|
|
32
32
|
const fsp = require('node:fs/promises');
|
|
33
33
|
const path = require('node:path');
|
|
34
34
|
const crypto = require('node:crypto');
|
|
35
|
+
const wsHub = require('./wsHub');
|
|
36
|
+
|
|
37
|
+
function _wsChannel(key) { return 'chat:' + key; }
|
|
38
|
+
function _publishChange(session) {
|
|
39
|
+
// Push minimal event — клиент сам fetch'нет /api/chat/state.
|
|
40
|
+
wsHub.publish(_wsChannel(session.key), {
|
|
41
|
+
kind: 'state-changed',
|
|
42
|
+
busy: session.busy,
|
|
43
|
+
pendingCount: session.pendingToolCalls?.length || 0,
|
|
44
|
+
historyLen: session.history.length,
|
|
45
|
+
updatedAt: session.updatedAt,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
35
48
|
|
|
36
49
|
const MAX_TOOL_ITERATIONS = 12;
|
|
37
50
|
const PERSIST_DEBOUNCE_MS = 800;
|
|
@@ -119,6 +132,9 @@ async function getSession(key, { create = true } = {}) {
|
|
|
119
132
|
function schedulePersist(session) {
|
|
120
133
|
clearTimeout(session._persistTimer);
|
|
121
134
|
session._persistTimer = setTimeout(() => _persist(session), PERSIST_DEBOUNCE_MS);
|
|
135
|
+
// Также сразу пушим WS-event — клиент видит изменение мгновенно
|
|
136
|
+
// (без ожидания дебаунса persist).
|
|
137
|
+
_publishChange(session);
|
|
122
138
|
}
|
|
123
139
|
|
|
124
140
|
// ============== TOOL-CALL PARSER ==============
|
|
@@ -234,6 +250,7 @@ function _waitForToolResults(session, toolCalls) {
|
|
|
234
250
|
return new Promise(resolve => {
|
|
235
251
|
session.pendingToolCalls = toolCalls;
|
|
236
252
|
session.pendingResolve = resolve;
|
|
253
|
+
_publishChange(session); // мгновенно сообщаем клиенту что появились pending tools
|
|
237
254
|
});
|
|
238
255
|
}
|
|
239
256
|
|
package/lib/jobsHub.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// lib/jobsHub.js — Server-side тракинг фоновых задач (генерации,
|
|
2
|
+
// chat-loops). Клиент сообщает старт/конец через POST /api/jobs/track,
|
|
3
|
+
// сервер хранит in-memory + push'ит WebSocket-событие 'jobs:changed'
|
|
4
|
+
// чтобы welcome-индикаторы обновлялись live и без поллинга.
|
|
5
|
+
//
|
|
6
|
+
// Структура:
|
|
7
|
+
// jobsByProject = Map<projectKey, Map<jobId, {kind, name, startedAt, ...}>>
|
|
8
|
+
//
|
|
9
|
+
// Channel для подписки: 'jobs:<projectKey>' — push при изменениях этого
|
|
10
|
+
// проекта. Также 'jobs:all' — глобальный поток для welcome-экрана.
|
|
11
|
+
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
const wsHub = require('./wsHub');
|
|
15
|
+
|
|
16
|
+
const jobsByProject = new Map(); // projectKey → Map<jobId, jobInfo>
|
|
17
|
+
|
|
18
|
+
function _emit(projectKey) {
|
|
19
|
+
const list = listForProject(projectKey);
|
|
20
|
+
wsHub.publish('jobs:' + projectKey, { kind: 'changed', list });
|
|
21
|
+
wsHub.publish('jobs:all', { kind: 'changed', projectKey, list });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function start({ projectKey, jobId, kind, name, type }) {
|
|
25
|
+
if (!projectKey || !jobId) return;
|
|
26
|
+
let m = jobsByProject.get(projectKey);
|
|
27
|
+
if (!m) { m = new Map(); jobsByProject.set(projectKey, m); }
|
|
28
|
+
m.set(jobId, { kind: kind || null, name: name || null, type: type || null, startedAt: Date.now() });
|
|
29
|
+
_emit(projectKey);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function end({ projectKey, jobId }) {
|
|
33
|
+
if (!projectKey || !jobId) return null;
|
|
34
|
+
const m = jobsByProject.get(projectKey);
|
|
35
|
+
if (!m) return null;
|
|
36
|
+
const job = m.get(jobId);
|
|
37
|
+
m.delete(jobId);
|
|
38
|
+
if (!m.size) jobsByProject.delete(projectKey);
|
|
39
|
+
_emit(projectKey);
|
|
40
|
+
return job || null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function listForProject(projectKey) {
|
|
44
|
+
const m = jobsByProject.get(projectKey);
|
|
45
|
+
if (!m) return [];
|
|
46
|
+
return Array.from(m.entries()).map(([jobId, info]) => ({ jobId, ...info }));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function listAll() {
|
|
50
|
+
const out = {};
|
|
51
|
+
for (const [k, m] of jobsByProject.entries()) {
|
|
52
|
+
out[k] = Array.from(m.entries()).map(([jobId, info]) => ({ jobId, ...info }));
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = { start, end, listForProject, listAll };
|
package/lib/providers.js
CHANGED
|
@@ -602,16 +602,34 @@ async function generateMusic(args) {
|
|
|
602
602
|
const { prompt, durationMs, settings: s } = args;
|
|
603
603
|
if (!prompt) throw new Error('нужен prompt');
|
|
604
604
|
|
|
605
|
-
// Приоритет direct ElevenLabs если включён + есть
|
|
606
|
-
// быть кастомные саб-настройки и платный ElevenLabs (зачем тратить
|
|
607
|
-
// Chatium-кредиты). Аналогичная логика в generateTts/generateSfx.
|
|
605
|
+
// Приоритет direct ElevenLabs если включён + есть ключ.
|
|
608
606
|
const directElevenAvailable = s.useElevenlabs && process.env.ELEVENLABS_API_KEY;
|
|
609
607
|
|
|
608
|
+
// Пробуем Chatium первым ТОЛЬКО если direct ElevenLabs не доступен.
|
|
609
|
+
// Если Chatium упал на музыке — auto-fallback на direct ElevenLabs если он
|
|
610
|
+
// тоже включён (бывало: Chatium-side music broken, у юзера только Eleven
|
|
611
|
+
// через Chatium → 500 → надо retry direct).
|
|
610
612
|
if (s.useChatium && s.chatium?.token && s.chatium?.base && !directElevenAvailable) {
|
|
611
|
-
|
|
613
|
+
try {
|
|
614
|
+
return await audioViaChatium(s, { kind: 'music', prompt, durationMs });
|
|
615
|
+
} catch (e) {
|
|
616
|
+
// Если есть direct Eleven (даже без env-key, попробуем s.elevenKey) —
|
|
617
|
+
// используем его как fallback.
|
|
618
|
+
if (s.useElevenlabs && (process.env.ELEVENLABS_API_KEY || s.elevenKey)) {
|
|
619
|
+
console.warn('[music] Chatium failed, falling back to direct ElevenLabs:', e?.message);
|
|
620
|
+
// Промосим в env если задан в settings но не в env (race с
|
|
621
|
+
// applySettingsToEnv после старта сервера).
|
|
622
|
+
if (!process.env.ELEVENLABS_API_KEY && s.elevenKey) {
|
|
623
|
+
process.env.ELEVENLABS_API_KEY = s.elevenKey;
|
|
624
|
+
}
|
|
625
|
+
// Падаем в direct-eleven path ниже.
|
|
626
|
+
} else {
|
|
627
|
+
throw new Error(`Музыка через KingKont недоступна: ${e.message?.slice(0, 200)}. Включи прямой ElevenLabs ключ в Настройках.`);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
612
630
|
}
|
|
613
631
|
if (!s.useElevenlabs) throw new Error('Войдите в KingKont или ElevenLabs для аудио.');
|
|
614
|
-
const key = process.env.ELEVENLABS_API_KEY;
|
|
632
|
+
const key = process.env.ELEVENLABS_API_KEY || s.elevenKey;
|
|
615
633
|
if (!key) throw new Error('ELEVENLABS_API_KEY не задан');
|
|
616
634
|
const body = { prompt };
|
|
617
635
|
if (durationMs) body.music_length_ms = +durationMs;
|
package/lib/wsHub.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// lib/wsHub.js — WebSocket hub для push-уведомлений клиентам.
|
|
2
|
+
//
|
|
3
|
+
// Клиент подключается к ws://localhost:<PORT>/ws и подписывается на
|
|
4
|
+
// "channels" (sessionKey, "jobs:*", и т.п.). Сервер push'ит JSON-events
|
|
5
|
+
// при изменениях — клиент слышит ОБРАТЯ ОБРАТНОЙ СВЯЗИ:
|
|
6
|
+
// чат-loop progressing, генерация завершилась, и т.п. — без поллинга.
|
|
7
|
+
//
|
|
8
|
+
// При фейле WebSocket'a клиент сам падает на polling (см. chat.js).
|
|
9
|
+
//
|
|
10
|
+
// Sub-ы хранятся в Map<channel, Set<ws>>. ws хранит свой Set каналов
|
|
11
|
+
// чтобы при close очистить все подписки.
|
|
12
|
+
//
|
|
13
|
+
// Сообщения от клиента → сервер:
|
|
14
|
+
// {type: 'subscribe', channel: 'chat:cloud:abc'}
|
|
15
|
+
// {type: 'unsubscribe', channel: 'chat:cloud:abc'}
|
|
16
|
+
// {type: 'ping'} — heartbeat (server отвечает {type:'pong'})
|
|
17
|
+
//
|
|
18
|
+
// Сообщения от сервера → клиенту:
|
|
19
|
+
// {type: 'event', channel: 'chat:cloud:abc', event: {kind:'state-changed', ...}}
|
|
20
|
+
|
|
21
|
+
'use strict';
|
|
22
|
+
|
|
23
|
+
const { WebSocketServer } = require('ws');
|
|
24
|
+
|
|
25
|
+
const channels = new Map(); // channel → Set<ws>
|
|
26
|
+
|
|
27
|
+
function attach(httpServer) {
|
|
28
|
+
const wss = new WebSocketServer({ server: httpServer, path: '/ws' });
|
|
29
|
+
wss.on('connection', (ws) => {
|
|
30
|
+
ws._subs = new Set();
|
|
31
|
+
ws.on('message', (raw) => {
|
|
32
|
+
let m;
|
|
33
|
+
try { m = JSON.parse(raw.toString()); } catch { return; }
|
|
34
|
+
if (m?.type === 'subscribe' && typeof m.channel === 'string') {
|
|
35
|
+
ws._subs.add(m.channel);
|
|
36
|
+
if (!channels.has(m.channel)) channels.set(m.channel, new Set());
|
|
37
|
+
channels.get(m.channel).add(ws);
|
|
38
|
+
} else if (m?.type === 'unsubscribe' && typeof m.channel === 'string') {
|
|
39
|
+
ws._subs.delete(m.channel);
|
|
40
|
+
channels.get(m.channel)?.delete(ws);
|
|
41
|
+
} else if (m?.type === 'ping') {
|
|
42
|
+
try { ws.send(JSON.stringify({ type: 'pong' })); } catch {}
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
ws.on('close', () => {
|
|
46
|
+
for (const ch of ws._subs) channels.get(ch)?.delete(ws);
|
|
47
|
+
ws._subs.clear();
|
|
48
|
+
});
|
|
49
|
+
ws.on('error', () => { /* swallow */ });
|
|
50
|
+
});
|
|
51
|
+
console.log('▶ WS attached at /ws');
|
|
52
|
+
return wss;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Push event на канал. Если нет подписчиков — no-op (event теряется
|
|
56
|
+
// до подключения; для критичных вещей клиент сам ре-фетчит state на
|
|
57
|
+
// connect через GET).
|
|
58
|
+
function publish(channel, event) {
|
|
59
|
+
const subs = channels.get(channel);
|
|
60
|
+
if (!subs || !subs.size) return;
|
|
61
|
+
const json = JSON.stringify({ type: 'event', channel, event });
|
|
62
|
+
for (const ws of subs) {
|
|
63
|
+
if (ws.readyState === ws.OPEN) {
|
|
64
|
+
try { ws.send(json); } catch { /* dead connection */ }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { attach, publish };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kingkont",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.1",
|
|
4
4
|
"description": "KingKont · Chatium — нод-редактор сцен с AI-генерацией (картинки/видео/голос/SFX/музыка/текст)",
|
|
5
5
|
"main": "main.js",
|
|
6
6
|
"bin": {
|
|
@@ -44,7 +44,8 @@
|
|
|
44
44
|
"postinstall": "node scripts/patch-electron-name.js"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"electron": "^38.8.6"
|
|
47
|
+
"electron": "^38.8.6",
|
|
48
|
+
"ws": "^8.20.0"
|
|
48
49
|
},
|
|
49
50
|
"devDependencies": {
|
|
50
51
|
"electron-builder": "^25.1.8"
|
package/renderer/board.js
CHANGED
|
@@ -609,6 +609,31 @@ window.addEventListener('bgjobs:changed', () => {
|
|
|
609
609
|
if (!state.filmHandle) renderWelcomeRecents().catch(() => {});
|
|
610
610
|
});
|
|
611
611
|
|
|
612
|
+
// WebSocket подписка на jobs:all — server push'ит при изменениях ЛЮБЫХ
|
|
613
|
+
// jobs (других вкладок, других клиентов). Если открыт welcome —
|
|
614
|
+
// перерисовываем чтобы видеть актуальные «⏳ N» badges.
|
|
615
|
+
let _welcomeWS = null;
|
|
616
|
+
function _connectWelcomeJobsWS() {
|
|
617
|
+
if (_welcomeWS && _welcomeWS.readyState === WebSocket.OPEN) return;
|
|
618
|
+
try {
|
|
619
|
+
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
620
|
+
_welcomeWS = new WebSocket(`${proto}://${location.host}/ws`);
|
|
621
|
+
} catch { _welcomeWS = null; return; }
|
|
622
|
+
_welcomeWS.onopen = () => {
|
|
623
|
+
try { _welcomeWS.send(JSON.stringify({ type: 'subscribe', channel: 'jobs:all' })); } catch {}
|
|
624
|
+
};
|
|
625
|
+
_welcomeWS.onmessage = (e) => {
|
|
626
|
+
let m; try { m = JSON.parse(e.data); } catch { return; }
|
|
627
|
+
if (m?.type === 'event' && m.channel === 'jobs:all') {
|
|
628
|
+
if (!state.filmHandle) renderWelcomeRecents().catch(() => {});
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
_welcomeWS.onclose = () => { _welcomeWS = null; setTimeout(_connectWelcomeJobsWS, 5000); };
|
|
632
|
+
_welcomeWS.onerror = () => {};
|
|
633
|
+
}
|
|
634
|
+
// Запуск при первом welcome render'е.
|
|
635
|
+
setTimeout(_connectWelcomeJobsWS, 500);
|
|
636
|
+
|
|
612
637
|
// Карточка локального (папочного) проекта — извлечена из renderWelcomeRecents.
|
|
613
638
|
// Удаление и save-as-template — через ПКМ (по аналогии с cloud-карточкой).
|
|
614
639
|
function makeRecentWelcomeCard(r) {
|
package/renderer/chat.js
CHANGED
|
@@ -636,7 +636,9 @@
|
|
|
636
636
|
if (r.ok) {
|
|
637
637
|
const snap = await r.json();
|
|
638
638
|
applyServerState(snap);
|
|
639
|
-
|
|
639
|
+
// Стартуем транспорт ВСЕГДА — даже если не busy сейчас, юзер
|
|
640
|
+
// может отправить сообщение, и WS-events будут приходить мгновенно.
|
|
641
|
+
startTransport(key);
|
|
640
642
|
return;
|
|
641
643
|
}
|
|
642
644
|
} catch (e) { console.warn('[chat] load from server failed:', e?.message); }
|
|
@@ -845,6 +847,27 @@
|
|
|
845
847
|
// при send(). Файлы сохраняются в <currentBoard>/inbox/<name>.
|
|
846
848
|
let manualAttachments = []; // [{relPath, name, size, mime}] — файлы из drag-drop
|
|
847
849
|
|
|
850
|
+
// Короткая строка-итог контекста для отображения в чате рядом с user-msg.
|
|
851
|
+
// null если нечего показать.
|
|
852
|
+
function _ctxToInlineLabel(ctx) {
|
|
853
|
+
const parts = [];
|
|
854
|
+
if (ctx.scene) parts.push(`🎬 ${ctx.scene.name}`);
|
|
855
|
+
const byType = {};
|
|
856
|
+
for (const sel of ctx.selected) (byType[sel.type] = byType[sel.type] || []).push(sel);
|
|
857
|
+
for (const [type, items] of Object.entries(byType)) {
|
|
858
|
+
const icon = type === 'image' ? '🖼' : type === 'video' ? '🎬' : type === 'audio' ? '🎙' : type === 'text' ? '📝' : '◉';
|
|
859
|
+
if (items.length === 1) parts.push(`${icon} ${items[0].name || items[0].id?.slice(0,6)}`);
|
|
860
|
+
else {
|
|
861
|
+
const noun = (typeof _typeForms !== 'undefined' && _typeForms[type]) || ['нода','ноды','нод'];
|
|
862
|
+
parts.push(`${icon} ${items.length} ${_ru_plural(items.length, noun)}`);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
if (ctx.attachments?.length) {
|
|
866
|
+
parts.push(`📎 ${ctx.attachments.length} файл${ctx.attachments.length > 1 ? 'а' : ''}`);
|
|
867
|
+
}
|
|
868
|
+
return parts.length ? parts.join(' · ') : null;
|
|
869
|
+
}
|
|
870
|
+
|
|
848
871
|
function buildContextSnapshot() {
|
|
849
872
|
const ctx = { scene: null, selected: [], attachments: manualAttachments.slice() };
|
|
850
873
|
if (state.currentBoard) {
|
|
@@ -1109,60 +1132,119 @@
|
|
|
1109
1132
|
return null;
|
|
1110
1133
|
}
|
|
1111
1134
|
|
|
1112
|
-
//
|
|
1113
|
-
//
|
|
1114
|
-
//
|
|
1115
|
-
//
|
|
1135
|
+
// === Транспорт чата с сервером ===
|
|
1136
|
+
// Приоритет: WebSocket (push с сервера, мгновенно). Fallback: polling
|
|
1137
|
+
// (каждые 800ms) когда WS отвалился или вообще не доступен. polling
|
|
1138
|
+
// используется и как полноценный транспорт — он корректно работает.
|
|
1139
|
+
//
|
|
1140
|
+
// На каждый «появилось изменение» (WS event ИЛИ poll-tick) — просто
|
|
1141
|
+
// перетягиваем GET /api/chat/state и применяем (см. _refreshAndExecute).
|
|
1116
1142
|
let pollTimer = null;
|
|
1117
1143
|
let pollKey = null;
|
|
1144
|
+
let ws = null;
|
|
1145
|
+
let wsKey = null;
|
|
1146
|
+
let wsConnectedAt = 0;
|
|
1147
|
+
let wsReconnectTimer = null;
|
|
1148
|
+
|
|
1149
|
+
async function _refreshAndExecute(key) {
|
|
1150
|
+
if (sessionKey() !== key) return false;
|
|
1151
|
+
let snap;
|
|
1152
|
+
try {
|
|
1153
|
+
const r = await fetch('/api/chat/state?key=' + encodeURIComponent(key));
|
|
1154
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
1155
|
+
snap = await r.json();
|
|
1156
|
+
} catch (e) {
|
|
1157
|
+
console.warn('[chat] state fetch failed:', e?.message);
|
|
1158
|
+
return false;
|
|
1159
|
+
}
|
|
1160
|
+
applyServerState(snap);
|
|
1161
|
+
// Pending tools? — исполняем локально и шлём results.
|
|
1162
|
+
if (snap.pendingToolCalls && snap.pendingToolCalls.length) {
|
|
1163
|
+
const results = [];
|
|
1164
|
+
for (const tc of snap.pendingToolCalls) {
|
|
1165
|
+
if (tc._parseError) {
|
|
1166
|
+
results.push({ id: tc.id, name: 'parse_error', ok: false, error: tc._parseError });
|
|
1167
|
+
continue;
|
|
1168
|
+
}
|
|
1169
|
+
try {
|
|
1170
|
+
const r2 = await runTool(tc.name, tc.args);
|
|
1171
|
+
results.push({ id: tc.id, name: tc.name, ok: true, result: r2 });
|
|
1172
|
+
} catch (e) {
|
|
1173
|
+
results.push({ id: tc.id, name: tc.name, ok: false, error: e?.message || String(e) });
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
try {
|
|
1177
|
+
await fetch('/api/chat/tool-results', {
|
|
1178
|
+
method: 'POST',
|
|
1179
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1180
|
+
body: JSON.stringify({ key, results }),
|
|
1181
|
+
});
|
|
1182
|
+
} catch {}
|
|
1183
|
+
}
|
|
1184
|
+
if (!snap.busy && snap.lastError) appendStatus('⚠ ' + snap.lastError, true);
|
|
1185
|
+
return snap.busy;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// ===== WebSocket =====
|
|
1189
|
+
function ensureWS(key) {
|
|
1190
|
+
if (ws && wsKey === key && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
|
|
1191
|
+
return;
|
|
1192
|
+
}
|
|
1193
|
+
closeWS();
|
|
1194
|
+
wsKey = key;
|
|
1195
|
+
try {
|
|
1196
|
+
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
1197
|
+
ws = new WebSocket(`${proto}://${location.host}/ws`);
|
|
1198
|
+
} catch (e) {
|
|
1199
|
+
console.warn('[chat] WS connect failed:', e?.message);
|
|
1200
|
+
ws = null;
|
|
1201
|
+
// Fallback на polling.
|
|
1202
|
+
startPolling(key);
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1205
|
+
ws.onopen = () => {
|
|
1206
|
+
wsConnectedAt = Date.now();
|
|
1207
|
+
try { ws.send(JSON.stringify({ type: 'subscribe', channel: 'chat:' + key })); } catch {}
|
|
1208
|
+
// Сразу подтягиваем актуальное состояние (могли пропустить events).
|
|
1209
|
+
_refreshAndExecute(key).catch(() => {});
|
|
1210
|
+
stopPolling(); // WS живёт — polling не нужен
|
|
1211
|
+
};
|
|
1212
|
+
ws.onmessage = (e) => {
|
|
1213
|
+
let m;
|
|
1214
|
+
try { m = JSON.parse(e.data); } catch { return; }
|
|
1215
|
+
if (m?.type === 'event' && m.channel === 'chat:' + key) {
|
|
1216
|
+
_refreshAndExecute(key).catch(() => {});
|
|
1217
|
+
}
|
|
1218
|
+
};
|
|
1219
|
+
ws.onerror = () => { /* close handler сделает остальное */ };
|
|
1220
|
+
ws.onclose = () => {
|
|
1221
|
+
ws = null;
|
|
1222
|
+
// Если ws закрылся быстро (<3s от open) — не reconnect'имся бесконечно
|
|
1223
|
+
// в фоне для этой сессии, fallback на polling.
|
|
1224
|
+
if (wsKey === key) {
|
|
1225
|
+
startPolling(key); // безопасно, если уже polling — startPolling no-op
|
|
1226
|
+
// И через 5s попробуем WS снова.
|
|
1227
|
+
clearTimeout(wsReconnectTimer);
|
|
1228
|
+
wsReconnectTimer = setTimeout(() => ensureWS(key), 5000);
|
|
1229
|
+
}
|
|
1230
|
+
};
|
|
1231
|
+
}
|
|
1232
|
+
function closeWS() {
|
|
1233
|
+
clearTimeout(wsReconnectTimer); wsReconnectTimer = null;
|
|
1234
|
+
if (ws) { try { ws.close(); } catch {} ws = null; }
|
|
1235
|
+
wsKey = null;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
// ===== Polling (fallback) =====
|
|
1118
1239
|
async function startPolling(key) {
|
|
1119
1240
|
if (pollKey === key && pollTimer) return;
|
|
1120
1241
|
stopPolling();
|
|
1121
1242
|
pollKey = key;
|
|
1122
1243
|
const tick = async () => {
|
|
1123
1244
|
if (pollKey !== key) return;
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
const snap = await r.json();
|
|
1128
|
-
// Применяем снимок только если сессия всё ещё активна на клиенте.
|
|
1129
|
-
if (sessionKey() === key) applyServerState(snap);
|
|
1130
|
-
// Если есть pending-tools — исполняем и шлём results (продолжаем
|
|
1131
|
-
// poll сразу, не ждём next tick).
|
|
1132
|
-
if (snap.pendingToolCalls && snap.pendingToolCalls.length) {
|
|
1133
|
-
const results = [];
|
|
1134
|
-
for (const tc of snap.pendingToolCalls) {
|
|
1135
|
-
if (tc._parseError) {
|
|
1136
|
-
results.push({ id: tc.id, name: 'parse_error', ok: false, error: tc._parseError });
|
|
1137
|
-
continue;
|
|
1138
|
-
}
|
|
1139
|
-
try {
|
|
1140
|
-
const r2 = await runTool(tc.name, tc.args);
|
|
1141
|
-
results.push({ id: tc.id, name: tc.name, ok: true, result: r2 });
|
|
1142
|
-
} catch (e) {
|
|
1143
|
-
results.push({ id: tc.id, name: tc.name, ok: false, error: e?.message || String(e) });
|
|
1144
|
-
}
|
|
1145
|
-
}
|
|
1146
|
-
await fetch('/api/chat/tool-results', {
|
|
1147
|
-
method: 'POST',
|
|
1148
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1149
|
-
body: JSON.stringify({ key, results }),
|
|
1150
|
-
}).catch(() => {});
|
|
1151
|
-
// Сразу продолжаем polling без задержки (loop уже резюмирован сервером).
|
|
1152
|
-
if (pollKey === key) pollTimer = setTimeout(tick, 50);
|
|
1153
|
-
return;
|
|
1154
|
-
}
|
|
1155
|
-
if (snap.busy) {
|
|
1156
|
-
if (pollKey === key) pollTimer = setTimeout(tick, 800);
|
|
1157
|
-
} else {
|
|
1158
|
-
// Loop завершён — последняя статус-строка может быть info'й.
|
|
1159
|
-
if (snap.lastError) appendStatus('⚠ ' + snap.lastError, true);
|
|
1160
|
-
stopPolling();
|
|
1161
|
-
}
|
|
1162
|
-
} catch (e) {
|
|
1163
|
-
console.warn('[chat] poll failed:', e?.message);
|
|
1164
|
-
if (pollKey === key) pollTimer = setTimeout(tick, 2000); // backoff
|
|
1165
|
-
}
|
|
1245
|
+
const stillBusy = await _refreshAndExecute(key);
|
|
1246
|
+
if (!stillBusy) { stopPolling(); return; }
|
|
1247
|
+
if (pollKey === key) pollTimer = setTimeout(tick, 800);
|
|
1166
1248
|
};
|
|
1167
1249
|
tick();
|
|
1168
1250
|
}
|
|
@@ -1172,6 +1254,12 @@
|
|
|
1172
1254
|
pollKey = null;
|
|
1173
1255
|
}
|
|
1174
1256
|
|
|
1257
|
+
// Главная entry-point: попытаться WS, если не получится — polling.
|
|
1258
|
+
function startTransport(key) {
|
|
1259
|
+
if (typeof WebSocket !== 'undefined') ensureWS(key);
|
|
1260
|
+
else startPolling(key);
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1175
1263
|
// Сервер прислал snapshot — применяем history к UI.
|
|
1176
1264
|
function applyServerState(snap) {
|
|
1177
1265
|
if (!snap) return;
|
|
@@ -1201,14 +1289,26 @@
|
|
|
1201
1289
|
// Snapshot НЕ берём автоматически — теперь это явный tool take_snapshot,
|
|
1202
1290
|
// который Claude вызывает перед destructive-действиями. См. system-prompt.
|
|
1203
1291
|
// Контекст + system-prompt: формируем тут (на клиенте) и отдаём серверу.
|
|
1292
|
+
// Также сохраняем context-snapshot в user-msg чтобы юзер видел в чате
|
|
1293
|
+
// на каком контексте он спрашивал (через много turns'ов это полезно).
|
|
1204
1294
|
const ctxSnap = buildContextSnapshot();
|
|
1205
1295
|
const system = buildSystemPrompt() + '\n\n' + buildContextBlock(ctxSnap);
|
|
1296
|
+
// Inline context summary в visible тексте сообщения для UI render'a.
|
|
1297
|
+
// Claude получает полный context через system, юзер — через ctxLabel
|
|
1298
|
+
// в самом message (см. renderHistoryFiltered).
|
|
1299
|
+
const ctxLabel = _ctxToInlineLabel(ctxSnap);
|
|
1300
|
+
// Если есть контекст — добавляем его как первой строкой в сообщение
|
|
1301
|
+
// (с маркером [ctx:...]). Это вид'но и юзеру (renderer стилизует),
|
|
1302
|
+
// и Claude (повторяет инструкцию из system-prompt в самом turn'е).
|
|
1303
|
+
const textWithCtx = ctxLabel
|
|
1304
|
+
? `[ctx: ${ctxLabel}]\n${userText}`
|
|
1305
|
+
: userText;
|
|
1206
1306
|
appendStatus('KingKont думает…');
|
|
1207
1307
|
try {
|
|
1208
1308
|
const r = await fetch('/api/chat/send', {
|
|
1209
1309
|
method: 'POST',
|
|
1210
1310
|
headers: { 'Content-Type': 'application/json' },
|
|
1211
|
-
body: JSON.stringify({ key, text:
|
|
1311
|
+
body: JSON.stringify({ key, text: textWithCtx, system }),
|
|
1212
1312
|
});
|
|
1213
1313
|
if (!r.ok) {
|
|
1214
1314
|
const err = await r.json().catch(() => ({}));
|
|
@@ -1216,7 +1316,7 @@
|
|
|
1216
1316
|
}
|
|
1217
1317
|
const snap = await r.json();
|
|
1218
1318
|
applyServerState(snap);
|
|
1219
|
-
|
|
1319
|
+
startTransport(key);
|
|
1220
1320
|
} catch (e) {
|
|
1221
1321
|
appendStatus('⚠ ' + (e?.message || e), true);
|
|
1222
1322
|
}
|
|
@@ -1268,10 +1368,24 @@
|
|
|
1268
1368
|
}
|
|
1269
1369
|
div.appendChild(lbl);
|
|
1270
1370
|
if (hasContent) {
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1371
|
+
// Парсим [ctx: ...] префикс если есть — рендерим как маленький
|
|
1372
|
+
// сабтайтл над основным текстом.
|
|
1373
|
+
let mainText = m.content;
|
|
1374
|
+
let ctxLine = null;
|
|
1375
|
+
const cm = m.content.match(/^\[ctx:\s*([^\]\n]+)\]\n?([\s\S]*)$/);
|
|
1376
|
+
if (cm) { ctxLine = cm[1].trim(); mainText = cm[2].trim(); }
|
|
1377
|
+
if (ctxLine) {
|
|
1378
|
+
const cx = document.createElement('div');
|
|
1379
|
+
cx.className = 'chat-msg-ctx';
|
|
1380
|
+
cx.textContent = ctxLine;
|
|
1381
|
+
div.appendChild(cx);
|
|
1382
|
+
}
|
|
1383
|
+
if (mainText) {
|
|
1384
|
+
const body = document.createElement('div');
|
|
1385
|
+
body.className = 'chat-msg-body';
|
|
1386
|
+
body.textContent = mainText;
|
|
1387
|
+
div.appendChild(body);
|
|
1388
|
+
}
|
|
1275
1389
|
}
|
|
1276
1390
|
if (hasTools) {
|
|
1277
1391
|
// Все tools в ОДНУ строку (separated " · "), без emoji-icons —
|
package/renderer/state.js
CHANGED
|
@@ -92,19 +92,29 @@ function bgJobStart(info) {
|
|
|
92
92
|
localStorage.setItem(bgJobsKey(pk), JSON.stringify(list));
|
|
93
93
|
window.dispatchEvent(new CustomEvent('bgjobs:changed'));
|
|
94
94
|
} catch {}
|
|
95
|
+
// Также шлём на сервер — чтобы welcome (любой клиент) видел через WS push.
|
|
96
|
+
fetch('/api/jobs/track', {
|
|
97
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
98
|
+
body: JSON.stringify({ action: 'start', projectKey: pk, jobId: info.nodeId, kind: info.kind, name: info.name }),
|
|
99
|
+
}).catch(() => {});
|
|
95
100
|
}
|
|
96
101
|
function bgJobEnd(nodeId, projectKey) {
|
|
97
102
|
const pk = projectKey || _projectKeyForCurrent();
|
|
98
103
|
if (!pk) return null;
|
|
104
|
+
let job = null;
|
|
99
105
|
try {
|
|
100
106
|
const list = JSON.parse(localStorage.getItem(bgJobsKey(pk)) || '[]');
|
|
101
|
-
|
|
107
|
+
job = list.find(j => j.nodeId === nodeId) || null;
|
|
102
108
|
const filtered = list.filter(j => j.nodeId !== nodeId);
|
|
103
109
|
if (filtered.length) localStorage.setItem(bgJobsKey(pk), JSON.stringify(filtered));
|
|
104
110
|
else localStorage.removeItem(bgJobsKey(pk));
|
|
105
111
|
window.dispatchEvent(new CustomEvent('bgjobs:changed'));
|
|
106
|
-
|
|
107
|
-
|
|
112
|
+
} catch {}
|
|
113
|
+
fetch('/api/jobs/track', {
|
|
114
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
115
|
+
body: JSON.stringify({ action: 'end', projectKey: pk, jobId: nodeId }),
|
|
116
|
+
}).catch(() => {});
|
|
117
|
+
return job;
|
|
108
118
|
}
|
|
109
119
|
function bgJobsAll() {
|
|
110
120
|
const result = {};
|
package/renderer/styles.css
CHANGED
|
@@ -365,20 +365,35 @@
|
|
|
365
365
|
.chat-msg-body {
|
|
366
366
|
color: #e0e0e0; white-space: pre-wrap; word-break: break-word;
|
|
367
367
|
}
|
|
368
|
+
/* [ctx: ...] префикс — маленький subtitle над user-msg, светло-серый. */
|
|
369
|
+
.chat-msg-ctx {
|
|
370
|
+
font-size: 10.5px; color: #889;
|
|
371
|
+
background: rgba(255,255,255,0.04); padding: 2px 6px;
|
|
372
|
+
border-radius: 4px; margin-bottom: 3px;
|
|
373
|
+
align-self: flex-start; max-width: 100%;
|
|
374
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
375
|
+
}
|
|
368
376
|
.chat-tool {
|
|
369
377
|
margin-top: 2px; background: transparent; border: none;
|
|
370
378
|
padding: 0; font-size: 10.5px; color: #666;
|
|
379
|
+
/* Гарантия что tool-блок занимает ровно одну строку (плюс развёрнутый pre)
|
|
380
|
+
— иначе при множестве tools или длинных args блок разрастался. */
|
|
381
|
+
max-width: 100%; min-width: 0;
|
|
371
382
|
}
|
|
372
383
|
.chat-tool summary {
|
|
373
|
-
cursor: pointer; color: #666; outline: none; opacity: 0.
|
|
384
|
+
cursor: pointer; color: #666; outline: none; opacity: 0.7;
|
|
374
385
|
font-family: ui-monospace, 'SF Mono', monospace;
|
|
375
386
|
list-style: none; padding: 1px 4px;
|
|
376
387
|
transition: color 0.1s, opacity 0.1s;
|
|
388
|
+
/* Один в строку, длинное → ellipsis. Click раскрывает <details>. */
|
|
389
|
+
display: block; white-space: nowrap; overflow: hidden;
|
|
390
|
+
text-overflow: ellipsis; max-width: 100%;
|
|
377
391
|
}
|
|
378
392
|
.chat-tool summary::-webkit-details-marker { display: none; }
|
|
379
393
|
.chat-tool summary::before {
|
|
380
394
|
content: '▸ '; color: #555; font-size: 9px;
|
|
381
395
|
}
|
|
396
|
+
.chat-tool[open] summary { white-space: normal; text-overflow: clip; }
|
|
382
397
|
.chat-tool[open] summary::before { content: '▾ '; }
|
|
383
398
|
.chat-tool summary:hover { color: #aaa; opacity: 1; }
|
|
384
399
|
.chat-tool pre {
|
|
@@ -478,10 +493,21 @@
|
|
|
478
493
|
}
|
|
479
494
|
.welcome-card.open-card .welcome-card-ts { color: #aac0d8; }
|
|
480
495
|
.welcome-card:hover { border-color: #4a6a9a; transform: translateY(-2px); }
|
|
496
|
+
/* Card теперь сама с фиксированным aspect-ratio + overflow:hidden →
|
|
497
|
+
обложка заполняет всю карту (вертикальная картинка кроп'ится по верху/
|
|
498
|
+
низу через object-fit:cover, не растягивает карту бесконечно вверх).
|
|
499
|
+
Meta (название + дата) — absolute поверх обложки внизу с тёмным
|
|
500
|
+
градиентом, читается на ЛЮБОЙ обложке. */
|
|
501
|
+
.welcome-card {
|
|
502
|
+
aspect-ratio: 16 / 9;
|
|
503
|
+
overflow: hidden;
|
|
504
|
+
}
|
|
481
505
|
.welcome-card-thumb {
|
|
482
|
-
|
|
506
|
+
position: absolute; inset: 0;
|
|
507
|
+
background: #1a1a1a;
|
|
483
508
|
display: flex; align-items: center; justify-content: center;
|
|
484
509
|
color: #444; font-size: 32px;
|
|
510
|
+
overflow: hidden; /* страховка чтобы img/badge не выезжали */
|
|
485
511
|
}
|
|
486
512
|
.welcome-card-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
|
487
513
|
/* ☁-бейдж в углу обложки облачного проекта. Делает cloud/folder-проекты
|
|
@@ -511,9 +537,17 @@
|
|
|
511
537
|
0%, 100% { opacity: 0.85; }
|
|
512
538
|
50% { opacity: 1; }
|
|
513
539
|
}
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
.welcome-card-
|
|
540
|
+
/* Meta теперь оверлеем поверх обложки внизу с тёмным градиентом —
|
|
541
|
+
название + дата всегда читаемы независимо от цвета обложки. */
|
|
542
|
+
.welcome-card-meta {
|
|
543
|
+
position: absolute; left: 0; right: 0; bottom: 0;
|
|
544
|
+
padding: 28px 12px 8px 12px;
|
|
545
|
+
background: linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,0.55) 30%, rgba(0,0,0,0.92) 100%);
|
|
546
|
+
color: #fff;
|
|
547
|
+
pointer-events: none; /* клик идёт на card */
|
|
548
|
+
}
|
|
549
|
+
.welcome-card-name { font-size: 13px; color: #fff; word-break: break-all; font-weight: 500; text-shadow: 0 1px 2px rgba(0,0,0,0.6); }
|
|
550
|
+
.welcome-card-ts { font-size: 11px; color: #ccc; margin-top: 2px; text-shadow: 0 1px 2px rgba(0,0,0,0.6); }
|
|
517
551
|
.welcome-card-del {
|
|
518
552
|
position: absolute; top: 6px; right: 6px;
|
|
519
553
|
background: rgba(0,0,0,0.6); color: #aaa;
|
package/server.js
CHANGED
|
@@ -14,6 +14,8 @@ const { extname, join, normalize, resolve } = require('node:path');
|
|
|
14
14
|
|
|
15
15
|
const providers = require('./lib/providers');
|
|
16
16
|
const chatSession = require('./lib/chatSession');
|
|
17
|
+
const jobsHub = require('./lib/jobsHub');
|
|
18
|
+
const wsHub = require('./lib/wsHub');
|
|
17
19
|
|
|
18
20
|
// ---------- .env loader (без зависимостей) ----------
|
|
19
21
|
function loadEnv() {
|
|
@@ -345,6 +347,30 @@ async function handleChatClear(req, res) {
|
|
|
345
347
|
} catch (e) { sendError(res, e, 500); }
|
|
346
348
|
}
|
|
347
349
|
|
|
350
|
+
// =============================================================================
|
|
351
|
+
// Jobs hub: client сообщает старт/конец фоновой работы (генерация ноды),
|
|
352
|
+
// сервер хранит в памяти и push'ит WS-event 'jobs:<projectKey>' и 'jobs:all'.
|
|
353
|
+
// Welcome-индикаторы и любые viewers подписываются на эти каналы.
|
|
354
|
+
// =============================================================================
|
|
355
|
+
async function handleJobsTrack(req, res) {
|
|
356
|
+
try {
|
|
357
|
+
const body = await readJson(req);
|
|
358
|
+
const { action, projectKey, jobId, kind, name, type } = body || {};
|
|
359
|
+
if (!projectKey || !jobId) return send(res, 400, { error: 'projectKey + jobId обязательны' });
|
|
360
|
+
if (action === 'start') jobsHub.start({ projectKey, jobId, kind, name, type });
|
|
361
|
+
else if (action === 'end') jobsHub.end({ projectKey, jobId });
|
|
362
|
+
else return send(res, 400, { error: 'action: start|end' });
|
|
363
|
+
send(res, 200, { ok: true });
|
|
364
|
+
} catch (e) { sendError(res, e, 500); }
|
|
365
|
+
}
|
|
366
|
+
async function handleJobsList(res, url) {
|
|
367
|
+
try {
|
|
368
|
+
const projectKey = url.searchParams.get('projectKey');
|
|
369
|
+
if (projectKey) send(res, 200, { jobs: jobsHub.listForProject(projectKey) });
|
|
370
|
+
else send(res, 200, { all: jobsHub.listAll() });
|
|
371
|
+
} catch (e) { sendError(res, e, 500); }
|
|
372
|
+
}
|
|
373
|
+
|
|
348
374
|
// =============================================================================
|
|
349
375
|
// Static files (renderer assets).
|
|
350
376
|
// =============================================================================
|
|
@@ -399,6 +425,9 @@ const server = createServer(async (req, res) => {
|
|
|
399
425
|
if (req.method === 'GET' && url.pathname === '/api/chat/state') return handleChatState(res, url);
|
|
400
426
|
if (req.method === 'POST' && url.pathname === '/api/chat/tool-results') return handleChatToolResults(req, res);
|
|
401
427
|
if (req.method === 'POST' && url.pathname === '/api/chat/clear') return handleChatClear(req, res);
|
|
428
|
+
// Jobs hub.
|
|
429
|
+
if (req.method === 'POST' && url.pathname === '/api/jobs/track') return handleJobsTrack(req, res);
|
|
430
|
+
if (req.method === 'GET' && url.pathname === '/api/jobs') return handleJobsList(res, url);
|
|
402
431
|
// Cloud-projects routes — зеркало templates, но для редактируемых проектов.
|
|
403
432
|
if (req.method === 'GET' && url.pathname === '/api/projects') return handleProjectsList(res);
|
|
404
433
|
if (req.method === 'POST' && url.pathname === '/api/projects') return handleProjectCreate(req, res);
|
|
@@ -430,6 +459,8 @@ function start(port = PORT, opts = {}) {
|
|
|
430
459
|
server.once('error', reject);
|
|
431
460
|
server.listen(port, () => {
|
|
432
461
|
const addr = server.address();
|
|
462
|
+
// Attach WebSocket hub поверх того же HTTP-сервера (path /ws).
|
|
463
|
+
try { wsHub.attach(server); } catch (e) { console.warn('WS attach failed:', e.message); }
|
|
433
464
|
console.log(`▶ http://localhost:${addr.port}`);
|
|
434
465
|
console.log(` KIE_API_KEY: ${process.env.KIE_API_KEY ? '✓' : '✗ missing'}`);
|
|
435
466
|
console.log(` ELEVENLABS_API_KEY: ${process.env.ELEVENLABS_API_KEY ? '✓' : '✗ missing'}`);
|