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 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) throw new Error('нужен 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
- const body = {
446
- prompt, model, system,
447
- images: Array.isArray(images) ? images.filter(i => i?.url) : undefined,
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
- const messages = [];
468
- if (system) messages.push({ role: 'system', content: system });
469
- if (Array.isArray(images) && images.length) {
470
- const content = [{ type: 'text', text: prompt }];
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.push({ role: 'user', content: prompt });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kingkont",
3
- "version": "0.9.3",
3
+ "version": "0.10.1",
4
4
  "description": "KingKont · Chatium — нод-редактор сцен с AI-генерацией (картинки/видео/голос/SFX/музыка/текст)",
5
5
  "main": "main.js",
6
6
  "bin": {
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
  // в новый проект остаётся фрейм/дорожки прошлого.
@@ -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
- // 1. Проверяем синхронизирован ли уже локально.
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
@@ -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;