kingkont 0.12.0 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kingkont",
3
- "version": "0.12.0",
3
+ "version": "0.13.1",
4
4
  "description": "KingKont · Chatium — нод-редактор сцен с AI-генерацией (картинки/видео/голос/SFX/музыка/текст)",
5
5
  "main": "main.js",
6
6
  "bin": {
package/renderer/board.js CHANGED
@@ -1080,10 +1080,11 @@ async function closeProject() {
1080
1080
  // Помечаем что юзер вышел явно — на следующем старте autoload пропускается.
1081
1081
  // Cmd+R после close = welcome, а не реоткрытие.
1082
1082
  try { localStorage.setItem('welcomeOnNextStart', '1'); } catch {}
1083
- // Чат привязан к одному проекту — сбрасываем in-memory (НЕ трогая
1084
- // .kingkont-chat.json на диске; при следующем открытии загрузится).
1083
+ // Чат привязан к одному проекту — flush pending-persist В ТЕКУЩИЙ
1084
+ // filmHandle, потом сбрасываем in-memory. Без flush'а debounced-write
1085
+ // мог бы попасть в новый проект (race на 600ms окно).
1085
1086
  // Прячем панель — на welcome без проекта чат не имеет смысла.
1086
- if (window.kingChat?.resetInMemory) window.kingChat.resetInMemory();
1087
+ if (window.kingChat?.resetInMemory) await window.kingChat.resetInMemory();
1087
1088
  if (window.kingChat?.close) window.kingChat.close();
1088
1089
  stopExternalWatcher();
1089
1090
  // Сбрасываем UI таймлайна/превью — иначе при возврате через welcome
package/renderer/chat.js CHANGED
@@ -373,6 +373,32 @@
373
373
  return lines.join('\n');
374
374
  }
375
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
+
376
402
  // ============== TOOL-CALL PARSER ==============
377
403
  function parseToolCalls(text) {
378
404
  const out = [];
@@ -412,14 +438,22 @@
412
438
  // Сохраняется debounced на каждое изменение history (см. persistDebounced).
413
439
  const CHAT_FILE = '.kingkont-chat.json';
414
440
  let _persistTimer = null;
415
- async function persistNow() {
416
- 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;
417
451
  try {
418
- const fh = await state.filmHandle.getFileHandle(CHAT_FILE, { create: true });
452
+ const fh = await fh0.getFileHandle(CHAT_FILE, { create: true });
419
453
  const w = await fh.createWritable();
420
454
  // Не сохраняем role==='system' (буфер) и tool_result-турны (промежуточные,
421
455
  // нужны только модели). При re-load восстановим только pure user/assistant.
422
- const persistable = history.filter(m =>
456
+ const persistable = histSnap.filter(m =>
423
457
  m.role !== 'system' &&
424
458
  !(m.role === 'user' && m.content?.startsWith('<tool_result>')),
425
459
  );
@@ -427,11 +461,29 @@
427
461
  await w.close();
428
462
  } catch (e) {
429
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
+ }
430
470
  }
431
471
  }
432
472
  function persistDebounced() {
473
+ // Snapshot текущего filmHandle и копии history НА МОМЕНТ дебаунса.
474
+ // На момент срабатывания (через 600ms) state.filmHandle мог уже измениться.
475
+ _persistTargetHandle = state.filmHandle;
476
+ _persistTargetHistory = history.slice();
433
477
  clearTimeout(_persistTimer);
434
- _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
+ }
435
487
  }
436
488
  async function loadHistoryFromCurrentProject() {
437
489
  if (!state.filmHandle) { history = []; renderHistory(); return; }
@@ -628,6 +680,93 @@
628
680
  }
629
681
  }
630
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
+
631
770
  // ============== UI ==============
632
771
  let history = []; // [{role: 'user'|'assistant'|'system', content: string, tools?: [...], results?: [...]}]
633
772
  let busy = false;
@@ -768,7 +907,11 @@
768
907
  renderHistory();
769
908
  persistDebounced();
770
909
  const status = appendStatus('KingKont думает…');
771
- const system = buildSystemPrompt();
910
+ // Привязываем context-snapshot НА МОМЕНТ send: текущая сцена,
911
+ // выделенные ноды, прикреплённые файлы. Передаётся в system-prompt
912
+ // дополнительным блоком — модель видит что у юзера сейчас в фокусе.
913
+ const ctxSnap = buildContextSnapshot();
914
+ const system = buildSystemPrompt() + '\n\n' + buildContextBlock(ctxSnap);
772
915
  try {
773
916
  let iter = 0;
774
917
  while (iter < MAX_TOOL_ITERATIONS) {
@@ -928,17 +1071,57 @@
928
1071
  <div class="chat-header">
929
1072
  <strong>💬 Чат</strong>
930
1073
  <span class="spacer"></span>
1074
+ <button id="chatFontMinus" title="Меньше шрифт (Cmd+-)">A−</button>
1075
+ <button id="chatFontPlus" title="Больше шрифт (Cmd++)">A+</button>
931
1076
  <button id="chatClear" title="Очистить историю">⌫</button>
932
1077
  <button id="chatClose" title="Закрыть">×</button>
933
1078
  </div>
934
1079
  <div id="chatSnapshotBar" class="chat-snapshot-bar" style="display:none;"></div>
935
1080
  <div id="chatList" class="chat-list"></div>
1081
+ <div id="chatContextRow" class="chat-context-row"></div>
936
1082
  <div class="chat-input-row">
937
- <textarea id="chatInput" placeholder="Что нужно сделать со сценой? (Cmd+Enter — отправить)" rows="3"></textarea>
1083
+ <textarea id="chatInput" placeholder="Что нужно сделать со сценой? (Cmd+Enter — отправить, можно перетащить файл)" rows="3"></textarea>
938
1084
  <button id="chatSend" class="primary" title="Отправить (Cmd+Enter)">▶</button>
939
1085
  </div>
940
1086
  `;
941
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
+ });
942
1125
  $('chatClose').addEventListener('click', () => panel.classList.add('hidden'));
943
1126
  $('chatClear').addEventListener('click', () => {
944
1127
  if (!confirm('Очистить историю чата?')) return;
@@ -968,10 +1151,29 @@
968
1151
  // Отрисовываем сохранённую историю при показе (на случай если она
969
1152
  // была подгружена в фоне через loadFromCurrentProject до первого toggle).
970
1153
  renderHistory();
1154
+ renderContextRow();
971
1155
  setTimeout(() => $('chatInput')?.focus(), 50);
972
1156
  }
973
1157
  }
974
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
+
975
1177
  // Public API.
976
1178
  window.kingChat = {
977
1179
  toggle,
@@ -980,12 +1182,17 @@
980
1182
  send,
981
1183
  // User-clear: чистит И на диске тоже (юзер явно нажал ⌫).
982
1184
  clear: () => { history = []; snapshots = []; renderHistory(); renderSnapshotBar(); persistNow().catch(() => {}); },
983
- // Reset-on-close: только in-memory + UI, на диске НЕ трогает (история
984
- // должна остаться чтобы при следующем открытии того же проекта подгрузилась).
985
- // Snapshots сбрасываем тоже они привязаны к текущему filmHandle.
986
- 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
+ },
987
1193
  // board.js зовёт после openFilm чтобы подгрузить chat-историю проекта.
988
1194
  loadFromCurrentProject: () => loadHistoryFromCurrentProject(),
1195
+ flushPersist,
989
1196
  tools: TOOLS,
990
1197
  };
991
1198
 
package/renderer/media.js CHANGED
@@ -14,31 +14,59 @@ const _canvasFrame = $('canvasFrame');
14
14
  // layout-affecting свойство, его смена на каждом wheel-tick форсила 751ms Layout.
15
15
  // Транформ на canvas идёт через GPU (will-change: transform → composited),
16
16
  // scroll-bounds во время burst могут быть слегка off, на idle подстраиваются.
17
+ // Канвас-frame теперь с padding'ом вокруг .canvas (см. styles.css
18
+ // .canvas-frame --canvas-pad-x/y). Координаты:
19
+ // - frame-coords: 0..(6000*z + 2*padX). Лево-верх frame = (0,0).
20
+ // - canvas-coords: 0..6000 (до scale). Канвас расположен в frame'е
21
+ // по offset (padX, padY) и масштабирован transform: scale(z).
22
+ // - mouseInFrame = scrollLeft + (mouseClient - wrapClient)
23
+ // - contentCoord = (mouseInFrame - padX) / z
24
+ function _getFramePadding() {
25
+ const cs = getComputedStyle(_canvasFrame);
26
+ return {
27
+ padX: parseInt(cs.getPropertyValue('--canvas-pad-x')) || 0,
28
+ padY: parseInt(cs.getPropertyValue('--canvas-pad-y')) || 0,
29
+ };
30
+ }
17
31
  let _frameSizeTimer = null;
18
32
  function applyZoomStyles(z) {
19
33
  canvas.style.transform = `scale(${z})`;
20
34
  clearTimeout(_frameSizeTimer);
21
35
  _frameSizeTimer = setTimeout(() => {
22
36
  _frameSizeTimer = null;
23
- _canvasFrame.style.width = (6000 * z) + 'px';
24
- _canvasFrame.style.height = (4000 * z) + 'px';
37
+ const { padX, padY } = _getFramePadding();
38
+ // Frame-размер = scaled-canvas + padding с обеих сторон.
39
+ // Раньше padding не учитывался → frame ужимался при zoom-out → весь
40
+ // padding-skill терялся, ноды с отрицательными coord'ами становились
41
+ // неперехватываемыми скроллом.
42
+ _canvasFrame.style.width = (6000 * z + 2 * padX) + 'px';
43
+ _canvasFrame.style.height = (4000 * z + 2 * padY) + 'px';
25
44
  }, 200);
26
45
  }
27
46
  function applyZoom(nextZoom, anchorClientX, anchorClientY) {
28
47
  nextZoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, nextZoom));
29
48
  if (nextZoom === state.zoom) return;
30
49
  const rect = canvasWrap.getBoundingClientRect();
31
- // Если якорь не задан берём центр видимой области
50
+ const { padX, padY } = _getFramePadding();
51
+ // Якорь = mouse client coord (или центр если не задан).
32
52
  const ax = anchorClientX ?? (rect.left + rect.width / 2);
33
53
  const ay = anchorClientY ?? (rect.top + rect.height / 2);
34
- const visualX = canvasWrap.scrollLeft + (ax - rect.left);
35
- const visualY = canvasWrap.scrollTop + (ay - rect.top);
36
- const contentX = visualX / state.zoom;
37
- const contentY = visualY / state.zoom;
54
+ // mouse внутри wrap (без учёта scroll).
55
+ const mouseWrapX = ax - rect.left;
56
+ const mouseWrapY = ay - rect.top;
57
+ // mouse в frame-coords (со scroll).
58
+ const mouseFrameX = canvasWrap.scrollLeft + mouseWrapX;
59
+ const mouseFrameY = canvasWrap.scrollTop + mouseWrapY;
60
+ // mouse в canvas content-coords (вычитаем padding и делим на zoom).
61
+ const contentX = (mouseFrameX - padX) / state.zoom;
62
+ const contentY = (mouseFrameY - padY) / state.zoom;
38
63
  state.zoom = nextZoom;
39
64
  applyZoomStyles(nextZoom);
40
- canvasWrap.scrollLeft = contentX * nextZoom - (ax - rect.left);
41
- canvasWrap.scrollTop = contentY * nextZoom - (ay - rect.top);
65
+ // После нового zoom: contentCoord должен оказаться там же где был курсор.
66
+ // newMouseFrame = contentCoord * nextZoom + padX
67
+ // newScrollLeft = newMouseFrame - mouseWrapX
68
+ canvasWrap.scrollLeft = contentX * nextZoom + padX - mouseWrapX;
69
+ canvasWrap.scrollTop = contentY * nextZoom + padY - mouseWrapY;
42
70
  $('zoomLabel').textContent = Math.round(nextZoom * 100) + '%';
43
71
  scheduleViewSave();
44
72
  }
@@ -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;
282
+ }
283
+ .chat-chip-x {
284
+ background: transparent; border: none; color: #889;
285
+ cursor: pointer; font-size: 13px; padding: 0 2px; line-height: 1;
258
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; }
@@ -336,7 +365,7 @@
336
365
  .chat-input-row textarea {
337
366
  flex: 1; resize: vertical; min-height: 50px; max-height: 200px;
338
367
  background: #0e0e0e; color: #e0e0e0; border: 1px solid #333;
339
- border-radius: 4px; padding: 8px; font-size: 13px; font-family: inherit;
368
+ border-radius: 4px; padding: 8px; font-size: inherit; font-family: inherit;
340
369
  }
341
370
  .chat-input-row textarea:focus { outline: none; border-color: #4a6a9a; }
342
371
  .chat-input-row button {