kingkont 0.9.3 → 0.10.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 +3 -0
- package/lib/providers.js +25 -16
- package/package.json +1 -1
- package/renderer/board.js +3 -0
- package/renderer/chat.js +507 -0
- package/renderer/cloudProjects.js +56 -1
- package/renderer/styles.css +77 -0
package/index.html
CHANGED
|
@@ -130,6 +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
134
|
<!-- скрытые, но используемые элементы (logic ссылается по id) -->
|
|
134
135
|
<span id="hint" class="path" style="display:none;"></span>
|
|
135
136
|
<span id="boardBadge" class="badge" style="display:none;"></span>
|
|
@@ -609,6 +610,8 @@
|
|
|
609
610
|
<!-- cloudProjects.js — после templates.js, переиспользует TPL_PROGRESS,
|
|
610
611
|
walkBoardFiles, collectProjectBoards, uploadProjectCoverIfAny из templates.js. -->
|
|
611
612
|
<script src="renderer/cloudProjects.js"></script>
|
|
613
|
+
<!-- chat.js — Claude-чат для управления сценой через tools (Cmd+J). -->
|
|
614
|
+
<script src="renderer/chat.js"></script>
|
|
612
615
|
|
|
613
616
|
</body>
|
|
614
617
|
</html>
|
package/lib/providers.js
CHANGED
|
@@ -432,8 +432,10 @@ async function waitForGeneration(taskId, settings, opts = {}) {
|
|
|
432
432
|
/**
|
|
433
433
|
* @returns {Promise<{ text, model, cost?, provider }>}
|
|
434
434
|
*/
|
|
435
|
-
async function generateText({ prompt, model = 'anthropic/claude-sonnet-4', system, images, settings: s }) {
|
|
436
|
-
if (!prompt
|
|
435
|
+
async function generateText({ prompt, messages: msgsIn, model = 'anthropic/claude-sonnet-4', system, images, settings: s }) {
|
|
436
|
+
if (!prompt && !(Array.isArray(msgsIn) && msgsIn.length)) {
|
|
437
|
+
throw new Error('нужен prompt или messages');
|
|
438
|
+
}
|
|
437
439
|
|
|
438
440
|
// Приоритет: OpenRouter direct (если useOpenrouter+key) → Chatium → ошибка.
|
|
439
441
|
// Юзер явно включил прямой коннектор → используем его (Chatium-кредиты
|
|
@@ -442,10 +444,11 @@ async function generateText({ prompt, model = 'anthropic/claude-sonnet-4', syste
|
|
|
442
444
|
const directOpenrouter = s.useOpenrouter && process.env.OPENROUTER_API_KEY;
|
|
443
445
|
|
|
444
446
|
if (s.useChatium && s.chatium?.token && s.chatium?.base && !directOpenrouter) {
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
447
|
+
// Если переданы messages — шлём их напрямую (chat-режим, multi-turn).
|
|
448
|
+
// Chatium-side /api/text уважает body.messages — игнорирует prompt+images.
|
|
449
|
+
const body = msgsIn?.length
|
|
450
|
+
? { messages: msgsIn, model, system }
|
|
451
|
+
: { prompt, model, system, images: Array.isArray(images) ? images.filter(i => i?.url) : undefined };
|
|
449
452
|
const taskId = await chatiumStart(s, 'text', body);
|
|
450
453
|
const result = await chatiumWait(s, taskId, { timeoutMs: 120000 });
|
|
451
454
|
return {
|
|
@@ -464,17 +467,23 @@ async function generateText({ prompt, model = 'anthropic/claude-sonnet-4', syste
|
|
|
464
467
|
}
|
|
465
468
|
const key = process.env.OPENROUTER_API_KEY;
|
|
466
469
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
if (
|
|
470
|
-
|
|
471
|
-
for (const img of images) {
|
|
472
|
-
if (!img?.url) continue;
|
|
473
|
-
content.push({ type: 'image_url', image_url: { url: img.url } });
|
|
474
|
-
}
|
|
475
|
-
messages.push({ role: 'user', content });
|
|
470
|
+
// Для OpenRouter: либо переданы готовые messages, либо собираем из prompt+images.
|
|
471
|
+
let messages;
|
|
472
|
+
if (msgsIn?.length) {
|
|
473
|
+
messages = system ? [{ role: 'system', content: system }, ...msgsIn] : msgsIn;
|
|
476
474
|
} else {
|
|
477
|
-
messages
|
|
475
|
+
messages = [];
|
|
476
|
+
if (system) messages.push({ role: 'system', content: system });
|
|
477
|
+
if (Array.isArray(images) && images.length) {
|
|
478
|
+
const content = [{ type: 'text', text: prompt }];
|
|
479
|
+
for (const img of images) {
|
|
480
|
+
if (!img?.url) continue;
|
|
481
|
+
content.push({ type: 'image_url', image_url: { url: img.url } });
|
|
482
|
+
}
|
|
483
|
+
messages.push({ role: 'user', content });
|
|
484
|
+
} else {
|
|
485
|
+
messages.push({ role: 'user', content: prompt });
|
|
486
|
+
}
|
|
478
487
|
}
|
|
479
488
|
logCall('POST', 'OpenRouter', 'https://openrouter.ai/api/v1/chat/completions', `model=${model} prompt=${prompt.length}ch`);
|
|
480
489
|
const r = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
package/package.json
CHANGED
package/renderer/board.js
CHANGED
|
@@ -949,6 +949,9 @@ async function closeProject() {
|
|
|
949
949
|
// Помечаем что юзер вышел явно — на следующем старте autoload пропускается.
|
|
950
950
|
// Cmd+R после close = welcome, а не реоткрытие.
|
|
951
951
|
try { localStorage.setItem('welcomeOnNextStart', '1'); } catch {}
|
|
952
|
+
// Чат привязан к одному проекту — чистим историю при выходе чтобы
|
|
953
|
+
// не путал контекст следующего открытого проекта.
|
|
954
|
+
if (window.kingChat?.clear) window.kingChat.clear();
|
|
952
955
|
stopExternalWatcher();
|
|
953
956
|
// Сбрасываем UI таймлайна/превью — иначе при возврате через welcome
|
|
954
957
|
// в новый проект остаётся фрейм/дорожки прошлого.
|
package/renderer/chat.js
ADDED
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
// renderer/chat.js — Чат с Claude для управления сценой через LLM-tools.
|
|
2
|
+
//
|
|
3
|
+
// Архитектура:
|
|
4
|
+
// - Отдельный модуль (этот файл) — НЕ трогает board.js / generate.js напрямую.
|
|
5
|
+
// - Использует window.* публичные функции (selectBoard, askName, scheduleSave,
|
|
6
|
+
// mutateNode, refreshSidebar, и т.п.), либо мутирует state.currentBoard
|
|
7
|
+
// и зовёт renderCanvas() / scheduleSave().
|
|
8
|
+
// - LLM через POST /api/text (server.js → providers.generateText). Это даёт
|
|
9
|
+
// доступ к KingKont (Chatium) и OpenRouter одновременно — выбор делает
|
|
10
|
+
// server по настройкам.
|
|
11
|
+
//
|
|
12
|
+
// Tool-protocol (text-embedded — работает с любой моделью без native function-calling):
|
|
13
|
+
// Модель в ответе может выдать одну или несколько строк вида:
|
|
14
|
+
// <tool>{"name":"add_node","args":{"kind":"image","prompt":"sunset"}}</tool>
|
|
15
|
+
// Мы парсим эти блоки, исполняем последовательно, формируем результаты:
|
|
16
|
+
// <tool_result>{"name":"add_node","ok":true,"result":{"id":"abc-123"}}</tool_result>
|
|
17
|
+
// Шлём их как user-message обратно модели → она продолжает и/или говорит
|
|
18
|
+
// итоговый ответ. Цикл до `done` (без tool_call'ов в ответе) или max-iterations.
|
|
19
|
+
//
|
|
20
|
+
// Tools (минимальный полезный набор для v1):
|
|
21
|
+
// list_scenes, select_scene, add_scene, read_scene, add_node,
|
|
22
|
+
// update_node_prompt, connect_nodes, delete_node, generate_node
|
|
23
|
+
//
|
|
24
|
+
// Сохранение conversation: in-memory (state.chatHistory), сбрасывается на
|
|
25
|
+
// новый проект. Для persistence — ToDo (дописать в .kingkont-meta.json).
|
|
26
|
+
|
|
27
|
+
(function () {
|
|
28
|
+
function $(id) { return document.getElementById(id); }
|
|
29
|
+
const MAX_TOOL_ITERATIONS = 8;
|
|
30
|
+
|
|
31
|
+
// ============== TOOL DEFINITIONS ==============
|
|
32
|
+
// Каждый tool: name, description, params (schema-doc для модели), handler.
|
|
33
|
+
// Handler — async (args) → result-object либо throw.
|
|
34
|
+
|
|
35
|
+
const TOOLS = {
|
|
36
|
+
list_scenes: {
|
|
37
|
+
description: 'Список всех досок (сцен / персонажей / общих) текущего проекта.',
|
|
38
|
+
params: '{}',
|
|
39
|
+
async handler() {
|
|
40
|
+
if (!state.filmHandle) throw new Error('проект не открыт');
|
|
41
|
+
const eps = (await listEpisodes(state.filmHandle)).map(b => ({ kind: 'episode', name: b.name }));
|
|
42
|
+
const chs = (await listCharacters(state.filmHandle)).map(b => ({ kind: 'character', name: b.name }));
|
|
43
|
+
const lcs = (await listLocations(state.filmHandle)).map(b => ({ kind: 'location', name: b.name }));
|
|
44
|
+
return { scenes: eps, characters: chs, locations: lcs, current: state.currentBoard ? { kind: state.currentBoard.kind, name: state.currentBoard.name } : null };
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
select_scene: {
|
|
49
|
+
description: 'Открыть указанную доску. После этого read_scene/add_node работают с ней.',
|
|
50
|
+
params: '{"kind":"episode|character|location","name":"<exact-name>"}',
|
|
51
|
+
async handler({ kind, name }) {
|
|
52
|
+
if (!state.filmHandle) throw new Error('проект не открыт');
|
|
53
|
+
const list = kind === 'character' ? await listCharacters(state.filmHandle)
|
|
54
|
+
: kind === 'location' ? await listLocations(state.filmHandle)
|
|
55
|
+
: await listEpisodes(state.filmHandle);
|
|
56
|
+
const found = list.find(b => b.name === name);
|
|
57
|
+
if (!found) throw new Error(`не найдено: ${kind}/${name}`);
|
|
58
|
+
await selectBoard({ kind, ...found });
|
|
59
|
+
return { ok: true };
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
add_scene: {
|
|
64
|
+
description: 'Создать новую доску.',
|
|
65
|
+
params: '{"kind":"episode|character|location","name":"<unique-name>"}',
|
|
66
|
+
async handler({ kind, name }) {
|
|
67
|
+
if (!state.filmHandle) throw new Error('проект не открыт');
|
|
68
|
+
let parent;
|
|
69
|
+
if (kind === 'character') parent = await state.filmHandle.getDirectoryHandle('_characters', { create: true });
|
|
70
|
+
else if (kind === 'location') parent = await state.filmHandle.getDirectoryHandle('_locations', { create: true });
|
|
71
|
+
else parent = state.filmHandle;
|
|
72
|
+
const handle = await parent.getDirectoryHandle(name, { create: true });
|
|
73
|
+
if (typeof refreshSidebar === 'function') await refreshSidebar();
|
|
74
|
+
await selectBoard({ kind, name, handle });
|
|
75
|
+
return { ok: true, kind, name };
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
read_scene: {
|
|
80
|
+
description: 'Текущая доска: список нод (id, type, name?, prompt?, file?, x, y) и связи.',
|
|
81
|
+
params: '{}',
|
|
82
|
+
async handler() {
|
|
83
|
+
const b = state.currentBoard;
|
|
84
|
+
if (!b) throw new Error('доска не выбрана — используй select_scene');
|
|
85
|
+
const nodes = (b.metadata.nodes || []).map(n => ({
|
|
86
|
+
id: n.id,
|
|
87
|
+
type: n.type,
|
|
88
|
+
name: n.name || null,
|
|
89
|
+
x: n.x, y: n.y,
|
|
90
|
+
prompt: n.generated?.rawPrompt || n.generated?.prompt || null,
|
|
91
|
+
file: n.file || null,
|
|
92
|
+
status: n.status || (n.file ? 'done' : 'draft'),
|
|
93
|
+
modelKey: n.generated?.modelKey || null,
|
|
94
|
+
}));
|
|
95
|
+
const connections = (b.metadata.connections || []).map(c => ({ from: c.from, to: c.to, toPort: c.toPort || null }));
|
|
96
|
+
const settings = b.metadata.settings || {};
|
|
97
|
+
return { kind: b.kind, name: b.name, nodes, connections, settings };
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
|
|
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 }) {
|
|
105
|
+
if (!state.currentBoard) throw new Error('доска не выбрана');
|
|
106
|
+
if (!['image','video','audio','text','label'].includes(type)) throw new Error(`unknown type: ${type}`);
|
|
107
|
+
// Авто-координаты — справа от последней ноды.
|
|
108
|
+
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
|
+
const id = (crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2));
|
|
112
|
+
const node = { id, type, x: nx, y: ny };
|
|
113
|
+
if (name) node.name = name;
|
|
114
|
+
if (type === 'text' && typeof text === 'string') {
|
|
115
|
+
node.text = text;
|
|
116
|
+
// .md создастся при saveBoardMetadata.
|
|
117
|
+
}
|
|
118
|
+
if (prompt && type !== 'text' && type !== 'label') {
|
|
119
|
+
node.status = 'draft';
|
|
120
|
+
node.generated = { rawPrompt: prompt, prompt, modelKey: modelKey || undefined };
|
|
121
|
+
}
|
|
122
|
+
state.currentBoard.metadata.nodes.push(node);
|
|
123
|
+
scheduleSave();
|
|
124
|
+
if (typeof renderCanvas === 'function') await renderCanvas();
|
|
125
|
+
return { ok: true, id };
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
update_node_prompt: {
|
|
130
|
+
description: 'Обновить промпт у image/video/audio ноды (без re-генерации).',
|
|
131
|
+
params: '{"id":"<node-id>","prompt":"<new>"}',
|
|
132
|
+
async handler({ id, prompt }) {
|
|
133
|
+
const b = state.currentBoard;
|
|
134
|
+
if (!b) throw new Error('доска не выбрана');
|
|
135
|
+
const node = b.metadata.nodes.find(n => n.id === id);
|
|
136
|
+
if (!node) throw new Error(`нода не найдена: ${id}`);
|
|
137
|
+
node.generated = { ...(node.generated || {}), rawPrompt: prompt, prompt };
|
|
138
|
+
scheduleSave();
|
|
139
|
+
if (typeof renderCanvas === 'function') await renderCanvas();
|
|
140
|
+
return { ok: true };
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
connect_nodes: {
|
|
145
|
+
description: 'Соединить две ноды (from → to). toPort: "image" | "video" | "audio" — куда подаём ссылку (опционально, для @-резолва при генерации).',
|
|
146
|
+
params: '{"from":"<id>","to":"<id>","toPort":"image|video|audio (optional)"}',
|
|
147
|
+
async handler({ from, to, toPort }) {
|
|
148
|
+
const b = state.currentBoard;
|
|
149
|
+
if (!b) throw new Error('доска не выбрана');
|
|
150
|
+
if (!b.metadata.nodes.find(n => n.id === from)) throw new Error(`from-нода не найдена: ${from}`);
|
|
151
|
+
if (!b.metadata.nodes.find(n => n.id === to)) throw new Error(`to-нода не найдена: ${to}`);
|
|
152
|
+
const conns = b.metadata.connections;
|
|
153
|
+
if (conns.find(c => c.from === from && c.to === to)) return { ok: true, note: 'связь уже есть' };
|
|
154
|
+
const conn = { from, to };
|
|
155
|
+
if (toPort) conn.toPort = toPort;
|
|
156
|
+
conns.push(conn);
|
|
157
|
+
scheduleSave();
|
|
158
|
+
if (typeof renderConnections === 'function') renderConnections();
|
|
159
|
+
return { ok: true };
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
delete_node: {
|
|
164
|
+
description: 'Удалить ноду. Файл (если есть) переедет в _deleted/.',
|
|
165
|
+
params: '{"id":"<node-id>"}',
|
|
166
|
+
async handler({ id }) {
|
|
167
|
+
const b = state.currentBoard;
|
|
168
|
+
if (!b) throw new Error('доска не выбрана');
|
|
169
|
+
const node = b.metadata.nodes.find(n => n.id === id);
|
|
170
|
+
if (!node) throw new Error(`нода не найдена: ${id}`);
|
|
171
|
+
const el = document.querySelector(`.node[data-id="${id}"]`);
|
|
172
|
+
if (typeof deleteNode === 'function') await deleteNode(node, el);
|
|
173
|
+
else {
|
|
174
|
+
// Fallback: просто убираем из массива.
|
|
175
|
+
b.metadata.nodes = b.metadata.nodes.filter(n => n.id !== id);
|
|
176
|
+
b.metadata.connections = (b.metadata.connections || []).filter(c => c.from !== id && c.to !== id);
|
|
177
|
+
scheduleSave();
|
|
178
|
+
if (typeof renderCanvas === 'function') await renderCanvas();
|
|
179
|
+
}
|
|
180
|
+
return { ok: true };
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
generate_node: {
|
|
185
|
+
description: 'Запустить генерацию для draft-ноды (image/video/audio). Возвращает сразу — генерация идёт в фоне.',
|
|
186
|
+
params: '{"id":"<node-id>"}',
|
|
187
|
+
async handler({ id }) {
|
|
188
|
+
const b = state.currentBoard;
|
|
189
|
+
if (!b) throw new Error('доска не выбрана');
|
|
190
|
+
const node = b.metadata.nodes.find(n => n.id === id);
|
|
191
|
+
if (!node) throw new Error(`нода не найдена: ${id}`);
|
|
192
|
+
if (typeof regenerateNode !== 'function') throw new Error('regenerateNode недоступен');
|
|
193
|
+
regenerateNode(node);
|
|
194
|
+
return { ok: true, note: 'генерация запущена в фоне' };
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// System-prompt: объясняет модели tool-protocol + что доступно.
|
|
200
|
+
function buildSystemPrompt() {
|
|
201
|
+
const lines = [
|
|
202
|
+
'Ты — ассистент в KingKont (нод-редактор сцен с AI-генерацией).',
|
|
203
|
+
'Помогаешь пользователю строить раскадровки: создавать сцены, добавлять/связывать ноды (image/video/audio/text), задавать промпты, запускать генерацию.',
|
|
204
|
+
'',
|
|
205
|
+
'ИНСТРУМЕНТЫ. Чтобы что-то сделать, выдай в своём ответе одну или несколько строк ровно в таком формате:',
|
|
206
|
+
'<tool>{"name":"<tool_name>","args":{...}}</tool>',
|
|
207
|
+
'Я выполню каждый tool и пришлю результаты как <tool_result>{...}</tool_result> в следующем сообщении. После результатов продолжай (можешь звать ещё tools или дать финальный ответ).',
|
|
208
|
+
'',
|
|
209
|
+
'Доступные инструменты:',
|
|
210
|
+
];
|
|
211
|
+
for (const [name, def] of Object.entries(TOOLS)) {
|
|
212
|
+
lines.push(`- ${name} — ${def.description}`);
|
|
213
|
+
lines.push(` args: ${def.params}`);
|
|
214
|
+
}
|
|
215
|
+
lines.push('');
|
|
216
|
+
lines.push('Правила:');
|
|
217
|
+
lines.push('- Не выдумывай id нод — сначала read_scene/list_scenes.');
|
|
218
|
+
lines.push('- Не выдумывай имена сцен — list_scenes покажет реальные.');
|
|
219
|
+
lines.push('- Если нужен новый id для add_node — оставь поле пустым, вернётся в результате.');
|
|
220
|
+
lines.push('- Для генерации используй generate_node ПОСЛЕ add_node с promptом.');
|
|
221
|
+
lines.push('- Отвечай по-русски, кратко. Объясняй что делаешь, без лишней воды.');
|
|
222
|
+
return lines.join('\n');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ============== TOOL-CALL PARSER ==============
|
|
226
|
+
function parseToolCalls(text) {
|
|
227
|
+
const out = [];
|
|
228
|
+
const re = /<tool>\s*([\s\S]*?)\s*<\/tool>/g;
|
|
229
|
+
let m;
|
|
230
|
+
while ((m = re.exec(text)) !== null) {
|
|
231
|
+
try {
|
|
232
|
+
const obj = JSON.parse(m[1]);
|
|
233
|
+
if (obj && typeof obj.name === 'string') out.push({ name: obj.name, args: obj.args || {} });
|
|
234
|
+
} catch (e) {
|
|
235
|
+
out.push({ _parseError: e.message, raw: m[1] });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
240
|
+
function stripToolCalls(text) {
|
|
241
|
+
return text.replace(/<tool>[\s\S]*?<\/tool>/g, '').trim();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ============== UI ==============
|
|
245
|
+
let history = []; // [{role: 'user'|'assistant'|'system', content: string, tools?: [...], results?: [...]}]
|
|
246
|
+
let busy = false;
|
|
247
|
+
|
|
248
|
+
function renderHistory() {
|
|
249
|
+
const list = $('chatList');
|
|
250
|
+
if (!list) return;
|
|
251
|
+
list.innerHTML = '';
|
|
252
|
+
for (const m of history) {
|
|
253
|
+
if (m.role === 'system') continue;
|
|
254
|
+
const div = document.createElement('div');
|
|
255
|
+
div.className = 'chat-msg chat-msg-' + m.role;
|
|
256
|
+
const lbl = document.createElement('div');
|
|
257
|
+
lbl.className = 'chat-msg-role';
|
|
258
|
+
lbl.textContent = m.role === 'user' ? 'Вы' : 'Claude';
|
|
259
|
+
div.appendChild(lbl);
|
|
260
|
+
const body = document.createElement('div');
|
|
261
|
+
body.className = 'chat-msg-body';
|
|
262
|
+
body.textContent = m.content || '';
|
|
263
|
+
div.appendChild(body);
|
|
264
|
+
// Tool-calls collapsible.
|
|
265
|
+
if (Array.isArray(m.tools) && m.tools.length) {
|
|
266
|
+
for (const tc of m.tools) {
|
|
267
|
+
const t = document.createElement('details');
|
|
268
|
+
t.className = 'chat-tool';
|
|
269
|
+
const sum = document.createElement('summary');
|
|
270
|
+
sum.textContent = `🔧 ${tc.name}` + (tc._error ? ' (ошибка)' : tc._ok ? ' ✓' : '');
|
|
271
|
+
t.appendChild(sum);
|
|
272
|
+
const pre = document.createElement('pre');
|
|
273
|
+
pre.textContent = JSON.stringify({ args: tc.args, result: tc.result, error: tc._error }, null, 2);
|
|
274
|
+
t.appendChild(pre);
|
|
275
|
+
div.appendChild(t);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
list.appendChild(div);
|
|
279
|
+
}
|
|
280
|
+
list.scrollTop = list.scrollHeight;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function appendStatus(msg) {
|
|
284
|
+
const list = $('chatList');
|
|
285
|
+
if (!list) return;
|
|
286
|
+
const div = document.createElement('div');
|
|
287
|
+
div.className = 'chat-status';
|
|
288
|
+
div.textContent = msg;
|
|
289
|
+
list.appendChild(div);
|
|
290
|
+
list.scrollTop = list.scrollHeight;
|
|
291
|
+
return div;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ============== LLM call + tool-loop ==============
|
|
295
|
+
async function callLLM(messages, system) {
|
|
296
|
+
const r = await fetch('/api/text', {
|
|
297
|
+
method: 'POST',
|
|
298
|
+
headers: { 'Content-Type': 'application/json' },
|
|
299
|
+
body: JSON.stringify({
|
|
300
|
+
messages, system,
|
|
301
|
+
model: 'anthropic/claude-sonnet-4.6',
|
|
302
|
+
}),
|
|
303
|
+
});
|
|
304
|
+
if (!r.ok) {
|
|
305
|
+
const err = await r.json().catch(() => ({}));
|
|
306
|
+
throw new Error(err.error || `HTTP ${r.status}`);
|
|
307
|
+
}
|
|
308
|
+
const d = await r.json();
|
|
309
|
+
return d.text || '';
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Превращает internal history → массив для /api/text. tool_results
|
|
313
|
+
// объединяем в user-message (модель видит их как ответы юзера на её запрос).
|
|
314
|
+
function historyToMessages() {
|
|
315
|
+
const out = [];
|
|
316
|
+
for (const m of history) {
|
|
317
|
+
if (m.role === 'system') continue;
|
|
318
|
+
out.push({ role: m.role, content: m.content });
|
|
319
|
+
}
|
|
320
|
+
return out;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function runTool(name, args) {
|
|
324
|
+
const def = TOOLS[name];
|
|
325
|
+
if (!def) throw new Error(`unknown tool: ${name}`);
|
|
326
|
+
return await def.handler(args || {});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function send(userText) {
|
|
330
|
+
if (busy) return;
|
|
331
|
+
if (!userText.trim()) return;
|
|
332
|
+
busy = true;
|
|
333
|
+
history.push({ role: 'user', content: userText });
|
|
334
|
+
renderHistory();
|
|
335
|
+
const status = appendStatus('Claude думает…');
|
|
336
|
+
const system = buildSystemPrompt();
|
|
337
|
+
try {
|
|
338
|
+
let iter = 0;
|
|
339
|
+
while (iter < MAX_TOOL_ITERATIONS) {
|
|
340
|
+
iter++;
|
|
341
|
+
const text = await callLLM(historyToMessages(), system);
|
|
342
|
+
const toolCalls = parseToolCalls(text);
|
|
343
|
+
const cleanText = stripToolCalls(text);
|
|
344
|
+
const assistantMsg = {
|
|
345
|
+
role: 'assistant',
|
|
346
|
+
content: cleanText,
|
|
347
|
+
tools: [],
|
|
348
|
+
};
|
|
349
|
+
history.push(assistantMsg);
|
|
350
|
+
|
|
351
|
+
if (!toolCalls.length) {
|
|
352
|
+
// Финальный ответ — больше нечего исполнять.
|
|
353
|
+
status.remove();
|
|
354
|
+
renderHistory();
|
|
355
|
+
break;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Исполняем tools, собираем результаты.
|
|
359
|
+
status.textContent = `Выполняю ${toolCalls.length} tool(s)…`;
|
|
360
|
+
const results = [];
|
|
361
|
+
for (const tc of toolCalls) {
|
|
362
|
+
if (tc._parseError) {
|
|
363
|
+
results.push({ name: 'parse_error', error: tc._parseError, raw: tc.raw });
|
|
364
|
+
assistantMsg.tools.push({ name: 'parse_error', _error: tc._parseError });
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
try {
|
|
368
|
+
const r = await runTool(tc.name, tc.args);
|
|
369
|
+
results.push({ name: tc.name, ok: true, result: r });
|
|
370
|
+
assistantMsg.tools.push({ name: tc.name, args: tc.args, result: r, _ok: true });
|
|
371
|
+
} catch (e) {
|
|
372
|
+
results.push({ name: tc.name, ok: false, error: e?.message || String(e) });
|
|
373
|
+
assistantMsg.tools.push({ name: tc.name, args: tc.args, _error: e?.message || String(e) });
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
renderHistory();
|
|
377
|
+
|
|
378
|
+
// Шлём результаты обратно как user-message (чтобы Claude увидел и продолжил).
|
|
379
|
+
const resultsMsg = results.map(r => `<tool_result>${JSON.stringify(r)}</tool_result>`).join('\n');
|
|
380
|
+
history.push({ role: 'user', content: resultsMsg });
|
|
381
|
+
// Не рендерим этот системный turn (renderHistory его отфильтровывает) —
|
|
382
|
+
// на самом деле он role='user', придётся скрыть руками. Делаем простую
|
|
383
|
+
// эвристику: если content начинается с <tool_result>, не показываем.
|
|
384
|
+
// Тут же — это можно сделать через флаг.
|
|
385
|
+
}
|
|
386
|
+
if (iter >= MAX_TOOL_ITERATIONS) {
|
|
387
|
+
appendStatus(`⚠ остановился после ${MAX_TOOL_ITERATIONS} итераций`);
|
|
388
|
+
}
|
|
389
|
+
} catch (e) {
|
|
390
|
+
status.textContent = '⚠ ' + (e?.message || e);
|
|
391
|
+
status.classList.add('error');
|
|
392
|
+
} finally {
|
|
393
|
+
busy = false;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Скрываем tool_result-сообщения из UI (они нужны только модели).
|
|
398
|
+
// renderHistory вызывает renderHistory — оставлю как есть, плюс добавлю
|
|
399
|
+
// фильтр прямо там. Уже добавлен через role-check, но tool_result идут
|
|
400
|
+
// как role='user' с content начинающимся с <tool_result>. Дофильтруем:
|
|
401
|
+
const _origRender = renderHistory;
|
|
402
|
+
// (пере-оборачиваю чтобы не дублировать DOM-логику)
|
|
403
|
+
function renderHistoryFiltered() {
|
|
404
|
+
const list = $('chatList');
|
|
405
|
+
if (!list) return;
|
|
406
|
+
list.innerHTML = '';
|
|
407
|
+
for (const m of history) {
|
|
408
|
+
if (m.role === 'system') continue;
|
|
409
|
+
if (m.role === 'user' && m.content?.startsWith('<tool_result>')) continue; // системный turn
|
|
410
|
+
const div = document.createElement('div');
|
|
411
|
+
div.className = 'chat-msg chat-msg-' + m.role;
|
|
412
|
+
const lbl = document.createElement('div');
|
|
413
|
+
lbl.className = 'chat-msg-role';
|
|
414
|
+
lbl.textContent = m.role === 'user' ? 'Вы' : 'Claude';
|
|
415
|
+
div.appendChild(lbl);
|
|
416
|
+
const body = document.createElement('div');
|
|
417
|
+
body.className = 'chat-msg-body';
|
|
418
|
+
body.textContent = m.content || '';
|
|
419
|
+
div.appendChild(body);
|
|
420
|
+
if (Array.isArray(m.tools) && m.tools.length) {
|
|
421
|
+
for (const tc of m.tools) {
|
|
422
|
+
const t = document.createElement('details');
|
|
423
|
+
t.className = 'chat-tool';
|
|
424
|
+
const sum = document.createElement('summary');
|
|
425
|
+
sum.textContent = `🔧 ${tc.name}` + (tc._error ? ' (ошибка)' : tc._ok ? ' ✓' : '');
|
|
426
|
+
t.appendChild(sum);
|
|
427
|
+
const pre = document.createElement('pre');
|
|
428
|
+
pre.textContent = JSON.stringify({ args: tc.args, result: tc.result, error: tc._error }, null, 2);
|
|
429
|
+
t.appendChild(pre);
|
|
430
|
+
div.appendChild(t);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
list.appendChild(div);
|
|
434
|
+
}
|
|
435
|
+
list.scrollTop = list.scrollHeight;
|
|
436
|
+
}
|
|
437
|
+
// Перебиндим
|
|
438
|
+
renderHistory = renderHistoryFiltered;
|
|
439
|
+
|
|
440
|
+
// ============== UI wiring ==============
|
|
441
|
+
function ensureUI() {
|
|
442
|
+
if ($('chatPanel')) return;
|
|
443
|
+
const panel = document.createElement('aside');
|
|
444
|
+
panel.id = 'chatPanel';
|
|
445
|
+
panel.className = 'chat-panel hidden';
|
|
446
|
+
panel.innerHTML = `
|
|
447
|
+
<div class="chat-header">
|
|
448
|
+
<strong>💬 Чат</strong>
|
|
449
|
+
<span class="spacer"></span>
|
|
450
|
+
<button id="chatClear" title="Очистить историю">⌫</button>
|
|
451
|
+
<button id="chatClose" title="Закрыть">×</button>
|
|
452
|
+
</div>
|
|
453
|
+
<div id="chatList" class="chat-list"></div>
|
|
454
|
+
<div class="chat-input-row">
|
|
455
|
+
<textarea id="chatInput" placeholder="Что нужно сделать со сценой? (Cmd+Enter — отправить)" rows="3"></textarea>
|
|
456
|
+
<button id="chatSend" class="primary" title="Отправить (Cmd+Enter)">▶</button>
|
|
457
|
+
</div>
|
|
458
|
+
`;
|
|
459
|
+
document.body.appendChild(panel);
|
|
460
|
+
$('chatClose').addEventListener('click', () => panel.classList.add('hidden'));
|
|
461
|
+
$('chatClear').addEventListener('click', () => {
|
|
462
|
+
if (!confirm('Очистить историю чата?')) return;
|
|
463
|
+
history = [];
|
|
464
|
+
renderHistory();
|
|
465
|
+
});
|
|
466
|
+
$('chatSend').addEventListener('click', () => {
|
|
467
|
+
const ta = $('chatInput');
|
|
468
|
+
const txt = ta.value.trim();
|
|
469
|
+
if (!txt) return;
|
|
470
|
+
ta.value = '';
|
|
471
|
+
send(txt);
|
|
472
|
+
});
|
|
473
|
+
$('chatInput').addEventListener('keydown', e => {
|
|
474
|
+
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
475
|
+
e.preventDefault();
|
|
476
|
+
$('chatSend').click();
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function toggle() {
|
|
482
|
+
ensureUI();
|
|
483
|
+
const panel = $('chatPanel');
|
|
484
|
+
panel.classList.toggle('hidden');
|
|
485
|
+
if (!panel.classList.contains('hidden')) {
|
|
486
|
+
setTimeout(() => $('chatInput')?.focus(), 50);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Public API.
|
|
491
|
+
window.kingChat = {
|
|
492
|
+
toggle,
|
|
493
|
+
open: () => { ensureUI(); $('chatPanel').classList.remove('hidden'); setTimeout(() => $('chatInput')?.focus(), 50); },
|
|
494
|
+
close: () => $('chatPanel')?.classList.add('hidden'),
|
|
495
|
+
send,
|
|
496
|
+
clear: () => { history = []; renderHistory(); },
|
|
497
|
+
tools: TOOLS,
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
// Hot-key: Cmd+J (как Cmd+L в IDE, но не конфликтует) — toggle чата.
|
|
501
|
+
document.addEventListener('keydown', e => {
|
|
502
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'j') {
|
|
503
|
+
e.preventDefault();
|
|
504
|
+
toggle();
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
})();
|
|
@@ -196,7 +196,46 @@
|
|
|
196
196
|
async function openCloudProject(projectId, suggestedName, opts = {}) {
|
|
197
197
|
const s = await getSettings();
|
|
198
198
|
if (!isLoggedIn(s)) { alert('Войдите в KingKont'); return; }
|
|
199
|
-
|
|
199
|
+
|
|
200
|
+
// 0. Если этот cloud-проект ранее был сохранён ИЗ папочного — открываем
|
|
201
|
+
// оригинальную папку (с уже скачанными файлами) и не качаем ничего.
|
|
202
|
+
// Mapping cloudFolder:<projectId> → folderName ставит saveCloudProject.
|
|
203
|
+
// Папка должна быть granted в FSAH (юзер раз дал — handle живёт в IDB).
|
|
204
|
+
if (!opts.forceRefresh) {
|
|
205
|
+
try {
|
|
206
|
+
const folderName = await idbGet(`cloudFolder:${projectId}`);
|
|
207
|
+
if (folderName) {
|
|
208
|
+
const folderHandle = await idbGet(`handle:${folderName}`);
|
|
209
|
+
if (folderHandle) {
|
|
210
|
+
// Verify permission still granted (без user-gesture не запрашиваем).
|
|
211
|
+
let g = 'prompt';
|
|
212
|
+
try { g = await folderHandle.queryPermission({ mode: 'readwrite' }); } catch {}
|
|
213
|
+
if (g === 'granted') {
|
|
214
|
+
// Дополнительно проверим что в папке лежит meta с тем же cloudProjectId
|
|
215
|
+
// (юзер мог перенести/изменить — тогда fallback на download).
|
|
216
|
+
try {
|
|
217
|
+
const metaFh = await folderHandle.getFileHandle('.kingkont-meta.json');
|
|
218
|
+
const metaTxt = await (await metaFh.getFile()).text();
|
|
219
|
+
const meta = JSON.parse(metaTxt);
|
|
220
|
+
if (meta.cloudProjectId === projectId) {
|
|
221
|
+
state.cloudProjectId = projectId;
|
|
222
|
+
state.cloudDirty = false;
|
|
223
|
+
touchCloudOpened(projectId);
|
|
224
|
+
await openFilm(folderHandle);
|
|
225
|
+
setCloudButtonsVisibility();
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
} catch {}
|
|
229
|
+
// Permission granted но meta не совпала — обнуляем mapping
|
|
230
|
+
// (idbSet(null) — простой способ без отдельного idbDel).
|
|
231
|
+
try { await idbSet(`cloudFolder:${projectId}`, null); } catch {}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
} catch {}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// 1. Проверяем синхронизирован ли уже в userData/cloud-projects/<id>/.
|
|
200
239
|
if (window.cloudFs) {
|
|
201
240
|
try {
|
|
202
241
|
const metaText = await window.cloudFs.readText(projectId, '.kingkont-meta.json');
|
|
@@ -489,6 +528,22 @@
|
|
|
489
528
|
});
|
|
490
529
|
}
|
|
491
530
|
|
|
531
|
+
// 5) Если это ПАПОЧНЫЙ проект (не cloud-shim), запоминаем mapping
|
|
532
|
+
// cloudProjectId → folderName в IDB. Когда юзер позже откроет
|
|
533
|
+
// этот же cloud-проект из welcome, openCloudProject подхватит
|
|
534
|
+
// оригинальную папку (с уже скачанными файлами) и НЕ будет
|
|
535
|
+
// качать всё заново. Mapping живёт в той же IDB-схеме что
|
|
536
|
+
// handle:<name>: cloudFolder:<projectId> = name.
|
|
537
|
+
if (!window.cloudFsShim?.isCloudHandle?.(state.filmHandle)) {
|
|
538
|
+
try {
|
|
539
|
+
await idbSet(`cloudFolder:${projectId}`, state.filmHandle.name);
|
|
540
|
+
// Также запоминаем cloudProjectId на текущей сессии — чтобы
|
|
541
|
+
// следующий save без перезахода не ловил «папочный → новый cloud».
|
|
542
|
+
state.cloudProjectId = projectId;
|
|
543
|
+
if (window.cloudProjects?.setVisibility) window.cloudProjects.setVisibility();
|
|
544
|
+
} catch {}
|
|
545
|
+
}
|
|
546
|
+
|
|
492
547
|
PROGRESS.hide();
|
|
493
548
|
markClean();
|
|
494
549
|
const stats = reused
|
package/renderer/styles.css
CHANGED
|
@@ -247,6 +247,83 @@
|
|
|
247
247
|
justify-content: flex-end;
|
|
248
248
|
}
|
|
249
249
|
/* Используем те же .balance-info pill'ы что и в sidebar-footer'е. */
|
|
250
|
+
|
|
251
|
+
/* === Чат-панель (Cmd+J — toggle, или window.kingChat.toggle()) === */
|
|
252
|
+
.chat-panel {
|
|
253
|
+
position: fixed; right: 0; top: 0; bottom: 0; width: 420px;
|
|
254
|
+
background: #1a1a1a; border-left: 1px solid #333;
|
|
255
|
+
display: flex; flex-direction: column;
|
|
256
|
+
z-index: 80;
|
|
257
|
+
box-shadow: -8px 0 32px rgba(0,0,0,0.4);
|
|
258
|
+
}
|
|
259
|
+
.chat-panel.hidden { display: none; }
|
|
260
|
+
.chat-header {
|
|
261
|
+
display: flex; align-items: center; gap: 6px;
|
|
262
|
+
padding: 10px 12px; border-bottom: 1px solid #2a2a2a;
|
|
263
|
+
color: #ddd; font-size: 13px;
|
|
264
|
+
}
|
|
265
|
+
.chat-header .spacer { flex: 1; }
|
|
266
|
+
.chat-header button {
|
|
267
|
+
background: transparent; border: 1px solid #333; color: #aaa;
|
|
268
|
+
border-radius: 4px; padding: 2px 8px; cursor: pointer; font-size: 12px;
|
|
269
|
+
}
|
|
270
|
+
.chat-header button:hover { background: #2a2a2a; color: #fff; }
|
|
271
|
+
.chat-list {
|
|
272
|
+
flex: 1; overflow-y: auto; padding: 12px;
|
|
273
|
+
display: flex; flex-direction: column; gap: 12px;
|
|
274
|
+
}
|
|
275
|
+
.chat-msg {
|
|
276
|
+
display: flex; flex-direction: column; gap: 4px;
|
|
277
|
+
padding: 10px 12px; border-radius: 8px;
|
|
278
|
+
font-size: 13px; line-height: 1.5;
|
|
279
|
+
}
|
|
280
|
+
.chat-msg-user { background: #1f2a3a; border: 1px solid #2a3a4a; }
|
|
281
|
+
.chat-msg-assistant { background: #1f1f1f; border: 1px solid #2a2a2a; }
|
|
282
|
+
.chat-msg-role {
|
|
283
|
+
font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px;
|
|
284
|
+
color: #888;
|
|
285
|
+
}
|
|
286
|
+
.chat-msg-user .chat-msg-role { color: #6a9; }
|
|
287
|
+
.chat-msg-assistant .chat-msg-role { color: #c97; }
|
|
288
|
+
.chat-msg-body {
|
|
289
|
+
color: #e0e0e0; white-space: pre-wrap; word-break: break-word;
|
|
290
|
+
}
|
|
291
|
+
.chat-tool {
|
|
292
|
+
margin-top: 6px; background: #0e0e0e; border: 1px solid #2a2a2a;
|
|
293
|
+
border-radius: 4px; padding: 4px 8px; font-size: 11px;
|
|
294
|
+
}
|
|
295
|
+
.chat-tool summary {
|
|
296
|
+
cursor: pointer; color: #aac; outline: none;
|
|
297
|
+
font-family: ui-monospace, 'SF Mono', monospace;
|
|
298
|
+
}
|
|
299
|
+
.chat-tool pre {
|
|
300
|
+
margin: 6px 0 0; color: #888; font-size: 10px;
|
|
301
|
+
white-space: pre-wrap; word-break: break-all; max-height: 200px;
|
|
302
|
+
overflow-y: auto;
|
|
303
|
+
}
|
|
304
|
+
.chat-status {
|
|
305
|
+
color: #888; font-size: 11px; padding: 6px 10px;
|
|
306
|
+
background: #1a1a1a; border-radius: 4px; align-self: center;
|
|
307
|
+
font-style: italic;
|
|
308
|
+
}
|
|
309
|
+
.chat-status.error { color: #f88; background: #2a1818; }
|
|
310
|
+
.chat-input-row {
|
|
311
|
+
display: flex; gap: 6px; padding: 10px 12px;
|
|
312
|
+
border-top: 1px solid #2a2a2a; background: #1a1a1a;
|
|
313
|
+
}
|
|
314
|
+
.chat-input-row textarea {
|
|
315
|
+
flex: 1; resize: vertical; min-height: 50px; max-height: 200px;
|
|
316
|
+
background: #0e0e0e; color: #e0e0e0; border: 1px solid #333;
|
|
317
|
+
border-radius: 4px; padding: 8px; font-size: 13px; font-family: inherit;
|
|
318
|
+
}
|
|
319
|
+
.chat-input-row textarea:focus { outline: none; border-color: #4a6a9a; }
|
|
320
|
+
.chat-input-row button {
|
|
321
|
+
align-self: stretch; padding: 0 16px;
|
|
322
|
+
background: #e33377; color: #fff; border: none; border-radius: 4px;
|
|
323
|
+
cursor: pointer; font-size: 16px;
|
|
324
|
+
}
|
|
325
|
+
.chat-input-row button:hover { background: #d12a6a; }
|
|
326
|
+
.chat-input-row button:disabled { opacity: 0.5; cursor: default; }
|
|
250
327
|
.welcome-open {
|
|
251
328
|
margin-top: 16px;
|
|
252
329
|
padding: 12px 28px; font-size: 15px; font-weight: 600;
|