kingkont 0.16.1 → 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 +84 -6
- package/package.json +1 -1
- package/renderer/board.js +142 -14
- package/renderer/generate.js +16 -5
- package/renderer/state.js +57 -18
- package/renderer/styles.css +4 -2
- package/server.js +5 -2
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
|
-
|
|
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
|
-
|
|
21
|
-
wsHub.publish('jobs:
|
|
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, {
|
|
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
package/renderer/board.js
CHANGED
|
@@ -101,14 +101,17 @@ window.addEventListener('DOMContentLoaded', async () => {
|
|
|
101
101
|
setInterval(() => { refreshBalance().catch(() => {}); }, 60_000);
|
|
102
102
|
|
|
103
103
|
// Тихий autoload: первый recent с granted-permission (если есть).
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
|
|
104
|
+
// skipAutoload работает по двум флагам:
|
|
105
|
+
// - welcomeOnNextStart (одноразовый, ставится в closeProject)
|
|
106
|
+
// - lastLocation === 'welcome' (постоянный, ставится при показе welcome)
|
|
107
|
+
// Без второго: после Cmd+R на welcome flag сбрасывался, и второй Cmd+R
|
|
108
|
+
// открывал последний проект. Теперь lastLocation сохраняется пока юзер
|
|
109
|
+
// не открыл проект.
|
|
110
|
+
const skipAutoload = localStorage.getItem('welcomeOnNextStart') === '1'
|
|
111
|
+
|| localStorage.getItem('lastLocation') === 'welcome';
|
|
109
112
|
if (skipAutoload) {
|
|
110
113
|
localStorage.removeItem('welcomeOnNextStart');
|
|
111
|
-
vlog('info', 'restore: skipped (
|
|
114
|
+
vlog('info', 'restore: skipped (welcome state persisted)');
|
|
112
115
|
} else {
|
|
113
116
|
try {
|
|
114
117
|
const recents = await getRecents();
|
|
@@ -458,8 +461,21 @@ async function renderWelcomeRecents() {
|
|
|
458
461
|
if (!grid || !wrap) return;
|
|
459
462
|
grid.innerHTML = '';
|
|
460
463
|
wrap.style.display = '';
|
|
464
|
+
// Запоминаем что юзер на welcome — Cmd+R должен оставить здесь.
|
|
465
|
+
try { localStorage.setItem('lastLocation', 'welcome'); } catch {}
|
|
461
466
|
// Identity-pill в правом верхнем — обновляем при каждом показе welcome.
|
|
462
467
|
renderWelcomeIdentity().catch(() => {});
|
|
468
|
+
// Server-side bg-jobs cache: обновим в фоне (если изменится — снова
|
|
469
|
+
// re-render через bgjobs:changed event). Без infinite loop: проверяем
|
|
470
|
+
// что состояние реально поменялось.
|
|
471
|
+
(async () => {
|
|
472
|
+
const before = JSON.stringify(_bgServerCache);
|
|
473
|
+
await refreshBgServerCache();
|
|
474
|
+
const after = JSON.stringify(_bgServerCache);
|
|
475
|
+
if (before !== after && !state.filmHandle) {
|
|
476
|
+
window.dispatchEvent(new CustomEvent('bgjobs:changed'));
|
|
477
|
+
}
|
|
478
|
+
})().catch(() => {});
|
|
463
479
|
const list = await getRecents();
|
|
464
480
|
|
|
465
481
|
// Первой картой — «Открыть проект». Кликается → дёргает скрытый
|
|
@@ -603,10 +619,110 @@ function _ru_jobs(n) {
|
|
|
603
619
|
if (n10 >= 2 && n10 <= 4) return 'генерации';
|
|
604
620
|
return 'генераций';
|
|
605
621
|
}
|
|
622
|
+
// Server-side cache всех bg-jobs. Source of truth — server jobsHub
|
|
623
|
+
// (через GET /api/jobs или WS push). localStorage остаётся только как
|
|
624
|
+
// fallback и для оптимистичных обновлений на самом клиенте.
|
|
625
|
+
let _bgServerCache = {}; // projectKey → [{jobId, kind, name, startedAt}]
|
|
626
|
+
async function refreshBgServerCache() {
|
|
627
|
+
try {
|
|
628
|
+
const r = await fetch('/api/jobs');
|
|
629
|
+
if (!r.ok) return;
|
|
630
|
+
const d = await r.json();
|
|
631
|
+
_bgServerCache = d?.all || {};
|
|
632
|
+
} catch {}
|
|
633
|
+
}
|
|
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
|
+
|
|
606
720
|
// Live-update welcome когда bg-jobs меняются (start/end). Перерисовываем
|
|
607
721
|
// только если открыт welcome (нет проекта), иначе UI не виден.
|
|
608
722
|
window.addEventListener('bgjobs:changed', () => {
|
|
609
|
-
if (!state.filmHandle)
|
|
723
|
+
if (!state.filmHandle) {
|
|
724
|
+
refreshBgServerCache().then(() => renderWelcomeRecents().catch(() => {}));
|
|
725
|
+
}
|
|
610
726
|
});
|
|
611
727
|
|
|
612
728
|
// WebSocket подписка на jobs:all — server push'ит при изменениях ЛЮБЫХ
|
|
@@ -625,7 +741,11 @@ function _connectWelcomeJobsWS() {
|
|
|
625
741
|
_welcomeWS.onmessage = (e) => {
|
|
626
742
|
let m; try { m = JSON.parse(e.data); } catch { return; }
|
|
627
743
|
if (m?.type === 'event' && m.channel === 'jobs:all') {
|
|
628
|
-
|
|
744
|
+
// Refresh cache from server first (m.event.list содержит свежий
|
|
745
|
+
// список для конкретного projectKey, но проще сделать GET /api/jobs).
|
|
746
|
+
refreshBgServerCache().then(() => {
|
|
747
|
+
if (!state.filmHandle) renderWelcomeRecents().catch(() => {});
|
|
748
|
+
});
|
|
629
749
|
}
|
|
630
750
|
};
|
|
631
751
|
_welcomeWS.onclose = () => { _welcomeWS = null; setTimeout(_connectWelcomeJobsWS, 5000); };
|
|
@@ -647,9 +767,8 @@ function makeRecentWelcomeCard(r) {
|
|
|
647
767
|
img.onload = () => setTimeout(() => URL.revokeObjectURL(img.src), 60_000);
|
|
648
768
|
thumb.appendChild(img);
|
|
649
769
|
} else thumb.textContent = '🎬';
|
|
650
|
-
// Бейдж «N в фоне»
|
|
651
|
-
|
|
652
|
-
const bgList = (typeof bgJobsAll === 'function' ? bgJobsAll() : {})['folder:' + r.name];
|
|
770
|
+
// Бейдж «N в фоне» — server-side jobsHub source-of-truth (см. _bgServerCache).
|
|
771
|
+
const bgList = _bgServerCache['folder:' + r.name];
|
|
653
772
|
if (bgList?.length) thumb.appendChild(_makeBgBadge(bgList.length));
|
|
654
773
|
const meta = document.createElement('div');
|
|
655
774
|
meta.className = 'welcome-card-meta';
|
|
@@ -804,8 +923,8 @@ function makeCloudWelcomeCard(p) {
|
|
|
804
923
|
cloudBadge.textContent = '☁';
|
|
805
924
|
cloudBadge.title = 'Облачный проект';
|
|
806
925
|
thumb.appendChild(cloudBadge);
|
|
807
|
-
// Бейдж «N в фоне»
|
|
808
|
-
const bgList =
|
|
926
|
+
// Бейдж «N в фоне» — server-side jobsHub source-of-truth.
|
|
927
|
+
const bgList = _bgServerCache['cloud:' + p.id];
|
|
809
928
|
if (bgList?.length) thumb.appendChild(_makeBgBadge(bgList.length));
|
|
810
929
|
|
|
811
930
|
const meta = document.createElement('div');
|
|
@@ -1060,6 +1179,8 @@ async function openFilm(handle) {
|
|
|
1060
1179
|
state.filmHandle = handle;
|
|
1061
1180
|
state.currentBoard = null;
|
|
1062
1181
|
document.body.classList.remove('no-project');
|
|
1182
|
+
// Запоминаем что юзер сейчас в проекте → Cmd+R откроет его снова.
|
|
1183
|
+
try { localStorage.setItem('lastLocation', 'project'); } catch {}
|
|
1063
1184
|
window.appProject?.notifyState(true);
|
|
1064
1185
|
ensureClaudeMd(handle).catch(() => {});
|
|
1065
1186
|
// Подзаголовок шапки = имя открытого проекта (вместо «Видео-редактор»).
|
|
@@ -1106,6 +1227,10 @@ async function openFilm(handle) {
|
|
|
1106
1227
|
|
|
1107
1228
|
// Сканируем все доски на незавершённые задачи генерации (после перезагрузки)
|
|
1108
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();
|
|
1109
1234
|
// Подгружаем настройки всех локаций
|
|
1110
1235
|
loadAllLocationsInfo().catch(() => {});
|
|
1111
1236
|
// Подгружаем настройки всех персонажей (для add-menu и т.д.)
|
|
@@ -1133,7 +1258,10 @@ async function openFilm(handle) {
|
|
|
1133
1258
|
async function closeProject() {
|
|
1134
1259
|
// Помечаем что юзер вышел явно — на следующем старте autoload пропускается.
|
|
1135
1260
|
// Cmd+R после close = welcome, а не реоткрытие.
|
|
1136
|
-
try {
|
|
1261
|
+
try {
|
|
1262
|
+
localStorage.setItem('welcomeOnNextStart', '1');
|
|
1263
|
+
localStorage.setItem('lastLocation', 'welcome');
|
|
1264
|
+
} catch {}
|
|
1137
1265
|
// Чат привязан к одному проекту — flush pending-persist В ТЕКУЩИЙ
|
|
1138
1266
|
// filmHandle, потом сбрасываем in-memory. Без flush'а debounced-write
|
|
1139
1267
|
// мог бы попасть в новый проект (race на 600ms окно).
|
package/renderer/generate.js
CHANGED
|
@@ -1725,11 +1725,15 @@ async function runTTSJob(node, text, boardHandle, bKey, voiceId) {
|
|
|
1725
1725
|
}
|
|
1726
1726
|
|
|
1727
1727
|
async function startGenerationJob(node, kind, prompt, mediaRefs, boardHandle, bKey, modelKey) {
|
|
1728
|
-
|
|
1728
|
+
// КЛЮЧЕВОЕ: captures projectKey НА МОМЕНТ старта — иначе если юзер
|
|
1729
|
+
// позже закроет проект, на completion мы не знаем чьё это «end».
|
|
1730
|
+
const projectKey = (state.cloudProjectId ? 'cloud:' + state.cloudProjectId
|
|
1731
|
+
: state.filmHandle?.name ? 'folder:' + state.filmHandle.name : null);
|
|
1732
|
+
const job = { boardKey: bKey, boardHandle, kind, taskId: null, nodeId: node.id, projectKey };
|
|
1729
1733
|
state.jobs.set(node.id, job);
|
|
1730
1734
|
updateJobsBadge();
|
|
1731
1735
|
// Track в global bg-jobs (для welcome-индикаторов и system-notif при завершении).
|
|
1732
|
-
if (typeof bgJobStart === 'function') bgJobStart({ nodeId: node.id, kind, name: node.name });
|
|
1736
|
+
if (typeof bgJobStart === 'function') bgJobStart({ nodeId: node.id, kind, name: node.name, projectKey });
|
|
1733
1737
|
logJob(node.id, `gen start kind=${kind} model=${modelKey || '—'} refs=${mediaRefs?.length || 0} prompt="${(prompt||'').slice(0,200)}"`);
|
|
1734
1738
|
// Подробный дамп всех refs со всеми полями
|
|
1735
1739
|
logJob(node.id, `refs dump: ${logSafe((mediaRefs || []).map(r => ({
|
|
@@ -1847,6 +1851,10 @@ async function startGenerationJob(node, kind, prompt, mediaRefs, boardHandle, bK
|
|
|
1847
1851
|
}
|
|
1848
1852
|
job.taskId = data.taskId;
|
|
1849
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);
|
|
1850
1858
|
await mutateNode(bKey, boardHandle, node.id, n => {
|
|
1851
1859
|
n.generated = { ...(n.generated || {}), taskId: data.taskId, state: 'queued' };
|
|
1852
1860
|
});
|
|
@@ -1856,9 +1864,10 @@ async function startGenerationJob(node, kind, prompt, mediaRefs, boardHandle, bK
|
|
|
1856
1864
|
await mutateNode(bKey, boardHandle, node.id, n => {
|
|
1857
1865
|
n.status = 'error'; n.error = e.message;
|
|
1858
1866
|
});
|
|
1867
|
+
const projectKeyOnError = state.jobs.get(node.id)?.projectKey;
|
|
1859
1868
|
state.jobs.delete(node.id);
|
|
1860
1869
|
updateJobsBadge();
|
|
1861
|
-
if (typeof bgJobEnd === 'function') bgJobEnd(node.id);
|
|
1870
|
+
if (typeof bgJobEnd === 'function') bgJobEnd(node.id, projectKeyOnError);
|
|
1862
1871
|
if (typeof showToast === 'function') showToast(`⚠ ${kind || 'gen'}: ${e.message?.slice(0, 80)}`, 'error');
|
|
1863
1872
|
if (typeof systemNotify === 'function' && document.hidden) {
|
|
1864
1873
|
systemNotify('KingKont: ошибка генерации', e.message?.slice(0, 100), { tag: 'gen-err-' + node.id }).catch(() => {});
|
|
@@ -1986,10 +1995,12 @@ async function pollJob(job, nodeId, bKey, boardHandle, kind) {
|
|
|
1986
1995
|
n.generated = { ...(n.generated || {}), creditsCharged: cost };
|
|
1987
1996
|
}
|
|
1988
1997
|
});
|
|
1998
|
+
const projectKeyAtCompletion = state.jobs.get(nodeId)?.projectKey;
|
|
1989
1999
|
state.jobs.delete(nodeId);
|
|
1990
2000
|
updateJobsBadge();
|
|
1991
|
-
// bgJob убираем (
|
|
1992
|
-
|
|
2001
|
+
// bgJob убираем — projectKey берём из job (captured at start), а не
|
|
2002
|
+
// _projectKeyForCurrent (юзер мог переключить проект, тогда было бы null).
|
|
2003
|
+
if (typeof bgJobEnd === 'function') bgJobEnd(nodeId, projectKeyAtCompletion);
|
|
1993
2004
|
if (typeof window.refreshBalance === 'function') window.refreshBalance();
|
|
1994
2005
|
// Toast — ВСЕГДА (юзер хочет видеть когда что-то сделалось).
|
|
1995
2006
|
// Стиль 'ok' для current-board, 'info' для другой доски.
|
package/renderer/state.js
CHANGED
|
@@ -82,25 +82,48 @@ function _projectKeyForCurrent() {
|
|
|
82
82
|
return null;
|
|
83
83
|
}
|
|
84
84
|
function bgJobStart(info) {
|
|
85
|
-
// info: {nodeId, kind, name?, projectKey?}
|
|
85
|
+
// info: {nodeId, kind, name?, projectKey?, taskId?}
|
|
86
|
+
// taskId важен — server-side poller использует его чтобы опрашивать
|
|
87
|
+
// провайдера сам (Chatium/KIE) даже когда renderer закрыт.
|
|
86
88
|
const pk = info.projectKey || _projectKeyForCurrent();
|
|
87
|
-
if (!pk) return;
|
|
89
|
+
if (!pk) { console.warn('bgJobStart: no projectKey, skipping'); return; }
|
|
88
90
|
try {
|
|
89
91
|
const list = JSON.parse(localStorage.getItem(bgJobsKey(pk)) || '[]');
|
|
90
|
-
if (list.some(j => j.nodeId === info.nodeId))
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
92
|
+
if (!list.some(j => j.nodeId === info.nodeId)) {
|
|
93
|
+
list.push({ nodeId: info.nodeId, kind: info.kind, name: info.name || null, startedAt: Date.now() });
|
|
94
|
+
localStorage.setItem(bgJobsKey(pk), JSON.stringify(list));
|
|
95
|
+
window.dispatchEvent(new CustomEvent('bgjobs:changed'));
|
|
96
|
+
}
|
|
94
97
|
} catch {}
|
|
95
|
-
// Также шлём на сервер — чтобы welcome (любой клиент) видел через WS push.
|
|
96
98
|
fetch('/api/jobs/track', {
|
|
97
99
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
98
|
-
body: JSON.stringify({
|
|
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
|
+
}),
|
|
99
108
|
}).catch(() => {});
|
|
100
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;
|
|
101
120
|
function bgJobEnd(nodeId, projectKey) {
|
|
121
|
+
// projectKey ОБЯЗАТЕЛЕН — без него юзер на welcome теряет уведомление
|
|
122
|
+
// и счётчик зависает. Раньше фоллбэк на _projectKeyForCurrent давал
|
|
123
|
+
// null (welcome) → no-op. Теперь шлём end даже если localStorage не
|
|
124
|
+
// знает проект (важно для server jobsHub).
|
|
102
125
|
const pk = projectKey || _projectKeyForCurrent();
|
|
103
|
-
if (!pk) return null;
|
|
126
|
+
if (!pk) { console.warn('bgJobEnd: no projectKey for', nodeId); return null; }
|
|
104
127
|
let job = null;
|
|
105
128
|
try {
|
|
106
129
|
const list = JSON.parse(localStorage.getItem(bgJobsKey(pk)) || '[]');
|
|
@@ -133,8 +156,16 @@ window.bgJobEnd = bgJobEnd;
|
|
|
133
156
|
window.bgJobsAll = bgJobsAll;
|
|
134
157
|
|
|
135
158
|
// === SYSTEM NOTIFICATION (HTML5 Notification API) ===
|
|
136
|
-
// Просим разрешение
|
|
159
|
+
// Просим разрешение eagerly при старте приложения чтобы первое уведомление
|
|
160
|
+
// уже работало (без race с первой генерацией).
|
|
137
161
|
let _notifPermAsked = false;
|
|
162
|
+
if (typeof Notification !== 'undefined' && Notification.permission === 'default') {
|
|
163
|
+
// Откладываем чуть-чуть — иначе диалог появляется до того как юзер увидел UI.
|
|
164
|
+
setTimeout(() => {
|
|
165
|
+
_notifPermAsked = true;
|
|
166
|
+
Notification.requestPermission().catch(() => {});
|
|
167
|
+
}, 2000);
|
|
168
|
+
}
|
|
138
169
|
async function systemNotify(title, body, opts = {}) {
|
|
139
170
|
if (typeof Notification === 'undefined') return null;
|
|
140
171
|
let perm = Notification.permission;
|
|
@@ -142,7 +173,11 @@ async function systemNotify(title, body, opts = {}) {
|
|
|
142
173
|
_notifPermAsked = true;
|
|
143
174
|
try { perm = await Notification.requestPermission(); } catch {}
|
|
144
175
|
}
|
|
145
|
-
if (perm !== 'granted')
|
|
176
|
+
if (perm !== 'granted') {
|
|
177
|
+
// Не вышло — fallback на toast (юзер хоть так увидит).
|
|
178
|
+
if (typeof showToast === 'function') showToast('🔔 ' + title + (body ? ': ' + body : ''), 'info');
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
146
181
|
try {
|
|
147
182
|
const n = new Notification(title, {
|
|
148
183
|
body: body || '',
|
|
@@ -160,26 +195,30 @@ async function systemNotify(title, body, opts = {}) {
|
|
|
160
195
|
window.systemNotify = systemNotify;
|
|
161
196
|
|
|
162
197
|
// Глобальный toast для информативных уведомлений (генерация завершилась,
|
|
163
|
-
// resume сработал, ...). Стек справа
|
|
198
|
+
// resume сработал, ...). Стек справа сверху. Auto-dismiss:
|
|
199
|
+
// - ok/info — 10s (юзер должен заметить успех)
|
|
200
|
+
// - error — 30s (важно, не пропустить)
|
|
164
201
|
function showToast(text, kind) {
|
|
165
202
|
let host = document.getElementById('toastHost');
|
|
166
203
|
if (!host) {
|
|
167
204
|
host = document.createElement('div');
|
|
168
205
|
host.id = 'toastHost';
|
|
169
|
-
host.style.cssText = 'position:fixed; right:16px; top:64px; z-index:9999; display:flex; flex-direction:column; gap:
|
|
206
|
+
host.style.cssText = 'position:fixed; right:16px; top:64px; z-index:9999; display:flex; flex-direction:column; gap:8px; pointer-events:none; max-width:340px;';
|
|
170
207
|
document.body.appendChild(host);
|
|
171
208
|
}
|
|
172
209
|
const t = document.createElement('div');
|
|
173
210
|
const colors = {
|
|
174
|
-
ok: 'background:#
|
|
175
|
-
error: 'background:#
|
|
176
|
-
info: 'background:#
|
|
211
|
+
ok: 'background:#1f4a1f; border:1px solid #4cc44c; color:#dcffdc;',
|
|
212
|
+
error: 'background:#4a1f1f; border:1px solid #c44c4c; color:#ffdcdc;',
|
|
213
|
+
info: 'background:#1f2f4a; border:1px solid #4c7cc4; color:#dceaff;',
|
|
177
214
|
};
|
|
178
|
-
t.style.cssText = `${colors[kind] || colors.info} padding:
|
|
215
|
+
t.style.cssText = `${colors[kind] || colors.info} padding:10px 14px; border-radius:8px; font-size:13px; font-weight:500; pointer-events:auto; box-shadow:0 6px 20px rgba(0,0,0,0.5); cursor:pointer; line-height:1.4; word-break:break-word;`;
|
|
179
216
|
t.textContent = text;
|
|
217
|
+
t.title = 'Кликни чтобы закрыть';
|
|
180
218
|
t.addEventListener('click', () => t.remove());
|
|
181
219
|
host.appendChild(t);
|
|
182
|
-
|
|
220
|
+
const ttl = kind === 'error' ? 30000 : 10000;
|
|
221
|
+
setTimeout(() => t.remove(), ttl);
|
|
183
222
|
return t;
|
|
184
223
|
}
|
|
185
224
|
window.showToast = showToast;
|
package/renderer/styles.css
CHANGED
|
@@ -497,9 +497,11 @@
|
|
|
497
497
|
обложка заполняет всю карту (вертикальная картинка кроп'ится по верху/
|
|
498
498
|
низу через object-fit:cover, не растягивает карту бесконечно вверх).
|
|
499
499
|
Meta (название + дата) — absolute поверх обложки внизу с тёмным
|
|
500
|
-
градиентом, читается на ЛЮБОЙ обложке.
|
|
500
|
+
градиентом, читается на ЛЮБОЙ обложке.
|
|
501
|
+
4:3 (а не 16:9) — карты выше, лучше выглядят на стандартных и
|
|
502
|
+
вертикальных обложках, занимают меньше горизонтального места. */
|
|
501
503
|
.welcome-card {
|
|
502
|
-
aspect-ratio:
|
|
504
|
+
aspect-ratio: 4 / 3;
|
|
503
505
|
overflow: hidden;
|
|
504
506
|
}
|
|
505
507
|
.welcome-card-thumb {
|
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, () => {
|