kingkont 0.14.0 → 0.14.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/index.html CHANGED
@@ -130,7 +130,7 @@
130
130
  </div>
131
131
  <button id="charSettingsBtn" style="display:none;" title="Настройки персонажа">⚙</button>
132
132
  <button id="timelineBtn" title="Показать/скрыть таймлайн">🎬 Таймлайн</button>
133
- <button id="chatBtn" title="Чат с Claude (Cmd+J)" onclick="window.kingChat?.toggle?.()">💬 Чат</button>
133
+ <button id="chatBtn" title="Открыть/скрыть чат с KingKont (⌘J / Ctrl+J)" onclick="window.kingChat?.toggle?.()">💬 Чат <span style="opacity:0.6; font-size:10px;">⌘J</span></button>
134
134
  <!-- скрытые, но используемые элементы (logic ссылается по id) -->
135
135
  <span id="hint" class="path" style="display:none;"></span>
136
136
  <span id="boardBadge" class="badge" style="display:none;"></span>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kingkont",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
4
4
  "description": "KingKont · Chatium — нод-редактор сцен с AI-генерацией (картинки/видео/голос/SFX/музыка/текст)",
5
5
  "main": "main.js",
6
6
  "bin": {
package/renderer/chat.js CHANGED
@@ -99,19 +99,49 @@
99
99
  },
100
100
 
101
101
  add_node: {
102
- description: 'Добавить ноду на текущую доску. Для image/video/audio promptа можно задать prompt — нода будет в draft-состоянии (юзер запустит generation отдельно или используй generate_node).',
103
- params: '{"type":"image|video|audio|text","name":"<optional>","prompt":"<optional>","x":<optional>,"y":<optional>,"text":"<for-type=text>","modelKey":"<optional>"}',
104
- async handler({ type, name, prompt, x, y, text, modelKey }) {
102
+ description: 'Добавить ноду на текущую доску. Для image/video/audio promptа можно задать prompt — нода будет в draft-состоянии (юзер запустит generation отдельно или используй generate_node). Для audio укажи subKind="music"|"sfx"|"voice" — иначе по умолчанию voice (TTS).',
103
+ params: '{"type":"image|video|audio|text","subKind":"music|sfx|voice (only for audio)","name":"<optional>","prompt":"<optional>","x":<optional>,"y":<optional>,"text":"<for-type=text>","modelKey":"<optional>","durationMs":<for-music-or-sfx>}',
104
+ async handler({ type, subKind, name, prompt, x, y, text, modelKey, durationMs }) {
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
- // Авто-координаты справа от последней ноды. Clamp к >= 50, чтобы
108
- // нода не ушла в отрицательные координаты (canvas начинается с 0,0,
109
- // негативные позиции невидимы без скролла).
107
+ // Поиск незанятого места: пытаемся справа от последней ноды,
108
+ // потом проверяем что не перекрывает существующие. Если
109
+ // перекрывает сдвигаем вправо/вниз пошагово (по сетке 32px).
110
110
  const last = state.currentBoard.metadata.nodes[state.currentBoard.metadata.nodes.length - 1];
111
111
  let nx = typeof x === 'number' ? x : (last ? last.x + 320 : 100);
112
112
  let ny = typeof y === 'number' ? y : (last ? last.y : 100);
113
113
  if (nx < 50) nx = 50;
114
114
  if (ny < 50) ny = 50;
115
+ // Дефолтные размеры для overlap-detection. Реальные node.width/height
116
+ // выставляются renderCanvas'ом по типу — но 280×220 — типичная
117
+ // прикидка для image/video, 200×120 — text/label/audio.
118
+ const w = (type === 'text' || type === 'label' || type === 'audio') ? 200 : 280;
119
+ const h = (type === 'text' || type === 'label' || type === 'audio') ? 120 : 220;
120
+ function overlaps(ax, ay) {
121
+ for (const n of state.currentBoard.metadata.nodes) {
122
+ const nw = n.width || ((n.type === 'text' || n.type === 'label' || n.type === 'audio') ? 200 : 280);
123
+ const nh = n.height || ((n.type === 'text' || n.type === 'label' || n.type === 'audio') ? 120 : 220);
124
+ // Считаем overlap'ом если центр новой ноды попадает В существующую.
125
+ // Это позволяет частично пересекаться (выглядит как «сдвиг»),
126
+ // но юзер увидит обе ноды.
127
+ const cx = ax + w / 2, cy = ay + h / 2;
128
+ if (cx >= n.x && cx <= n.x + nw && cy >= n.y && cy <= n.y + nh) return true;
129
+ }
130
+ return false;
131
+ }
132
+ // Если первое место занято — двигаем по сетке. Пытаемся вправо до
133
+ // 6 шагов, потом строкой ниже.
134
+ if (overlaps(nx, ny)) {
135
+ const step = 40;
136
+ let found = false;
137
+ for (let row = 0; row < 8 && !found; row++) {
138
+ for (let col = 0; col < 8 && !found; col++) {
139
+ const tx = nx + col * step;
140
+ const ty = ny + row * step;
141
+ if (!overlaps(tx, ty)) { nx = tx; ny = ty; found = true; }
142
+ }
143
+ }
144
+ }
115
145
  const id = (crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2));
116
146
  const node = { id, type, x: nx, y: ny };
117
147
  if (name) node.name = name;
@@ -122,6 +152,14 @@
122
152
  if (prompt && type !== 'text' && type !== 'label') {
123
153
  node.status = 'draft';
124
154
  node.generated = { rawPrompt: prompt, prompt, modelKey: modelKey || undefined };
155
+ // Audio имеет 3 sub-kind'а: voice (TTS — дефолт), music, sfx.
156
+ // generate_node роутит в нужный job по этому полю.
157
+ if (type === 'audio') {
158
+ node.generated.kind = 'audio';
159
+ node.generated.subKind = (subKind === 'music' || subKind === 'sfx') ? subKind : 'voice';
160
+ if (typeof durationMs === 'number') node.generated.durationMs = durationMs;
161
+ if (subKind === 'music') node.generated.model = 'eleven-music';
162
+ }
125
163
  }
126
164
  state.currentBoard.metadata.nodes.push(node);
127
165
  scheduleSave();
@@ -322,11 +360,21 @@
322
360
  node.generated = { ...(node.generated || {}), kind, prompt, rawPrompt: node.generated?.rawPrompt || prompt };
323
361
  scheduleSave();
324
362
  if (typeof renderCanvas === 'function') await renderCanvas();
325
- // Маршрутизация по kind — копия логики из resumeJob/«Повторить», чтобы
326
- // запустить job напрямую без gen-modal'а.
363
+ // Маршрутизация по kind/subKind — копия логики из resumeJob/«Повторить»,
364
+ // чтобы запустить job напрямую без gen-modal'а.
327
365
  if (kind === 'audio') {
328
- if (typeof runTTSJob !== 'function') throw new Error('runTTSJob недоступен');
329
- runTTSJob(node, prompt, boardHandle, bKey, node.generated?.voiceId).catch(e => console.error('TTS job failed:', e));
366
+ const subKind = node.generated?.subKind || 'voice';
367
+ if (subKind === 'music') {
368
+ if (typeof runMusicJob !== 'function') throw new Error('runMusicJob недоступен');
369
+ runMusicJob(node, prompt, node.generated?.durationMs || null, boardHandle, bKey).catch(e => console.error('music job failed:', e));
370
+ } else if (subKind === 'sfx') {
371
+ if (typeof runSfxJob !== 'function') throw new Error('runSfxJob недоступен');
372
+ runSfxJob(node, prompt, node.generated?.durationMs ? node.generated.durationMs / 1000 : null, boardHandle, bKey).catch(e => console.error('sfx job failed:', e));
373
+ } else {
374
+ // voice (TTS) — дефолт.
375
+ if (typeof runTTSJob !== 'function') throw new Error('runTTSJob недоступен');
376
+ runTTSJob(node, prompt, boardHandle, bKey, node.generated?.voiceId).catch(e => console.error('TTS job failed:', e));
377
+ }
330
378
  } else if (kind === 'text') {
331
379
  if (typeof runTextJob !== 'function') throw new Error('runTextJob недоступен');
332
380
  const imageRefs = refs.filter(r => r.type === 'image' && r.file);
@@ -714,6 +762,21 @@
714
762
  return ctx;
715
763
  }
716
764
 
765
+ // Декл-форма множественного: «1 картинка», «2 картинки», «5 картинок».
766
+ function _ru_plural(n, forms) {
767
+ const n10 = n % 10, n100 = n % 100;
768
+ if (n100 >= 11 && n100 <= 14) return forms[2];
769
+ if (n10 === 1) return forms[0];
770
+ if (n10 >= 2 && n10 <= 4) return forms[1];
771
+ return forms[2];
772
+ }
773
+ const _typeForms = {
774
+ image: ['картинка','картинки','картинок'],
775
+ video: ['видео','видео','видео'],
776
+ audio: ['аудио','аудио','аудио'],
777
+ text: ['текст','текста','текстов'],
778
+ label: ['подпись','подписи','подписей'],
779
+ };
717
780
  function renderContextRow() {
718
781
  const row = document.getElementById('chatContextRow');
719
782
  if (!row) return;
@@ -721,8 +784,21 @@
721
784
  const ctx = buildContextSnapshot();
722
785
  const parts = [];
723
786
  if (ctx.scene) parts.push({ key: 'scene', label: `🎬 ${ctx.scene.name}`, removable: false });
787
+ // Группируем выделенные ноды по type. Если 1 — показываем имя/id;
788
+ // если 2+ — «N картинок» с локализацией.
789
+ const byType = {};
724
790
  for (const sel of ctx.selected) {
725
- 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 });
791
+ (byType[sel.type] = byType[sel.type] || []).push(sel);
792
+ }
793
+ for (const [type, items] of Object.entries(byType)) {
794
+ const icon = type === 'image' ? '🖼' : type === 'video' ? '🎬' : type === 'audio' ? '🎙' : type === 'text' ? '📝' : '◉';
795
+ if (items.length === 1) {
796
+ const s = items[0];
797
+ parts.push({ key: 'sel:' + s.id, label: `${icon} ${s.name || s.id.slice(0, 6)}`, removable: false });
798
+ } else {
799
+ const noun = _typeForms[type] || ['нода','ноды','нод'];
800
+ parts.push({ key: 'sel-grp:' + type, label: `${icon} ${items.length} ${_ru_plural(items.length, noun)}`, removable: false });
801
+ }
726
802
  }
727
803
  for (const a of ctx.attachments) {
728
804
  parts.push({ key: 'att:' + a.relPath, label: `📎 ${a.name}`, removable: true, onRemove: () => { manualAttachments = manualAttachments.filter(x => x.relPath !== a.relPath); renderContextRow(); } });
@@ -1083,27 +1159,30 @@
1083
1159
  div.appendChild(body);
1084
1160
  }
1085
1161
  if (hasTools) {
1086
- for (const tc of m.tools) {
1087
- const t = document.createElement('details');
1088
- t.className = 'chat-tool';
1089
- const sum = document.createElement('summary');
1090
- // Компактный summary: имя tool + ключевой arg (если короткий) + статус.
1162
+ // Все tools в ОДНУ строку (separated " · "), без emoji-icons —
1163
+ // компактнее. Раскрываются click'ом одной общей <details>.
1164
+ const t = document.createElement('details');
1165
+ t.className = 'chat-tool';
1166
+ const sum = document.createElement('summary');
1167
+ const parts = m.tools.map(tc => {
1168
+ const status = tc._error ? '⚠' : (tc._ok ? '✓' : '·');
1091
1169
  const argHint = _argHint(tc.args);
1092
- const status = tc._error ? ' ⚠' : tc._ok ? ' ✓' : '';
1093
- sum.textContent = `🔧 ${tc.name}${argHint}${status}`;
1094
- t.appendChild(sum);
1095
- // pre добавляем только если есть что показать (args/result/error не пусты).
1096
- const dump = {};
1097
- if (tc.args && Object.keys(tc.args).length) dump.args = tc.args;
1098
- if (tc.result !== undefined) dump.result = tc.result;
1099
- if (tc._error) dump.error = tc._error;
1100
- if (Object.keys(dump).length) {
1101
- const pre = document.createElement('pre');
1102
- pre.textContent = JSON.stringify(dump, null, 2);
1103
- t.appendChild(pre);
1104
- }
1105
- div.appendChild(t);
1106
- }
1170
+ return `${tc.name}${argHint} ${status}`;
1171
+ });
1172
+ sum.textContent = parts.join(' · ');
1173
+ t.appendChild(sum);
1174
+ // pre с полным JSON всех вызовов (для debug/details).
1175
+ const dumpAll = m.tools.map(tc => {
1176
+ const d = { tool: tc.name };
1177
+ if (tc.args && Object.keys(tc.args).length) d.args = tc.args;
1178
+ if (tc.result !== undefined) d.result = tc.result;
1179
+ if (tc._error) d.error = tc._error;
1180
+ return d;
1181
+ });
1182
+ const pre = document.createElement('pre');
1183
+ pre.textContent = JSON.stringify(dumpAll, null, 2);
1184
+ t.appendChild(pre);
1185
+ div.appendChild(t);
1107
1186
  }
1108
1187
  list.appendChild(div);
1109
1188
  }
@@ -1215,6 +1294,9 @@
1215
1294
  const panel = $('chatPanel');
1216
1295
  panel.classList.toggle('hidden');
1217
1296
  if (!panel.classList.contains('hidden')) {
1297
+ // Сворачиваем preview-панель — иначе она перекрывает чат справа
1298
+ // (preview z-index 40 > chat z-index 30 by design).
1299
+ if (typeof setPreviewCollapsed === 'function') setPreviewCollapsed(true);
1218
1300
  // Отрисовываем сохранённую историю при показе (на случай если она
1219
1301
  // была подгружена в фоне через loadFromCurrentProject до первого toggle).
1220
1302
  renderHistory();
@@ -253,7 +253,10 @@
253
253
  position: fixed; right: 0; top: 0; bottom: 0; width: 420px;
254
254
  background: #1a1a1a; border-left: 1px solid #333;
255
255
  display: flex; flex-direction: column;
256
- z-index: 80;
256
+ /* z-index 30 — НИЖЕ preview-panel (z-index 40). Preview перекрывает чат
257
+ визуально; при открытии чата preview сворачивается автоматически
258
+ (см. toggle() в chat.js → setPreviewCollapsed(true)). */
259
+ z-index: 30;
257
260
  box-shadow: -8px 0 32px rgba(0,0,0,0.4);
258
261
  /* --chat-font задаётся inline в chat.js (Cmd+/Cmd-). Дефолт 13px. */
259
262
  font-size: var(--chat-font, 13px);