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 +1 -1
- package/renderer/chat.js +199 -4
- package/renderer/styles.css +9 -0
package/package.json
CHANGED
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
|
-
//
|
|
200
|
-
//
|
|
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
|
-
|
|
868
|
+
// Snapshots сбрасываем тоже — они привязаны к текущему filmHandle.
|
|
869
|
+
resetInMemory: () => { history = []; snapshots = []; renderHistory(); renderSnapshotBar(); },
|
|
675
870
|
// board.js зовёт после openFilm чтобы подгрузить chat-историю проекта.
|
|
676
871
|
loadFromCurrentProject: () => loadHistoryFromCurrentProject(),
|
|
677
872
|
tools: TOOLS,
|
package/renderer/styles.css
CHANGED
|
@@ -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;
|