kingkont 0.15.1 → 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.
@@ -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/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.15.1",
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
@@ -636,7 +636,9 @@
636
636
  if (r.ok) {
637
637
  const snap = await r.json();
638
638
  applyServerState(snap);
639
- if (snap.busy) startPolling(key);
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); }
@@ -1109,60 +1111,119 @@
1109
1111
  return null;
1110
1112
  }
1111
1113
 
1112
- // Длинный pollin цикл. Стартует после send(). Опрашивает GET /api/chat/state
1113
- // каждые 800ms, пока сервер busy=true. Если сервер вернул pendingToolCalls
1114
- // исполняет их локально (тут есть FSAH-доступ) и шлёт results через
1115
- // POST /api/chat/tool-results, после чего сервер продолжает loop.
1114
+ // === Транспорт чата с сервером ===
1115
+ // Приоритет: WebSocket (push с сервера, мгновенно). Fallback: polling
1116
+ // (каждые 800ms) когда WS отвалился или вообще не доступен. polling
1117
+ // используется и как полноценный транспорт он корректно работает.
1118
+ //
1119
+ // На каждый «появилось изменение» (WS event ИЛИ poll-tick) — просто
1120
+ // перетягиваем GET /api/chat/state и применяем (см. _refreshAndExecute).
1116
1121
  let pollTimer = null;
1117
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) =====
1118
1218
  async function startPolling(key) {
1119
1219
  if (pollKey === key && pollTimer) return;
1120
1220
  stopPolling();
1121
1221
  pollKey = key;
1122
1222
  const tick = async () => {
1123
1223
  if (pollKey !== key) return;
1124
- try {
1125
- const r = await fetch('/api/chat/state?key=' + encodeURIComponent(key));
1126
- if (!r.ok) throw new Error('HTTP ' + r.status);
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
- }
1224
+ const stillBusy = await _refreshAndExecute(key);
1225
+ if (!stillBusy) { stopPolling(); return; }
1226
+ if (pollKey === key) pollTimer = setTimeout(tick, 800);
1166
1227
  };
1167
1228
  tick();
1168
1229
  }
@@ -1172,6 +1233,12 @@
1172
1233
  pollKey = null;
1173
1234
  }
1174
1235
 
1236
+ // Главная entry-point: попытаться WS, если не получится — polling.
1237
+ function startTransport(key) {
1238
+ if (typeof WebSocket !== 'undefined') ensureWS(key);
1239
+ else startPolling(key);
1240
+ }
1241
+
1175
1242
  // Сервер прислал snapshot — применяем history к UI.
1176
1243
  function applyServerState(snap) {
1177
1244
  if (!snap) return;
@@ -1216,7 +1283,7 @@
1216
1283
  }
1217
1284
  const snap = await r.json();
1218
1285
  applyServerState(snap);
1219
- startPolling(key);
1286
+ startTransport(key);
1220
1287
  } catch (e) {
1221
1288
  appendStatus('⚠ ' + (e?.message || e), true);
1222
1289
  }
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
- const job = list.find(j => j.nodeId === nodeId);
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
- return job;
107
- } catch { return null; }
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'}`);