kingkont 0.11.4 → 0.11.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kingkont",
3
- "version": "0.11.4",
3
+ "version": "0.11.5",
4
4
  "description": "KingKont · Chatium — нод-редактор сцен с AI-генерацией (картинки/видео/голос/SFX/музыка/текст)",
5
5
  "main": "main.js",
6
6
  "bin": {
package/renderer/chat.js CHANGED
@@ -196,8 +196,17 @@
196
196
  const modelKey = node.generated?.modelKey;
197
197
  const boardHandle = b.handle;
198
198
  const bKey = b.key;
199
- // Маршрутизация по kind копия логики из resumeJob, чтобы запустить
200
- // job напрямую без gen-modal'а.
199
+ // КРИТИЧНО: выставить status='generating' ДО запуска job иначе
200
+ // нода не показывает spinner, и наша же ветка состояний может не
201
+ // подхватиться. Также чистим прошлый error и пишем kind в generated
202
+ // (resumeJob и UI зависят от node.generated.kind).
203
+ node.status = 'generating';
204
+ node.error = undefined;
205
+ node.generated = { ...(node.generated || {}), kind, prompt, rawPrompt: node.generated?.rawPrompt || prompt };
206
+ scheduleSave();
207
+ if (typeof renderCanvas === 'function') await renderCanvas();
208
+ // Маршрутизация по kind — копия логики из resumeJob/«Повторить», чтобы
209
+ // запустить job напрямую без gen-modal'а.
201
210
  if (kind === 'audio') {
202
211
  if (typeof runTTSJob !== 'function') throw new Error('runTTSJob недоступен');
203
212
  runTTSJob(node, prompt, boardHandle, bKey, node.generated?.voiceId).catch(e => console.error('TTS job failed:', e));
@@ -323,6 +332,185 @@
323
332
  renderHistory();
324
333
  }
325
334
 
335
+ // ============== SNAPSHOTS (rollback chat actions) ==============
336
+ // Перед каждым user-сообщением чат снимает snapshot ВСЕГО проекта:
337
+ // для каждой доски — её scene.json и список media-файлов (только пути!).
338
+ // Файлы НЕ копируются. Для отката:
339
+ // 1. Восстанавливаем scene.json для каждой доски.
340
+ // 2. Если файл из snapshot отсутствует — ищем в `<board>/_deleted/`
341
+ // по basename (deleteNode складывает туда с unique-suffix).
342
+ // Хранится в памяти (последние MAX_SNAPSHOTS), теряется при close project.
343
+ const MAX_SNAPSHOTS = 10;
344
+ let snapshots = []; // [{ts, label, boards: [{kind, name, sceneText, files:[relPath]}]}]
345
+
346
+ async function takeSnapshot(label) {
347
+ if (!state.filmHandle) return null;
348
+ const boards = [];
349
+ const lists = [
350
+ { kind: 'episode', getter: listEpisodes },
351
+ { kind: 'character', getter: listCharacters },
352
+ { kind: 'location', getter: listLocations },
353
+ ];
354
+ for (const { kind, getter } of lists) {
355
+ try {
356
+ const items = await getter(state.filmHandle);
357
+ for (const it of items) {
358
+ try {
359
+ const sceneFh = await it.handle.getFileHandle('scene.json');
360
+ const sceneText = await (await sceneFh.getFile()).text();
361
+ // Список media-файлов (пути) — без чтения содержимого.
362
+ let files = [];
363
+ try {
364
+ const all = await walkBoardFiles(it.handle);
365
+ files = all
366
+ .map(f => f.relPath)
367
+ .filter(p => p !== 'scene.json' && !p.startsWith('.'));
368
+ } catch {}
369
+ boards.push({ kind, name: it.name, sceneText, files });
370
+ } catch {}
371
+ }
372
+ } catch {}
373
+ }
374
+ const snap = { ts: Date.now(), label: label || '', boards };
375
+ snapshots.push(snap);
376
+ if (snapshots.length > MAX_SNAPSHOTS) snapshots.shift();
377
+ renderSnapshotBar();
378
+ return snap;
379
+ }
380
+
381
+ // Найти в `<board>/_deleted/` файл по basename (deleteNode даёт unique-suffix
382
+ // типа `<basename>` или `<basename>(1)` если был конфликт). Перемещаем
383
+ // обратно в boardHandle/<relPath>. Возвращает true если успешно.
384
+ async function tryRestoreFromDeleted(boardHandle, relPath) {
385
+ let delDir;
386
+ try { delDir = await boardHandle.getDirectoryHandle('_deleted'); }
387
+ catch { return false; }
388
+ const wanted = relPath.split('/').pop(); // basename
389
+ const baseStem = wanted.replace(/\.[^.]+$/, '');
390
+ const ext = wanted.match(/\.[^.]+$/)?.[0] || '';
391
+ let foundName = null;
392
+ try {
393
+ for await (const [name, h] of delDir.entries()) {
394
+ if (h.kind !== 'file') continue;
395
+ if (name === wanted) { foundName = name; break; }
396
+ // uniqueName может добавить (1), (2)…
397
+ if (name.startsWith(baseStem) && name.endsWith(ext)) { foundName = name; break; }
398
+ }
399
+ } catch {}
400
+ if (!foundName) return false;
401
+ try {
402
+ const srcFh = await delDir.getFileHandle(foundName);
403
+ const blob = await srcFh.getFile();
404
+ // Пишем обратно по relPath (создаём подпапки).
405
+ const parts = relPath.split('/');
406
+ let dh = boardHandle;
407
+ for (let i = 0; i < parts.length - 1; i++) {
408
+ dh = await dh.getDirectoryHandle(parts[i], { create: true });
409
+ }
410
+ const dstFh = await dh.getFileHandle(parts[parts.length - 1], { create: true });
411
+ const w = await dstFh.createWritable();
412
+ await w.write(blob);
413
+ await w.close();
414
+ // И удаляем из _deleted.
415
+ await delDir.removeEntry(foundName);
416
+ return true;
417
+ } catch (e) {
418
+ console.warn('restore from _deleted failed:', relPath, e?.message);
419
+ return false;
420
+ }
421
+ }
422
+
423
+ async function rollbackToSnapshot(idx) {
424
+ const snap = snapshots[idx];
425
+ if (!snap || !state.filmHandle) return;
426
+ const stats = { sceneRestored: 0, filesRestored: 0, filesMissing: 0 };
427
+ for (const b of snap.boards) {
428
+ let parent = state.filmHandle;
429
+ try {
430
+ if (b.kind === 'character') parent = await state.filmHandle.getDirectoryHandle('_characters', { create: true });
431
+ else if (b.kind === 'location') parent = await state.filmHandle.getDirectoryHandle('_locations', { create: true });
432
+ } catch {}
433
+ let boardHandle;
434
+ try { boardHandle = await parent.getDirectoryHandle(b.name, { create: true }); }
435
+ catch (e) { console.warn('rollback: cannot get board', b.name, e); continue; }
436
+ // 1) scene.json — overwrite.
437
+ try {
438
+ const sceneFh = await boardHandle.getFileHandle('scene.json', { create: true });
439
+ const w = await sceneFh.createWritable();
440
+ await w.write(b.sceneText);
441
+ await w.close();
442
+ stats.sceneRestored++;
443
+ } catch (e) { console.warn('rollback: scene write failed', b.name, e); }
444
+ // 2) media-файлы: что отсутствует — пробуем restore из _deleted/.
445
+ for (const relPath of b.files) {
446
+ try {
447
+ await resolveBoardFile(boardHandle, relPath);
448
+ continue; // файл уже на месте
449
+ } catch {}
450
+ if (await tryRestoreFromDeleted(boardHandle, relPath)) stats.filesRestored++;
451
+ else stats.filesMissing++;
452
+ }
453
+ }
454
+ // Удаляем snapshot'ы более новые чем восстановленный (он стал текущим).
455
+ snapshots = snapshots.slice(0, idx);
456
+ renderSnapshotBar();
457
+ // Перечитать текущую доску в UI.
458
+ if (state.currentBoard && typeof selectBoard === 'function') {
459
+ try { await selectBoard({ kind: state.currentBoard.kind, name: state.currentBoard.name, handle: state.currentBoard.handle }); }
460
+ catch {}
461
+ }
462
+ if (typeof refreshSidebar === 'function') await refreshSidebar();
463
+ appendStatus(`↩ Откат: scene=${stats.sceneRestored}, files restored=${stats.filesRestored}, missing=${stats.filesMissing}`);
464
+ }
465
+
466
+ function renderSnapshotBar() {
467
+ const bar = $('chatSnapshotBar');
468
+ if (!bar) return;
469
+ bar.innerHTML = '';
470
+ if (!snapshots.length) { bar.style.display = 'none'; return; }
471
+ bar.style.display = '';
472
+ const lbl = document.createElement('span');
473
+ lbl.textContent = `Снепшоты: ${snapshots.length} `;
474
+ lbl.style.cssText = 'color:#888; font-size:11px;';
475
+ bar.appendChild(lbl);
476
+ // Кнопки: undo до последнего snapshot. Меню по клику для выбора более старого.
477
+ const undoBtn = document.createElement('button');
478
+ undoBtn.textContent = '↩ Откатить';
479
+ undoBtn.title = `Откат до: ${snapshots[snapshots.length - 1]?.label || '(без метки)'}`;
480
+ undoBtn.addEventListener('click', () => {
481
+ if (!confirm(`Откатить до snapshot'а: «${snapshots[snapshots.length - 1].label || '(без метки)'}»?`)) return;
482
+ rollbackToSnapshot(snapshots.length - 1);
483
+ });
484
+ bar.appendChild(undoBtn);
485
+ if (snapshots.length > 1) {
486
+ const moreBtn = document.createElement('button');
487
+ moreBtn.textContent = '…';
488
+ moreBtn.title = 'Выбрать более старый snapshot';
489
+ moreBtn.addEventListener('click', e => {
490
+ e.stopPropagation();
491
+ const menu = $('nodeMenu');
492
+ if (!menu) return;
493
+ menu.innerHTML = '';
494
+ for (let i = snapshots.length - 1; i >= 0; i--) {
495
+ const s = snapshots[i];
496
+ const dt = new Date(s.ts).toLocaleTimeString('ru-RU');
497
+ const b = document.createElement('button');
498
+ b.textContent = `↩ ${dt} · ${s.label || '(без метки)'}`;
499
+ const idx = i;
500
+ b.addEventListener('click', () => {
501
+ menu.classList.add('hidden');
502
+ if (!confirm(`Откатить до «${s.label || '(без метки)'}»?`)) return;
503
+ rollbackToSnapshot(idx);
504
+ });
505
+ menu.appendChild(b);
506
+ }
507
+ positionFloatingMenu(menu, e.clientX, e.clientY);
508
+ setTimeout(() => document.addEventListener('mousedown', closeNodeMenu, { once: true }), 0);
509
+ });
510
+ bar.appendChild(moreBtn);
511
+ }
512
+ }
513
+
326
514
  // ============== UI ==============
327
515
  let history = []; // [{role: 'user'|'assistant'|'system', content: string, tools?: [...], results?: [...]}]
328
516
  let busy = false;
@@ -454,6 +642,11 @@
454
642
  if (busy) return;
455
643
  if (!userText.trim()) return;
456
644
  busy = true;
645
+ // Snapshot ДО мутации — чтобы юзер мог откатить «всё что чат сделал
646
+ // в ответ на это сообщение». Шортнем label первыми 60 символами user-msg.
647
+ try {
648
+ await takeSnapshot(userText.length > 60 ? userText.slice(0, 60) + '…' : userText);
649
+ } catch (e) { console.warn('snapshot failed:', e?.message); }
457
650
  history.push({ role: 'user', content: userText });
458
651
  renderHistory();
459
652
  persistDebounced();
@@ -621,6 +814,7 @@
621
814
  <button id="chatClear" title="Очистить историю">⌫</button>
622
815
  <button id="chatClose" title="Закрыть">×</button>
623
816
  </div>
817
+ <div id="chatSnapshotBar" class="chat-snapshot-bar" style="display:none;"></div>
624
818
  <div id="chatList" class="chat-list"></div>
625
819
  <div class="chat-input-row">
626
820
  <textarea id="chatInput" placeholder="Что нужно сделать со сценой? (Cmd+Enter — отправить)" rows="3"></textarea>
@@ -668,10 +862,11 @@
668
862
  close: () => $('chatPanel')?.classList.add('hidden'),
669
863
  send,
670
864
  // User-clear: чистит И на диске тоже (юзер явно нажал ⌫).
671
- clear: () => { history = []; renderHistory(); persistNow().catch(() => {}); },
865
+ clear: () => { history = []; snapshots = []; renderHistory(); renderSnapshotBar(); persistNow().catch(() => {}); },
672
866
  // Reset-on-close: только in-memory + UI, на диске НЕ трогает (история
673
867
  // должна остаться чтобы при следующем открытии того же проекта подгрузилась).
674
- resetInMemory: () => { history = []; renderHistory(); },
868
+ // Snapshots сбрасываем тоже они привязаны к текущему filmHandle.
869
+ resetInMemory: () => { history = []; snapshots = []; renderHistory(); renderSnapshotBar(); },
675
870
  // board.js зовёт после openFilm чтобы подгрузить chat-историю проекта.
676
871
  loadFromCurrentProject: () => loadHistoryFromCurrentProject(),
677
872
  tools: TOOLS,
@@ -268,6 +268,15 @@
268
268
  border-radius: 4px; padding: 2px 8px; cursor: pointer; font-size: 12px;
269
269
  }
270
270
  .chat-header button:hover { background: #2a2a2a; color: #fff; }
271
+ .chat-snapshot-bar {
272
+ padding: 6px 10px; background: #1f1f1f; border-bottom: 1px solid #2a2a2a;
273
+ display: flex; align-items: center; gap: 6px; flex-shrink: 0;
274
+ }
275
+ .chat-snapshot-bar button {
276
+ background: #2a2a2a; border: 1px solid #444; color: #cde;
277
+ padding: 2px 8px; border-radius: 4px; font-size: 11px; cursor: pointer;
278
+ }
279
+ .chat-snapshot-bar button:hover { background: #3a3a3a; }
271
280
  .chat-list {
272
281
  flex: 1; overflow-y: auto; padding: 10px;
273
282
  display: flex; flex-direction: column; gap: 6px;