kingkont 0.11.3 → 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/board.js +82 -31
- package/renderer/chat.js +226 -7
- package/renderer/cloudProjects.js +2 -30
- package/renderer/styles.css +14 -0
package/package.json
CHANGED
package/renderer/board.js
CHANGED
|
@@ -588,6 +588,7 @@ async function renderWelcomeRecents() {
|
|
|
588
588
|
}
|
|
589
589
|
|
|
590
590
|
// Карточка локального (папочного) проекта — извлечена из renderWelcomeRecents.
|
|
591
|
+
// Удаление и save-as-template — через ПКМ (по аналогии с cloud-карточкой).
|
|
591
592
|
function makeRecentWelcomeCard(r) {
|
|
592
593
|
const card = document.createElement('div');
|
|
593
594
|
card.className = 'welcome-card';
|
|
@@ -609,17 +610,6 @@ function makeRecentWelcomeCard(r) {
|
|
|
609
610
|
tsEl.textContent = fmtRelativeTime(r.ts);
|
|
610
611
|
meta.append(nameEl, tsEl);
|
|
611
612
|
card.append(thumb, meta);
|
|
612
|
-
const del = document.createElement('div');
|
|
613
|
-
del.className = 'welcome-card-del';
|
|
614
|
-
del.textContent = '×';
|
|
615
|
-
del.title = 'Удалить из недавних';
|
|
616
|
-
del.addEventListener('click', async e => {
|
|
617
|
-
e.stopPropagation();
|
|
618
|
-
if (!confirm(`Убрать «${r.name}» из недавних? (папка не удаляется)`)) return;
|
|
619
|
-
await removeRecent(r.name);
|
|
620
|
-
await renderWelcomeRecents();
|
|
621
|
-
});
|
|
622
|
-
card.appendChild(del);
|
|
623
613
|
card.addEventListener('click', async () => {
|
|
624
614
|
try {
|
|
625
615
|
if (r.handle) {
|
|
@@ -642,9 +632,50 @@ function makeRecentWelcomeCard(r) {
|
|
|
642
632
|
alert('Ошибка: ' + (err?.message || err));
|
|
643
633
|
}
|
|
644
634
|
});
|
|
635
|
+
card.addEventListener('contextmenu', e => {
|
|
636
|
+
e.preventDefault();
|
|
637
|
+
e.stopPropagation();
|
|
638
|
+
showRecentCardContextMenu(r, e.clientX, e.clientY);
|
|
639
|
+
});
|
|
645
640
|
return card;
|
|
646
641
|
}
|
|
647
642
|
|
|
643
|
+
// ПКМ-меню для recent (folder) welcome-карточки. По аналогии с cloud:
|
|
644
|
+
// «Сохранить как шаблон» (открывает проект → saveCurrentProjectAsTemplate)
|
|
645
|
+
// + «Убрать из недавних».
|
|
646
|
+
function showRecentCardContextMenu(r, clientX, clientY) {
|
|
647
|
+
const menu = $('nodeMenu');
|
|
648
|
+
if (!menu) return;
|
|
649
|
+
menu.innerHTML = '';
|
|
650
|
+
const add = (label, fn, opts = {}) => {
|
|
651
|
+
const b = document.createElement('button');
|
|
652
|
+
b.textContent = label;
|
|
653
|
+
if (opts.danger) b.style.color = '#f88';
|
|
654
|
+
b.addEventListener('click', () => { menu.classList.add('hidden'); fn(); });
|
|
655
|
+
menu.appendChild(b);
|
|
656
|
+
};
|
|
657
|
+
add('💾 Сохранить как шаблон…', async () => {
|
|
658
|
+
if (!r.handle) { alert('Handle потерян, сначала открой проект и сохрани заново.'); return; }
|
|
659
|
+
try {
|
|
660
|
+
// Открываем сначала — saveCurrentProjectAsTemplate работает с filmHandle.
|
|
661
|
+
let g = (await r.handle.queryPermission({ mode: 'readwrite' })) === 'granted';
|
|
662
|
+
if (!g) g = (await r.handle.requestPermission({ mode: 'readwrite' })) === 'granted';
|
|
663
|
+
if (!g) { alert('Доступ к папке не подтверждён.'); return; }
|
|
664
|
+
await openFilm(r.handle);
|
|
665
|
+
if (typeof saveCurrentProjectAsTemplate === 'function') {
|
|
666
|
+
await saveCurrentProjectAsTemplate();
|
|
667
|
+
}
|
|
668
|
+
} catch (e) { alert('Ошибка: ' + (e?.message || e)); }
|
|
669
|
+
});
|
|
670
|
+
add('✖ Убрать из недавних', async () => {
|
|
671
|
+
if (!confirm(`Убрать «${r.name}» из недавних? (папка не удаляется)`)) return;
|
|
672
|
+
await removeRecent(r.name);
|
|
673
|
+
await renderWelcomeRecents();
|
|
674
|
+
}, { danger: true });
|
|
675
|
+
positionFloatingMenu(menu, clientX, clientY);
|
|
676
|
+
setTimeout(() => document.addEventListener('mousedown', closeNodeMenu, { once: true }), 0);
|
|
677
|
+
}
|
|
678
|
+
|
|
648
679
|
// Карточка облачного проекта. Визуально такая же как у папочного, плюс
|
|
649
680
|
// ☁ бейдж в углу обложки. Клик → cloudProjects.open() (использует local
|
|
650
681
|
// cache если синхронизирован, иначе скачивает manifest+файлы).
|
|
@@ -715,6 +746,42 @@ function showCloudCardContextMenu(p, clientX, clientY) {
|
|
|
715
746
|
add('↻ Обновить из облака', () => {
|
|
716
747
|
if (window.cloudProjects?.open) window.cloudProjects.open(p.id, p.name, { forceRefresh: true });
|
|
717
748
|
});
|
|
749
|
+
add('💾 Сохранить как шаблон…', async () => {
|
|
750
|
+
// Берём cloud-проект с сервера (manifest+files) и постим в /api/templates
|
|
751
|
+
// в template-формате (board.scene → board.manifest). Без открытия проекта.
|
|
752
|
+
const tplName = await askName('Имя шаблона:', '', p.name || '', { okText: 'Сохранить' });
|
|
753
|
+
if (!tplName) return;
|
|
754
|
+
try {
|
|
755
|
+
const r = await fetch('/api/projects/' + encodeURIComponent(p.id));
|
|
756
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
757
|
+
const proj = await r.json();
|
|
758
|
+
const tplBoards = (proj.manifest?.boards || []).map(b => ({
|
|
759
|
+
kind: b.kind, name: b.name,
|
|
760
|
+
manifest: b.scene || {}, // board.scene в cloud → board.manifest в template
|
|
761
|
+
files: b.files || {},
|
|
762
|
+
}));
|
|
763
|
+
const allFiles = {};
|
|
764
|
+
for (const b of tplBoards) {
|
|
765
|
+
for (const [rel, url] of Object.entries(b.files || {})) allFiles[`${b.kind}/${b.name}/${rel}`] = url;
|
|
766
|
+
}
|
|
767
|
+
const tR = await fetch('/api/templates', {
|
|
768
|
+
method: 'POST',
|
|
769
|
+
headers: { 'Content-Type': 'application/json' },
|
|
770
|
+
body: JSON.stringify({
|
|
771
|
+
name: tplName, kind: 'project',
|
|
772
|
+
manifest: { boards: tplBoards, coverUrl: proj.coverUrl || null },
|
|
773
|
+
files: allFiles,
|
|
774
|
+
}),
|
|
775
|
+
});
|
|
776
|
+
if (!tR.ok) {
|
|
777
|
+
const err = await tR.json().catch(() => ({}));
|
|
778
|
+
throw new Error(err.error || 'HTTP ' + tR.status);
|
|
779
|
+
}
|
|
780
|
+
alert(`Шаблон «${tplName}» создан.`);
|
|
781
|
+
} catch (e) {
|
|
782
|
+
alert('Не удалось сохранить шаблон: ' + (e?.message || e));
|
|
783
|
+
}
|
|
784
|
+
});
|
|
718
785
|
add('🗑 Удалить с сервера', async () => {
|
|
719
786
|
if (!confirm(`Удалить «${p.name}» с сервера? Локальная копия в userData останется.`)) return;
|
|
720
787
|
try {
|
|
@@ -2315,27 +2382,11 @@ function showNodeContextMenu(node, clientX, clientY) {
|
|
|
2315
2382
|
setTimeout(() => document.addEventListener('mousedown', closeNodeMenu, { once: true }), 0);
|
|
2316
2383
|
}
|
|
2317
2384
|
|
|
2318
|
-
// ПКМ на brand-area
|
|
2319
|
-
//
|
|
2320
|
-
//
|
|
2321
|
-
// определит mode по .kingkont-meta.json).
|
|
2385
|
+
// (ПКМ на brand-area внутри проекта раньше показывал «Сохранить как шаблон» —
|
|
2386
|
+
// убрано, переехало в ПКМ карточки на welcome-screen. Функция оставлена
|
|
2387
|
+
// для backward-compat если будут добавляться другие project-actions.)
|
|
2322
2388
|
function showProjectContextMenu(clientX, clientY) {
|
|
2323
|
-
|
|
2324
|
-
menu.innerHTML = '';
|
|
2325
|
-
const add = (label, fn) => {
|
|
2326
|
-
const b = document.createElement('button');
|
|
2327
|
-
b.textContent = label;
|
|
2328
|
-
b.addEventListener('click', () => {
|
|
2329
|
-
menu.classList.add('hidden');
|
|
2330
|
-
fn();
|
|
2331
|
-
});
|
|
2332
|
-
menu.appendChild(b);
|
|
2333
|
-
};
|
|
2334
|
-
add('💾 Сохранить как шаблон…', () => {
|
|
2335
|
-
if (typeof saveCurrentProjectAsTemplate === 'function') saveCurrentProjectAsTemplate();
|
|
2336
|
-
});
|
|
2337
|
-
positionFloatingMenu(menu, clientX, clientY);
|
|
2338
|
-
setTimeout(() => document.addEventListener('mousedown', closeNodeMenu, { once: true }), 0);
|
|
2389
|
+
// intentional no-op: пока что внутри проекта ПКМ на brand ничего не делает.
|
|
2339
2390
|
}
|
|
2340
2391
|
|
|
2341
2392
|
// ПКМ на 📚 Templates-кнопке — действия проектного уровня.
|
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;
|
|
@@ -337,7 +525,19 @@
|
|
|
337
525
|
div.className = 'chat-msg chat-msg-' + m.role;
|
|
338
526
|
const lbl = document.createElement('div');
|
|
339
527
|
lbl.className = 'chat-msg-role';
|
|
340
|
-
|
|
528
|
+
if (m.role === 'assistant') {
|
|
529
|
+
const img = document.createElement('img');
|
|
530
|
+
img.src = 'assets/icon.png';
|
|
531
|
+
img.className = 'chat-msg-avatar';
|
|
532
|
+
img.draggable = false;
|
|
533
|
+
img.alt = '';
|
|
534
|
+
lbl.appendChild(img);
|
|
535
|
+
const span = document.createElement('span');
|
|
536
|
+
span.textContent = 'KingKont';
|
|
537
|
+
lbl.appendChild(span);
|
|
538
|
+
} else {
|
|
539
|
+
lbl.textContent = 'Вы';
|
|
540
|
+
}
|
|
341
541
|
div.appendChild(lbl);
|
|
342
542
|
const body = document.createElement('div');
|
|
343
543
|
body.className = 'chat-msg-body';
|
|
@@ -442,10 +642,15 @@
|
|
|
442
642
|
if (busy) return;
|
|
443
643
|
if (!userText.trim()) return;
|
|
444
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); }
|
|
445
650
|
history.push({ role: 'user', content: userText });
|
|
446
651
|
renderHistory();
|
|
447
652
|
persistDebounced();
|
|
448
|
-
const status = appendStatus('
|
|
653
|
+
const status = appendStatus('KingKont думает…');
|
|
449
654
|
const system = buildSystemPrompt();
|
|
450
655
|
try {
|
|
451
656
|
let iter = 0;
|
|
@@ -530,7 +735,19 @@
|
|
|
530
735
|
div.className = 'chat-msg chat-msg-' + m.role;
|
|
531
736
|
const lbl = document.createElement('div');
|
|
532
737
|
lbl.className = 'chat-msg-role';
|
|
533
|
-
|
|
738
|
+
if (m.role === 'assistant') {
|
|
739
|
+
const img = document.createElement('img');
|
|
740
|
+
img.src = 'assets/icon.png';
|
|
741
|
+
img.className = 'chat-msg-avatar';
|
|
742
|
+
img.draggable = false;
|
|
743
|
+
img.alt = '';
|
|
744
|
+
lbl.appendChild(img);
|
|
745
|
+
const span = document.createElement('span');
|
|
746
|
+
span.textContent = 'KingKont';
|
|
747
|
+
lbl.appendChild(span);
|
|
748
|
+
} else {
|
|
749
|
+
lbl.textContent = 'Вы';
|
|
750
|
+
}
|
|
534
751
|
div.appendChild(lbl);
|
|
535
752
|
if (hasContent) {
|
|
536
753
|
const body = document.createElement('div');
|
|
@@ -597,6 +814,7 @@
|
|
|
597
814
|
<button id="chatClear" title="Очистить историю">⌫</button>
|
|
598
815
|
<button id="chatClose" title="Закрыть">×</button>
|
|
599
816
|
</div>
|
|
817
|
+
<div id="chatSnapshotBar" class="chat-snapshot-bar" style="display:none;"></div>
|
|
600
818
|
<div id="chatList" class="chat-list"></div>
|
|
601
819
|
<div class="chat-input-row">
|
|
602
820
|
<textarea id="chatInput" placeholder="Что нужно сделать со сценой? (Cmd+Enter — отправить)" rows="3"></textarea>
|
|
@@ -644,10 +862,11 @@
|
|
|
644
862
|
close: () => $('chatPanel')?.classList.add('hidden'),
|
|
645
863
|
send,
|
|
646
864
|
// User-clear: чистит И на диске тоже (юзер явно нажал ⌫).
|
|
647
|
-
clear: () => { history = []; renderHistory(); persistNow().catch(() => {}); },
|
|
865
|
+
clear: () => { history = []; snapshots = []; renderHistory(); renderSnapshotBar(); persistNow().catch(() => {}); },
|
|
648
866
|
// Reset-on-close: только in-memory + UI, на диске НЕ трогает (история
|
|
649
867
|
// должна остаться чтобы при следующем открытии того же проекта подгрузилась).
|
|
650
|
-
|
|
868
|
+
// Snapshots сбрасываем тоже — они привязаны к текущему filmHandle.
|
|
869
|
+
resetInMemory: () => { history = []; snapshots = []; renderHistory(); renderSnapshotBar(); },
|
|
651
870
|
// board.js зовёт после openFilm чтобы подгрузить chat-историю проекта.
|
|
652
871
|
loadFromCurrentProject: () => loadHistoryFromCurrentProject(),
|
|
653
872
|
tools: TOOLS,
|
|
@@ -644,36 +644,8 @@
|
|
|
644
644
|
$('newCloudProject')?.addEventListener('click', createNewCloudProject);
|
|
645
645
|
$('openCloudProjects')?.addEventListener('click', openCloudProjectsModal);
|
|
646
646
|
$('saveProjectCloud')?.addEventListener('click', saveCloudProject);
|
|
647
|
-
// ПКМ на «☁ Сохранить на сервер» —
|
|
648
|
-
//
|
|
649
|
-
// в templates.js работает через filmHandle, cloudFs-shim тоже подходит).
|
|
650
|
-
$('saveProjectCloud')?.addEventListener('contextmenu', e => {
|
|
651
|
-
e.preventDefault();
|
|
652
|
-
e.stopPropagation();
|
|
653
|
-
const menu = document.getElementById('nodeMenu');
|
|
654
|
-
if (!menu) return;
|
|
655
|
-
menu.innerHTML = '';
|
|
656
|
-
const add = (label, fn) => {
|
|
657
|
-
const b = document.createElement('button');
|
|
658
|
-
b.textContent = label;
|
|
659
|
-
b.addEventListener('click', () => { menu.classList.add('hidden'); fn(); });
|
|
660
|
-
menu.appendChild(b);
|
|
661
|
-
};
|
|
662
|
-
add('☁ Сохранить на сервер', saveCloudProject);
|
|
663
|
-
add('💾 Сохранить как шаблон…', () => {
|
|
664
|
-
if (typeof saveCurrentProjectAsTemplate === 'function') saveCurrentProjectAsTemplate();
|
|
665
|
-
else alert('saveCurrentProjectAsTemplate недоступен');
|
|
666
|
-
});
|
|
667
|
-
if (typeof positionFloatingMenu === 'function') {
|
|
668
|
-
positionFloatingMenu(menu, e.clientX, e.clientY);
|
|
669
|
-
} else {
|
|
670
|
-
menu.style.cssText = `position:fixed; left:${e.clientX}px; top:${e.clientY}px; z-index:9999;`;
|
|
671
|
-
}
|
|
672
|
-
// Используем глобальный closeNodeMenu — он умеет НЕ закрываться на клик
|
|
673
|
-
// по активной кнопке внутри меню (даёт click-обработчику сработать).
|
|
674
|
-
// Без этого mousedown на кнопке закрывал меню до click → нажатие тонуло.
|
|
675
|
-
setTimeout(() => document.addEventListener('mousedown', closeNodeMenu, { once: true }), 0);
|
|
676
|
-
});
|
|
647
|
+
// (ПКМ на «☁ Сохранить на сервер» убран — «Сохранить как шаблон»
|
|
648
|
+
// переехал на welcome-screen в ПКМ карточки проекта.)
|
|
677
649
|
setCloudButtonsVisibility();
|
|
678
650
|
// Переинициализируем видимость кнопок раз в 5 сек — для случая когда юзер
|
|
679
651
|
// login/logout в Chatium через настройки (нет других сигналов).
|
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;
|
|
@@ -282,6 +291,11 @@
|
|
|
282
291
|
.chat-msg-role {
|
|
283
292
|
font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px;
|
|
284
293
|
color: #888;
|
|
294
|
+
display: flex; align-items: center; gap: 6px;
|
|
295
|
+
}
|
|
296
|
+
.chat-msg-avatar {
|
|
297
|
+
width: 16px; height: 16px; border-radius: 4px;
|
|
298
|
+
object-fit: contain; background: #1f1f1f; padding: 1px;
|
|
285
299
|
}
|
|
286
300
|
.chat-msg-user .chat-msg-role { color: #6a9; }
|
|
287
301
|
.chat-msg-assistant .chat-msg-role { color: #c97; }
|