kingkont 0.16.2 → 0.17.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/jobsHub.js CHANGED
@@ -15,22 +15,43 @@ const wsHub = require('./wsHub');
15
15
 
16
16
  const jobsByProject = new Map(); // projectKey → Map<jobId, jobInfo>
17
17
 
18
- function _emit(projectKey) {
18
+ // Серверные polling-таймеры. Для каждого jobId с known taskId+kind
19
+ // сервер сам опрашивает провайдера каждые POLL_INTERVAL_MS. На completion
20
+ // — WS-event с готовым URL+cost. Renderer (если жив) скачивает + пишет
21
+ // файл; если был закрыт — при возврате найдёт по scanAllBoardsForPendingJobs.
22
+ const pollers = new Map(); // jobId → { intervalId, taskId, kind, projectKey, lastPollAt }
23
+ const POLL_INTERVAL_MS = 4000;
24
+
25
+ let _settingsGetter = () => ({});
26
+ function setSettingsGetter(fn) { if (typeof fn === 'function') _settingsGetter = fn; }
27
+
28
+ function _emit(projectKey, extra) {
19
29
  const list = listForProject(projectKey);
20
- wsHub.publish('jobs:' + projectKey, { kind: 'changed', list });
21
- wsHub.publish('jobs:all', { kind: 'changed', projectKey, list });
30
+ const evt = { kind: 'changed', list, ...(extra || {}) };
31
+ wsHub.publish('jobs:' + projectKey, evt);
32
+ wsHub.publish('jobs:all', { ...evt, projectKey });
22
33
  }
23
34
 
24
- function start({ projectKey, jobId, kind, name, type }) {
35
+ function start({ projectKey, jobId, kind, name, type, taskId }) {
25
36
  if (!projectKey || !jobId) return;
26
37
  let m = jobsByProject.get(projectKey);
27
38
  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() });
39
+ m.set(jobId, {
40
+ kind: kind || null, name: name || null, type: type || null,
41
+ taskId: taskId || null, startedAt: Date.now(),
42
+ });
29
43
  _emit(projectKey);
44
+ // Если есть taskId + kind — стартуем серверный poller. Renderer тоже
45
+ // полит параллельно (старый flow), но если он закрыт — серверный
46
+ // продолжает и шлёт WS-event на completion.
47
+ if (taskId && (kind === 'image' || kind === 'video')) {
48
+ _startPoller({ projectKey, jobId, taskId, kind });
49
+ }
30
50
  }
31
51
 
32
52
  function end({ projectKey, jobId }) {
33
53
  if (!projectKey || !jobId) return null;
54
+ _stopPoller(jobId);
34
55
  const m = jobsByProject.get(projectKey);
35
56
  if (!m) return null;
36
57
  const job = m.get(jobId);
@@ -40,6 +61,63 @@ function end({ projectKey, jobId }) {
40
61
  return job || null;
41
62
  }
42
63
 
64
+ function _startPoller({ projectKey, jobId, taskId, kind }) {
65
+ if (pollers.has(jobId)) return;
66
+ const providers = require('./providers');
67
+ const tick = async () => {
68
+ const entry = pollers.get(jobId);
69
+ if (!entry) return;
70
+ entry.lastPollAt = Date.now();
71
+ let r;
72
+ try { r = await providers.pollGeneration(taskId, _settingsGetter()); }
73
+ catch (e) {
74
+ console.warn('[jobsHub] poll failed for', jobId, e?.message);
75
+ return; // следующий tick попробует снова
76
+ }
77
+ if (r.status === 'done') {
78
+ _stopPoller(jobId);
79
+ // Push WS event с completion-info. Renderer (если жив) скачает
80
+ // через /api/proxy и запишет в FSAH. Если renderer закрыт — event
81
+ // пропадёт; при возврате scanAllBoardsForPendingJobs восстановит
82
+ // (там status='generating' + taskId в scene.json).
83
+ wsHub.publish('jobs:' + projectKey, {
84
+ kind: 'done', jobId, taskId, url: r.url, cost: r.cost ?? null, kindOf: kind, provider: r.provider,
85
+ });
86
+ wsHub.publish('jobs:all', {
87
+ kind: 'done', projectKey, jobId, taskId, url: r.url, cost: r.cost ?? null, kindOf: kind, provider: r.provider,
88
+ });
89
+ // Job-end ОТЛОЖЕН — ждём ack от клиента (он скачает файл).
90
+ // Но если за 60s никто не пришёл — auto-end (чтобы счётчики не зависали).
91
+ setTimeout(() => {
92
+ if (!pollers.has(jobId)) end({ projectKey, jobId });
93
+ }, 60000);
94
+ } else if (r.status === 'error') {
95
+ _stopPoller(jobId);
96
+ wsHub.publish('jobs:' + projectKey, {
97
+ kind: 'failed', jobId, taskId, error: r.error, kindOf: kind,
98
+ });
99
+ wsHub.publish('jobs:all', {
100
+ kind: 'failed', projectKey, jobId, taskId, error: r.error, kindOf: kind,
101
+ });
102
+ // На ошибке тоже auto-end через 60s.
103
+ setTimeout(() => {
104
+ if (!pollers.has(jobId)) end({ projectKey, jobId });
105
+ }, 60000);
106
+ }
107
+ // pending → ждём следующий tick (interval сам зовёт)
108
+ };
109
+ // Первый poll — через 2s (даём провайдеру время принять задачу).
110
+ setTimeout(tick, 2000);
111
+ const intervalId = setInterval(tick, POLL_INTERVAL_MS);
112
+ pollers.set(jobId, { intervalId, taskId, kind, projectKey, lastPollAt: 0 });
113
+ }
114
+ function _stopPoller(jobId) {
115
+ const entry = pollers.get(jobId);
116
+ if (!entry) return;
117
+ clearInterval(entry.intervalId);
118
+ pollers.delete(jobId);
119
+ }
120
+
43
121
  function listForProject(projectKey) {
44
122
  const m = jobsByProject.get(projectKey);
45
123
  if (!m) return [];
@@ -54,4 +132,4 @@ function listAll() {
54
132
  return out;
55
133
  }
56
134
 
57
- module.exports = { start, end, listForProject, listAll };
135
+ module.exports = { start, end, listForProject, listAll, setSettingsGetter };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kingkont",
3
- "version": "0.16.2",
3
+ "version": "0.17.0",
4
4
  "description": "KingKont · Chatium — нод-редактор сцен с AI-генерацией (картинки/видео/голос/SFX/музыка/текст)",
5
5
  "main": "main.js",
6
6
  "bin": {
package/renderer/board.js CHANGED
@@ -632,6 +632,91 @@ async function refreshBgServerCache() {
632
632
  } catch {}
633
633
  }
634
634
 
635
+ // === WS subscription для активного проекта: server-side polling ===
636
+ // Сервер сам опрашивает провайдера (см. lib/jobsHub.js _startPoller).
637
+ // На completion пушит 'done' event с url+cost. Renderer скачивает и
638
+ // записывает файл — это и есть "пробуждение" даже после Cmd+R.
639
+ let _projectJobsWS = null;
640
+ function _subscribeProjectJobsWS() {
641
+ const pk = state.cloudProjectId ? 'cloud:' + state.cloudProjectId
642
+ : state.filmHandle?.name ? 'folder:' + state.filmHandle.name : null;
643
+ if (!pk) return;
644
+ // Reuse тот же WS что использует welcome для jobs:all — но теперь
645
+ // подписка ещё и на jobs:<pk>.
646
+ if (!_projectJobsWS || _projectJobsWS.readyState !== WebSocket.OPEN) {
647
+ try {
648
+ const proto = location.protocol === 'https:' ? 'wss' : 'ws';
649
+ _projectJobsWS = new WebSocket(`${proto}://${location.host}/ws`);
650
+ } catch { return; }
651
+ _projectJobsWS.onopen = () => {
652
+ try { _projectJobsWS.send(JSON.stringify({ type: 'subscribe', channel: 'jobs:' + pk })); } catch {}
653
+ };
654
+ _projectJobsWS.onmessage = (e) => {
655
+ let m; try { m = JSON.parse(e.data); } catch { return; }
656
+ if (m?.type === 'event') _handleProjectJobsEvent(m.event);
657
+ };
658
+ _projectJobsWS.onclose = () => { _projectJobsWS = null; setTimeout(_subscribeProjectJobsWS, 5000); };
659
+ _projectJobsWS.onerror = () => {};
660
+ } else {
661
+ try { _projectJobsWS.send(JSON.stringify({ type: 'subscribe', channel: 'jobs:' + pk })); } catch {}
662
+ }
663
+ }
664
+
665
+ // Обработчик WS-event'ов от server-side jobsHub. На 'done' — скачиваем
666
+ // результат через /api/proxy и пишем файл (это работает только если
667
+ // FSAH-handle ещё валиден — т.е. project открыт).
668
+ async function _handleProjectJobsEvent(evt) {
669
+ if (!evt) return;
670
+ if (evt.kind === 'done') {
671
+ if (state.jobs.has(evt.jobId)) {
672
+ // Local pollJob уже видит этот же taskId и сам обработает completion.
673
+ // Server-side push — это backup. Не дублируем работу.
674
+ return;
675
+ }
676
+ // Local job не активен — значит pollJob не запущен (после Cmd+R).
677
+ // Скачиваем + пишем + помечаем done. Сначала найдём ноду.
678
+ if (!state.currentBoard) { vlog('info', `WS done arrived but no currentBoard, jobId=${evt.jobId}`); return; }
679
+ const node = state.currentBoard.metadata.nodes.find(n => n.id === evt.jobId);
680
+ if (!node) { vlog('info', `WS done: nodeId not found in current board ${evt.jobId}`); return; }
681
+ try {
682
+ const r = await fetch('/api/proxy?url=' + encodeURIComponent(evt.url));
683
+ if (!r.ok) throw new Error(`download HTTP ${r.status}`);
684
+ const blob = await r.blob();
685
+ const ext = evt.kindOf === 'image' ? 'jpg' : 'mp4';
686
+ const sub = evt.kindOf === 'image' ? 'frames' : 'clips';
687
+ const dir = await getOrCreateBoardSubdir(state.currentBoard.handle, sub);
688
+ const baseName = await uniqueName(dir, `gen_${Date.now()}.${ext}`);
689
+ await writeFile(dir, baseName, blob);
690
+ const relPath = `${sub}/${baseName}`;
691
+ await mutateNode(state.currentBoard.key, state.currentBoard.handle, evt.jobId, n => {
692
+ n.status = undefined; n.error = undefined; n.file = relPath;
693
+ if (typeof evt.cost === 'number') {
694
+ n.generated = { ...(n.generated || {}), creditsCharged: evt.cost };
695
+ }
696
+ });
697
+ if (typeof showToast === 'function') {
698
+ showToast(`✓ ${evt.kindOf} «${node.name || node.id.slice(0,6)}» готов (server-side)`, 'ok');
699
+ }
700
+ if (typeof systemNotify === 'function' && document.hidden) {
701
+ systemNotify('KingKont', `${evt.kindOf} «${node.name || ''}» готов`).catch(() => {});
702
+ }
703
+ // Завершаем job на сервере (free up the slot).
704
+ const pk = state.cloudProjectId ? 'cloud:' + state.cloudProjectId
705
+ : state.filmHandle?.name ? 'folder:' + state.filmHandle.name : null;
706
+ if (pk) bgJobEnd(evt.jobId, pk);
707
+ } catch (e) {
708
+ console.warn('[ws-done] download/write failed:', e?.message);
709
+ }
710
+ } else if (evt.kind === 'failed') {
711
+ if (typeof showToast === 'function') {
712
+ showToast(`⚠ Генерация провалилась: ${evt.error?.slice?.(0,80) || 'unknown'}`, 'error');
713
+ }
714
+ const pk = state.cloudProjectId ? 'cloud:' + state.cloudProjectId
715
+ : state.filmHandle?.name ? 'folder:' + state.filmHandle.name : null;
716
+ if (pk) bgJobEnd(evt.jobId, pk);
717
+ }
718
+ }
719
+
635
720
  // Live-update welcome когда bg-jobs меняются (start/end). Перерисовываем
636
721
  // только если открыт welcome (нет проекта), иначе UI не виден.
637
722
  window.addEventListener('bgjobs:changed', () => {
@@ -1142,6 +1227,10 @@ async function openFilm(handle) {
1142
1227
 
1143
1228
  // Сканируем все доски на незавершённые задачи генерации (после перезагрузки)
1144
1229
  scanAllBoardsForPendingJobs(handle).catch(e => console.warn('scan failed', e));
1230
+ // Subscribe на server-side WS events для этого проекта — сервер сам
1231
+ // полит провайдера и пушит 'done'/'failed' даже если у нас нет local
1232
+ // pollJob (например после Cmd+R). См. _handleProjectJobsEvent.
1233
+ _subscribeProjectJobsWS();
1145
1234
  // Подгружаем настройки всех локаций
1146
1235
  loadAllLocationsInfo().catch(() => {});
1147
1236
  // Подгружаем настройки всех персонажей (для add-menu и т.д.)
@@ -1851,6 +1851,10 @@ async function startGenerationJob(node, kind, prompt, mediaRefs, boardHandle, bK
1851
1851
  }
1852
1852
  job.taskId = data.taskId;
1853
1853
  logJob(node.id, `taskId=${data.taskId}`);
1854
+ // Сообщаем server-side poller'у taskId — теперь сервер тоже опрашивает
1855
+ // провайдера. Если renderer закроется/Cmd+R — server продолжит и
1856
+ // пушнёт WS-event на completion.
1857
+ if (typeof bgJobUpdateTaskId === 'function') bgJobUpdateTaskId(node.id, data.taskId, job.projectKey);
1854
1858
  await mutateNode(bKey, boardHandle, node.id, n => {
1855
1859
  n.generated = { ...(n.generated || {}), taskId: data.taskId, state: 'queued' };
1856
1860
  });
package/renderer/state.js CHANGED
@@ -82,10 +82,9 @@ function _projectKeyForCurrent() {
82
82
  return null;
83
83
  }
84
84
  function bgJobStart(info) {
85
- // info: {nodeId, kind, name?, projectKey?}
86
- // ВАЖНО: projectKey обязателен иначе job висит на сервере вечно
87
- // (если юзер закроет проект до bgJobEnd, _projectKeyForCurrent вернёт
88
- // null, и end-call станет no-op).
85
+ // info: {nodeId, kind, name?, projectKey?, taskId?}
86
+ // taskId важенserver-side poller использует его чтобы опрашивать
87
+ // провайдера сам (Chatium/KIE) даже когда renderer закрыт.
89
88
  const pk = info.projectKey || _projectKeyForCurrent();
90
89
  if (!pk) { console.warn('bgJobStart: no projectKey, skipping'); return; }
91
90
  try {
@@ -98,9 +97,26 @@ function bgJobStart(info) {
98
97
  } catch {}
99
98
  fetch('/api/jobs/track', {
100
99
  method: 'POST', headers: { 'Content-Type': 'application/json' },
101
- body: JSON.stringify({ action: 'start', projectKey: pk, jobId: info.nodeId, kind: info.kind, name: info.name }),
100
+ body: JSON.stringify({
101
+ action: 'start',
102
+ projectKey: pk,
103
+ jobId: info.nodeId,
104
+ kind: info.kind,
105
+ name: info.name,
106
+ taskId: info.taskId || null, // ← server-side poller цепляется по taskId
107
+ }),
102
108
  }).catch(() => {});
103
109
  }
110
+ // Update taskId for an existing job (server didn't have it on initial track).
111
+ function bgJobUpdateTaskId(nodeId, taskId, projectKey) {
112
+ const pk = projectKey || _projectKeyForCurrent();
113
+ if (!pk || !taskId) return;
114
+ fetch('/api/jobs/track', {
115
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
116
+ body: JSON.stringify({ action: 'start', projectKey: pk, jobId: nodeId, taskId }),
117
+ }).catch(() => {});
118
+ }
119
+ window.bgJobUpdateTaskId = bgJobUpdateTaskId;
104
120
  function bgJobEnd(nodeId, projectKey) {
105
121
  // projectKey ОБЯЗАТЕЛЕН — без него юзер на welcome теряет уведомление
106
122
  // и счётчик зависает. Раньше фоллбэк на _projectKeyForCurrent давал
package/server.js CHANGED
@@ -355,9 +355,9 @@ async function handleChatClear(req, res) {
355
355
  async function handleJobsTrack(req, res) {
356
356
  try {
357
357
  const body = await readJson(req);
358
- const { action, projectKey, jobId, kind, name, type } = body || {};
358
+ const { action, projectKey, jobId, kind, name, type, taskId } = body || {};
359
359
  if (!projectKey || !jobId) return send(res, 400, { error: 'projectKey + jobId обязательны' });
360
- if (action === 'start') jobsHub.start({ projectKey, jobId, kind, name, type });
360
+ if (action === 'start') jobsHub.start({ projectKey, jobId, kind, name, type, taskId });
361
361
  else if (action === 'end') jobsHub.end({ projectKey, jobId });
362
362
  else return send(res, 400, { error: 'action: start|end' });
363
363
  send(res, 200, { ok: true });
@@ -455,6 +455,9 @@ function start(port = PORT, opts = {}) {
455
455
  // Init chatSession с userDataDir для персистентности историй.
456
456
  // Без opts.userDataDir чат живёт только in-memory (CLI-режим).
457
457
  chatSession.init({ userDataDir: opts.userDataDir || null });
458
+ // jobsHub нужен settingsGetter чтобы поллить провайдеров (Chatium token,
459
+ // KIE_API_KEY и т.п.).
460
+ jobsHub.setSettingsGetter(getSettings);
458
461
  return new Promise((resolveOk, reject) => {
459
462
  server.once('error', reject);
460
463
  server.listen(port, () => {