kingkont 0.15.0 → 0.16.0
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 +6 -1
- package/lib/wsHub.js +69 -0
- package/package.json +3 -2
- package/renderer/board.js +25 -0
- package/renderer/chat.js +183 -54
- package/renderer/state.js +13 -3
- 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,7 +602,12 @@ async function generateMusic(args) {
|
|
|
602
602
|
const { prompt, durationMs, settings: s } = args;
|
|
603
603
|
if (!prompt) throw new Error('нужен prompt');
|
|
604
604
|
|
|
605
|
-
|
|
605
|
+
// Приоритет direct ElevenLabs если включён + есть ключ — у юзера могут
|
|
606
|
+
// быть кастомные саб-настройки и платный ElevenLabs (зачем тратить
|
|
607
|
+
// Chatium-кредиты). Аналогичная логика в generateTts/generateSfx.
|
|
608
|
+
const directElevenAvailable = s.useElevenlabs && process.env.ELEVENLABS_API_KEY;
|
|
609
|
+
|
|
610
|
+
if (s.useChatium && s.chatium?.token && s.chatium?.base && !directElevenAvailable) {
|
|
606
611
|
return await audioViaChatium(s, { kind: 'music', prompt, durationMs });
|
|
607
612
|
}
|
|
608
613
|
if (!s.useElevenlabs) throw new Error('Войдите в KingKont или ElevenLabs для аудио.');
|
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.0",
|
|
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
|
@@ -183,6 +183,50 @@
|
|
|
183
183
|
},
|
|
184
184
|
},
|
|
185
185
|
|
|
186
|
+
move_node: {
|
|
187
|
+
description: 'Передвинуть ноду в новые координаты (или сдвинуть относительно текущих через dx/dy). x/y абсолютные в canvas-coord. Если нужно сдвинуть несколько нод подряд — выдавай несколько move_node.',
|
|
188
|
+
params: '{"id":"<node-id>","x":<absolute>,"y":<absolute>,"dx":<relative-shift>,"dy":<relative-shift>}',
|
|
189
|
+
async handler({ id, x, y, dx, dy }) {
|
|
190
|
+
const b = state.currentBoard;
|
|
191
|
+
if (!b) throw new Error('доска не выбрана');
|
|
192
|
+
const node = b.metadata.nodes.find(n => n.id === id);
|
|
193
|
+
if (!node) throw new Error(`нода не найдена: ${id}`);
|
|
194
|
+
if (typeof x === 'number') node.x = x;
|
|
195
|
+
if (typeof y === 'number') node.y = y;
|
|
196
|
+
if (typeof dx === 'number') node.x = (node.x || 0) + dx;
|
|
197
|
+
if (typeof dy === 'number') node.y = (node.y || 0) + dy;
|
|
198
|
+
// Clamp к >= 50 чтобы не уехала в негатив за viewport.
|
|
199
|
+
if (node.x < 50) node.x = 50;
|
|
200
|
+
if (node.y < 50) node.y = 50;
|
|
201
|
+
scheduleSave();
|
|
202
|
+
if (typeof renderCanvas === 'function') await renderCanvas();
|
|
203
|
+
return { ok: true, x: node.x, y: node.y };
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
edit_scene_json: {
|
|
208
|
+
description: 'Прямое редактирование scene.json текущей доски — для нестандартных операций (массовая правка позиций, sort нод, чистка metadata, и т.п.). Передай patch как объект — поля заменяют существующие. Для замены ВСЕГО массива nodes — передай {nodes: [...]}. ВНИМАНИЕ: ошибка тут разрушит сцену — обязательно вызови take_snapshot ПЕРЕД.',
|
|
209
|
+
params: '{"patch":{...}}',
|
|
210
|
+
async handler({ patch }) {
|
|
211
|
+
const b = state.currentBoard;
|
|
212
|
+
if (!b) throw new Error('доска не выбрана');
|
|
213
|
+
if (!patch || typeof patch !== 'object') throw new Error('patch должен быть объектом');
|
|
214
|
+
// Применяем patch к metadata (top-level merge). Для nodes/connections
|
|
215
|
+
// если переданы — заменяем массив целиком (не merge).
|
|
216
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
217
|
+
b.metadata[k] = v;
|
|
218
|
+
}
|
|
219
|
+
scheduleSave();
|
|
220
|
+
if (typeof renderCanvas === 'function') await renderCanvas();
|
|
221
|
+
if (typeof renderConnections === 'function') renderConnections();
|
|
222
|
+
return {
|
|
223
|
+
ok: true,
|
|
224
|
+
nodesCount: (b.metadata.nodes || []).length,
|
|
225
|
+
connectionsCount: (b.metadata.connections || []).length,
|
|
226
|
+
};
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
|
|
186
230
|
update_node_prompt: {
|
|
187
231
|
description: 'Обновить промпт у image/video/audio ноды (без re-генерации).',
|
|
188
232
|
params: '{"id":"<node-id>","prompt":"<new>"}',
|
|
@@ -278,9 +322,18 @@
|
|
|
278
322
|
async handler({ nodeId, trackKind, start, duration }) {
|
|
279
323
|
const b = state.currentBoard;
|
|
280
324
|
if (!b) throw new Error('доска не выбрана');
|
|
325
|
+
// Re-read scene с диска чтобы видеть свежий node.file (после
|
|
326
|
+
// background-генерации). Без этого chat'оподобный flow «добавь и
|
|
327
|
+
// сразу в таймлайн» — файла ещё нет в node.file in-memory.
|
|
328
|
+
try {
|
|
329
|
+
const sceneFh = await b.handle.getFileHandle('scene.json');
|
|
330
|
+
const txt = await (await sceneFh.getFile()).text();
|
|
331
|
+
const scene = JSON.parse(txt);
|
|
332
|
+
if (Array.isArray(scene.nodes)) b.metadata.nodes = scene.nodes;
|
|
333
|
+
} catch {}
|
|
281
334
|
const node = b.metadata.nodes.find(n => n.id === nodeId);
|
|
282
335
|
if (!node) throw new Error(`нода не найдена: ${nodeId}`);
|
|
283
|
-
if (!node.file) throw new Error('у ноды нет файла (нужно сначала сгенерировать)');
|
|
336
|
+
if (!node.file) throw new Error('у ноды нет файла (нужно сначала сгенерировать и дождаться)');
|
|
284
337
|
if (typeof getTimeline !== 'function') throw new Error('getTimeline недоступен');
|
|
285
338
|
const tl = getTimeline();
|
|
286
339
|
const tk = trackKind === 'audio' ? 'audio' : 'video';
|
|
@@ -304,8 +357,14 @@
|
|
|
304
357
|
};
|
|
305
358
|
track.clips.push(clip);
|
|
306
359
|
scheduleSave();
|
|
307
|
-
|
|
308
|
-
|
|
360
|
+
// Force open + render: иначе thumbnails не загружаются (рендер скип'ался
|
|
361
|
+
// при скрытой панели, и при следующем открытии состояние UI было stale).
|
|
362
|
+
const tlPanel = document.getElementById('timelinePanel');
|
|
363
|
+
if (tlPanel?.classList.contains('hidden')) {
|
|
364
|
+
// Открываем таймлайн чтобы юзер видел что добавилось.
|
|
365
|
+
document.getElementById('timelineBtn')?.click();
|
|
366
|
+
} else if (typeof renderTimeline === 'function') {
|
|
367
|
+
await renderTimeline();
|
|
309
368
|
}
|
|
310
369
|
return { ok: true, clipId: clip.id, trackId: track.id, start: clip.start, duration: clip.duration };
|
|
311
370
|
},
|
|
@@ -577,7 +636,9 @@
|
|
|
577
636
|
if (r.ok) {
|
|
578
637
|
const snap = await r.json();
|
|
579
638
|
applyServerState(snap);
|
|
580
|
-
|
|
639
|
+
// Стартуем транспорт ВСЕГДА — даже если не busy сейчас, юзер
|
|
640
|
+
// может отправить сообщение, и WS-events будут приходить мгновенно.
|
|
641
|
+
startTransport(key);
|
|
581
642
|
return;
|
|
582
643
|
}
|
|
583
644
|
} catch (e) { console.warn('[chat] load from server failed:', e?.message); }
|
|
@@ -1050,60 +1111,119 @@
|
|
|
1050
1111
|
return null;
|
|
1051
1112
|
}
|
|
1052
1113
|
|
|
1053
|
-
//
|
|
1054
|
-
//
|
|
1055
|
-
//
|
|
1056
|
-
//
|
|
1114
|
+
// === Транспорт чата с сервером ===
|
|
1115
|
+
// Приоритет: WebSocket (push с сервера, мгновенно). Fallback: polling
|
|
1116
|
+
// (каждые 800ms) когда WS отвалился или вообще не доступен. polling
|
|
1117
|
+
// используется и как полноценный транспорт — он корректно работает.
|
|
1118
|
+
//
|
|
1119
|
+
// На каждый «появилось изменение» (WS event ИЛИ poll-tick) — просто
|
|
1120
|
+
// перетягиваем GET /api/chat/state и применяем (см. _refreshAndExecute).
|
|
1057
1121
|
let pollTimer = null;
|
|
1058
1122
|
let pollKey = null;
|
|
1123
|
+
let ws = null;
|
|
1124
|
+
let wsKey = null;
|
|
1125
|
+
let wsConnectedAt = 0;
|
|
1126
|
+
let wsReconnectTimer = null;
|
|
1127
|
+
|
|
1128
|
+
async function _refreshAndExecute(key) {
|
|
1129
|
+
if (sessionKey() !== key) return false;
|
|
1130
|
+
let snap;
|
|
1131
|
+
try {
|
|
1132
|
+
const r = await fetch('/api/chat/state?key=' + encodeURIComponent(key));
|
|
1133
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
1134
|
+
snap = await r.json();
|
|
1135
|
+
} catch (e) {
|
|
1136
|
+
console.warn('[chat] state fetch failed:', e?.message);
|
|
1137
|
+
return false;
|
|
1138
|
+
}
|
|
1139
|
+
applyServerState(snap);
|
|
1140
|
+
// Pending tools? — исполняем локально и шлём results.
|
|
1141
|
+
if (snap.pendingToolCalls && snap.pendingToolCalls.length) {
|
|
1142
|
+
const results = [];
|
|
1143
|
+
for (const tc of snap.pendingToolCalls) {
|
|
1144
|
+
if (tc._parseError) {
|
|
1145
|
+
results.push({ id: tc.id, name: 'parse_error', ok: false, error: tc._parseError });
|
|
1146
|
+
continue;
|
|
1147
|
+
}
|
|
1148
|
+
try {
|
|
1149
|
+
const r2 = await runTool(tc.name, tc.args);
|
|
1150
|
+
results.push({ id: tc.id, name: tc.name, ok: true, result: r2 });
|
|
1151
|
+
} catch (e) {
|
|
1152
|
+
results.push({ id: tc.id, name: tc.name, ok: false, error: e?.message || String(e) });
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
try {
|
|
1156
|
+
await fetch('/api/chat/tool-results', {
|
|
1157
|
+
method: 'POST',
|
|
1158
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1159
|
+
body: JSON.stringify({ key, results }),
|
|
1160
|
+
});
|
|
1161
|
+
} catch {}
|
|
1162
|
+
}
|
|
1163
|
+
if (!snap.busy && snap.lastError) appendStatus('⚠ ' + snap.lastError, true);
|
|
1164
|
+
return snap.busy;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// ===== WebSocket =====
|
|
1168
|
+
function ensureWS(key) {
|
|
1169
|
+
if (ws && wsKey === key && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
closeWS();
|
|
1173
|
+
wsKey = key;
|
|
1174
|
+
try {
|
|
1175
|
+
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
1176
|
+
ws = new WebSocket(`${proto}://${location.host}/ws`);
|
|
1177
|
+
} catch (e) {
|
|
1178
|
+
console.warn('[chat] WS connect failed:', e?.message);
|
|
1179
|
+
ws = null;
|
|
1180
|
+
// Fallback на polling.
|
|
1181
|
+
startPolling(key);
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
ws.onopen = () => {
|
|
1185
|
+
wsConnectedAt = Date.now();
|
|
1186
|
+
try { ws.send(JSON.stringify({ type: 'subscribe', channel: 'chat:' + key })); } catch {}
|
|
1187
|
+
// Сразу подтягиваем актуальное состояние (могли пропустить events).
|
|
1188
|
+
_refreshAndExecute(key).catch(() => {});
|
|
1189
|
+
stopPolling(); // WS живёт — polling не нужен
|
|
1190
|
+
};
|
|
1191
|
+
ws.onmessage = (e) => {
|
|
1192
|
+
let m;
|
|
1193
|
+
try { m = JSON.parse(e.data); } catch { return; }
|
|
1194
|
+
if (m?.type === 'event' && m.channel === 'chat:' + key) {
|
|
1195
|
+
_refreshAndExecute(key).catch(() => {});
|
|
1196
|
+
}
|
|
1197
|
+
};
|
|
1198
|
+
ws.onerror = () => { /* close handler сделает остальное */ };
|
|
1199
|
+
ws.onclose = () => {
|
|
1200
|
+
ws = null;
|
|
1201
|
+
// Если ws закрылся быстро (<3s от open) — не reconnect'имся бесконечно
|
|
1202
|
+
// в фоне для этой сессии, fallback на polling.
|
|
1203
|
+
if (wsKey === key) {
|
|
1204
|
+
startPolling(key); // безопасно, если уже polling — startPolling no-op
|
|
1205
|
+
// И через 5s попробуем WS снова.
|
|
1206
|
+
clearTimeout(wsReconnectTimer);
|
|
1207
|
+
wsReconnectTimer = setTimeout(() => ensureWS(key), 5000);
|
|
1208
|
+
}
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
function closeWS() {
|
|
1212
|
+
clearTimeout(wsReconnectTimer); wsReconnectTimer = null;
|
|
1213
|
+
if (ws) { try { ws.close(); } catch {} ws = null; }
|
|
1214
|
+
wsKey = null;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
// ===== Polling (fallback) =====
|
|
1059
1218
|
async function startPolling(key) {
|
|
1060
1219
|
if (pollKey === key && pollTimer) return;
|
|
1061
1220
|
stopPolling();
|
|
1062
1221
|
pollKey = key;
|
|
1063
1222
|
const tick = async () => {
|
|
1064
1223
|
if (pollKey !== key) return;
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
const snap = await r.json();
|
|
1069
|
-
// Применяем снимок только если сессия всё ещё активна на клиенте.
|
|
1070
|
-
if (sessionKey() === key) applyServerState(snap);
|
|
1071
|
-
// Если есть pending-tools — исполняем и шлём results (продолжаем
|
|
1072
|
-
// poll сразу, не ждём next tick).
|
|
1073
|
-
if (snap.pendingToolCalls && snap.pendingToolCalls.length) {
|
|
1074
|
-
const results = [];
|
|
1075
|
-
for (const tc of snap.pendingToolCalls) {
|
|
1076
|
-
if (tc._parseError) {
|
|
1077
|
-
results.push({ id: tc.id, name: 'parse_error', ok: false, error: tc._parseError });
|
|
1078
|
-
continue;
|
|
1079
|
-
}
|
|
1080
|
-
try {
|
|
1081
|
-
const r2 = await runTool(tc.name, tc.args);
|
|
1082
|
-
results.push({ id: tc.id, name: tc.name, ok: true, result: r2 });
|
|
1083
|
-
} catch (e) {
|
|
1084
|
-
results.push({ id: tc.id, name: tc.name, ok: false, error: e?.message || String(e) });
|
|
1085
|
-
}
|
|
1086
|
-
}
|
|
1087
|
-
await fetch('/api/chat/tool-results', {
|
|
1088
|
-
method: 'POST',
|
|
1089
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1090
|
-
body: JSON.stringify({ key, results }),
|
|
1091
|
-
}).catch(() => {});
|
|
1092
|
-
// Сразу продолжаем polling без задержки (loop уже резюмирован сервером).
|
|
1093
|
-
if (pollKey === key) pollTimer = setTimeout(tick, 50);
|
|
1094
|
-
return;
|
|
1095
|
-
}
|
|
1096
|
-
if (snap.busy) {
|
|
1097
|
-
if (pollKey === key) pollTimer = setTimeout(tick, 800);
|
|
1098
|
-
} else {
|
|
1099
|
-
// Loop завершён — последняя статус-строка может быть info'й.
|
|
1100
|
-
if (snap.lastError) appendStatus('⚠ ' + snap.lastError, true);
|
|
1101
|
-
stopPolling();
|
|
1102
|
-
}
|
|
1103
|
-
} catch (e) {
|
|
1104
|
-
console.warn('[chat] poll failed:', e?.message);
|
|
1105
|
-
if (pollKey === key) pollTimer = setTimeout(tick, 2000); // backoff
|
|
1106
|
-
}
|
|
1224
|
+
const stillBusy = await _refreshAndExecute(key);
|
|
1225
|
+
if (!stillBusy) { stopPolling(); return; }
|
|
1226
|
+
if (pollKey === key) pollTimer = setTimeout(tick, 800);
|
|
1107
1227
|
};
|
|
1108
1228
|
tick();
|
|
1109
1229
|
}
|
|
@@ -1113,6 +1233,12 @@
|
|
|
1113
1233
|
pollKey = null;
|
|
1114
1234
|
}
|
|
1115
1235
|
|
|
1236
|
+
// Главная entry-point: попытаться WS, если не получится — polling.
|
|
1237
|
+
function startTransport(key) {
|
|
1238
|
+
if (typeof WebSocket !== 'undefined') ensureWS(key);
|
|
1239
|
+
else startPolling(key);
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1116
1242
|
// Сервер прислал snapshot — применяем history к UI.
|
|
1117
1243
|
function applyServerState(snap) {
|
|
1118
1244
|
if (!snap) return;
|
|
@@ -1157,7 +1283,7 @@
|
|
|
1157
1283
|
}
|
|
1158
1284
|
const snap = await r.json();
|
|
1159
1285
|
applyServerState(snap);
|
|
1160
|
-
|
|
1286
|
+
startTransport(key);
|
|
1161
1287
|
} catch (e) {
|
|
1162
1288
|
appendStatus('⚠ ' + (e?.message || e), true);
|
|
1163
1289
|
}
|
|
@@ -1423,12 +1549,15 @@
|
|
|
1423
1549
|
history = []; snapshots = [];
|
|
1424
1550
|
renderHistory(); renderSnapshotBar();
|
|
1425
1551
|
},
|
|
1426
|
-
// Reset-on-close: останавливаем polling
|
|
1427
|
-
//
|
|
1552
|
+
// Reset-on-close: НЕ останавливаем polling — серверный loop может ещё
|
|
1553
|
+
// ждать tool-results. Если бросим polling, loop зависает (tools никто
|
|
1554
|
+
// не исполнит). Клиентский UI чистим, но poll продолжается в фоне на
|
|
1555
|
+
// сессию ПОСЛЕДНЕГО открытого проекта (она же станет «фоновым chat'ом»).
|
|
1556
|
+
// При открытии другого проекта — startPolling переключит sessionKey.
|
|
1428
1557
|
resetInMemory: async () => {
|
|
1429
|
-
stopPolling();
|
|
1430
1558
|
history = []; snapshots = [];
|
|
1431
1559
|
renderHistory(); renderSnapshotBar();
|
|
1560
|
+
// Polling оставляем активным — это и есть «бг-чат».
|
|
1432
1561
|
},
|
|
1433
1562
|
// board.js зовёт после openFilm чтобы подгрузить chat-историю проекта.
|
|
1434
1563
|
loadFromCurrentProject: () => loadHistoryFromCurrentProject(),
|
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/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'}`);
|