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 +1 -1
- package/package.json +1 -1
- package/renderer/chat.js +113 -31
- package/renderer/styles.css +4 -1
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="
|
|
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
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
|
-
//
|
|
108
|
-
//
|
|
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
|
-
|
|
329
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
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
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
if (tc.
|
|
1100
|
-
if (
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
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();
|
package/renderer/styles.css
CHANGED
|
@@ -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
|
|
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);
|