kingkont 0.11.5 → 0.13.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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kingkont",
3
- "version": "0.11.5",
3
+ "version": "0.13.0",
4
4
  "description": "KingKont · Chatium — нод-редактор сцен с AI-генерацией (картинки/видео/голос/SFX/музыка/текст)",
5
5
  "main": "main.js",
6
6
  "bin": {
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+файлы).
@@ -1027,10 +1080,11 @@ async function closeProject() {
1027
1080
  // Помечаем что юзер вышел явно — на следующем старте autoload пропускается.
1028
1081
  // Cmd+R после close = welcome, а не реоткрытие.
1029
1082
  try { localStorage.setItem('welcomeOnNextStart', '1'); } catch {}
1030
- // Чат привязан к одному проекту — сбрасываем in-memory (НЕ трогая
1031
- // .kingkont-chat.json на диске; при следующем открытии загрузится).
1083
+ // Чат привязан к одному проекту — flush pending-persist В ТЕКУЩИЙ
1084
+ // filmHandle, потом сбрасываем in-memory. Без flush'а debounced-write
1085
+ // мог бы попасть в новый проект (race на 600ms окно).
1032
1086
  // Прячем панель — на welcome без проекта чат не имеет смысла.
1033
- if (window.kingChat?.resetInMemory) window.kingChat.resetInMemory();
1087
+ if (window.kingChat?.resetInMemory) await window.kingChat.resetInMemory();
1034
1088
  if (window.kingChat?.close) window.kingChat.close();
1035
1089
  stopExternalWatcher();
1036
1090
  // Сбрасываем UI таймлайна/превью — иначе при возврате через welcome
@@ -1772,7 +1826,11 @@ async function selectBoard(board) {
1772
1826
  scheduleUpdatePreview();
1773
1827
  }
1774
1828
 
1775
- // Восстановить pan/zoom доски
1829
+ // Восстановить pan/zoom доски. Default-scroll выставляем на canvas-pad-x/y
1830
+ // (см. styles.css .canvas-frame), чтобы canvas (0,0) был виден в top-left.
1831
+ // Без этого пользователь видел бы пустой padding слева/сверху.
1832
+ const padX = parseInt(getComputedStyle(document.querySelector('.canvas-frame')).getPropertyValue('--canvas-pad-x')) || 2000;
1833
+ const padY = parseInt(getComputedStyle(document.querySelector('.canvas-frame')).getPropertyValue('--canvas-pad-y')) || 1500;
1776
1834
  const view = state.currentBoard.metadata.view;
1777
1835
  if (view) {
1778
1836
  if (typeof view.zoom === 'number') {
@@ -1780,14 +1838,21 @@ async function selectBoard(board) {
1780
1838
  applyZoomStyles(state.zoom);
1781
1839
  $('zoomLabel').textContent = Math.round(state.zoom * 100) + '%';
1782
1840
  }
1783
- if (typeof view.scrollLeft === 'number') canvasWrap.scrollLeft = view.scrollLeft;
1784
- if (typeof view.scrollTop === 'number') canvasWrap.scrollTop = view.scrollTop;
1841
+ // ВАЖНО: для backward-compat старых view (где scrollLeft/Top сохранены
1842
+ // БЕЗ padding'а — до этой фичи) добавляем padding к сохранённому
1843
+ // значению. Если view свежий и >= padding'а, считаем что padding уже учтён.
1844
+ if (typeof view.scrollLeft === 'number') {
1845
+ canvasWrap.scrollLeft = view.scrollLeft >= padX ? view.scrollLeft : view.scrollLeft + padX;
1846
+ } else canvasWrap.scrollLeft = padX;
1847
+ if (typeof view.scrollTop === 'number') {
1848
+ canvasWrap.scrollTop = view.scrollTop >= padY ? view.scrollTop : view.scrollTop + padY;
1849
+ } else canvasWrap.scrollTop = padY;
1785
1850
  } else {
1786
1851
  state.zoom = 1;
1787
1852
  applyZoomStyles(1);
1788
1853
  $('zoomLabel').textContent = '100%';
1789
- canvasWrap.scrollLeft = 0;
1790
- canvasWrap.scrollTop = 0;
1854
+ canvasWrap.scrollLeft = padX;
1855
+ canvasWrap.scrollTop = padY;
1791
1856
  }
1792
1857
 
1793
1858
  // Возобновить незавершённые джобы текущей доски
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
- const nx = typeof x === 'number' ? x : (last ? last.x + 320 : 100);
110
- const ny = typeof y === 'number' ? y : (last ? last.y : 100);
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>"}',
@@ -256,6 +373,32 @@
256
373
  return lines.join('\n');
257
374
  }
258
375
 
376
+ // Контекст-блок для system-prompt: текущая сцена + выделенные ноды +
377
+ // прикреплённые файлы. Перерисовывается на каждый send().
378
+ function buildContextBlock(ctx) {
379
+ const lines = ['=== ТЕКУЩИЙ КОНТЕКСТ ==='];
380
+ if (ctx.scene) {
381
+ lines.push(`Активная сцена: ${ctx.scene.kind}/${ctx.scene.name}`);
382
+ } else {
383
+ lines.push('Сцена не выбрана.');
384
+ }
385
+ if (ctx.selected?.length) {
386
+ lines.push(`Выделено нод (${ctx.selected.length}):`);
387
+ for (const s of ctx.selected) {
388
+ lines.push(` - id=${s.id} type=${s.type}${s.name ? ` name="${s.name}"` : ''}`);
389
+ }
390
+ lines.push('Если юзер говорит «эта», «эти», «выделенные» — он имеет в виду эти ноды.');
391
+ }
392
+ if (ctx.attachments?.length) {
393
+ lines.push(`Прикреплённые файлы (${ctx.attachments.length}):`);
394
+ for (const a of ctx.attachments) {
395
+ lines.push(` - ${a.relPath} (${a.mime || '?'}, ${a.size || '?'} bytes)`);
396
+ }
397
+ lines.push('Файлы лежат в текущей сцене по этим путям. Можешь использовать их в add_node как "file" поле или как референс.');
398
+ }
399
+ return lines.join('\n');
400
+ }
401
+
259
402
  // ============== TOOL-CALL PARSER ==============
260
403
  function parseToolCalls(text) {
261
404
  const out = [];
@@ -295,14 +438,22 @@
295
438
  // Сохраняется debounced на каждое изменение history (см. persistDebounced).
296
439
  const CHAT_FILE = '.kingkont-chat.json';
297
440
  let _persistTimer = null;
298
- async function persistNow() {
299
- if (!state.filmHandle) return;
441
+ // Snapshot filmHandle В МОМЕНТ дебаунса — иначе если юзер переключил
442
+ // проект пока таймер ждал, persistNow запишет историю A в B.
443
+ let _persistTargetHandle = null;
444
+ let _persistTargetHistory = null;
445
+ async function persistNow(opts = {}) {
446
+ // По умолчанию пишем в текущий filmHandle (если debounce не успел)
447
+ // или в targetHandle, который snapshot'нут на момент debounce.
448
+ const fh0 = opts.handle || _persistTargetHandle || state.filmHandle;
449
+ const histSnap = opts.history || _persistTargetHistory || history;
450
+ if (!fh0) return;
300
451
  try {
301
- const fh = await state.filmHandle.getFileHandle(CHAT_FILE, { create: true });
452
+ const fh = await fh0.getFileHandle(CHAT_FILE, { create: true });
302
453
  const w = await fh.createWritable();
303
454
  // Не сохраняем role==='system' (буфер) и tool_result-турны (промежуточные,
304
455
  // нужны только модели). При re-load восстановим только pure user/assistant.
305
- const persistable = history.filter(m =>
456
+ const persistable = histSnap.filter(m =>
306
457
  m.role !== 'system' &&
307
458
  !(m.role === 'user' && m.content?.startsWith('<tool_result>')),
308
459
  );
@@ -310,11 +461,29 @@
310
461
  await w.close();
311
462
  } catch (e) {
312
463
  console.warn('[chat] persist failed:', e?.message || e);
464
+ } finally {
465
+ // Если только что сохранили pending-дебаунс, чистим target.
466
+ if (!opts.handle && _persistTargetHandle) {
467
+ _persistTargetHandle = null;
468
+ _persistTargetHistory = null;
469
+ }
313
470
  }
314
471
  }
315
472
  function persistDebounced() {
473
+ // Snapshot текущего filmHandle и копии history НА МОМЕНТ дебаунса.
474
+ // На момент срабатывания (через 600ms) state.filmHandle мог уже измениться.
475
+ _persistTargetHandle = state.filmHandle;
476
+ _persistTargetHistory = history.slice();
316
477
  clearTimeout(_persistTimer);
317
- _persistTimer = setTimeout(persistNow, 600);
478
+ _persistTimer = setTimeout(() => persistNow(), 600);
479
+ }
480
+ // Sync-flush: ждём pending-дебаунс прямо сейчас (перед project switch).
481
+ async function flushPersist() {
482
+ if (_persistTimer) {
483
+ clearTimeout(_persistTimer);
484
+ _persistTimer = null;
485
+ await persistNow();
486
+ }
318
487
  }
319
488
  async function loadHistoryFromCurrentProject() {
320
489
  if (!state.filmHandle) { history = []; renderHistory(); return; }
@@ -511,6 +680,93 @@
511
680
  }
512
681
  }
513
682
 
683
+ // ============== CONTEXT (выделенные ноды + сцена + приложенные файлы) ==============
684
+ // Контекст-чипы видны над input'ом и автоматически инжектятся в system-prompt
685
+ // при send(). Файлы сохраняются в <currentBoard>/inbox/<name>.
686
+ let manualAttachments = []; // [{relPath, name, size, mime}] — файлы из drag-drop
687
+
688
+ function buildContextSnapshot() {
689
+ const ctx = { scene: null, selected: [], attachments: manualAttachments.slice() };
690
+ if (state.currentBoard) {
691
+ ctx.scene = { kind: state.currentBoard.kind, name: state.currentBoard.name };
692
+ }
693
+ if (state.selectedNodeIds && state.currentBoard) {
694
+ const ids = Array.from(state.selectedNodeIds);
695
+ for (const id of ids) {
696
+ const n = state.currentBoard.metadata.nodes.find(x => x.id === id);
697
+ if (n) ctx.selected.push({ id: n.id, type: n.type, name: n.name || null });
698
+ }
699
+ }
700
+ return ctx;
701
+ }
702
+
703
+ function renderContextRow() {
704
+ const row = document.getElementById('chatContextRow');
705
+ if (!row) return;
706
+ row.innerHTML = '';
707
+ const ctx = buildContextSnapshot();
708
+ const parts = [];
709
+ if (ctx.scene) parts.push({ key: 'scene', label: `🎬 ${ctx.scene.name}`, removable: false });
710
+ for (const sel of ctx.selected) {
711
+ parts.push({ key: 'sel:' + sel.id, label: `◉ ${sel.type === 'image' ? '🖼' : sel.type === 'video' ? '🎬' : sel.type === 'audio' ? '🎙' : '📝'} ${sel.name || sel.id.slice(0, 6)}`, removable: false });
712
+ }
713
+ for (const a of ctx.attachments) {
714
+ parts.push({ key: 'att:' + a.relPath, label: `📎 ${a.name}`, removable: true, onRemove: () => { manualAttachments = manualAttachments.filter(x => x.relPath !== a.relPath); renderContextRow(); } });
715
+ }
716
+ if (!parts.length) { row.style.display = 'none'; return; }
717
+ row.style.display = '';
718
+ for (const p of parts) {
719
+ const chip = document.createElement('span');
720
+ chip.className = 'chat-chip';
721
+ chip.textContent = p.label;
722
+ if (p.removable) {
723
+ const x = document.createElement('button');
724
+ x.textContent = '×';
725
+ x.className = 'chat-chip-x';
726
+ x.addEventListener('click', e => { e.stopPropagation(); p.onRemove?.(); });
727
+ chip.appendChild(x);
728
+ }
729
+ row.appendChild(chip);
730
+ }
731
+ }
732
+
733
+ // Drag-drop: сохраняем файл в <currentBoard>/inbox/<unique-name>, регистрируем
734
+ // как attachment. Чат-tools и Claude увидят его в context-snapshot.
735
+ async function attachFileToChat(file) {
736
+ if (!state.currentBoard?.handle) {
737
+ alert('Сначала открой сцену — файл некуда положить');
738
+ return;
739
+ }
740
+ try {
741
+ const inbox = await state.currentBoard.handle.getDirectoryHandle('inbox', { create: true });
742
+ // Уникальное имя — если файл уже есть, добавим (1)/(2).
743
+ const baseName = (file.name || 'file.bin').replace(/[^\w.\-а-яА-Я]+/g, '_');
744
+ let name = baseName;
745
+ let i = 1;
746
+ while (true) {
747
+ try { await inbox.getFileHandle(name); }
748
+ catch { break; }
749
+ const dot = baseName.lastIndexOf('.');
750
+ name = dot > 0 ? `${baseName.slice(0, dot)}(${i})${baseName.slice(dot)}` : `${baseName}(${i})`;
751
+ i++;
752
+ }
753
+ const fh = await inbox.getFileHandle(name, { create: true });
754
+ const w = await fh.createWritable();
755
+ await w.write(file);
756
+ await w.close();
757
+ manualAttachments.push({
758
+ relPath: `inbox/${name}`,
759
+ name,
760
+ size: file.size,
761
+ mime: file.type || 'application/octet-stream',
762
+ });
763
+ renderContextRow();
764
+ appendStatus(`📎 Прикреплён файл: inbox/${name} (${(file.size/1024).toFixed(0)} KB)`);
765
+ } catch (e) {
766
+ alert('Не удалось прикрепить файл: ' + (e?.message || e));
767
+ }
768
+ }
769
+
514
770
  // ============== UI ==============
515
771
  let history = []; // [{role: 'user'|'assistant'|'system', content: string, tools?: [...], results?: [...]}]
516
772
  let busy = false;
@@ -651,7 +907,11 @@
651
907
  renderHistory();
652
908
  persistDebounced();
653
909
  const status = appendStatus('KingKont думает…');
654
- const system = buildSystemPrompt();
910
+ // Привязываем context-snapshot НА МОМЕНТ send: текущая сцена,
911
+ // выделенные ноды, прикреплённые файлы. Передаётся в system-prompt
912
+ // дополнительным блоком — модель видит что у юзера сейчас в фокусе.
913
+ const ctxSnap = buildContextSnapshot();
914
+ const system = buildSystemPrompt() + '\n\n' + buildContextBlock(ctxSnap);
655
915
  try {
656
916
  let iter = 0;
657
917
  while (iter < MAX_TOOL_ITERATIONS) {
@@ -811,17 +1071,57 @@
811
1071
  <div class="chat-header">
812
1072
  <strong>💬 Чат</strong>
813
1073
  <span class="spacer"></span>
1074
+ <button id="chatFontMinus" title="Меньше шрифт (Cmd+-)">A−</button>
1075
+ <button id="chatFontPlus" title="Больше шрифт (Cmd++)">A+</button>
814
1076
  <button id="chatClear" title="Очистить историю">⌫</button>
815
1077
  <button id="chatClose" title="Закрыть">×</button>
816
1078
  </div>
817
1079
  <div id="chatSnapshotBar" class="chat-snapshot-bar" style="display:none;"></div>
818
1080
  <div id="chatList" class="chat-list"></div>
1081
+ <div id="chatContextRow" class="chat-context-row"></div>
819
1082
  <div class="chat-input-row">
820
- <textarea id="chatInput" placeholder="Что нужно сделать со сценой? (Cmd+Enter — отправить)" rows="3"></textarea>
1083
+ <textarea id="chatInput" placeholder="Что нужно сделать со сценой? (Cmd+Enter — отправить, можно перетащить файл)" rows="3"></textarea>
821
1084
  <button id="chatSend" class="primary" title="Отправить (Cmd+Enter)">▶</button>
822
1085
  </div>
823
1086
  `;
824
1087
  document.body.appendChild(panel);
1088
+ // Font scaling: Cmd+/Cmd- ВНУТРИ panel'а меняет --chat-font (px). Сохраняется
1089
+ // в localStorage. Дефолт 13. Ограничиваем 10..22.
1090
+ const _fontInit = parseInt(localStorage.getItem('chatFontPx') || '13', 10);
1091
+ panel.style.setProperty('--chat-font', _fontInit + 'px');
1092
+ function adjustFont(delta) {
1093
+ const cur = parseInt(panel.style.getPropertyValue('--chat-font') || '13', 10);
1094
+ const next = Math.max(10, Math.min(22, cur + delta));
1095
+ panel.style.setProperty('--chat-font', next + 'px');
1096
+ try { localStorage.setItem('chatFontPx', String(next)); } catch {}
1097
+ }
1098
+ $('chatFontPlus')?.addEventListener('click', () => adjustFont(1));
1099
+ $('chatFontMinus')?.addEventListener('click', () => adjustFont(-1));
1100
+ // Cmd+/Cmd- (и Cmd++) пока чат-панель в фокусе — внутри-панельный font scale.
1101
+ panel.addEventListener('keydown', e => {
1102
+ if (!(e.metaKey || e.ctrlKey)) return;
1103
+ if (e.key === '+' || e.key === '=') { e.preventDefault(); adjustFont(1); }
1104
+ else if (e.key === '-') { e.preventDefault(); adjustFont(-1); }
1105
+ else if (e.key === '0') { e.preventDefault(); panel.style.setProperty('--chat-font', '13px'); localStorage.setItem('chatFontPx', '13'); }
1106
+ });
1107
+
1108
+ // === Drag-and-drop файлов в чат: сохраняем в <currentBoard>/inbox/<name>,
1109
+ // добавляем в context, юзер потом может ссылаться при send. ===
1110
+ panel.addEventListener('dragover', e => {
1111
+ if (!e.dataTransfer?.types?.includes?.('Files')) return;
1112
+ e.preventDefault();
1113
+ panel.classList.add('chat-drag');
1114
+ });
1115
+ panel.addEventListener('dragleave', e => {
1116
+ if (e.target === panel) panel.classList.remove('chat-drag');
1117
+ });
1118
+ panel.addEventListener('drop', async e => {
1119
+ panel.classList.remove('chat-drag');
1120
+ const files = Array.from(e.dataTransfer?.files || []);
1121
+ if (!files.length) return;
1122
+ e.preventDefault();
1123
+ for (const f of files) await attachFileToChat(f);
1124
+ });
825
1125
  $('chatClose').addEventListener('click', () => panel.classList.add('hidden'));
826
1126
  $('chatClear').addEventListener('click', () => {
827
1127
  if (!confirm('Очистить историю чата?')) return;
@@ -851,10 +1151,29 @@
851
1151
  // Отрисовываем сохранённую историю при показе (на случай если она
852
1152
  // была подгружена в фоне через loadFromCurrentProject до первого toggle).
853
1153
  renderHistory();
1154
+ renderContextRow();
854
1155
  setTimeout(() => $('chatInput')?.focus(), 50);
855
1156
  }
856
1157
  }
857
1158
 
1159
+ // Поллинг изменений выделения / текущей сцены для авто-update context-row.
1160
+ // Селекшен меняется в множестве мест без единого event'а — проще опрашивать.
1161
+ // Комфортная частота 500ms — overhead nil, но реактивно для юзера.
1162
+ let _ctxLastSig = '';
1163
+ setInterval(() => {
1164
+ const panel = document.getElementById('chatPanel');
1165
+ if (!panel || panel.classList.contains('hidden')) return;
1166
+ const ctx = buildContextSnapshot();
1167
+ const sig = JSON.stringify({
1168
+ s: ctx.scene?.name || null,
1169
+ sel: ctx.selected.map(x => x.id),
1170
+ att: ctx.attachments.map(x => x.relPath),
1171
+ });
1172
+ if (sig === _ctxLastSig) return;
1173
+ _ctxLastSig = sig;
1174
+ renderContextRow();
1175
+ }, 500);
1176
+
858
1177
  // Public API.
859
1178
  window.kingChat = {
860
1179
  toggle,
@@ -863,12 +1182,17 @@
863
1182
  send,
864
1183
  // User-clear: чистит И на диске тоже (юзер явно нажал ⌫).
865
1184
  clear: () => { history = []; snapshots = []; renderHistory(); renderSnapshotBar(); persistNow().catch(() => {}); },
866
- // Reset-on-close: только in-memory + UI, на диске НЕ трогает (история
867
- // должна остаться чтобы при следующем открытии того же проекта подгрузилась).
868
- // Snapshots сбрасываем тоже они привязаны к текущему filmHandle.
869
- resetInMemory: () => { history = []; snapshots = []; renderHistory(); renderSnapshotBar(); },
1185
+ // Reset-on-close: ФЛАШИМ pending-дебаунс В ТЕКУЩИЙ filmHandle (чтобы
1186
+ // история не утекла в следующий проект), потом чистим in-memory.
1187
+ // На диске история того проекта остаётся re-open подгрузит.
1188
+ resetInMemory: async () => {
1189
+ try { await flushPersist(); } catch {}
1190
+ history = []; snapshots = [];
1191
+ renderHistory(); renderSnapshotBar();
1192
+ },
870
1193
  // board.js зовёт после openFilm чтобы подгрузить chat-историю проекта.
871
1194
  loadFromCurrentProject: () => loadHistoryFromCurrentProject(),
1195
+ flushPersist,
872
1196
  tools: TOOLS,
873
1197
  };
874
1198
 
@@ -255,7 +255,36 @@
255
255
  display: flex; flex-direction: column;
256
256
  z-index: 80;
257
257
  box-shadow: -8px 0 32px rgba(0,0,0,0.4);
258
+ /* --chat-font задаётся inline в chat.js (Cmd+/Cmd-). Дефолт 13px. */
259
+ font-size: var(--chat-font, 13px);
260
+ }
261
+ .chat-panel.chat-drag::before {
262
+ content: '📎 Отпусти, чтобы прикрепить файл к чату';
263
+ position: absolute; inset: 0; z-index: 100;
264
+ background: rgba(227, 51, 119, 0.18);
265
+ border: 2px dashed #e33377; border-radius: 8px;
266
+ display: flex; align-items: center; justify-content: center;
267
+ color: #fff; font-size: 16px; font-weight: 600;
268
+ pointer-events: none;
269
+ }
270
+ .chat-context-row {
271
+ display: flex; flex-wrap: wrap; gap: 4px;
272
+ padding: 6px 10px;
273
+ background: #1a1a1a; border-top: 1px solid #2a2a2a;
274
+ }
275
+ .chat-context-row:empty { display: none; }
276
+ .chat-chip {
277
+ display: inline-flex; align-items: center; gap: 4px;
278
+ background: #232a36; border: 1px solid #2c3848;
279
+ color: #aac; font-size: 11px;
280
+ padding: 2px 8px; border-radius: 999px;
281
+ max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
258
282
  }
283
+ .chat-chip-x {
284
+ background: transparent; border: none; color: #889;
285
+ cursor: pointer; font-size: 13px; padding: 0 2px; line-height: 1;
286
+ }
287
+ .chat-chip-x:hover { color: #f88; }
259
288
  .chat-panel.hidden { display: none; }
260
289
  .chat-header {
261
290
  display: flex; align-items: center; gap: 6px;
@@ -284,7 +313,7 @@
284
313
  .chat-msg {
285
314
  display: flex; flex-direction: column; gap: 2px;
286
315
  padding: 6px 10px; border-radius: 6px;
287
- font-size: 13px; line-height: 1.45;
316
+ font-size: inherit; line-height: 1.45; /* наследуем --chat-font от .chat-panel */
288
317
  }
289
318
  .chat-msg-user { background: #1f2a3a; border: 1px solid #2a3a4a; }
290
319
  .chat-msg-assistant { background: #1f1f1f; border: 1px solid #2a2a2a; }
@@ -303,17 +332,25 @@
303
332
  color: #e0e0e0; white-space: pre-wrap; word-break: break-word;
304
333
  }
305
334
  .chat-tool {
306
- margin-top: 2px; background: #0e0e0e; border: 1px solid #2a2a2a;
307
- border-radius: 3px; padding: 2px 6px; font-size: 11px;
335
+ margin-top: 2px; background: transparent; border: none;
336
+ padding: 0; font-size: 10.5px; color: #666;
308
337
  }
309
338
  .chat-tool summary {
310
- cursor: pointer; color: #aac; outline: none;
339
+ cursor: pointer; color: #666; outline: none; opacity: 0.75;
311
340
  font-family: ui-monospace, 'SF Mono', monospace;
341
+ list-style: none; padding: 1px 4px;
342
+ transition: color 0.1s, opacity 0.1s;
343
+ }
344
+ .chat-tool summary::-webkit-details-marker { display: none; }
345
+ .chat-tool summary::before {
346
+ content: '▸ '; color: #555; font-size: 9px;
312
347
  }
348
+ .chat-tool[open] summary::before { content: '▾ '; }
349
+ .chat-tool summary:hover { color: #aaa; opacity: 1; }
313
350
  .chat-tool pre {
314
- margin: 6px 0 0; color: #888; font-size: 10px;
351
+ margin: 4px 0 4px 14px; color: #777; font-size: 10px;
315
352
  white-space: pre-wrap; word-break: break-all; max-height: 200px;
316
- overflow-y: auto;
353
+ overflow-y: auto; background: #161616; padding: 4px 6px; border-radius: 3px;
317
354
  }
318
355
  .chat-status {
319
356
  color: #888; font-size: 11px; padding: 6px 10px;
@@ -328,7 +365,7 @@
328
365
  .chat-input-row textarea {
329
366
  flex: 1; resize: vertical; min-height: 50px; max-height: 200px;
330
367
  background: #0e0e0e; color: #e0e0e0; border: 1px solid #333;
331
- border-radius: 4px; padding: 8px; font-size: 13px; font-family: inherit;
368
+ border-radius: 4px; padding: 8px; font-size: inherit; font-family: inherit;
332
369
  }
333
370
  .chat-input-row textarea:focus { outline: none; border-color: #4a6a9a; }
334
371
  .chat-input-row button {
@@ -511,12 +548,22 @@
511
548
  background-image: radial-gradient(rgba(255,255,255,0.06) 1px, transparent 1px);
512
549
  background-size: 24px 24px;
513
550
  }
551
+ /* canvas-frame даёт virtual coord-system: canvas внутри сдвинут на
552
+ PADDING_LEFT/PADDING_TOP (см. CSS-переменные ниже). Это позволяет
553
+ юзеру скроллить в «отрицательные» координаты (нода с x=-500 видна
554
+ в frame-координатах = PADDING + (-500) = 1500). Без этого ноды с
555
+ отрицательным x/y оставались за левым краем и были недоступны. */
514
556
  .canvas-frame {
515
557
  position: relative;
516
- width: 6000px; height: 4000px;
558
+ --canvas-pad-x: 2000px;
559
+ --canvas-pad-y: 1500px;
560
+ width: calc(6000px + 2 * var(--canvas-pad-x));
561
+ height: calc(4000px + 2 * var(--canvas-pad-y));
517
562
  }
518
563
  .canvas {
519
- position: absolute; left: 0; top: 0; width: 6000px; height: 4000px;
564
+ position: absolute;
565
+ left: var(--canvas-pad-x); top: var(--canvas-pad-y);
566
+ width: 6000px; height: 4000px;
520
567
  transform-origin: 0 0;
521
568
  /* `will-change: transform` — постоянная промоция в композитный слой.
522
569
  Trade-off: hover/scroll иногда вызывают re-raster плитки (~200ms