kingkont 0.17.1 → 0.17.3

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/index.html CHANGED
@@ -612,6 +612,8 @@
612
612
  <script src="renderer/cloudProjects.js"></script>
613
613
  <!-- chat.js — Claude-чат для управления сценой через tools (Cmd+J). -->
614
614
  <script src="renderer/chat.js"></script>
615
+ <!-- notifyPanel.js — окно событий (генерации, чат-ответы, ошибки). 🔔 в углу. -->
616
+ <script src="renderer/notifyPanel.js"></script>
615
617
 
616
618
  </body>
617
619
  </html>
package/lib/jobsHub.js CHANGED
@@ -36,16 +36,25 @@ function start({ projectKey, jobId, kind, name, type, taskId }) {
36
36
  if (!projectKey || !jobId) return;
37
37
  let m = jobsByProject.get(projectKey);
38
38
  if (!m) { m = new Map(); jobsByProject.set(projectKey, m); }
39
- m.set(jobId, {
40
- kind: kind || null, name: name || null, type: type || null,
41
- taskId: taskId || null, startedAt: Date.now(),
42
- });
39
+ // MERGE с существующей записью — иначе второй call (например
40
+ // bgJobUpdateTaskId) затёр бы kind/name nullами. Это и был bug:
41
+ // после получения taskId server-side poller не стартовал потому что
42
+ // kind становился null.
43
+ const existing = m.get(jobId) || {};
44
+ const merged = {
45
+ kind: kind ?? existing.kind ?? null,
46
+ name: name ?? existing.name ?? null,
47
+ type: type ?? existing.type ?? null,
48
+ taskId: taskId ?? existing.taskId ?? null,
49
+ startedAt: existing.startedAt || Date.now(),
50
+ };
51
+ m.set(jobId, merged);
52
+ console.log('[jobsHub] start/update', { projectKey, jobId, ...merged });
43
53
  _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 });
54
+ // Если есть И taskId, И kind — стартуем серверный poller. Renderer тоже
55
+ // полит параллельно. Если renderer закрыт — серверный продолжает.
56
+ if (merged.taskId && (merged.kind === 'image' || merged.kind === 'video')) {
57
+ _startPoller({ projectKey, jobId, taskId: merged.taskId, kind: merged.kind });
49
58
  }
50
59
  }
51
60
 
@@ -62,19 +71,27 @@ function end({ projectKey, jobId }) {
62
71
  }
63
72
 
64
73
  function _startPoller({ projectKey, jobId, taskId, kind }) {
65
- if (pollers.has(jobId)) return;
74
+ if (pollers.has(jobId)) {
75
+ console.log('[jobsHub] poller already running for', jobId);
76
+ return;
77
+ }
78
+ console.log('[jobsHub] _startPoller', { projectKey, jobId, taskId, kind });
66
79
  const providers = require('./providers');
67
80
  const tick = async () => {
68
81
  const entry = pollers.get(jobId);
69
82
  if (!entry) return;
70
83
  entry.lastPollAt = Date.now();
71
84
  let r;
72
- try { r = await providers.pollGeneration(taskId, _settingsGetter()); }
85
+ try {
86
+ r = await providers.pollGeneration(taskId, _settingsGetter());
87
+ console.log('[jobsHub] poll', jobId, '→', r.status, r.state || '');
88
+ }
73
89
  catch (e) {
74
90
  console.warn('[jobsHub] poll failed for', jobId, e?.message);
75
91
  return; // следующий tick попробует снова
76
92
  }
77
93
  if (r.status === 'done') {
94
+ console.log('[jobsHub] DONE', jobId, '→', r.url);
78
95
  _stopPoller(jobId);
79
96
  // Push WS event с completion-info. Renderer (если жив) скачает
80
97
  // через /api/proxy и запишет в FSAH. Если renderer закрыт — event
@@ -0,0 +1,62 @@
1
+ // lib/projectPaths.js — server-side registry абсолютных путей проектов.
2
+ //
3
+ // FSAH (File System Access API) — это browser-only. Сервер (Node.js) не
4
+ // может работать с его DirectoryHandle. Но в Electron мы можем извлечь
5
+ // абс-путь любого файла внутри папки через webUtils.getPathForFile и
6
+ // зарегистрировать здесь — после чего сервер напрямую читает/пишет через
7
+ // node:fs.
8
+ //
9
+ // Для cloud-проектов путь однозначно: userData/cloud-projects/<id>/
10
+ // (см. main.js cloudResolve). Их регистрировать не нужно — путь
11
+ // derivable.
12
+ //
13
+ // Для folder-проектов (showDirectoryPicker) путь регистрируется
14
+ // renderer'ом на openFilm через POST /api/project/register
15
+ // {projectKey, absPath}.
16
+ //
17
+ // API:
18
+ // register(projectKey, absPath)
19
+ // resolve(projectKey) → absPath или null
20
+ // resolveBoardPath(projectKey, kind, name) → абс-путь board-folder'a
21
+
22
+ 'use strict';
23
+
24
+ const path = require('node:path');
25
+
26
+ // Map<projectKey, absPath>.
27
+ // projectKey формат: 'cloud:<id>' | 'folder:<name>'.
28
+ const paths = new Map();
29
+ let _userDataDir = null;
30
+
31
+ function init({ userDataDir }) {
32
+ _userDataDir = userDataDir;
33
+ }
34
+
35
+ function register(projectKey, absPath) {
36
+ if (!projectKey || !absPath) return;
37
+ paths.set(projectKey, absPath);
38
+ }
39
+
40
+ function resolve(projectKey) {
41
+ if (!projectKey) return null;
42
+ // Cloud — derivable из userDataDir.
43
+ if (projectKey.startsWith('cloud:')) {
44
+ if (!_userDataDir) return null;
45
+ const id = projectKey.slice('cloud:'.length);
46
+ const safe = String(id).replace(/[\\/]/g, '_');
47
+ return path.join(_userDataDir, 'cloud-projects', safe);
48
+ }
49
+ // Folder — из registry.
50
+ return paths.get(projectKey) || null;
51
+ }
52
+
53
+ // Полный путь к папке доски (scene) внутри проекта. kind: episode|character|location.
54
+ function resolveBoardPath(projectKey, kind, name) {
55
+ const root = resolve(projectKey);
56
+ if (!root) return null;
57
+ if (kind === 'character') return path.join(root, '_characters', name);
58
+ if (kind === 'location') return path.join(root, '_locations', name);
59
+ return path.join(root, name); // episode
60
+ }
61
+
62
+ module.exports = { init, register, resolve, resolveBoardPath };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kingkont",
3
- "version": "0.17.1",
3
+ "version": "0.17.3",
4
4
  "description": "KingKont · Chatium — нод-редактор сцен с AI-генерацией (картинки/видео/голос/SFX/музыка/текст)",
5
5
  "main": "main.js",
6
6
  "bin": {
package/renderer/board.js CHANGED
@@ -1182,6 +1182,22 @@ async function openFilm(handle) {
1182
1182
  // Запоминаем что юзер сейчас в проекте → Cmd+R откроет его снова.
1183
1183
  try { localStorage.setItem('lastLocation', 'project'); } catch {}
1184
1184
  window.appProject?.notifyState(true);
1185
+ // Register абс-путь folder-проекта на сервере — так server-side задачи
1186
+ // (jobsHub poller, future server-tools) могут писать файлы напрямую без
1187
+ // FSAH. Cloud не регистрируем (path derivable из cloudProjectId).
1188
+ // Не блокируем дальнейший openFilm — сервер просто не получит путь и
1189
+ // server-side ops для этого проекта будут недоступны.
1190
+ if (handle && !window.cloudFsShim?.isCloudHandle?.(handle)) {
1191
+ try {
1192
+ const absPath = await deriveFolderAbsPath(handle);
1193
+ if (absPath) {
1194
+ await fetch('/api/project/register', {
1195
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
1196
+ body: JSON.stringify({ projectKey: 'folder:' + handle.name, absPath }),
1197
+ });
1198
+ }
1199
+ } catch (e) { vlog('warn', 'project register failed: ' + e?.message); }
1200
+ }
1185
1201
  ensureClaudeMd(handle).catch(() => {});
1186
1202
  // Подзаголовок шапки = имя открытого проекта (вместо «Видео-редактор»).
1187
1203
  const sub = $('brandSub');
@@ -1853,8 +1853,9 @@ async function startGenerationJob(node, kind, prompt, mediaRefs, boardHandle, bK
1853
1853
  logJob(node.id, `taskId=${data.taskId}`);
1854
1854
  // Сообщаем server-side poller'у taskId — теперь сервер тоже опрашивает
1855
1855
  // провайдера. Если renderer закроется/Cmd+R — server продолжит и
1856
- // пушнёт WS-event на completion.
1857
- if (typeof bgJobUpdateTaskId === 'function') bgJobUpdateTaskId(node.id, data.taskId, job.projectKey);
1856
+ // пушнёт WS-event на completion. ВАЖНО передать kind — без него
1857
+ // server-merge не запустит poller (kind остался бы null после второго call).
1858
+ if (typeof bgJobUpdateTaskId === 'function') bgJobUpdateTaskId(node.id, data.taskId, job.projectKey, kind);
1858
1859
  await mutateNode(bKey, boardHandle, node.id, n => {
1859
1860
  n.generated = { ...(n.generated || {}), taskId: data.taskId, state: 'queued' };
1860
1861
  });
@@ -0,0 +1,156 @@
1
+ // renderer/notifyPanel.js — Окно уведомлений (event log).
2
+ //
3
+ // Отдельная панель для всех bg-event'ов: запуск/завершение генераций,
4
+ // чат-ответы, ошибки. Раскрывается кликом на 🔔 в шапке. История
5
+ // держится in-memory (~100 последних) — не персистится.
6
+ //
7
+ // Источники событий (всё через addEvent):
8
+ // - showToast (в state.js) — оборачиваем чтобы дублировать в панель
9
+ // - WS jobs:all kind:done — server-side gen completion
10
+ // - WS chat:<key> kind:final — chat final response
11
+ // - bgjobs:changed — start/end jobs
12
+ //
13
+ // Бейдж на 🔔 — счётчик новых событий (увиденных = открытие панели).
14
+
15
+ (function () {
16
+ function $(id) { return document.getElementById(id); }
17
+ const events = []; // [{ts, kind, text, projectKey?, raw?}]
18
+ const MAX_EVENTS = 100;
19
+ let unread = 0;
20
+ let panelOpen = false;
21
+
22
+ function _ensureUI() {
23
+ if ($('notifyBtn')) return;
24
+ // Кнопка-колокольчик в верхнем тулбаре справа.
25
+ const btn = document.createElement('button');
26
+ btn.id = 'notifyBtn';
27
+ btn.title = 'События (генерации, чат, ошибки)';
28
+ btn.style.cssText = 'position:fixed; top:12px; right:24px; z-index:9998; background:rgba(30,30,40,0.85); border:1px solid #444; color:#ccc; font-size:14px; width:32px; height:32px; border-radius:50%; cursor:pointer; backdrop-filter:blur(4px); display:flex; align-items:center; justify-content:center;';
29
+ btn.innerHTML = '🔔';
30
+ document.body.appendChild(btn);
31
+ btn.addEventListener('click', toggle);
32
+ // Бейдж со счётчиком unread.
33
+ const badge = document.createElement('span');
34
+ badge.id = 'notifyBadge';
35
+ badge.style.cssText = 'position:absolute; top:-4px; right:-4px; background:#e33377; color:#fff; font-size:9px; min-width:14px; height:14px; line-height:14px; text-align:center; border-radius:8px; padding:0 4px; display:none; pointer-events:none;';
36
+ btn.appendChild(badge);
37
+
38
+ // Панель.
39
+ const panel = document.createElement('div');
40
+ panel.id = 'notifyPanel';
41
+ panel.style.cssText = 'position:fixed; top:50px; right:16px; width:380px; max-height:75vh; background:#1a1a1a; border:1px solid #333; border-radius:8px; z-index:9999; display:none; flex-direction:column; box-shadow:0 8px 32px rgba(0,0,0,0.55); overflow:hidden;';
42
+ panel.innerHTML = `
43
+ <div style="padding:10px 12px; border-bottom:1px solid #2a2a2a; display:flex; align-items:center; gap:8px;">
44
+ <strong style="font-size:13px; color:#ddd;">События</strong>
45
+ <span style="flex:1;"></span>
46
+ <button id="notifyClear" title="Очистить" style="background:transparent; border:1px solid #333; color:#aaa; padding:2px 8px; border-radius:4px; cursor:pointer; font-size:11px;">⌫</button>
47
+ <button id="notifyClose" title="Закрыть" style="background:transparent; border:1px solid #333; color:#aaa; padding:2px 8px; border-radius:4px; cursor:pointer; font-size:11px;">×</button>
48
+ </div>
49
+ <div id="notifyList" style="flex:1; overflow-y:auto; padding:6px;"></div>
50
+ `;
51
+ document.body.appendChild(panel);
52
+ $('notifyClose').addEventListener('click', () => setOpen(false));
53
+ $('notifyClear').addEventListener('click', () => { events.length = 0; unread = 0; render(); });
54
+ }
55
+
56
+ function setOpen(open) {
57
+ _ensureUI();
58
+ panelOpen = open;
59
+ $('notifyPanel').style.display = open ? 'flex' : 'none';
60
+ if (open) { unread = 0; updateBadge(); render(); }
61
+ }
62
+ function toggle() { setOpen(!panelOpen); }
63
+
64
+ function updateBadge() {
65
+ const b = $('notifyBadge');
66
+ if (!b) return;
67
+ if (unread > 0) {
68
+ b.textContent = unread > 99 ? '99+' : String(unread);
69
+ b.style.display = '';
70
+ } else b.style.display = 'none';
71
+ }
72
+
73
+ function render() {
74
+ const list = $('notifyList');
75
+ if (!list) return;
76
+ list.innerHTML = '';
77
+ if (!events.length) {
78
+ list.innerHTML = '<div style="padding:24px; text-align:center; color:#555; font-size:12px;">Нет событий</div>';
79
+ return;
80
+ }
81
+ for (let i = events.length - 1; i >= 0; i--) {
82
+ const e = events[i];
83
+ const row = document.createElement('div');
84
+ const colors = {
85
+ ok: '#3a6a3a', error: '#8a3a3a', info: '#3a5a8a', warn: '#8a7a3a', chat: '#7a4a8a',
86
+ };
87
+ const c = colors[e.kind] || colors.info;
88
+ const dt = new Date(e.ts);
89
+ const time = `${String(dt.getHours()).padStart(2,'0')}:${String(dt.getMinutes()).padStart(2,'0')}:${String(dt.getSeconds()).padStart(2,'0')}`;
90
+ row.style.cssText = `padding:8px 10px; border-radius:4px; margin-bottom:4px; background:${c}22; border-left:3px solid ${c}; cursor:default;`;
91
+ row.innerHTML = `<div style="display:flex; justify-content:space-between; gap:8px; align-items:flex-start;">
92
+ <div style="color:#e0e0e0; font-size:12px; line-height:1.4; word-break:break-word; flex:1;"></div>
93
+ <span style="color:#777; font-size:10px; font-family:ui-monospace,monospace; flex-shrink:0;">${time}</span>
94
+ </div>`;
95
+ row.querySelector('div > div').textContent = e.text;
96
+ list.appendChild(row);
97
+ }
98
+ }
99
+
100
+ // Public API.
101
+ function addEvent({ kind, text, raw }) {
102
+ if (!text) return;
103
+ events.push({ ts: Date.now(), kind: kind || 'info', text: String(text).slice(0, 300), raw });
104
+ if (events.length > MAX_EVENTS) events.shift();
105
+ if (!panelOpen) {
106
+ unread++;
107
+ updateBadge();
108
+ } else {
109
+ render();
110
+ }
111
+ }
112
+ window.notifyPanel = { open: () => setOpen(true), close: () => setOpen(false), toggle, addEvent, render };
113
+
114
+ // Init: ensure UI exists ASAP, hook into showToast и WS-events.
115
+ document.addEventListener('DOMContentLoaded', () => {
116
+ _ensureUI();
117
+ // Wrap showToast чтобы дублировать в панель.
118
+ if (typeof window.showToast === 'function') {
119
+ const orig = window.showToast;
120
+ window.showToast = function (text, kind) {
121
+ try { addEvent({ kind: kind || 'info', text }); } catch {}
122
+ return orig.apply(this, arguments);
123
+ };
124
+ }
125
+ // WS-канал jobs:all для start/end/done/failed events. Connect к /ws.
126
+ function _connect() {
127
+ let ws;
128
+ try {
129
+ const proto = location.protocol === 'https:' ? 'wss' : 'ws';
130
+ ws = new WebSocket(`${proto}://${location.host}/ws`);
131
+ } catch { setTimeout(_connect, 5000); return; }
132
+ ws.onopen = () => {
133
+ try { ws.send(JSON.stringify({ type: 'subscribe', channel: 'jobs:all' })); } catch {}
134
+ };
135
+ ws.onmessage = (ev) => {
136
+ let m; try { m = JSON.parse(ev.data); } catch { return; }
137
+ if (m?.type !== 'event') return;
138
+ const e = m.event || {};
139
+ const ch = m.channel || '';
140
+ if (ch === 'jobs:all') {
141
+ if (e.kind === 'done') {
142
+ addEvent({ kind: 'ok', text: `✓ ${e.kindOf || 'gen'} готов в ${e.projectKey || ''} (jobId=${(e.jobId||'').slice(0,8)})` });
143
+ } else if (e.kind === 'failed') {
144
+ addEvent({ kind: 'error', text: `⚠ ${e.kindOf || 'gen'} провалился: ${(e.error || '').slice(0, 100)}` });
145
+ } else if (e.kind === 'changed' && Array.isArray(e.list)) {
146
+ // Только при изменении (start/end) — добавим краткую инфу.
147
+ // Без шума — addEvent только если что-то стало активно или закончилось.
148
+ }
149
+ }
150
+ };
151
+ ws.onclose = () => setTimeout(_connect, 5000);
152
+ ws.onerror = () => {};
153
+ }
154
+ _connect();
155
+ });
156
+ })();
package/renderer/state.js CHANGED
@@ -108,12 +108,13 @@ function bgJobStart(info) {
108
108
  }).catch(() => {});
109
109
  }
110
110
  // Update taskId for an existing job (server didn't have it on initial track).
111
- function bgJobUpdateTaskId(nodeId, taskId, projectKey) {
111
+ // kind важен без него server-side merge оставит null и poller не стартует.
112
+ function bgJobUpdateTaskId(nodeId, taskId, projectKey, kind) {
112
113
  const pk = projectKey || _projectKeyForCurrent();
113
114
  if (!pk || !taskId) return;
114
115
  fetch('/api/jobs/track', {
115
116
  method: 'POST', headers: { 'Content-Type': 'application/json' },
116
- body: JSON.stringify({ action: 'start', projectKey: pk, jobId: nodeId, taskId }),
117
+ body: JSON.stringify({ action: 'start', projectKey: pk, jobId: nodeId, taskId, kind }),
117
118
  }).catch(() => {});
118
119
  }
119
120
  window.bgJobUpdateTaskId = bgJobUpdateTaskId;
package/server.js CHANGED
@@ -16,6 +16,7 @@ const providers = require('./lib/providers');
16
16
  const chatSession = require('./lib/chatSession');
17
17
  const jobsHub = require('./lib/jobsHub');
18
18
  const wsHub = require('./lib/wsHub');
19
+ const projectPaths = require('./lib/projectPaths');
19
20
 
20
21
  // ---------- .env loader (без зависимостей) ----------
21
22
  function loadEnv() {
@@ -371,6 +372,30 @@ async function handleJobsList(res, url) {
371
372
  } catch (e) { sendError(res, e, 500); }
372
373
  }
373
374
 
375
+ // =============================================================================
376
+ // Projects: regission абсолютного пути folder-проекта (renderer достаёт через
377
+ // webUtils.getPathForFile). Cloud-проекты регистрировать не нужно — путь
378
+ // derivable (userData/cloud-projects/<id>/).
379
+ // =============================================================================
380
+ async function handleProjectRegister(req, res) {
381
+ try {
382
+ const body = await readJson(req);
383
+ const { projectKey, absPath } = body || {};
384
+ if (!projectKey) return send(res, 400, { error: 'projectKey обязателен' });
385
+ if (!absPath || typeof absPath !== 'string') return send(res, 400, { error: 'absPath обязателен' });
386
+ projectPaths.register(projectKey, absPath);
387
+ send(res, 200, { ok: true, registered: { projectKey, absPath } });
388
+ } catch (e) { sendError(res, e, 500); }
389
+ }
390
+ async function handleProjectResolve(res, url) {
391
+ try {
392
+ const projectKey = url.searchParams.get('projectKey');
393
+ if (!projectKey) return send(res, 400, { error: 'projectKey обязателен' });
394
+ const absPath = projectPaths.resolve(projectKey);
395
+ send(res, 200, { projectKey, absPath, registered: !!absPath });
396
+ } catch (e) { sendError(res, e, 500); }
397
+ }
398
+
374
399
  // =============================================================================
375
400
  // Static files (renderer assets).
376
401
  // =============================================================================
@@ -428,6 +453,9 @@ const server = createServer(async (req, res) => {
428
453
  // Jobs hub.
429
454
  if (req.method === 'POST' && url.pathname === '/api/jobs/track') return handleJobsTrack(req, res);
430
455
  if (req.method === 'GET' && url.pathname === '/api/jobs') return handleJobsList(res, url);
456
+ // Projects: register абс-путя для server-side fs-доступа.
457
+ if (req.method === 'POST' && url.pathname === '/api/project/register') return handleProjectRegister(req, res);
458
+ if (req.method === 'GET' && url.pathname === '/api/project/resolve') return handleProjectResolve(res, url);
431
459
  // Cloud-projects routes — зеркало templates, но для редактируемых проектов.
432
460
  if (req.method === 'GET' && url.pathname === '/api/projects') return handleProjectsList(res);
433
461
  if (req.method === 'POST' && url.pathname === '/api/projects') return handleProjectCreate(req, res);
@@ -455,6 +483,7 @@ function start(port = PORT, opts = {}) {
455
483
  // Init chatSession с userDataDir для персистентности историй.
456
484
  // Без opts.userDataDir чат живёт только in-memory (CLI-режим).
457
485
  chatSession.init({ userDataDir: opts.userDataDir || null });
486
+ projectPaths.init({ userDataDir: opts.userDataDir || null });
458
487
  // jobsHub нужен settingsGetter чтобы поллить провайдеров (Chatium token,
459
488
  // KIE_API_KEY и т.п.).
460
489
  jobsHub.setSettingsGetter(getSettings);