kingkont 0.11.4 → 0.12.0
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/main.js +14 -0
- package/package.json +1 -1
- package/preload.js +10 -0
- package/renderer/board.js +69 -5
- package/renderer/chat.js +319 -7
- package/renderer/styles.css +34 -7
package/main.js
CHANGED
|
@@ -745,6 +745,20 @@ ipcMain.handle('cloudFs:openInFinder', async (_e, projectId) => {
|
|
|
745
745
|
}
|
|
746
746
|
});
|
|
747
747
|
|
|
748
|
+
// Универсальный shell.openPath — для папочных проектов где знаем абс-путь.
|
|
749
|
+
// Renderer выводит путь через webUtils.getPathForFile (см. appPath.pathForFile
|
|
750
|
+
// в preload). Безопасность: shell.openPath сам проверяет что path существует
|
|
751
|
+
// + macOS sandbox предотвращает «поход куда не следует».
|
|
752
|
+
ipcMain.handle('shell:openPath', async (_e, absPath) => {
|
|
753
|
+
if (!absPath || typeof absPath !== 'string') return { ok: false, error: 'no path' };
|
|
754
|
+
try {
|
|
755
|
+
const err = await shell.openPath(absPath);
|
|
756
|
+
return { ok: !err, error: err || null };
|
|
757
|
+
} catch (e) {
|
|
758
|
+
return { ok: false, error: e.message };
|
|
759
|
+
}
|
|
760
|
+
});
|
|
761
|
+
|
|
748
762
|
ipcMain.handle('window:close', () => {
|
|
749
763
|
if (win && !win.isDestroyed()) win.close();
|
|
750
764
|
});
|
package/package.json
CHANGED
package/preload.js
CHANGED
|
@@ -93,6 +93,16 @@ contextBridge.exposeInMainWorld('claudeMd', {
|
|
|
93
93
|
installSkill: () => ipcRenderer.invoke('claudeMd:installSkill'),
|
|
94
94
|
});
|
|
95
95
|
|
|
96
|
+
// Filesystem-path utils. Нужно для «Открыть в Finder» папочных проектов:
|
|
97
|
+
// FSAH directoryHandle не даёт абс-путь, но webUtils.getPathForFile(file)
|
|
98
|
+
// — даёт. Берём любой файл изнутри папки → парсим parent → openPath.
|
|
99
|
+
contextBridge.exposeInMainWorld('appPath', {
|
|
100
|
+
pathForFile: (file) => {
|
|
101
|
+
try { return webUtils.getPathForFile(file) || null; } catch { return null; }
|
|
102
|
+
},
|
|
103
|
+
openPath: (absPath) => ipcRenderer.invoke('shell:openPath', absPath),
|
|
104
|
+
});
|
|
105
|
+
|
|
96
106
|
// Cloud-projects: тонкая обёртка над userData/cloud-projects/<id>/.
|
|
97
107
|
// Используется renderer'ом в режиме «облачного проекта» (без showDirectoryPicker).
|
|
98
108
|
// На веб-версии (без preload) — undefined; web-shell использует in-memory модель
|
package/renderer/board.js
CHANGED
|
@@ -642,6 +642,7 @@ function makeRecentWelcomeCard(r) {
|
|
|
642
642
|
|
|
643
643
|
// ПКМ-меню для recent (folder) welcome-карточки. По аналогии с cloud:
|
|
644
644
|
// «Сохранить как шаблон» (открывает проект → saveCurrentProjectAsTemplate)
|
|
645
|
+
// + «Открыть в Finder» (абс-путь через webUtils.getPathForFile)
|
|
645
646
|
// + «Убрать из недавних».
|
|
646
647
|
function showRecentCardContextMenu(r, clientX, clientY) {
|
|
647
648
|
const menu = $('nodeMenu');
|
|
@@ -654,6 +655,21 @@ function showRecentCardContextMenu(r, clientX, clientY) {
|
|
|
654
655
|
b.addEventListener('click', () => { menu.classList.add('hidden'); fn(); });
|
|
655
656
|
menu.appendChild(b);
|
|
656
657
|
};
|
|
658
|
+
add('📂 Открыть в Finder', async () => {
|
|
659
|
+
if (!r.handle || !window.appPath?.pathForFile || !window.appPath?.openPath) {
|
|
660
|
+
alert('Доступно только в Electron-приложении');
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
try {
|
|
664
|
+
let g = (await r.handle.queryPermission({ mode: 'read' })) === 'granted';
|
|
665
|
+
if (!g) g = (await r.handle.requestPermission({ mode: 'read' })) === 'granted';
|
|
666
|
+
if (!g) { alert('Доступ к папке не подтверждён.'); return; }
|
|
667
|
+
const folderPath = await deriveFolderAbsPath(r.handle);
|
|
668
|
+
if (!folderPath) { alert('Не удалось определить путь к папке (нет файлов внутри).'); return; }
|
|
669
|
+
const res = await window.appPath.openPath(folderPath);
|
|
670
|
+
if (!res?.ok) alert('Не удалось открыть: ' + (res?.error || 'unknown'));
|
|
671
|
+
} catch (e) { alert('Ошибка: ' + (e?.message || e)); }
|
|
672
|
+
});
|
|
657
673
|
add('💾 Сохранить как шаблон…', async () => {
|
|
658
674
|
if (!r.handle) { alert('Handle потерян, сначала открой проект и сохрани заново.'); return; }
|
|
659
675
|
try {
|
|
@@ -676,6 +692,43 @@ function showRecentCardContextMenu(r, clientX, clientY) {
|
|
|
676
692
|
setTimeout(() => document.addEventListener('mousedown', closeNodeMenu, { once: true }), 0);
|
|
677
693
|
}
|
|
678
694
|
|
|
695
|
+
// Получить абс-путь FSAH-папки. webUtils.getPathForFile работает только
|
|
696
|
+
// для File объектов (не для DirectoryHandle), поэтому ищем любой файл
|
|
697
|
+
// внутри и берём parent. Тестируем: scene.json в любом board → up 2 dirs.
|
|
698
|
+
// Или meta-файл в корне → up 1 dir. Если ничего нет — возвращаем null.
|
|
699
|
+
async function deriveFolderAbsPath(filmHandle) {
|
|
700
|
+
if (!window.appPath?.pathForFile) return null;
|
|
701
|
+
// 1) Любой файл в корне (например .kingkont-meta.json или .cover.*).
|
|
702
|
+
try {
|
|
703
|
+
for await (const [name, h] of filmHandle.entries()) {
|
|
704
|
+
if (h.kind === 'file' && !name.startsWith('.git') && !name.startsWith('node_modules')) {
|
|
705
|
+
const file = await h.getFile();
|
|
706
|
+
const p = window.appPath.pathForFile(file);
|
|
707
|
+
if (p) return p.replace(/[\/\\][^\/\\]+$/, '');
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
} catch {}
|
|
711
|
+
// 2) Файл из подпапки (любой board) — up 2 уровня.
|
|
712
|
+
try {
|
|
713
|
+
for await (const [, dirH] of filmHandle.entries()) {
|
|
714
|
+
if (dirH.kind !== 'directory' || dirH.name.startsWith('.')) continue;
|
|
715
|
+
try {
|
|
716
|
+
for await (const [name2, h2] of dirH.entries()) {
|
|
717
|
+
if (h2.kind === 'file') {
|
|
718
|
+
const file = await h2.getFile();
|
|
719
|
+
const p = window.appPath.pathForFile(file);
|
|
720
|
+
if (p) {
|
|
721
|
+
// up 2: removes filename + parent dir name
|
|
722
|
+
return p.replace(/[\/\\][^\/\\]+[\/\\][^\/\\]+$/, '');
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
} catch {}
|
|
727
|
+
}
|
|
728
|
+
} catch {}
|
|
729
|
+
return null;
|
|
730
|
+
}
|
|
731
|
+
|
|
679
732
|
// Карточка облачного проекта. Визуально такая же как у папочного, плюс
|
|
680
733
|
// ☁ бейдж в углу обложки. Клик → cloudProjects.open() (использует local
|
|
681
734
|
// cache если синхронизирован, иначе скачивает manifest+файлы).
|
|
@@ -1772,7 +1825,11 @@ async function selectBoard(board) {
|
|
|
1772
1825
|
scheduleUpdatePreview();
|
|
1773
1826
|
}
|
|
1774
1827
|
|
|
1775
|
-
// Восстановить pan/zoom
|
|
1828
|
+
// Восстановить pan/zoom доски. Default-scroll выставляем на canvas-pad-x/y
|
|
1829
|
+
// (см. styles.css .canvas-frame), чтобы canvas (0,0) был виден в top-left.
|
|
1830
|
+
// Без этого пользователь видел бы пустой padding слева/сверху.
|
|
1831
|
+
const padX = parseInt(getComputedStyle(document.querySelector('.canvas-frame')).getPropertyValue('--canvas-pad-x')) || 2000;
|
|
1832
|
+
const padY = parseInt(getComputedStyle(document.querySelector('.canvas-frame')).getPropertyValue('--canvas-pad-y')) || 1500;
|
|
1776
1833
|
const view = state.currentBoard.metadata.view;
|
|
1777
1834
|
if (view) {
|
|
1778
1835
|
if (typeof view.zoom === 'number') {
|
|
@@ -1780,14 +1837,21 @@ async function selectBoard(board) {
|
|
|
1780
1837
|
applyZoomStyles(state.zoom);
|
|
1781
1838
|
$('zoomLabel').textContent = Math.round(state.zoom * 100) + '%';
|
|
1782
1839
|
}
|
|
1783
|
-
|
|
1784
|
-
|
|
1840
|
+
// ВАЖНО: для backward-compat старых view (где scrollLeft/Top сохранены
|
|
1841
|
+
// БЕЗ padding'а — до этой фичи) добавляем padding к сохранённому
|
|
1842
|
+
// значению. Если view свежий и >= padding'а, считаем что padding уже учтён.
|
|
1843
|
+
if (typeof view.scrollLeft === 'number') {
|
|
1844
|
+
canvasWrap.scrollLeft = view.scrollLeft >= padX ? view.scrollLeft : view.scrollLeft + padX;
|
|
1845
|
+
} else canvasWrap.scrollLeft = padX;
|
|
1846
|
+
if (typeof view.scrollTop === 'number') {
|
|
1847
|
+
canvasWrap.scrollTop = view.scrollTop >= padY ? view.scrollTop : view.scrollTop + padY;
|
|
1848
|
+
} else canvasWrap.scrollTop = padY;
|
|
1785
1849
|
} else {
|
|
1786
1850
|
state.zoom = 1;
|
|
1787
1851
|
applyZoomStyles(1);
|
|
1788
1852
|
$('zoomLabel').textContent = '100%';
|
|
1789
|
-
canvasWrap.scrollLeft =
|
|
1790
|
-
canvasWrap.scrollTop =
|
|
1853
|
+
canvasWrap.scrollLeft = padX;
|
|
1854
|
+
canvasWrap.scrollTop = padY;
|
|
1791
1855
|
}
|
|
1792
1856
|
|
|
1793
1857
|
// Возобновить незавершённые джобы текущей доски
|
package/renderer/chat.js
CHANGED
|
@@ -104,10 +104,14 @@
|
|
|
104
104
|
async handler({ type, name, prompt, x, y, text, modelKey }) {
|
|
105
105
|
if (!state.currentBoard) throw new Error('доска не выбрана');
|
|
106
106
|
if (!['image','video','audio','text','label'].includes(type)) throw new Error(`unknown type: ${type}`);
|
|
107
|
-
// Авто-координаты — справа от последней ноды.
|
|
107
|
+
// Авто-координаты — справа от последней ноды. Clamp к >= 50, чтобы
|
|
108
|
+
// нода не ушла в отрицательные координаты (canvas начинается с 0,0,
|
|
109
|
+
// негативные позиции невидимы без скролла).
|
|
108
110
|
const last = state.currentBoard.metadata.nodes[state.currentBoard.metadata.nodes.length - 1];
|
|
109
|
-
|
|
110
|
-
|
|
111
|
+
let nx = typeof x === 'number' ? x : (last ? last.x + 320 : 100);
|
|
112
|
+
let ny = typeof y === 'number' ? y : (last ? last.y : 100);
|
|
113
|
+
if (nx < 50) nx = 50;
|
|
114
|
+
if (ny < 50) ny = 50;
|
|
111
115
|
const id = (crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2));
|
|
112
116
|
const node = { id, type, x: nx, y: ny };
|
|
113
117
|
if (name) node.name = name;
|
|
@@ -181,6 +185,119 @@
|
|
|
181
185
|
},
|
|
182
186
|
},
|
|
183
187
|
|
|
188
|
+
list_timeline: {
|
|
189
|
+
description: 'Список дорожек и клипов таймлайна текущей доски. Возвращает {tracks: [{id, name, kind, clips: [{id, nodeId, file, start, duration, name}]}], totalDuration}.',
|
|
190
|
+
params: '{}',
|
|
191
|
+
async handler() {
|
|
192
|
+
const b = state.currentBoard;
|
|
193
|
+
if (!b) throw new Error('доска не выбрана');
|
|
194
|
+
if (typeof getTimeline !== 'function') throw new Error('getTimeline недоступен');
|
|
195
|
+
const tl = getTimeline();
|
|
196
|
+
const tracks = (tl?.tracks || []).map(t => ({
|
|
197
|
+
id: t.id, name: t.name, kind: t.kind,
|
|
198
|
+
clips: (t.clips || []).map(c => ({
|
|
199
|
+
id: c.id, nodeId: c.nodeId || null, type: c.type,
|
|
200
|
+
file: c.file || null, name: c.name || null,
|
|
201
|
+
start: c.start || 0, duration: c.duration || 0,
|
|
202
|
+
})),
|
|
203
|
+
}));
|
|
204
|
+
const totalDuration = tracks.reduce((max, t) => Math.max(
|
|
205
|
+
max,
|
|
206
|
+
(t.clips || []).reduce((m, c) => Math.max(m, (c.start || 0) + (c.duration || 0)), 0),
|
|
207
|
+
), 0);
|
|
208
|
+
return { tracks, totalDuration };
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
|
|
212
|
+
add_clip_to_timeline: {
|
|
213
|
+
description: 'Добавить ноду как клип в таймлайн. trackKind="video" для image/video, "audio" для audio. Если start не задан — добавляется в конец дорожки.',
|
|
214
|
+
params: '{"nodeId":"<node-id>","trackKind":"video|audio","start":<seconds (optional)>,"duration":<seconds (optional, default по типу)>}',
|
|
215
|
+
async handler({ nodeId, trackKind, start, duration }) {
|
|
216
|
+
const b = state.currentBoard;
|
|
217
|
+
if (!b) throw new Error('доска не выбрана');
|
|
218
|
+
const node = b.metadata.nodes.find(n => n.id === nodeId);
|
|
219
|
+
if (!node) throw new Error(`нода не найдена: ${nodeId}`);
|
|
220
|
+
if (!node.file) throw new Error('у ноды нет файла (нужно сначала сгенерировать)');
|
|
221
|
+
if (typeof getTimeline !== 'function') throw new Error('getTimeline недоступен');
|
|
222
|
+
const tl = getTimeline();
|
|
223
|
+
const tk = trackKind === 'audio' ? 'audio' : 'video';
|
|
224
|
+
let track = tl.tracks.find(t => t.kind === tk);
|
|
225
|
+
if (!track) {
|
|
226
|
+
track = { id: crypto.randomUUID(), name: tk === 'audio' ? 'Аудио' : 'Видео', kind: tk, clips: [] };
|
|
227
|
+
tl.tracks.push(track);
|
|
228
|
+
}
|
|
229
|
+
const trackEnd = track.clips.reduce((m, c) => Math.max(m, (+c.start || 0) + (+c.duration || 0)), 0);
|
|
230
|
+
const dur = typeof duration === 'number' && duration > 0
|
|
231
|
+
? duration
|
|
232
|
+
: (typeof defaultClipDuration === 'function' ? defaultClipDuration(node) : (node.type === 'image' ? 3 : 5));
|
|
233
|
+
const clip = {
|
|
234
|
+
id: crypto.randomUUID(),
|
|
235
|
+
nodeId: node.id,
|
|
236
|
+
type: node.type === 'audio' ? 'audio' : (node.type === 'video' ? 'video' : 'image'),
|
|
237
|
+
file: node.file,
|
|
238
|
+
name: node.name || null,
|
|
239
|
+
duration: dur,
|
|
240
|
+
start: typeof start === 'number' ? Math.max(0, start) : trackEnd,
|
|
241
|
+
};
|
|
242
|
+
track.clips.push(clip);
|
|
243
|
+
scheduleSave();
|
|
244
|
+
if (!document.getElementById('timelinePanel').classList.contains('hidden')) {
|
|
245
|
+
if (typeof renderTimeline === 'function') renderTimeline();
|
|
246
|
+
}
|
|
247
|
+
return { ok: true, clipId: clip.id, trackId: track.id, start: clip.start, duration: clip.duration };
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
|
|
251
|
+
remove_clip_from_timeline: {
|
|
252
|
+
description: 'Удалить клип из таймлайна по clipId.',
|
|
253
|
+
params: '{"clipId":"<clip-id>"}',
|
|
254
|
+
async handler({ clipId }) {
|
|
255
|
+
const b = state.currentBoard;
|
|
256
|
+
if (!b) throw new Error('доска не выбрана');
|
|
257
|
+
const tl = getTimeline();
|
|
258
|
+
if (!tl) throw new Error('нет таймлайна');
|
|
259
|
+
let removed = false;
|
|
260
|
+
for (const t of tl.tracks) {
|
|
261
|
+
const i = t.clips.findIndex(c => c.id === clipId);
|
|
262
|
+
if (i >= 0) { t.clips.splice(i, 1); removed = true; break; }
|
|
263
|
+
}
|
|
264
|
+
if (!removed) throw new Error(`клип не найден: ${clipId}`);
|
|
265
|
+
scheduleSave();
|
|
266
|
+
if (!document.getElementById('timelinePanel').classList.contains('hidden')) {
|
|
267
|
+
if (typeof renderTimeline === 'function') renderTimeline();
|
|
268
|
+
}
|
|
269
|
+
return { ok: true };
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
|
|
273
|
+
clear_timeline: {
|
|
274
|
+
description: 'Очистить таймлайн (все клипы со всех дорожек). Дорожки остаются.',
|
|
275
|
+
params: '{}',
|
|
276
|
+
async handler() {
|
|
277
|
+
const b = state.currentBoard;
|
|
278
|
+
if (!b) throw new Error('доска не выбрана');
|
|
279
|
+
const tl = getTimeline();
|
|
280
|
+
if (!tl) throw new Error('нет таймлайна');
|
|
281
|
+
for (const t of tl.tracks) t.clips = [];
|
|
282
|
+
scheduleSave();
|
|
283
|
+
if (!document.getElementById('timelinePanel').classList.contains('hidden')) {
|
|
284
|
+
if (typeof renderTimeline === 'function') renderTimeline();
|
|
285
|
+
}
|
|
286
|
+
return { ok: true };
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
|
|
290
|
+
open_timeline: {
|
|
291
|
+
description: 'Раскрыть панель таймлайна (если скрыта).',
|
|
292
|
+
params: '{}',
|
|
293
|
+
async handler() {
|
|
294
|
+
const panel = document.getElementById('timelinePanel');
|
|
295
|
+
if (!panel) throw new Error('timeline panel не найдена');
|
|
296
|
+
if (panel.classList.contains('hidden')) document.getElementById('timelineBtn')?.click();
|
|
297
|
+
return { ok: true };
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
|
|
184
301
|
generate_node: {
|
|
185
302
|
description: 'Запустить генерацию для draft-ноды (image/video/audio/text). Стартует напрямую в фоне БЕЗ показа диалога — не интерактивная UI-форма как regenerateNode.',
|
|
186
303
|
params: '{"id":"<node-id>"}',
|
|
@@ -196,8 +313,17 @@
|
|
|
196
313
|
const modelKey = node.generated?.modelKey;
|
|
197
314
|
const boardHandle = b.handle;
|
|
198
315
|
const bKey = b.key;
|
|
199
|
-
//
|
|
200
|
-
//
|
|
316
|
+
// КРИТИЧНО: выставить status='generating' ДО запуска job — иначе
|
|
317
|
+
// нода не показывает spinner, и наша же ветка состояний может не
|
|
318
|
+
// подхватиться. Также чистим прошлый error и пишем kind в generated
|
|
319
|
+
// (resumeJob и UI зависят от node.generated.kind).
|
|
320
|
+
node.status = 'generating';
|
|
321
|
+
node.error = undefined;
|
|
322
|
+
node.generated = { ...(node.generated || {}), kind, prompt, rawPrompt: node.generated?.rawPrompt || prompt };
|
|
323
|
+
scheduleSave();
|
|
324
|
+
if (typeof renderCanvas === 'function') await renderCanvas();
|
|
325
|
+
// Маршрутизация по kind — копия логики из resumeJob/«Повторить», чтобы
|
|
326
|
+
// запустить job напрямую без gen-modal'а.
|
|
201
327
|
if (kind === 'audio') {
|
|
202
328
|
if (typeof runTTSJob !== 'function') throw new Error('runTTSJob недоступен');
|
|
203
329
|
runTTSJob(node, prompt, boardHandle, bKey, node.generated?.voiceId).catch(e => console.error('TTS job failed:', e));
|
|
@@ -323,6 +449,185 @@
|
|
|
323
449
|
renderHistory();
|
|
324
450
|
}
|
|
325
451
|
|
|
452
|
+
// ============== SNAPSHOTS (rollback chat actions) ==============
|
|
453
|
+
// Перед каждым user-сообщением чат снимает snapshot ВСЕГО проекта:
|
|
454
|
+
// для каждой доски — её scene.json и список media-файлов (только пути!).
|
|
455
|
+
// Файлы НЕ копируются. Для отката:
|
|
456
|
+
// 1. Восстанавливаем scene.json для каждой доски.
|
|
457
|
+
// 2. Если файл из snapshot отсутствует — ищем в `<board>/_deleted/`
|
|
458
|
+
// по basename (deleteNode складывает туда с unique-suffix).
|
|
459
|
+
// Хранится в памяти (последние MAX_SNAPSHOTS), теряется при close project.
|
|
460
|
+
const MAX_SNAPSHOTS = 10;
|
|
461
|
+
let snapshots = []; // [{ts, label, boards: [{kind, name, sceneText, files:[relPath]}]}]
|
|
462
|
+
|
|
463
|
+
async function takeSnapshot(label) {
|
|
464
|
+
if (!state.filmHandle) return null;
|
|
465
|
+
const boards = [];
|
|
466
|
+
const lists = [
|
|
467
|
+
{ kind: 'episode', getter: listEpisodes },
|
|
468
|
+
{ kind: 'character', getter: listCharacters },
|
|
469
|
+
{ kind: 'location', getter: listLocations },
|
|
470
|
+
];
|
|
471
|
+
for (const { kind, getter } of lists) {
|
|
472
|
+
try {
|
|
473
|
+
const items = await getter(state.filmHandle);
|
|
474
|
+
for (const it of items) {
|
|
475
|
+
try {
|
|
476
|
+
const sceneFh = await it.handle.getFileHandle('scene.json');
|
|
477
|
+
const sceneText = await (await sceneFh.getFile()).text();
|
|
478
|
+
// Список media-файлов (пути) — без чтения содержимого.
|
|
479
|
+
let files = [];
|
|
480
|
+
try {
|
|
481
|
+
const all = await walkBoardFiles(it.handle);
|
|
482
|
+
files = all
|
|
483
|
+
.map(f => f.relPath)
|
|
484
|
+
.filter(p => p !== 'scene.json' && !p.startsWith('.'));
|
|
485
|
+
} catch {}
|
|
486
|
+
boards.push({ kind, name: it.name, sceneText, files });
|
|
487
|
+
} catch {}
|
|
488
|
+
}
|
|
489
|
+
} catch {}
|
|
490
|
+
}
|
|
491
|
+
const snap = { ts: Date.now(), label: label || '', boards };
|
|
492
|
+
snapshots.push(snap);
|
|
493
|
+
if (snapshots.length > MAX_SNAPSHOTS) snapshots.shift();
|
|
494
|
+
renderSnapshotBar();
|
|
495
|
+
return snap;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Найти в `<board>/_deleted/` файл по basename (deleteNode даёт unique-suffix
|
|
499
|
+
// типа `<basename>` или `<basename>(1)` если был конфликт). Перемещаем
|
|
500
|
+
// обратно в boardHandle/<relPath>. Возвращает true если успешно.
|
|
501
|
+
async function tryRestoreFromDeleted(boardHandle, relPath) {
|
|
502
|
+
let delDir;
|
|
503
|
+
try { delDir = await boardHandle.getDirectoryHandle('_deleted'); }
|
|
504
|
+
catch { return false; }
|
|
505
|
+
const wanted = relPath.split('/').pop(); // basename
|
|
506
|
+
const baseStem = wanted.replace(/\.[^.]+$/, '');
|
|
507
|
+
const ext = wanted.match(/\.[^.]+$/)?.[0] || '';
|
|
508
|
+
let foundName = null;
|
|
509
|
+
try {
|
|
510
|
+
for await (const [name, h] of delDir.entries()) {
|
|
511
|
+
if (h.kind !== 'file') continue;
|
|
512
|
+
if (name === wanted) { foundName = name; break; }
|
|
513
|
+
// uniqueName может добавить (1), (2)…
|
|
514
|
+
if (name.startsWith(baseStem) && name.endsWith(ext)) { foundName = name; break; }
|
|
515
|
+
}
|
|
516
|
+
} catch {}
|
|
517
|
+
if (!foundName) return false;
|
|
518
|
+
try {
|
|
519
|
+
const srcFh = await delDir.getFileHandle(foundName);
|
|
520
|
+
const blob = await srcFh.getFile();
|
|
521
|
+
// Пишем обратно по relPath (создаём подпапки).
|
|
522
|
+
const parts = relPath.split('/');
|
|
523
|
+
let dh = boardHandle;
|
|
524
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
525
|
+
dh = await dh.getDirectoryHandle(parts[i], { create: true });
|
|
526
|
+
}
|
|
527
|
+
const dstFh = await dh.getFileHandle(parts[parts.length - 1], { create: true });
|
|
528
|
+
const w = await dstFh.createWritable();
|
|
529
|
+
await w.write(blob);
|
|
530
|
+
await w.close();
|
|
531
|
+
// И удаляем из _deleted.
|
|
532
|
+
await delDir.removeEntry(foundName);
|
|
533
|
+
return true;
|
|
534
|
+
} catch (e) {
|
|
535
|
+
console.warn('restore from _deleted failed:', relPath, e?.message);
|
|
536
|
+
return false;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
async function rollbackToSnapshot(idx) {
|
|
541
|
+
const snap = snapshots[idx];
|
|
542
|
+
if (!snap || !state.filmHandle) return;
|
|
543
|
+
const stats = { sceneRestored: 0, filesRestored: 0, filesMissing: 0 };
|
|
544
|
+
for (const b of snap.boards) {
|
|
545
|
+
let parent = state.filmHandle;
|
|
546
|
+
try {
|
|
547
|
+
if (b.kind === 'character') parent = await state.filmHandle.getDirectoryHandle('_characters', { create: true });
|
|
548
|
+
else if (b.kind === 'location') parent = await state.filmHandle.getDirectoryHandle('_locations', { create: true });
|
|
549
|
+
} catch {}
|
|
550
|
+
let boardHandle;
|
|
551
|
+
try { boardHandle = await parent.getDirectoryHandle(b.name, { create: true }); }
|
|
552
|
+
catch (e) { console.warn('rollback: cannot get board', b.name, e); continue; }
|
|
553
|
+
// 1) scene.json — overwrite.
|
|
554
|
+
try {
|
|
555
|
+
const sceneFh = await boardHandle.getFileHandle('scene.json', { create: true });
|
|
556
|
+
const w = await sceneFh.createWritable();
|
|
557
|
+
await w.write(b.sceneText);
|
|
558
|
+
await w.close();
|
|
559
|
+
stats.sceneRestored++;
|
|
560
|
+
} catch (e) { console.warn('rollback: scene write failed', b.name, e); }
|
|
561
|
+
// 2) media-файлы: что отсутствует — пробуем restore из _deleted/.
|
|
562
|
+
for (const relPath of b.files) {
|
|
563
|
+
try {
|
|
564
|
+
await resolveBoardFile(boardHandle, relPath);
|
|
565
|
+
continue; // файл уже на месте
|
|
566
|
+
} catch {}
|
|
567
|
+
if (await tryRestoreFromDeleted(boardHandle, relPath)) stats.filesRestored++;
|
|
568
|
+
else stats.filesMissing++;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
// Удаляем snapshot'ы более новые чем восстановленный (он стал текущим).
|
|
572
|
+
snapshots = snapshots.slice(0, idx);
|
|
573
|
+
renderSnapshotBar();
|
|
574
|
+
// Перечитать текущую доску в UI.
|
|
575
|
+
if (state.currentBoard && typeof selectBoard === 'function') {
|
|
576
|
+
try { await selectBoard({ kind: state.currentBoard.kind, name: state.currentBoard.name, handle: state.currentBoard.handle }); }
|
|
577
|
+
catch {}
|
|
578
|
+
}
|
|
579
|
+
if (typeof refreshSidebar === 'function') await refreshSidebar();
|
|
580
|
+
appendStatus(`↩ Откат: scene=${stats.sceneRestored}, files restored=${stats.filesRestored}, missing=${stats.filesMissing}`);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function renderSnapshotBar() {
|
|
584
|
+
const bar = $('chatSnapshotBar');
|
|
585
|
+
if (!bar) return;
|
|
586
|
+
bar.innerHTML = '';
|
|
587
|
+
if (!snapshots.length) { bar.style.display = 'none'; return; }
|
|
588
|
+
bar.style.display = '';
|
|
589
|
+
const lbl = document.createElement('span');
|
|
590
|
+
lbl.textContent = `Снепшоты: ${snapshots.length} `;
|
|
591
|
+
lbl.style.cssText = 'color:#888; font-size:11px;';
|
|
592
|
+
bar.appendChild(lbl);
|
|
593
|
+
// Кнопки: undo до последнего snapshot. Меню по клику для выбора более старого.
|
|
594
|
+
const undoBtn = document.createElement('button');
|
|
595
|
+
undoBtn.textContent = '↩ Откатить';
|
|
596
|
+
undoBtn.title = `Откат до: ${snapshots[snapshots.length - 1]?.label || '(без метки)'}`;
|
|
597
|
+
undoBtn.addEventListener('click', () => {
|
|
598
|
+
if (!confirm(`Откатить до snapshot'а: «${snapshots[snapshots.length - 1].label || '(без метки)'}»?`)) return;
|
|
599
|
+
rollbackToSnapshot(snapshots.length - 1);
|
|
600
|
+
});
|
|
601
|
+
bar.appendChild(undoBtn);
|
|
602
|
+
if (snapshots.length > 1) {
|
|
603
|
+
const moreBtn = document.createElement('button');
|
|
604
|
+
moreBtn.textContent = '…';
|
|
605
|
+
moreBtn.title = 'Выбрать более старый snapshot';
|
|
606
|
+
moreBtn.addEventListener('click', e => {
|
|
607
|
+
e.stopPropagation();
|
|
608
|
+
const menu = $('nodeMenu');
|
|
609
|
+
if (!menu) return;
|
|
610
|
+
menu.innerHTML = '';
|
|
611
|
+
for (let i = snapshots.length - 1; i >= 0; i--) {
|
|
612
|
+
const s = snapshots[i];
|
|
613
|
+
const dt = new Date(s.ts).toLocaleTimeString('ru-RU');
|
|
614
|
+
const b = document.createElement('button');
|
|
615
|
+
b.textContent = `↩ ${dt} · ${s.label || '(без метки)'}`;
|
|
616
|
+
const idx = i;
|
|
617
|
+
b.addEventListener('click', () => {
|
|
618
|
+
menu.classList.add('hidden');
|
|
619
|
+
if (!confirm(`Откатить до «${s.label || '(без метки)'}»?`)) return;
|
|
620
|
+
rollbackToSnapshot(idx);
|
|
621
|
+
});
|
|
622
|
+
menu.appendChild(b);
|
|
623
|
+
}
|
|
624
|
+
positionFloatingMenu(menu, e.clientX, e.clientY);
|
|
625
|
+
setTimeout(() => document.addEventListener('mousedown', closeNodeMenu, { once: true }), 0);
|
|
626
|
+
});
|
|
627
|
+
bar.appendChild(moreBtn);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
326
631
|
// ============== UI ==============
|
|
327
632
|
let history = []; // [{role: 'user'|'assistant'|'system', content: string, tools?: [...], results?: [...]}]
|
|
328
633
|
let busy = false;
|
|
@@ -454,6 +759,11 @@
|
|
|
454
759
|
if (busy) return;
|
|
455
760
|
if (!userText.trim()) return;
|
|
456
761
|
busy = true;
|
|
762
|
+
// Snapshot ДО мутации — чтобы юзер мог откатить «всё что чат сделал
|
|
763
|
+
// в ответ на это сообщение». Шортнем label первыми 60 символами user-msg.
|
|
764
|
+
try {
|
|
765
|
+
await takeSnapshot(userText.length > 60 ? userText.slice(0, 60) + '…' : userText);
|
|
766
|
+
} catch (e) { console.warn('snapshot failed:', e?.message); }
|
|
457
767
|
history.push({ role: 'user', content: userText });
|
|
458
768
|
renderHistory();
|
|
459
769
|
persistDebounced();
|
|
@@ -621,6 +931,7 @@
|
|
|
621
931
|
<button id="chatClear" title="Очистить историю">⌫</button>
|
|
622
932
|
<button id="chatClose" title="Закрыть">×</button>
|
|
623
933
|
</div>
|
|
934
|
+
<div id="chatSnapshotBar" class="chat-snapshot-bar" style="display:none;"></div>
|
|
624
935
|
<div id="chatList" class="chat-list"></div>
|
|
625
936
|
<div class="chat-input-row">
|
|
626
937
|
<textarea id="chatInput" placeholder="Что нужно сделать со сценой? (Cmd+Enter — отправить)" rows="3"></textarea>
|
|
@@ -668,10 +979,11 @@
|
|
|
668
979
|
close: () => $('chatPanel')?.classList.add('hidden'),
|
|
669
980
|
send,
|
|
670
981
|
// User-clear: чистит И на диске тоже (юзер явно нажал ⌫).
|
|
671
|
-
clear: () => { history = []; renderHistory(); persistNow().catch(() => {}); },
|
|
982
|
+
clear: () => { history = []; snapshots = []; renderHistory(); renderSnapshotBar(); persistNow().catch(() => {}); },
|
|
672
983
|
// Reset-on-close: только in-memory + UI, на диске НЕ трогает (история
|
|
673
984
|
// должна остаться чтобы при следующем открытии того же проекта подгрузилась).
|
|
674
|
-
|
|
985
|
+
// Snapshots сбрасываем тоже — они привязаны к текущему filmHandle.
|
|
986
|
+
resetInMemory: () => { history = []; snapshots = []; renderHistory(); renderSnapshotBar(); },
|
|
675
987
|
// board.js зовёт после openFilm чтобы подгрузить chat-историю проекта.
|
|
676
988
|
loadFromCurrentProject: () => loadHistoryFromCurrentProject(),
|
|
677
989
|
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;
|
|
@@ -294,17 +303,25 @@
|
|
|
294
303
|
color: #e0e0e0; white-space: pre-wrap; word-break: break-word;
|
|
295
304
|
}
|
|
296
305
|
.chat-tool {
|
|
297
|
-
margin-top: 2px; background:
|
|
298
|
-
|
|
306
|
+
margin-top: 2px; background: transparent; border: none;
|
|
307
|
+
padding: 0; font-size: 10.5px; color: #666;
|
|
299
308
|
}
|
|
300
309
|
.chat-tool summary {
|
|
301
|
-
cursor: pointer; color: #
|
|
310
|
+
cursor: pointer; color: #666; outline: none; opacity: 0.75;
|
|
302
311
|
font-family: ui-monospace, 'SF Mono', monospace;
|
|
312
|
+
list-style: none; padding: 1px 4px;
|
|
313
|
+
transition: color 0.1s, opacity 0.1s;
|
|
314
|
+
}
|
|
315
|
+
.chat-tool summary::-webkit-details-marker { display: none; }
|
|
316
|
+
.chat-tool summary::before {
|
|
317
|
+
content: '▸ '; color: #555; font-size: 9px;
|
|
303
318
|
}
|
|
319
|
+
.chat-tool[open] summary::before { content: '▾ '; }
|
|
320
|
+
.chat-tool summary:hover { color: #aaa; opacity: 1; }
|
|
304
321
|
.chat-tool pre {
|
|
305
|
-
margin:
|
|
322
|
+
margin: 4px 0 4px 14px; color: #777; font-size: 10px;
|
|
306
323
|
white-space: pre-wrap; word-break: break-all; max-height: 200px;
|
|
307
|
-
overflow-y: auto;
|
|
324
|
+
overflow-y: auto; background: #161616; padding: 4px 6px; border-radius: 3px;
|
|
308
325
|
}
|
|
309
326
|
.chat-status {
|
|
310
327
|
color: #888; font-size: 11px; padding: 6px 10px;
|
|
@@ -502,12 +519,22 @@
|
|
|
502
519
|
background-image: radial-gradient(rgba(255,255,255,0.06) 1px, transparent 1px);
|
|
503
520
|
background-size: 24px 24px;
|
|
504
521
|
}
|
|
522
|
+
/* canvas-frame даёт virtual coord-system: canvas внутри сдвинут на
|
|
523
|
+
PADDING_LEFT/PADDING_TOP (см. CSS-переменные ниже). Это позволяет
|
|
524
|
+
юзеру скроллить в «отрицательные» координаты (нода с x=-500 видна
|
|
525
|
+
в frame-координатах = PADDING + (-500) = 1500). Без этого ноды с
|
|
526
|
+
отрицательным x/y оставались за левым краем и были недоступны. */
|
|
505
527
|
.canvas-frame {
|
|
506
528
|
position: relative;
|
|
507
|
-
|
|
529
|
+
--canvas-pad-x: 2000px;
|
|
530
|
+
--canvas-pad-y: 1500px;
|
|
531
|
+
width: calc(6000px + 2 * var(--canvas-pad-x));
|
|
532
|
+
height: calc(4000px + 2 * var(--canvas-pad-y));
|
|
508
533
|
}
|
|
509
534
|
.canvas {
|
|
510
|
-
position: absolute;
|
|
535
|
+
position: absolute;
|
|
536
|
+
left: var(--canvas-pad-x); top: var(--canvas-pad-y);
|
|
537
|
+
width: 6000px; height: 4000px;
|
|
511
538
|
transform-origin: 0 0;
|
|
512
539
|
/* `will-change: transform` — постоянная промоция в композитный слой.
|
|
513
540
|
Trade-off: hover/scroll иногда вызывают re-raster плитки (~200ms
|