kingkont 0.9.2 → 0.10.0

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/main.js CHANGED
@@ -369,12 +369,84 @@ function runChatiumLoginFlow() {
369
369
  });
370
370
  }
371
371
 
372
+ // Inline-base64 логотипа — страница callback'а живёт на random-port localhost
373
+ // и не имеет доступа к assets/. Кэшируем в module-scope чтобы не читать
374
+ // файл на каждый login.
375
+ let _logoDataUri = null;
376
+ function getLogoDataUri() {
377
+ if (_logoDataUri !== null) return _logoDataUri;
378
+ try {
379
+ const buf = fs.readFileSync(path.join(__dirname, 'assets', 'icon.png'));
380
+ _logoDataUri = `data:image/png;base64,${buf.toString('base64')}`;
381
+ } catch { _logoDataUri = ''; }
382
+ return _logoDataUri;
383
+ }
384
+
372
385
  function authResultPage(kind, message) {
373
- const color = kind === 'ok' ? '#16a34a' : '#dc2626';
374
- return `<!DOCTYPE html><html><head><meta charset="utf-8"><title>KingKont</title>
375
- <style>body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;max-width:520px;margin:80px auto;padding:0 20px;text-align:center;color:#222}
376
- h1{color:${color}}p{color:#555;line-height:1.5}</style></head>
377
- <body><h1>${kind === 'ok' ? 'Подключено' : 'Ошибка'}</h1><p>${message}</p></body></html>`;
386
+ const ok = kind === 'ok';
387
+ const title = ok ? 'Подключено' : 'Ошибка';
388
+ const accent = ok ? '#5cb85c' : '#e53e3e';
389
+ const logo = getLogoDataUri();
390
+ return `<!DOCTYPE html><html lang="ru"><head>
391
+ <meta charset="utf-8">
392
+ <meta name="viewport" content="width=device-width, initial-scale=1">
393
+ <title>KingKont — ${title}</title>
394
+ <style>
395
+ * { box-sizing: border-box; }
396
+ html, body { margin: 0; padding: 0; height: 100%; }
397
+ body {
398
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
399
+ background: #1a1a1a; color: #e0e0e0;
400
+ display: flex; align-items: center; justify-content: center;
401
+ min-height: 100vh; padding: 24px;
402
+ }
403
+ .card {
404
+ text-align: center; max-width: 460px; width: 100%;
405
+ padding: 48px 32px;
406
+ }
407
+ .logo {
408
+ width: 120px; height: 120px; margin: 0 auto 24px; display: block;
409
+ background: #1f1f1f; border-radius: 24px; padding: 12px;
410
+ box-shadow: 0 8px 48px rgba(227, 51, 119, 0.22);
411
+ object-fit: contain;
412
+ }
413
+ h1 {
414
+ font-size: 28px; font-weight: 600; margin: 0 0 6px;
415
+ color: #fff;
416
+ }
417
+ .accent {
418
+ color: ${accent}; font-size: 14px; letter-spacing: 0.5px;
419
+ text-transform: uppercase; margin-bottom: 20px;
420
+ }
421
+ p {
422
+ color: #aaa; line-height: 1.6; font-size: 15px;
423
+ margin: 0 0 32px;
424
+ }
425
+ button {
426
+ background: #e33377; color: #fff; border: none; cursor: pointer;
427
+ font-size: 16px; font-weight: 600; letter-spacing: 0.3px;
428
+ padding: 14px 48px; border-radius: 8px;
429
+ transition: background 0.12s, transform 0.06s;
430
+ -webkit-tap-highlight-color: transparent;
431
+ }
432
+ button:hover { background: #d12a6a; }
433
+ button:active { transform: scale(0.98); }
434
+ .hint {
435
+ color: #555; font-size: 11px; margin-top: 16px;
436
+ font-family: ui-monospace, 'SF Mono', monospace;
437
+ }
438
+ </style>
439
+ </head>
440
+ <body>
441
+ <div class="card">
442
+ ${logo ? `<img class="logo" src="${logo}" alt="KingKont">` : ''}
443
+ <div class="accent">KingKont</div>
444
+ <h1>${title}</h1>
445
+ <p>${message}</p>
446
+ <button onclick="window.close()">Закрыть</button>
447
+ <div class="hint">Если вкладка не закрылась — закрой её сам</div>
448
+ </div>
449
+ </body></html>`;
378
450
  }
379
451
 
380
452
  // ===== Settings window =====
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kingkont",
3
- "version": "0.9.2",
3
+ "version": "0.10.0",
4
4
  "description": "KingKont · Chatium — нод-редактор сцен с AI-генерацией (картинки/видео/голос/SFX/музыка/текст)",
5
5
  "main": "main.js",
6
6
  "bin": {
@@ -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
+ })();
@@ -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;