kingkont 0.14.7 → 0.15.1

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/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
- if (s.useChatium && s.chatium?.token && s.chatium?.base) {
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kingkont",
3
- "version": "0.14.7",
3
+ "version": "0.15.1",
4
4
  "description": "KingKont · Chatium — нод-редактор сцен с AI-генерацией (картинки/видео/голос/SFX/музыка/текст)",
5
5
  "main": "main.js",
6
6
  "bin": {
package/renderer/board.js CHANGED
@@ -587,6 +587,28 @@ async function renderWelcomeRecents() {
587
587
  }
588
588
  }
589
589
 
590
+ // Бейдж «N в фоне» в верхнем-левом углу обложки welcome-карточки.
591
+ // Показывается когда в этом проекте есть активные bg-jobs (см. bgJobsAll).
592
+ function _makeBgBadge(n) {
593
+ const b = document.createElement('div');
594
+ b.className = 'welcome-card-bg-badge';
595
+ b.title = `${n} ${_ru_jobs(n)} в фоне`;
596
+ b.textContent = `⏳ ${n}`;
597
+ return b;
598
+ }
599
+ function _ru_jobs(n) {
600
+ const n10 = n % 10, n100 = n % 100;
601
+ if (n100 >= 11 && n100 <= 14) return 'генераций';
602
+ if (n10 === 1) return 'генерация';
603
+ if (n10 >= 2 && n10 <= 4) return 'генерации';
604
+ return 'генераций';
605
+ }
606
+ // Live-update welcome когда bg-jobs меняются (start/end). Перерисовываем
607
+ // только если открыт welcome (нет проекта), иначе UI не виден.
608
+ window.addEventListener('bgjobs:changed', () => {
609
+ if (!state.filmHandle) renderWelcomeRecents().catch(() => {});
610
+ });
611
+
590
612
  // Карточка локального (папочного) проекта — извлечена из renderWelcomeRecents.
591
613
  // Удаление и save-as-template — через ПКМ (по аналогии с cloud-карточкой).
592
614
  function makeRecentWelcomeCard(r) {
@@ -600,6 +622,10 @@ function makeRecentWelcomeCard(r) {
600
622
  img.onload = () => setTimeout(() => URL.revokeObjectURL(img.src), 60_000);
601
623
  thumb.appendChild(img);
602
624
  } else thumb.textContent = '🎬';
625
+ // Бейдж «N в фоне» если в этом проекте крутятся генерации.
626
+ // Map проектов → projectKey: для folder-проектов = 'folder:' + r.name.
627
+ const bgList = (typeof bgJobsAll === 'function' ? bgJobsAll() : {})['folder:' + r.name];
628
+ if (bgList?.length) thumb.appendChild(_makeBgBadge(bgList.length));
603
629
  const meta = document.createElement('div');
604
630
  meta.className = 'welcome-card-meta';
605
631
  const nameEl = document.createElement('div');
@@ -753,6 +779,9 @@ function makeCloudWelcomeCard(p) {
753
779
  cloudBadge.textContent = '☁';
754
780
  cloudBadge.title = 'Облачный проект';
755
781
  thumb.appendChild(cloudBadge);
782
+ // Бейдж «N в фоне» если в этом проекте крутятся генерации.
783
+ const bgList = (typeof bgJobsAll === 'function' ? bgJobsAll() : {})['cloud:' + p.id];
784
+ if (bgList?.length) thumb.appendChild(_makeBgBadge(bgList.length));
756
785
 
757
786
  const meta = document.createElement('div');
758
787
  meta.className = 'welcome-card-meta';
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
- if (!document.getElementById('timelinePanel').classList.contains('hidden')) {
308
- if (typeof renderTimeline === 'function') renderTimeline();
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
  },
@@ -1374,9 +1433,8 @@
1374
1433
  const panel = $('chatPanel');
1375
1434
  panel.classList.toggle('hidden');
1376
1435
  if (!panel.classList.contains('hidden')) {
1377
- // Сворачиваем preview-панель иначе она перекрывает чат справа
1378
- // (preview z-index 40 > chat z-index 30 by design).
1379
- if (typeof setPreviewCollapsed === 'function') setPreviewCollapsed(true);
1436
+ // Z-index сам разруливает: preview.collapsed ниже чата (z=40 vs 45),
1437
+ // preview открыт поверх (z=50). Auto-collapse больше не нужен.
1380
1438
  // Отрисовываем сохранённую историю при показе (на случай если она
1381
1439
  // была подгружена в фоне через loadFromCurrentProject до первого toggle).
1382
1440
  renderHistory();
@@ -1424,12 +1482,15 @@
1424
1482
  history = []; snapshots = [];
1425
1483
  renderHistory(); renderSnapshotBar();
1426
1484
  },
1427
- // Reset-on-close: останавливаем polling, чистим UI. История на сервере
1428
- // остаётся (server-side persistence). re-open того же проекта подгрузит.
1485
+ // Reset-on-close: НЕ останавливаем polling серверный loop может ещё
1486
+ // ждать tool-results. Если бросим polling, loop зависает (tools никто
1487
+ // не исполнит). Клиентский UI чистим, но poll продолжается в фоне на
1488
+ // сессию ПОСЛЕДНЕГО открытого проекта (она же станет «фоновым chat'ом»).
1489
+ // При открытии другого проекта — startPolling переключит sessionKey.
1429
1490
  resetInMemory: async () => {
1430
- stopPolling();
1431
1491
  history = []; snapshots = [];
1432
1492
  renderHistory(); renderSnapshotBar();
1493
+ // Polling оставляем активным — это и есть «бг-чат».
1433
1494
  },
1434
1495
  // board.js зовёт после openFilm чтобы подгрузить chat-историю проекта.
1435
1496
  loadFromCurrentProject: () => loadHistoryFromCurrentProject(),
@@ -1728,6 +1728,8 @@ async function startGenerationJob(node, kind, prompt, mediaRefs, boardHandle, bK
1728
1728
  const job = { boardKey: bKey, boardHandle, kind, taskId: null, nodeId: node.id };
1729
1729
  state.jobs.set(node.id, job);
1730
1730
  updateJobsBadge();
1731
+ // Track в global bg-jobs (для welcome-индикаторов и system-notif при завершении).
1732
+ if (typeof bgJobStart === 'function') bgJobStart({ nodeId: node.id, kind, name: node.name });
1731
1733
  logJob(node.id, `gen start kind=${kind} model=${modelKey || '—'} refs=${mediaRefs?.length || 0} prompt="${(prompt||'').slice(0,200)}"`);
1732
1734
  // Подробный дамп всех refs со всеми полями
1733
1735
  logJob(node.id, `refs dump: ${logSafe((mediaRefs || []).map(r => ({
@@ -1856,6 +1858,11 @@ async function startGenerationJob(node, kind, prompt, mediaRefs, boardHandle, bK
1856
1858
  });
1857
1859
  state.jobs.delete(node.id);
1858
1860
  updateJobsBadge();
1861
+ if (typeof bgJobEnd === 'function') bgJobEnd(node.id);
1862
+ if (typeof showToast === 'function') showToast(`⚠ ${kind || 'gen'}: ${e.message?.slice(0, 80)}`, 'error');
1863
+ if (typeof systemNotify === 'function' && document.hidden) {
1864
+ systemNotify('KingKont: ошибка генерации', e.message?.slice(0, 100), { tag: 'gen-err-' + node.id }).catch(() => {});
1865
+ }
1859
1866
  }
1860
1867
  }
1861
1868
 
@@ -1981,10 +1988,23 @@ async function pollJob(job, nodeId, bKey, boardHandle, kind) {
1981
1988
  });
1982
1989
  state.jobs.delete(nodeId);
1983
1990
  updateJobsBadge();
1991
+ // bgJob убираем (welcome перечитает счётчики через event).
1992
+ if (typeof bgJobEnd === 'function') bgJobEnd(nodeId);
1984
1993
  if (typeof window.refreshBalance === 'function') window.refreshBalance();
1985
- // Toast если завершилось НЕ на текущей доске (юзер мог переключиться).
1986
- if (typeof showToast === 'function' && state.currentBoard?.key !== bKey) {
1987
- showToast(`✓ ${kind} «${nodeName || nodeId.slice(0,6)}» готов в ${bKey}`, 'ok');
1994
+ // Toast ВСЕГДА (юзер хочет видеть когда что-то сделалось).
1995
+ // Стиль 'ok' для current-board, 'info' для другой доски.
1996
+ const isCurrent = state.currentBoard?.key === bKey;
1997
+ if (typeof showToast === 'function') {
1998
+ const where = isCurrent ? '' : ` в ${bKey}`;
1999
+ showToast(`✓ ${kind} «${nodeName || nodeId.slice(0,6)}» готов${where}`, isCurrent ? 'ok' : 'info');
2000
+ }
2001
+ // System notification — только когда генерация завершилась в фоне
2002
+ // (не на активной доске или когда окно скрыто). На активном UI
2003
+ // юзер и так видит результат — не нужен лишний попап.
2004
+ if (typeof systemNotify === 'function' && (!isCurrent || document.hidden)) {
2005
+ systemNotify(`KingKont: ${kind} готов`, `«${nodeName || ''}» в ${bKey}`, {
2006
+ tag: 'gen-' + nodeId,
2007
+ }).catch(() => {});
1988
2008
  }
1989
2009
  return;
1990
2010
  }
package/renderer/state.js CHANGED
@@ -70,6 +70,85 @@ async function listCharacters(filmHandle) {
70
70
  } catch { return []; }
71
71
  }
72
72
 
73
+ // === BG-JOBS TRACKING ===
74
+ // Хранит активные генерации МЕЖДУ всеми проектами в localStorage:
75
+ // bgJobs:<projectKey> = [{nodeId, kind, name, startedAt}]
76
+ // Используется для показа индикаторов на welcome-карточках («N генераций
77
+ // в фоне») и системных уведомлений когда что-то завершилось.
78
+ function bgJobsKey(projectKey) { return 'bgJobs:' + projectKey; }
79
+ function _projectKeyForCurrent() {
80
+ if (state.cloudProjectId) return 'cloud:' + state.cloudProjectId;
81
+ if (state.filmHandle?.name) return 'folder:' + state.filmHandle.name;
82
+ return null;
83
+ }
84
+ function bgJobStart(info) {
85
+ // info: {nodeId, kind, name?, projectKey?}
86
+ const pk = info.projectKey || _projectKeyForCurrent();
87
+ if (!pk) return;
88
+ try {
89
+ const list = JSON.parse(localStorage.getItem(bgJobsKey(pk)) || '[]');
90
+ if (list.some(j => j.nodeId === info.nodeId)) return; // уже трекается
91
+ list.push({ nodeId: info.nodeId, kind: info.kind, name: info.name || null, startedAt: Date.now() });
92
+ localStorage.setItem(bgJobsKey(pk), JSON.stringify(list));
93
+ window.dispatchEvent(new CustomEvent('bgjobs:changed'));
94
+ } catch {}
95
+ }
96
+ function bgJobEnd(nodeId, projectKey) {
97
+ const pk = projectKey || _projectKeyForCurrent();
98
+ if (!pk) return null;
99
+ try {
100
+ const list = JSON.parse(localStorage.getItem(bgJobsKey(pk)) || '[]');
101
+ const job = list.find(j => j.nodeId === nodeId);
102
+ const filtered = list.filter(j => j.nodeId !== nodeId);
103
+ if (filtered.length) localStorage.setItem(bgJobsKey(pk), JSON.stringify(filtered));
104
+ else localStorage.removeItem(bgJobsKey(pk));
105
+ window.dispatchEvent(new CustomEvent('bgjobs:changed'));
106
+ return job;
107
+ } catch { return null; }
108
+ }
109
+ function bgJobsAll() {
110
+ const result = {};
111
+ try {
112
+ for (let i = 0; i < localStorage.length; i++) {
113
+ const k = localStorage.key(i);
114
+ if (!k || !k.startsWith('bgJobs:')) continue;
115
+ const list = JSON.parse(localStorage.getItem(k) || '[]');
116
+ if (list.length) result[k.slice('bgJobs:'.length)] = list;
117
+ }
118
+ } catch {}
119
+ return result;
120
+ }
121
+ window.bgJobStart = bgJobStart;
122
+ window.bgJobEnd = bgJobEnd;
123
+ window.bgJobsAll = bgJobsAll;
124
+
125
+ // === SYSTEM NOTIFICATION (HTML5 Notification API) ===
126
+ // Просим разрешение лениво — на первом успешном вызове.
127
+ let _notifPermAsked = false;
128
+ async function systemNotify(title, body, opts = {}) {
129
+ if (typeof Notification === 'undefined') return null;
130
+ let perm = Notification.permission;
131
+ if (perm === 'default' && !_notifPermAsked) {
132
+ _notifPermAsked = true;
133
+ try { perm = await Notification.requestPermission(); } catch {}
134
+ }
135
+ if (perm !== 'granted') return null;
136
+ try {
137
+ const n = new Notification(title, {
138
+ body: body || '',
139
+ icon: opts.icon || 'assets/icon.png',
140
+ tag: opts.tag, // dedup по tag (одинаковый = заменяет)
141
+ silent: opts.silent === true,
142
+ });
143
+ if (opts.onClick) n.onclick = opts.onClick;
144
+ return n;
145
+ } catch (e) {
146
+ console.warn('systemNotify failed:', e?.message);
147
+ return null;
148
+ }
149
+ }
150
+ window.systemNotify = systemNotify;
151
+
73
152
  // Глобальный toast для информативных уведомлений (генерация завершилась,
74
153
  // resume сработал, ...). Стек справа сверху, auto-dismiss через 5s.
75
154
  function showToast(text, kind) {
@@ -253,10 +253,10 @@
253
253
  position: fixed; right: 0; top: 0; bottom: 0; width: 420px;
254
254
  background: #1a1a1a; border-left: 1px solid #333;
255
255
  display: flex; flex-direction: column;
256
- /* z-index 30НИЖЕ preview-panel (z-index 40). Preview перекрывает чат
257
- визуально; при открытии чата preview сворачивается автоматически
258
- (см. toggle() в chat.js → setPreviewCollapsed(true)). */
259
- z-index: 30;
256
+ /* z-index 45ВЫШЕ свёрнутого preview (40). Когда preview открыт
257
+ (НЕ .collapsed), он получает z-index 50 → перекрывает чат. См.
258
+ правило body:has(.preview-panel:not(.collapsed)) ниже. */
259
+ z-index: 45;
260
260
  box-shadow: -8px 0 32px rgba(0,0,0,0.4);
261
261
  /* --chat-font задаётся inline в chat.js (Cmd+/Cmd-). Дефолт 13px. */
262
262
  font-size: var(--chat-font, 13px);
@@ -265,6 +265,12 @@
265
265
  position спасал, но без этого max-width дочерних не работал. */
266
266
  overflow-x: hidden; box-sizing: border-box;
267
267
  }
268
+ /* Preview открыт (развёрнут) — поднимаем над чатом. Когда collapsed
269
+ (полоска справа) — преview опускается ниже чата (z-index по умолчанию
270
+ 40 vs наш chat 45). */
271
+ body:has(.preview-panel:not(.collapsed)) .preview-panel {
272
+ z-index: 50;
273
+ }
268
274
  .chat-panel.chat-drag::before {
269
275
  content: '📎 Отпусти, чтобы прикрепить файл к чату';
270
276
  position: absolute; inset: 0; z-index: 100;
@@ -490,6 +496,21 @@
490
496
  font-size: 13px; line-height: 1;
491
497
  backdrop-filter: blur(2px);
492
498
  }
499
+ /* «⏳ N» — индикатор активных фоновых генераций. Top-left угла обложки. */
500
+ .welcome-card-bg-badge {
501
+ position: absolute; left: 6px; top: 6px;
502
+ background: rgba(50, 30, 20, 0.88); color: #fc9;
503
+ border: 1px solid rgba(255, 200, 160, 0.4);
504
+ border-radius: 12px; padding: 2px 8px;
505
+ display: inline-flex; align-items: center; gap: 4px;
506
+ font-size: 11px; line-height: 1.2; font-weight: 500;
507
+ backdrop-filter: blur(2px);
508
+ animation: bgBadgePulse 1.6s ease-in-out infinite;
509
+ }
510
+ @keyframes bgBadgePulse {
511
+ 0%, 100% { opacity: 0.85; }
512
+ 50% { opacity: 1; }
513
+ }
493
514
  .welcome-card-meta { padding: 8px 12px; }
494
515
  .welcome-card-name { font-size: 13px; color: #ddd; word-break: break-all; }
495
516
  .welcome-card-ts { font-size: 11px; color: #666; margin-top: 2px; }