kingkont 0.13.1 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.html CHANGED
@@ -130,7 +130,7 @@
130
130
  </div>
131
131
  <button id="charSettingsBtn" style="display:none;" title="Настройки персонажа">⚙</button>
132
132
  <button id="timelineBtn" title="Показать/скрыть таймлайн">🎬 Таймлайн</button>
133
- <button id="chatBtn" title="Чат с Claude (Cmd+J)" onclick="window.kingChat?.toggle?.()">💬 Чат</button>
133
+ <button id="chatBtn" title="Открыть/скрыть чат с KingKont (⌘J / Ctrl+J)" onclick="window.kingChat?.toggle?.()">💬 Чат <span style="opacity:0.6; font-size:10px;">⌘J</span></button>
134
134
  <!-- скрытые, но используемые элементы (logic ссылается по id) -->
135
135
  <span id="hint" class="path" style="display:none;"></span>
136
136
  <span id="boardBadge" class="badge" style="display:none;"></span>
@@ -0,0 +1,310 @@
1
+ // lib/chatSession.js — Server-side chat sessions для KingKont.
2
+ //
3
+ // Каждая сессия привязана к проекту (sessionKey = projectKey клиента,
4
+ // например "cloud:abc123" или "folder:Сериал-вживую"). Хранит:
5
+ // - history: [{role, content, tools?}]
6
+ // - busy: true пока крутится LLM-loop
7
+ // - pendingToolCalls: [{name, args, id}] — то что Claude хочет запустить;
8
+ // клиент исполняет → posts results → loop продолжается
9
+ // - lastError: строка ошибки если loop упал
10
+ //
11
+ // Lifecycle:
12
+ // 1. Клиент: POST /api/chat/send {sessionKey, text, system, context}
13
+ // → серверный send() пушит user-msg, стартует LLM-loop в фоне.
14
+ // 2. Loop ждёт ответ от LLM. Парсит <tool>JSON</tool> блоки.
15
+ // Если есть → выставляет pendingToolCalls + ставит ожидание results.
16
+ // 3. Клиент поллит GET /api/chat/state?sessionKey=...
17
+ // Видит pendingToolCalls → исполняет локально (с FSAH доступом!) →
18
+ // POST /api/chat/tool-results {sessionKey, results}.
19
+ // 4. Loop резюмирует с tool-results, идёт следующая итерация.
20
+ // 5. Когда нет tools → loop останавливается, busy=false.
21
+ //
22
+ // Persistence: на каждое изменение пишем в `<userDataDir>/chats/<key>.json`
23
+ // (debounced). Загружается на первый запрос session'а. Это позволяет
24
+ // чату пережить рестарт Electron.
25
+ //
26
+ // Tools НЕ исполняются на сервере — только пробрасываются клиенту.
27
+ // Это потому что тулы работают с FSAH-handle'ами проекта (renderer-only).
28
+
29
+ 'use strict';
30
+
31
+ const fs = require('node:fs');
32
+ const fsp = require('node:fs/promises');
33
+ const path = require('node:path');
34
+ const crypto = require('node:crypto');
35
+
36
+ const MAX_TOOL_ITERATIONS = 12;
37
+ const PERSIST_DEBOUNCE_MS = 800;
38
+
39
+ // ============== STORAGE ==============
40
+ let _userDataDir = null; // выставляется через init() из main.js
41
+ function init({ userDataDir }) {
42
+ _userDataDir = userDataDir;
43
+ if (_userDataDir) {
44
+ const dir = path.join(_userDataDir, 'chats');
45
+ try { fs.mkdirSync(dir, { recursive: true }); } catch {}
46
+ }
47
+ }
48
+ function _sessionFilePath(key) {
49
+ if (!_userDataDir) return null;
50
+ // sessionKey может содержать /, : и т.п. — encode через URI.
51
+ const safe = encodeURIComponent(key);
52
+ return path.join(_userDataDir, 'chats', safe + '.json');
53
+ }
54
+ async function _persist(session) {
55
+ if (!_userDataDir) return;
56
+ const p = _sessionFilePath(session.key);
57
+ if (!p) return;
58
+ // Не сохраняем pending state — на restart loop стартанётся заново
59
+ // (или останется незавершённым — клиент увидит busy=false и предложит
60
+ // повторить).
61
+ const persistable = {
62
+ key: session.key,
63
+ history: session.history.filter(m =>
64
+ m.role !== 'system' &&
65
+ !(m.role === 'user' && typeof m.content === 'string' && m.content.startsWith('<tool_result>')),
66
+ ),
67
+ savedAt: Date.now(),
68
+ };
69
+ try {
70
+ await fsp.writeFile(p, JSON.stringify(persistable, null, 2), 'utf-8');
71
+ } catch (e) {
72
+ console.warn('[chatSession] persist failed for', session.key, e?.message);
73
+ }
74
+ }
75
+ async function _load(key) {
76
+ if (!_userDataDir) return null;
77
+ const p = _sessionFilePath(key);
78
+ if (!p) return null;
79
+ try {
80
+ const text = await fsp.readFile(p, 'utf-8');
81
+ return JSON.parse(text);
82
+ } catch { return null; }
83
+ }
84
+
85
+ // ============== SESSIONS ==============
86
+ const SESSIONS = new Map(); // key → Session
87
+
88
+ function _emptySession(key) {
89
+ return {
90
+ key,
91
+ history: [], // [{role, content, tools?}]
92
+ busy: false,
93
+ pendingToolCalls: null, // null | [{id, name, args}]
94
+ pendingResolve: null, // function(results) — used by loop awaiting
95
+ lastError: null,
96
+ createdAt: Date.now(),
97
+ updatedAt: Date.now(),
98
+ _persistTimer: null,
99
+ };
100
+ }
101
+
102
+ async function getSession(key, { create = true } = {}) {
103
+ let s = SESSIONS.get(key);
104
+ if (s) return s;
105
+ // Try load from disk.
106
+ const saved = await _load(key);
107
+ if (saved) {
108
+ s = _emptySession(key);
109
+ s.history = Array.isArray(saved.history) ? saved.history : [];
110
+ SESSIONS.set(key, s);
111
+ return s;
112
+ }
113
+ if (!create) return null;
114
+ s = _emptySession(key);
115
+ SESSIONS.set(key, s);
116
+ return s;
117
+ }
118
+
119
+ function schedulePersist(session) {
120
+ clearTimeout(session._persistTimer);
121
+ session._persistTimer = setTimeout(() => _persist(session), PERSIST_DEBOUNCE_MS);
122
+ }
123
+
124
+ // ============== TOOL-CALL PARSER ==============
125
+ function parseToolCalls(text) {
126
+ if (typeof text !== 'string' || !text) return [];
127
+ const out = [];
128
+ const re = /<tool>\s*([\s\S]*?)\s*<\/tool>/g;
129
+ let m;
130
+ while ((m = re.exec(text)) !== null) {
131
+ try {
132
+ const obj = JSON.parse(m[1]);
133
+ if (obj && typeof obj.name === 'string') {
134
+ out.push({ id: crypto.randomUUID(), name: obj.name, args: obj.args || {} });
135
+ }
136
+ } catch (e) {
137
+ out.push({ id: crypto.randomUUID(), _parseError: e.message, raw: m[1] });
138
+ }
139
+ }
140
+ return out;
141
+ }
142
+ function stripToolCalls(text) {
143
+ if (typeof text !== 'string') return '';
144
+ return text
145
+ .replace(/<tool>[\s\S]*?<\/tool>/g, '')
146
+ .replace(/<tool_result>[\s\S]*?<\/tool_result>/g, '')
147
+ .replace(/[ \t]{2,}/g, ' ')
148
+ .replace(/\n{3,}/g, '\n\n')
149
+ .trim();
150
+ }
151
+
152
+ // ============== LLM CALL ==============
153
+ // Использует ту же providers.generateText (single-prompt с transcript), что и
154
+ // клиентский код раньше. Передаём messages как готовый transcript-prompt.
155
+ function _historyToPrompt(history) {
156
+ const lines = [];
157
+ for (const m of history) {
158
+ if (m.role === 'system') continue;
159
+ const role = m.role === 'assistant' ? 'ASSISTANT' : m.role === 'user' ? 'USER' : m.role.toUpperCase();
160
+ const content = typeof m.content === 'string' ? m.content : '';
161
+ lines.push(`${role}: ${content}`);
162
+ }
163
+ lines.push('ASSISTANT:');
164
+ return lines.join('\n\n');
165
+ }
166
+
167
+ async function _callLLM(history, system, settings) {
168
+ const providers = require('./providers');
169
+ const prompt = _historyToPrompt(history);
170
+ const r = await providers.generateText({
171
+ prompt, system,
172
+ model: 'anthropic/claude-sonnet-4.6',
173
+ settings,
174
+ });
175
+ return r.text || '';
176
+ }
177
+
178
+ // ============== LOOP ==============
179
+ // Запускается в фоне после send() / submitToolResults(). Делает один-два
180
+ // (или больше) round-trips к LLM, между ними ждёт tool-results от клиента.
181
+ async function _runLoop(session, system, settingsGetter) {
182
+ if (session.busy) return; // уже идёт
183
+ session.busy = true;
184
+ session.lastError = null;
185
+ schedulePersist(session);
186
+ let iter = 0;
187
+ try {
188
+ while (iter < MAX_TOOL_ITERATIONS) {
189
+ iter++;
190
+ const text = await _callLLM(session.history, system, settingsGetter());
191
+ const toolCalls = parseToolCalls(text);
192
+ const cleanText = stripToolCalls(text);
193
+ const assistantMsg = { role: 'assistant', content: cleanText, tools: [] };
194
+ session.history.push(assistantMsg);
195
+ schedulePersist(session);
196
+ if (!toolCalls.length) break; // финальный ответ
197
+ // Выставляем pendingToolCalls и ЖДЁМ что клиент пришлёт results.
198
+ // _waitForToolResults возвращает массив {id, ok, result, error}.
199
+ const results = await _waitForToolResults(session, toolCalls);
200
+ // Записываем info про tool-вызовы в assistantMsg для UI.
201
+ for (let i = 0; i < toolCalls.length; i++) {
202
+ const tc = toolCalls[i];
203
+ const r = results.find(x => x.id === tc.id) || {};
204
+ assistantMsg.tools.push({
205
+ name: tc.name, args: tc.args,
206
+ _ok: !!r.ok, _error: r.error || tc._parseError, result: r.result,
207
+ });
208
+ }
209
+ schedulePersist(session);
210
+ // Шлём результаты в LLM как user-msg (Claude видит их в transcript).
211
+ const resultsMsg = results.map(r => `<tool_result>${JSON.stringify({
212
+ name: r.name, ok: !!r.ok, result: r.result, error: r.error,
213
+ })}</tool_result>`).join('\n');
214
+ session.history.push({ role: 'user', content: resultsMsg });
215
+ }
216
+ if (iter >= MAX_TOOL_ITERATIONS) {
217
+ session.history.push({ role: 'assistant', content: `⚠ Остановлен после ${MAX_TOOL_ITERATIONS} итераций.`, tools: [] });
218
+ }
219
+ } catch (e) {
220
+ console.error('[chatSession] loop error:', e?.stack || e?.message || e);
221
+ session.lastError = e?.message || String(e);
222
+ session.history.push({ role: 'assistant', content: `⚠ Ошибка: ${session.lastError}`, tools: [] });
223
+ } finally {
224
+ session.busy = false;
225
+ session.pendingToolCalls = null;
226
+ session.pendingResolve = null;
227
+ schedulePersist(session);
228
+ }
229
+ }
230
+
231
+ function _waitForToolResults(session, toolCalls) {
232
+ // Выставляем pending — клиент через GET state увидит, исполнит локально,
233
+ // вызовет submitToolResults. Тот резолвит promise.
234
+ return new Promise(resolve => {
235
+ session.pendingToolCalls = toolCalls;
236
+ session.pendingResolve = resolve;
237
+ });
238
+ }
239
+
240
+ // ============== PUBLIC API ==============
241
+
242
+ // Снимок состояния для клиента (без internal-полей).
243
+ function snapshot(session) {
244
+ return {
245
+ key: session.key,
246
+ history: session.history,
247
+ busy: session.busy,
248
+ pendingToolCalls: session.pendingToolCalls,
249
+ lastError: session.lastError,
250
+ updatedAt: session.updatedAt,
251
+ };
252
+ }
253
+
254
+ // Push user-msg + start loop. Если loop уже бежит — отказ.
255
+ async function send(key, { text, system, settingsGetter }) {
256
+ if (!text) throw new Error('text обязателен');
257
+ const session = await getSession(key);
258
+ if (session.busy) throw new Error('сессия занята — дождитесь окончания текущего turn');
259
+ session.history.push({ role: 'user', content: text });
260
+ session.updatedAt = Date.now();
261
+ schedulePersist(session);
262
+ // Запускаем loop в фоне (не await'им).
263
+ _runLoop(session, system, settingsGetter).catch(e => {
264
+ console.error('[chatSession] _runLoop unhandled:', e);
265
+ });
266
+ return snapshot(session);
267
+ }
268
+
269
+ // Клиент исполнил pendingToolCalls и шлёт results. Резолвим promise →
270
+ // loop продолжается.
271
+ function submitToolResults(key, results) {
272
+ const session = SESSIONS.get(key);
273
+ if (!session) throw new Error('сессия не найдена');
274
+ if (!session.pendingResolve) throw new Error('нет ожидающих tool-вызовов');
275
+ const resolve = session.pendingResolve;
276
+ session.pendingResolve = null;
277
+ session.pendingToolCalls = null;
278
+ // Привязываем results к pending-toolCall id'ам — клиент должен прислать
279
+ // тот же id что мы дали в pendingToolCalls.
280
+ resolve(results || []);
281
+ return snapshot(session);
282
+ }
283
+
284
+ async function clear(key) {
285
+ const session = SESSIONS.get(key);
286
+ if (session) {
287
+ session.history = [];
288
+ session.lastError = null;
289
+ schedulePersist(session);
290
+ return snapshot(session);
291
+ }
292
+ // Нет в памяти — создадим пустую и сохраним.
293
+ const fresh = await getSession(key);
294
+ fresh.history = [];
295
+ schedulePersist(fresh);
296
+ return snapshot(fresh);
297
+ }
298
+
299
+ async function getState(key) {
300
+ const session = await getSession(key);
301
+ return snapshot(session);
302
+ }
303
+
304
+ module.exports = {
305
+ init,
306
+ getState,
307
+ send,
308
+ submitToolResults,
309
+ clear,
310
+ };
package/main.js CHANGED
@@ -974,7 +974,10 @@ app.whenReady().then(async () => {
974
974
  // падаем на random и теряем handle. 8×500мс = 4 сек обычно хватает.
975
975
  // server.js на каждый запрос читает live-настройки через getSettings —
976
976
  // settings.json на диске единственный источник правды.
977
- const startOpts = { getSettings: () => readSettings() };
977
+ const startOpts = {
978
+ getSettings: () => readSettings(),
979
+ userDataDir: app.getPath('userData'),
980
+ };
978
981
  const PRIMARY_PORT = 17893;
979
982
  const RETRIES = 8;
980
983
  let lastErr;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kingkont",
3
- "version": "0.13.1",
3
+ "version": "0.14.1",
4
4
  "description": "KingKont · Chatium — нод-редактор сцен с AI-генерацией (картинки/видео/голос/SFX/музыка/текст)",
5
5
  "main": "main.js",
6
6
  "bin": {
@@ -19,7 +19,8 @@
19
19
  "scripts/**/*",
20
20
  "skill/**/*",
21
21
  "README.md",
22
- "updates.html"
22
+ "updates.html",
23
+ "web.html"
23
24
  ],
24
25
  "engines": {
25
26
  "node": ">=18"
package/renderer/chat.js CHANGED
@@ -99,19 +99,49 @@
99
99
  },
100
100
 
101
101
  add_node: {
102
- description: 'Добавить ноду на текущую доску. Для image/video/audio promptа можно задать prompt — нода будет в draft-состоянии (юзер запустит generation отдельно или используй generate_node).',
103
- params: '{"type":"image|video|audio|text","name":"<optional>","prompt":"<optional>","x":<optional>,"y":<optional>,"text":"<for-type=text>","modelKey":"<optional>"}',
104
- async handler({ type, name, prompt, x, y, text, modelKey }) {
102
+ description: 'Добавить ноду на текущую доску. Для image/video/audio promptа можно задать prompt — нода будет в draft-состоянии (юзер запустит generation отдельно или используй generate_node). Для audio укажи subKind="music"|"sfx"|"voice" — иначе по умолчанию voice (TTS).',
103
+ params: '{"type":"image|video|audio|text","subKind":"music|sfx|voice (only for audio)","name":"<optional>","prompt":"<optional>","x":<optional>,"y":<optional>,"text":"<for-type=text>","modelKey":"<optional>","durationMs":<for-music-or-sfx>}',
104
+ async handler({ type, subKind, name, prompt, x, y, text, modelKey, durationMs }) {
105
105
  if (!state.currentBoard) throw new Error('доска не выбрана');
106
106
  if (!['image','video','audio','text','label'].includes(type)) throw new Error(`unknown type: ${type}`);
107
- // Авто-координаты справа от последней ноды. Clamp к >= 50, чтобы
108
- // нода не ушла в отрицательные координаты (canvas начинается с 0,0,
109
- // негативные позиции невидимы без скролла).
107
+ // Поиск незанятого места: пытаемся справа от последней ноды,
108
+ // потом проверяем что не перекрывает существующие. Если
109
+ // перекрывает сдвигаем вправо/вниз пошагово (по сетке 32px).
110
110
  const last = state.currentBoard.metadata.nodes[state.currentBoard.metadata.nodes.length - 1];
111
111
  let nx = typeof x === 'number' ? x : (last ? last.x + 320 : 100);
112
112
  let ny = typeof y === 'number' ? y : (last ? last.y : 100);
113
113
  if (nx < 50) nx = 50;
114
114
  if (ny < 50) ny = 50;
115
+ // Дефолтные размеры для overlap-detection. Реальные node.width/height
116
+ // выставляются renderCanvas'ом по типу — но 280×220 — типичная
117
+ // прикидка для image/video, 200×120 — text/label/audio.
118
+ const w = (type === 'text' || type === 'label' || type === 'audio') ? 200 : 280;
119
+ const h = (type === 'text' || type === 'label' || type === 'audio') ? 120 : 220;
120
+ function overlaps(ax, ay) {
121
+ for (const n of state.currentBoard.metadata.nodes) {
122
+ const nw = n.width || ((n.type === 'text' || n.type === 'label' || n.type === 'audio') ? 200 : 280);
123
+ const nh = n.height || ((n.type === 'text' || n.type === 'label' || n.type === 'audio') ? 120 : 220);
124
+ // Считаем overlap'ом если центр новой ноды попадает В существующую.
125
+ // Это позволяет частично пересекаться (выглядит как «сдвиг»),
126
+ // но юзер увидит обе ноды.
127
+ const cx = ax + w / 2, cy = ay + h / 2;
128
+ if (cx >= n.x && cx <= n.x + nw && cy >= n.y && cy <= n.y + nh) return true;
129
+ }
130
+ return false;
131
+ }
132
+ // Если первое место занято — двигаем по сетке. Пытаемся вправо до
133
+ // 6 шагов, потом строкой ниже.
134
+ if (overlaps(nx, ny)) {
135
+ const step = 40;
136
+ let found = false;
137
+ for (let row = 0; row < 8 && !found; row++) {
138
+ for (let col = 0; col < 8 && !found; col++) {
139
+ const tx = nx + col * step;
140
+ const ty = ny + row * step;
141
+ if (!overlaps(tx, ty)) { nx = tx; ny = ty; found = true; }
142
+ }
143
+ }
144
+ }
115
145
  const id = (crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2));
116
146
  const node = { id, type, x: nx, y: ny };
117
147
  if (name) node.name = name;
@@ -122,6 +152,14 @@
122
152
  if (prompt && type !== 'text' && type !== 'label') {
123
153
  node.status = 'draft';
124
154
  node.generated = { rawPrompt: prompt, prompt, modelKey: modelKey || undefined };
155
+ // Audio имеет 3 sub-kind'а: voice (TTS — дефолт), music, sfx.
156
+ // generate_node роутит в нужный job по этому полю.
157
+ if (type === 'audio') {
158
+ node.generated.kind = 'audio';
159
+ node.generated.subKind = (subKind === 'music' || subKind === 'sfx') ? subKind : 'voice';
160
+ if (typeof durationMs === 'number') node.generated.durationMs = durationMs;
161
+ if (subKind === 'music') node.generated.model = 'eleven-music';
162
+ }
125
163
  }
126
164
  state.currentBoard.metadata.nodes.push(node);
127
165
  scheduleSave();
@@ -322,11 +360,21 @@
322
360
  node.generated = { ...(node.generated || {}), kind, prompt, rawPrompt: node.generated?.rawPrompt || prompt };
323
361
  scheduleSave();
324
362
  if (typeof renderCanvas === 'function') await renderCanvas();
325
- // Маршрутизация по kind — копия логики из resumeJob/«Повторить», чтобы
326
- // запустить job напрямую без gen-modal'а.
363
+ // Маршрутизация по kind/subKind — копия логики из resumeJob/«Повторить»,
364
+ // чтобы запустить job напрямую без gen-modal'а.
327
365
  if (kind === 'audio') {
328
- if (typeof runTTSJob !== 'function') throw new Error('runTTSJob недоступен');
329
- runTTSJob(node, prompt, boardHandle, bKey, node.generated?.voiceId).catch(e => console.error('TTS job failed:', e));
366
+ const subKind = node.generated?.subKind || 'voice';
367
+ if (subKind === 'music') {
368
+ if (typeof runMusicJob !== 'function') throw new Error('runMusicJob недоступен');
369
+ runMusicJob(node, prompt, node.generated?.durationMs || null, boardHandle, bKey).catch(e => console.error('music job failed:', e));
370
+ } else if (subKind === 'sfx') {
371
+ if (typeof runSfxJob !== 'function') throw new Error('runSfxJob недоступен');
372
+ runSfxJob(node, prompt, node.generated?.durationMs ? node.generated.durationMs / 1000 : null, boardHandle, bKey).catch(e => console.error('sfx job failed:', e));
373
+ } else {
374
+ // voice (TTS) — дефолт.
375
+ if (typeof runTTSJob !== 'function') throw new Error('runTTSJob недоступен');
376
+ runTTSJob(node, prompt, boardHandle, bKey, node.generated?.voiceId).catch(e => console.error('TTS job failed:', e));
377
+ }
330
378
  } else if (kind === 'text') {
331
379
  if (typeof runTextJob !== 'function') throw new Error('runTextJob недоступен');
332
380
  const imageRefs = refs.filter(r => r.type === 'image' && r.file);
@@ -485,7 +533,21 @@
485
533
  await persistNow();
486
534
  }
487
535
  }
536
+ // Забираем историю с сервера (server-side state — source of truth).
537
+ // Если на сервере есть pending tool-loop — сразу стартуем polling.
488
538
  async function loadHistoryFromCurrentProject() {
539
+ const key = sessionKey();
540
+ if (!key) { history = []; renderHistory(); return; }
541
+ try {
542
+ const r = await fetch('/api/chat/state?key=' + encodeURIComponent(key));
543
+ if (r.ok) {
544
+ const snap = await r.json();
545
+ applyServerState(snap);
546
+ if (snap.busy) startPolling(key);
547
+ return;
548
+ }
549
+ } catch (e) { console.warn('[chat] load from server failed:', e?.message); }
550
+ // Fallback на legacy disk-loaded — если сервер недоступен (старая версия).
489
551
  if (!state.filmHandle) { history = []; renderHistory(); return; }
490
552
  try {
491
553
  const fh = await state.filmHandle.getFileHandle(CHAT_FILE);
@@ -700,6 +762,21 @@
700
762
  return ctx;
701
763
  }
702
764
 
765
+ // Декл-форма множественного: «1 картинка», «2 картинки», «5 картинок».
766
+ function _ru_plural(n, forms) {
767
+ const n10 = n % 10, n100 = n % 100;
768
+ if (n100 >= 11 && n100 <= 14) return forms[2];
769
+ if (n10 === 1) return forms[0];
770
+ if (n10 >= 2 && n10 <= 4) return forms[1];
771
+ return forms[2];
772
+ }
773
+ const _typeForms = {
774
+ image: ['картинка','картинки','картинок'],
775
+ video: ['видео','видео','видео'],
776
+ audio: ['аудио','аудио','аудио'],
777
+ text: ['текст','текста','текстов'],
778
+ label: ['подпись','подписи','подписей'],
779
+ };
703
780
  function renderContextRow() {
704
781
  const row = document.getElementById('chatContextRow');
705
782
  if (!row) return;
@@ -707,8 +784,21 @@
707
784
  const ctx = buildContextSnapshot();
708
785
  const parts = [];
709
786
  if (ctx.scene) parts.push({ key: 'scene', label: `🎬 ${ctx.scene.name}`, removable: false });
787
+ // Группируем выделенные ноды по type. Если 1 — показываем имя/id;
788
+ // если 2+ — «N картинок» с локализацией.
789
+ const byType = {};
710
790
  for (const sel of ctx.selected) {
711
- parts.push({ key: 'sel:' + sel.id, label: `◉ ${sel.type === 'image' ? '🖼' : sel.type === 'video' ? '🎬' : sel.type === 'audio' ? '🎙' : '📝'} ${sel.name || sel.id.slice(0, 6)}`, removable: false });
791
+ (byType[sel.type] = byType[sel.type] || []).push(sel);
792
+ }
793
+ for (const [type, items] of Object.entries(byType)) {
794
+ const icon = type === 'image' ? '🖼' : type === 'video' ? '🎬' : type === 'audio' ? '🎙' : type === 'text' ? '📝' : '◉';
795
+ if (items.length === 1) {
796
+ const s = items[0];
797
+ parts.push({ key: 'sel:' + s.id, label: `${icon} ${s.name || s.id.slice(0, 6)}`, removable: false });
798
+ } else {
799
+ const noun = _typeForms[type] || ['нода','ноды','нод'];
800
+ parts.push({ key: 'sel-grp:' + type, label: `${icon} ${items.length} ${_ru_plural(items.length, noun)}`, removable: false });
801
+ }
712
802
  }
713
803
  for (const a of ctx.attachments) {
714
804
  parts.push({ key: 'att:' + a.relPath, label: `📎 ${a.name}`, removable: true, onRemove: () => { manualAttachments = manualAttachments.filter(x => x.relPath !== a.relPath); renderContextRow(); } });
@@ -818,7 +908,8 @@
818
908
  list.scrollTop = list.scrollHeight;
819
909
  }
820
910
 
821
- function appendStatus(msg) {
911
+ // (старый appendStatus — заменён на нижний с поддержкой isError)
912
+ function _appendStatusOld(msg) {
822
913
  const list = $('chatList');
823
914
  if (!list) return;
824
915
  const div = document.createElement('div');
@@ -894,85 +985,137 @@
894
985
  return await def.handler(args || {});
895
986
  }
896
987
 
988
+ // Идентификатор сессии чата = projectKey клиента. Передаётся серверу.
989
+ function sessionKey() {
990
+ if (state.cloudProjectId) return 'cloud:' + state.cloudProjectId;
991
+ if (state.filmHandle?.name) return 'folder:' + state.filmHandle.name;
992
+ return null;
993
+ }
994
+
995
+ // Длинный pollin цикл. Стартует после send(). Опрашивает GET /api/chat/state
996
+ // каждые 800ms, пока сервер busy=true. Если сервер вернул pendingToolCalls
997
+ // — исполняет их локально (тут есть FSAH-доступ) и шлёт results через
998
+ // POST /api/chat/tool-results, после чего сервер продолжает loop.
999
+ let pollTimer = null;
1000
+ let pollKey = null;
1001
+ async function startPolling(key) {
1002
+ if (pollKey === key && pollTimer) return;
1003
+ stopPolling();
1004
+ pollKey = key;
1005
+ const tick = async () => {
1006
+ if (pollKey !== key) return;
1007
+ try {
1008
+ const r = await fetch('/api/chat/state?key=' + encodeURIComponent(key));
1009
+ if (!r.ok) throw new Error('HTTP ' + r.status);
1010
+ const snap = await r.json();
1011
+ // Применяем снимок только если сессия всё ещё активна на клиенте.
1012
+ if (sessionKey() === key) applyServerState(snap);
1013
+ // Если есть pending-tools — исполняем и шлём results (продолжаем
1014
+ // poll сразу, не ждём next tick).
1015
+ if (snap.pendingToolCalls && snap.pendingToolCalls.length) {
1016
+ const results = [];
1017
+ for (const tc of snap.pendingToolCalls) {
1018
+ if (tc._parseError) {
1019
+ results.push({ id: tc.id, name: 'parse_error', ok: false, error: tc._parseError });
1020
+ continue;
1021
+ }
1022
+ try {
1023
+ const r2 = await runTool(tc.name, tc.args);
1024
+ results.push({ id: tc.id, name: tc.name, ok: true, result: r2 });
1025
+ } catch (e) {
1026
+ results.push({ id: tc.id, name: tc.name, ok: false, error: e?.message || String(e) });
1027
+ }
1028
+ }
1029
+ await fetch('/api/chat/tool-results', {
1030
+ method: 'POST',
1031
+ headers: { 'Content-Type': 'application/json' },
1032
+ body: JSON.stringify({ key, results }),
1033
+ }).catch(() => {});
1034
+ // Сразу продолжаем polling без задержки (loop уже резюмирован сервером).
1035
+ if (pollKey === key) pollTimer = setTimeout(tick, 50);
1036
+ return;
1037
+ }
1038
+ if (snap.busy) {
1039
+ if (pollKey === key) pollTimer = setTimeout(tick, 800);
1040
+ } else {
1041
+ // Loop завершён — последняя статус-строка может быть info'й.
1042
+ if (snap.lastError) appendStatus('⚠ ' + snap.lastError, true);
1043
+ stopPolling();
1044
+ }
1045
+ } catch (e) {
1046
+ console.warn('[chat] poll failed:', e?.message);
1047
+ if (pollKey === key) pollTimer = setTimeout(tick, 2000); // backoff
1048
+ }
1049
+ };
1050
+ tick();
1051
+ }
1052
+ function stopPolling() {
1053
+ clearTimeout(pollTimer);
1054
+ pollTimer = null;
1055
+ pollKey = null;
1056
+ }
1057
+
1058
+ // Сервер прислал snapshot — применяем history к UI.
1059
+ function applyServerState(snap) {
1060
+ if (!snap) return;
1061
+ history = snap.history || [];
1062
+ busy = !!snap.busy;
1063
+ renderHistory();
1064
+ // Statusbar: если сервер думает — поддерживаем placeholder.
1065
+ let statusEl = document.querySelector('#chatList .chat-status:last-child');
1066
+ if (busy) {
1067
+ const txt = (snap.pendingToolCalls?.length)
1068
+ ? `Выполняю ${snap.pendingToolCalls.length} tool(s)…`
1069
+ : 'KingKont думает…';
1070
+ if (!statusEl || !statusEl.classList.contains('chat-status') || statusEl.classList.contains('error')) {
1071
+ statusEl = appendStatus(txt);
1072
+ } else {
1073
+ statusEl.textContent = txt;
1074
+ }
1075
+ } else if (statusEl && !statusEl.classList.contains('error')) {
1076
+ statusEl.remove();
1077
+ }
1078
+ }
1079
+
897
1080
  async function send(userText) {
898
- if (busy) return;
899
1081
  if (!userText.trim()) return;
900
- busy = true;
901
- // Snapshot ДО мутации чтобы юзер мог откатить «всё что чат сделал
902
- // в ответ на это сообщение». Шортнем label первыми 60 символами user-msg.
1082
+ const key = sessionKey();
1083
+ if (!key) { alert('Сначала открой проект'); return; }
1084
+ // Snapshot ДО мутации клиентский (для отката чат-действий).
903
1085
  try {
904
1086
  await takeSnapshot(userText.length > 60 ? userText.slice(0, 60) + '…' : userText);
905
1087
  } catch (e) { console.warn('snapshot failed:', e?.message); }
906
- history.push({ role: 'user', content: userText });
907
- renderHistory();
908
- persistDebounced();
909
- const status = appendStatus('KingKont думает…');
910
- // Привязываем context-snapshot НА МОМЕНТ send: текущая сцена,
911
- // выделенные ноды, прикреплённые файлы. Передаётся в system-prompt
912
- // дополнительным блоком — модель видит что у юзера сейчас в фокусе.
1088
+ // Контекст + system-prompt: формируем тут (на клиенте) и отдаём серверу.
913
1089
  const ctxSnap = buildContextSnapshot();
914
1090
  const system = buildSystemPrompt() + '\n\n' + buildContextBlock(ctxSnap);
1091
+ appendStatus('KingKont думает…');
915
1092
  try {
916
- let iter = 0;
917
- while (iter < MAX_TOOL_ITERATIONS) {
918
- iter++;
919
- const text = await callLLM(historyToMessages(), system);
920
- const toolCalls = parseToolCalls(text);
921
- const cleanText = stripToolCalls(text);
922
- const assistantMsg = {
923
- role: 'assistant',
924
- content: cleanText,
925
- tools: [],
926
- };
927
- history.push(assistantMsg);
928
-
929
- if (!toolCalls.length) {
930
- // Финальный ответ — больше нечего исполнять.
931
- status.remove();
932
- renderHistory();
933
- persistDebounced();
934
- break;
935
- }
936
-
937
- // Исполняем tools, собираем результаты.
938
- status.textContent = `Выполняю ${toolCalls.length} tool(s)…`;
939
- const results = [];
940
- for (const tc of toolCalls) {
941
- if (tc._parseError) {
942
- results.push({ name: 'parse_error', error: tc._parseError, raw: tc.raw });
943
- assistantMsg.tools.push({ name: 'parse_error', _error: tc._parseError });
944
- continue;
945
- }
946
- try {
947
- const r = await runTool(tc.name, tc.args);
948
- results.push({ name: tc.name, ok: true, result: r });
949
- assistantMsg.tools.push({ name: tc.name, args: tc.args, result: r, _ok: true });
950
- } catch (e) {
951
- results.push({ name: tc.name, ok: false, error: e?.message || String(e) });
952
- assistantMsg.tools.push({ name: tc.name, args: tc.args, _error: e?.message || String(e) });
953
- }
954
- }
955
- renderHistory();
956
- persistDebounced(); // assistant-турн с tool-вызовами уже частично готов
957
-
958
- // Шлём результаты обратно как user-message (чтобы Claude увидел и продолжил).
959
- const resultsMsg = results.map(r => `<tool_result>${JSON.stringify(r)}</tool_result>`).join('\n');
960
- history.push({ role: 'user', content: resultsMsg });
961
- // Не рендерим этот системный turn (renderHistory его отфильтровывает) —
962
- // на самом деле он role='user', придётся скрыть руками. Делаем простую
963
- // эвристику: если content начинается с <tool_result>, не показываем.
964
- // Тут же — это можно сделать через флаг.
965
- }
966
- if (iter >= MAX_TOOL_ITERATIONS) {
967
- appendStatus(`⚠ остановился после ${MAX_TOOL_ITERATIONS} итераций`);
1093
+ const r = await fetch('/api/chat/send', {
1094
+ method: 'POST',
1095
+ headers: { 'Content-Type': 'application/json' },
1096
+ body: JSON.stringify({ key, text: userText, system }),
1097
+ });
1098
+ if (!r.ok) {
1099
+ const err = await r.json().catch(() => ({}));
1100
+ throw new Error(err.error || 'HTTP ' + r.status);
968
1101
  }
1102
+ const snap = await r.json();
1103
+ applyServerState(snap);
1104
+ startPolling(key);
969
1105
  } catch (e) {
970
- status.textContent = '⚠ ' + (e?.message || e);
971
- status.classList.add('error');
972
- } finally {
973
- busy = false;
1106
+ appendStatus('⚠ ' + (e?.message || e), true);
974
1107
  }
975
1108
  }
1109
+ function appendStatus(text, isError) {
1110
+ const list = $('chatList');
1111
+ if (!list) return null;
1112
+ const div = document.createElement('div');
1113
+ div.className = 'chat-status' + (isError ? ' error' : '');
1114
+ div.textContent = text;
1115
+ list.appendChild(div);
1116
+ list.scrollTop = list.scrollHeight;
1117
+ return div;
1118
+ }
976
1119
 
977
1120
  // Скрываем tool_result-сообщения из UI (они нужны только модели).
978
1121
  // renderHistory вызывает renderHistory — оставлю как есть, плюс добавлю
@@ -1016,27 +1159,30 @@
1016
1159
  div.appendChild(body);
1017
1160
  }
1018
1161
  if (hasTools) {
1019
- for (const tc of m.tools) {
1020
- const t = document.createElement('details');
1021
- t.className = 'chat-tool';
1022
- const sum = document.createElement('summary');
1023
- // Компактный summary: имя tool + ключевой arg (если короткий) + статус.
1162
+ // Все tools в ОДНУ строку (separated " · "), без emoji-icons —
1163
+ // компактнее. Раскрываются click'ом одной общей <details>.
1164
+ const t = document.createElement('details');
1165
+ t.className = 'chat-tool';
1166
+ const sum = document.createElement('summary');
1167
+ const parts = m.tools.map(tc => {
1168
+ const status = tc._error ? '⚠' : (tc._ok ? '✓' : '·');
1024
1169
  const argHint = _argHint(tc.args);
1025
- const status = tc._error ? ' ⚠' : tc._ok ? ' ✓' : '';
1026
- sum.textContent = `🔧 ${tc.name}${argHint}${status}`;
1027
- t.appendChild(sum);
1028
- // pre добавляем только если есть что показать (args/result/error не пусты).
1029
- const dump = {};
1030
- if (tc.args && Object.keys(tc.args).length) dump.args = tc.args;
1031
- if (tc.result !== undefined) dump.result = tc.result;
1032
- if (tc._error) dump.error = tc._error;
1033
- if (Object.keys(dump).length) {
1034
- const pre = document.createElement('pre');
1035
- pre.textContent = JSON.stringify(dump, null, 2);
1036
- t.appendChild(pre);
1037
- }
1038
- div.appendChild(t);
1039
- }
1170
+ return `${tc.name}${argHint} ${status}`;
1171
+ });
1172
+ sum.textContent = parts.join(' · ');
1173
+ t.appendChild(sum);
1174
+ // pre с полным JSON всех вызовов (для debug/details).
1175
+ const dumpAll = m.tools.map(tc => {
1176
+ const d = { tool: tc.name };
1177
+ if (tc.args && Object.keys(tc.args).length) d.args = tc.args;
1178
+ if (tc.result !== undefined) d.result = tc.result;
1179
+ if (tc._error) d.error = tc._error;
1180
+ return d;
1181
+ });
1182
+ const pre = document.createElement('pre');
1183
+ pre.textContent = JSON.stringify(dumpAll, null, 2);
1184
+ t.appendChild(pre);
1185
+ div.appendChild(t);
1040
1186
  }
1041
1187
  list.appendChild(div);
1042
1188
  }
@@ -1148,6 +1294,9 @@
1148
1294
  const panel = $('chatPanel');
1149
1295
  panel.classList.toggle('hidden');
1150
1296
  if (!panel.classList.contains('hidden')) {
1297
+ // Сворачиваем preview-панель — иначе она перекрывает чат справа
1298
+ // (preview z-index 40 > chat z-index 30 by design).
1299
+ if (typeof setPreviewCollapsed === 'function') setPreviewCollapsed(true);
1151
1300
  // Отрисовываем сохранённую историю при показе (на случай если она
1152
1301
  // была подгружена в фоне через loadFromCurrentProject до первого toggle).
1153
1302
  renderHistory();
@@ -1180,13 +1329,25 @@
1180
1329
  open: () => { ensureUI(); $('chatPanel').classList.remove('hidden'); setTimeout(() => $('chatInput')?.focus(), 50); },
1181
1330
  close: () => $('chatPanel')?.classList.add('hidden'),
1182
1331
  send,
1183
- // User-clear: чистит И на диске тоже (юзер явно нажал ⌫).
1184
- clear: () => { history = []; snapshots = []; renderHistory(); renderSnapshotBar(); persistNow().catch(() => {}); },
1185
- // Reset-on-close: ФЛАШИМ pending-дебаунс В ТЕКУЩИЙ filmHandle (чтобы
1186
- // история не утекла в следующий проект), потом чистим in-memory.
1187
- // На диске история того проекта остаётся — re-open подгрузит.
1332
+ // User-clear: стираем И на сервере (юзер явно нажал ⌫).
1333
+ clear: async () => {
1334
+ const key = sessionKey();
1335
+ if (key) {
1336
+ try {
1337
+ await fetch('/api/chat/clear', {
1338
+ method: 'POST',
1339
+ headers: { 'Content-Type': 'application/json' },
1340
+ body: JSON.stringify({ key }),
1341
+ });
1342
+ } catch {}
1343
+ }
1344
+ history = []; snapshots = [];
1345
+ renderHistory(); renderSnapshotBar();
1346
+ },
1347
+ // Reset-on-close: останавливаем polling, чистим UI. История на сервере
1348
+ // остаётся (server-side persistence). re-open того же проекта подгрузит.
1188
1349
  resetInMemory: async () => {
1189
- try { await flushPersist(); } catch {}
1350
+ stopPolling();
1190
1351
  history = []; snapshots = [];
1191
1352
  renderHistory(); renderSnapshotBar();
1192
1353
  },
@@ -253,7 +253,10 @@
253
253
  position: fixed; right: 0; top: 0; bottom: 0; width: 420px;
254
254
  background: #1a1a1a; border-left: 1px solid #333;
255
255
  display: flex; flex-direction: column;
256
- z-index: 80;
256
+ /* z-index 30 — НИЖЕ preview-panel (z-index 40). Preview перекрывает чат
257
+ визуально; при открытии чата preview сворачивается автоматически
258
+ (см. toggle() в chat.js → setPreviewCollapsed(true)). */
259
+ z-index: 30;
257
260
  box-shadow: -8px 0 32px rgba(0,0,0,0.4);
258
261
  /* --chat-font задаётся inline в chat.js (Cmd+/Cmd-). Дефолт 13px. */
259
262
  font-size: var(--chat-font, 13px);
package/server.js CHANGED
@@ -13,6 +13,7 @@ const { readFileSync, existsSync } = require('node:fs');
13
13
  const { extname, join, normalize, resolve } = require('node:path');
14
14
 
15
15
  const providers = require('./lib/providers');
16
+ const chatSession = require('./lib/chatSession');
16
17
 
17
18
  // ---------- .env loader (без зависимостей) ----------
18
19
  function loadEnv() {
@@ -299,6 +300,51 @@ async function handleProjectDelete(res, id) {
299
300
  } catch (e) { sendError(res, e, 502); }
300
301
  }
301
302
 
303
+ // =============================================================================
304
+ // Chat sessions: server-side LLM-loop с tool-pump pattern.
305
+ // Клиент: POST /api/chat/send {key, text, system} → starts loop
306
+ // GET /api/chat/state?key=... → poll for state/pendingTools
307
+ // POST /api/chat/tool-results {key, results} → continue loop
308
+ // POST /api/chat/clear {key}
309
+ // Tools исполняются НА КЛИЕНТЕ (FSAH-доступ), сервер только пробрасывает.
310
+ // =============================================================================
311
+ async function handleChatSend(req, res) {
312
+ try {
313
+ const body = await readJson(req);
314
+ const { key, text, system } = body || {};
315
+ if (!key) return send(res, 400, { error: 'key обязателен' });
316
+ if (!text) return send(res, 400, { error: 'text обязателен' });
317
+ const snap = await chatSession.send(key, { text, system, settingsGetter: getSettings });
318
+ send(res, 200, snap);
319
+ } catch (e) { sendError(res, e, 500); }
320
+ }
321
+ async function handleChatState(res, url) {
322
+ try {
323
+ const key = url.searchParams.get('key');
324
+ if (!key) return send(res, 400, { error: 'key обязателен' });
325
+ const snap = await chatSession.getState(key);
326
+ send(res, 200, snap);
327
+ } catch (e) { sendError(res, e, 500); }
328
+ }
329
+ async function handleChatToolResults(req, res) {
330
+ try {
331
+ const body = await readJson(req);
332
+ const { key, results } = body || {};
333
+ if (!key) return send(res, 400, { error: 'key обязателен' });
334
+ const snap = chatSession.submitToolResults(key, results);
335
+ send(res, 200, snap);
336
+ } catch (e) { sendError(res, e, 500); }
337
+ }
338
+ async function handleChatClear(req, res) {
339
+ try {
340
+ const body = await readJson(req);
341
+ const { key } = body || {};
342
+ if (!key) return send(res, 400, { error: 'key обязателен' });
343
+ const snap = await chatSession.clear(key);
344
+ send(res, 200, snap);
345
+ } catch (e) { sendError(res, e, 500); }
346
+ }
347
+
302
348
  // =============================================================================
303
349
  // Static files (renderer assets).
304
350
  // =============================================================================
@@ -348,6 +394,11 @@ const server = createServer(async (req, res) => {
348
394
  if (req.method === 'DELETE') return handleTemplateDelete(res, decodeURIComponent(m[1]));
349
395
  }
350
396
  }
397
+ // Chat routes (server-side LLM loop, tools исполняются на клиенте).
398
+ if (req.method === 'POST' && url.pathname === '/api/chat/send') return handleChatSend(req, res);
399
+ if (req.method === 'GET' && url.pathname === '/api/chat/state') return handleChatState(res, url);
400
+ if (req.method === 'POST' && url.pathname === '/api/chat/tool-results') return handleChatToolResults(req, res);
401
+ if (req.method === 'POST' && url.pathname === '/api/chat/clear') return handleChatClear(req, res);
351
402
  // Cloud-projects routes — зеркало templates, но для редактируемых проектов.
352
403
  if (req.method === 'GET' && url.pathname === '/api/projects') return handleProjectsList(res);
353
404
  if (req.method === 'POST' && url.pathname === '/api/projects') return handleProjectCreate(req, res);
@@ -372,6 +423,9 @@ process.on('uncaughtException', (err) => console.error('[uncaughtException]', e
372
423
 
373
424
  function start(port = PORT, opts = {}) {
374
425
  if (typeof opts.getSettings === 'function') getSettings = opts.getSettings;
426
+ // Init chatSession с userDataDir для персистентности историй.
427
+ // Без opts.userDataDir чат живёт только in-memory (CLI-режим).
428
+ chatSession.init({ userDataDir: opts.userDataDir || null });
375
429
  return new Promise((resolveOk, reject) => {
376
430
  server.once('error', reject);
377
431
  server.listen(port, () => {
package/web.html ADDED
@@ -0,0 +1,105 @@
1
+ <!DOCTYPE html>
2
+ <!--
3
+ KingKont — web-shell (без FS Access API).
4
+ =========================================
5
+
6
+ Это вход для веб-версии. Отличается от index.html только тем что:
7
+ 1. Вначале выставляет window.__KINGKONT_WEB__ = true.
8
+ 2. После загрузки index.html-разметки прячет кнопки которые требуют
9
+ реальной FS-Access (#pickRoot и любые «Открыть папку…»).
10
+ 3. В renderer/cloudFs.js fallback на in-memory backend срабатывает
11
+ автоматически — preload не подгружен, window.cloudFs undefined.
12
+
13
+ Способы попасть сюда:
14
+ - локально: `node server.js` → http://localhost:8000/web.html
15
+ - чуть позже задеплоится отдельно как иной endpoint (когда будет готов).
16
+
17
+ ВАЖНО: всю работу с проектом веб-версия ведёт через cloud-projects.
18
+ Папочные проекты (через showDirectoryPicker) в вебе не поддерживаются.
19
+ -->
20
+ <html lang="ru">
21
+ <head>
22
+ <meta charset="UTF-8">
23
+ <meta name="viewport" content="width=device-width, initial-scale=1">
24
+ <title>KingKont (web)</title>
25
+ <link rel="stylesheet" href="renderer/styles.css">
26
+ <style>
27
+ /* Web-mode: прячем кнопки которые завязаны на FS Access API. */
28
+ body.web-mode #pickRoot,
29
+ body.web-mode #welcomeOpen,
30
+ body.web-mode .web-hide { display: none !important; }
31
+ /* Подсказку добавляем в шапку — что это web-вариант. */
32
+ body.web-mode .brand-version::after {
33
+ content: ' · web';
34
+ color: #ff9d6e;
35
+ margin-left: 4px;
36
+ }
37
+ </style>
38
+ <script>
39
+ // Маркер до загрузки прочих скриптов: cloudFs.js / cloudProjects.js
40
+ // могут проверить window.__KINGKONT_WEB__ для развилок.
41
+ window.__KINGKONT_WEB__ = true;
42
+ </script>
43
+ </head>
44
+ <body class="web-mode">
45
+ <!--
46
+ ВНИМАНИЕ: разметка ниже скопирована из index.html. Чтобы не плодить дубликат
47
+ при изменениях, держи их синхронизированными ИЛИ вынеси разметку в общий
48
+ partial. Сейчас минимальный shell — без копирования всех модалок: их можно
49
+ включить позже когда веб-режим будет покрывать больше сценариев.
50
+
51
+ TODO для следующих итераций:
52
+ - вынести `<aside class="sidebar">` и `<main class="canvas">` в common partial
53
+ - проверить что generate.js работает без window.appSettings (он делает
54
+ `window.appSettings.get()` для useChatium-флагов; на вебе нужно либо
55
+ mock'ать через server.js endpoint, либо хранить в localStorage).
56
+ - аудио/видео-плейбек таймлайна должен работать с blob-URL'ами от
57
+ in-memory cloud-handle'ов (cloudFs.js делает Blob через .getFile()).
58
+ -->
59
+ <div style="padding:60px; text-align:center; max-width:600px; margin:auto; color:#bbb; font-family:sans-serif;">
60
+ <h1 style="color:#fff; margin-bottom:16px;">KingKont · web</h1>
61
+ <p style="margin-bottom:24px;">Веб-версия в разработке. Сейчас доступно:</p>
62
+ <ul style="text-align:left; max-width:400px; margin:0 auto 24px; line-height:1.8;">
63
+ <li>создание облачного проекта (server-side)</li>
64
+ <li>редактирование в браузере (in-memory)</li>
65
+ <li>сохранение на сервер по кнопке</li>
66
+ </ul>
67
+ <p style="font-size:13px; opacity:0.7;">
68
+ Для полноценной работы открой Electron-приложение:<br>
69
+ <code style="background:#222; padding:2px 6px; border-radius:3px;">npx kingkont</code>
70
+ </p>
71
+ <hr style="margin:32px 0; border:none; border-top:1px solid #333;">
72
+ <p style="font-size:12px; opacity:0.6;">
73
+ Чтобы открыть полную web-версию (с canvas), <a href="/index.html" style="color:#7ab;">перейди на /index.html</a>
74
+ — там работает FS Access API в Chrome (можно открыть папку проекта).<br>
75
+ Эта страница (web.html) — заготовка для будущего билда без FS-API.
76
+ </p>
77
+ </div>
78
+
79
+ <script>
80
+ // Заглушки для главных API окружения, которых нет в web (приходят из
81
+ // preload в Electron). Renderer-модули проверяют наличие через optional
82
+ // chaining (window.appSettings?.get etc) — undefined → они просто не
83
+ // дёргают эти ручки.
84
+ if (!window.appSettings) {
85
+ window.appSettings = {
86
+ // На вебе settings храним в localStorage (минимально нужны useChatium
87
+ // и chatium.token — иначе cloud-projects кнопки не покажутся).
88
+ // Token придётся ввести вручную пока не сделан web-OAuth.
89
+ get: async () => {
90
+ try { return JSON.parse(localStorage.getItem('webSettings') || '{}'); }
91
+ catch { return {}; }
92
+ },
93
+ save: async (partial) => {
94
+ const cur = JSON.parse(localStorage.getItem('webSettings') || '{}');
95
+ const next = { ...cur, ...partial };
96
+ localStorage.setItem('webSettings', JSON.stringify(next));
97
+ return next;
98
+ },
99
+ };
100
+ }
101
+ if (!window.appProject) window.appProject = { notifyState: () => {} };
102
+ if (!window.appInfo) window.appInfo = { version: async () => 'web' };
103
+ </script>
104
+ </body>
105
+ </html>