kingkont 0.13.1 → 0.14.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.
@@ -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.0",
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
@@ -485,7 +485,21 @@
485
485
  await persistNow();
486
486
  }
487
487
  }
488
+ // Забираем историю с сервера (server-side state — source of truth).
489
+ // Если на сервере есть pending tool-loop — сразу стартуем polling.
488
490
  async function loadHistoryFromCurrentProject() {
491
+ const key = sessionKey();
492
+ if (!key) { history = []; renderHistory(); return; }
493
+ try {
494
+ const r = await fetch('/api/chat/state?key=' + encodeURIComponent(key));
495
+ if (r.ok) {
496
+ const snap = await r.json();
497
+ applyServerState(snap);
498
+ if (snap.busy) startPolling(key);
499
+ return;
500
+ }
501
+ } catch (e) { console.warn('[chat] load from server failed:', e?.message); }
502
+ // Fallback на legacy disk-loaded — если сервер недоступен (старая версия).
489
503
  if (!state.filmHandle) { history = []; renderHistory(); return; }
490
504
  try {
491
505
  const fh = await state.filmHandle.getFileHandle(CHAT_FILE);
@@ -818,7 +832,8 @@
818
832
  list.scrollTop = list.scrollHeight;
819
833
  }
820
834
 
821
- function appendStatus(msg) {
835
+ // (старый appendStatus — заменён на нижний с поддержкой isError)
836
+ function _appendStatusOld(msg) {
822
837
  const list = $('chatList');
823
838
  if (!list) return;
824
839
  const div = document.createElement('div');
@@ -894,85 +909,137 @@
894
909
  return await def.handler(args || {});
895
910
  }
896
911
 
912
+ // Идентификатор сессии чата = projectKey клиента. Передаётся серверу.
913
+ function sessionKey() {
914
+ if (state.cloudProjectId) return 'cloud:' + state.cloudProjectId;
915
+ if (state.filmHandle?.name) return 'folder:' + state.filmHandle.name;
916
+ return null;
917
+ }
918
+
919
+ // Длинный pollin цикл. Стартует после send(). Опрашивает GET /api/chat/state
920
+ // каждые 800ms, пока сервер busy=true. Если сервер вернул pendingToolCalls
921
+ // — исполняет их локально (тут есть FSAH-доступ) и шлёт results через
922
+ // POST /api/chat/tool-results, после чего сервер продолжает loop.
923
+ let pollTimer = null;
924
+ let pollKey = null;
925
+ async function startPolling(key) {
926
+ if (pollKey === key && pollTimer) return;
927
+ stopPolling();
928
+ pollKey = key;
929
+ const tick = async () => {
930
+ if (pollKey !== key) return;
931
+ try {
932
+ const r = await fetch('/api/chat/state?key=' + encodeURIComponent(key));
933
+ if (!r.ok) throw new Error('HTTP ' + r.status);
934
+ const snap = await r.json();
935
+ // Применяем снимок только если сессия всё ещё активна на клиенте.
936
+ if (sessionKey() === key) applyServerState(snap);
937
+ // Если есть pending-tools — исполняем и шлём results (продолжаем
938
+ // poll сразу, не ждём next tick).
939
+ if (snap.pendingToolCalls && snap.pendingToolCalls.length) {
940
+ const results = [];
941
+ for (const tc of snap.pendingToolCalls) {
942
+ if (tc._parseError) {
943
+ results.push({ id: tc.id, name: 'parse_error', ok: false, error: tc._parseError });
944
+ continue;
945
+ }
946
+ try {
947
+ const r2 = await runTool(tc.name, tc.args);
948
+ results.push({ id: tc.id, name: tc.name, ok: true, result: r2 });
949
+ } catch (e) {
950
+ results.push({ id: tc.id, name: tc.name, ok: false, error: e?.message || String(e) });
951
+ }
952
+ }
953
+ await fetch('/api/chat/tool-results', {
954
+ method: 'POST',
955
+ headers: { 'Content-Type': 'application/json' },
956
+ body: JSON.stringify({ key, results }),
957
+ }).catch(() => {});
958
+ // Сразу продолжаем polling без задержки (loop уже резюмирован сервером).
959
+ if (pollKey === key) pollTimer = setTimeout(tick, 50);
960
+ return;
961
+ }
962
+ if (snap.busy) {
963
+ if (pollKey === key) pollTimer = setTimeout(tick, 800);
964
+ } else {
965
+ // Loop завершён — последняя статус-строка может быть info'й.
966
+ if (snap.lastError) appendStatus('⚠ ' + snap.lastError, true);
967
+ stopPolling();
968
+ }
969
+ } catch (e) {
970
+ console.warn('[chat] poll failed:', e?.message);
971
+ if (pollKey === key) pollTimer = setTimeout(tick, 2000); // backoff
972
+ }
973
+ };
974
+ tick();
975
+ }
976
+ function stopPolling() {
977
+ clearTimeout(pollTimer);
978
+ pollTimer = null;
979
+ pollKey = null;
980
+ }
981
+
982
+ // Сервер прислал snapshot — применяем history к UI.
983
+ function applyServerState(snap) {
984
+ if (!snap) return;
985
+ history = snap.history || [];
986
+ busy = !!snap.busy;
987
+ renderHistory();
988
+ // Statusbar: если сервер думает — поддерживаем placeholder.
989
+ let statusEl = document.querySelector('#chatList .chat-status:last-child');
990
+ if (busy) {
991
+ const txt = (snap.pendingToolCalls?.length)
992
+ ? `Выполняю ${snap.pendingToolCalls.length} tool(s)…`
993
+ : 'KingKont думает…';
994
+ if (!statusEl || !statusEl.classList.contains('chat-status') || statusEl.classList.contains('error')) {
995
+ statusEl = appendStatus(txt);
996
+ } else {
997
+ statusEl.textContent = txt;
998
+ }
999
+ } else if (statusEl && !statusEl.classList.contains('error')) {
1000
+ statusEl.remove();
1001
+ }
1002
+ }
1003
+
897
1004
  async function send(userText) {
898
- if (busy) return;
899
1005
  if (!userText.trim()) return;
900
- busy = true;
901
- // Snapshot ДО мутации чтобы юзер мог откатить «всё что чат сделал
902
- // в ответ на это сообщение». Шортнем label первыми 60 символами user-msg.
1006
+ const key = sessionKey();
1007
+ if (!key) { alert('Сначала открой проект'); return; }
1008
+ // Snapshot ДО мутации клиентский (для отката чат-действий).
903
1009
  try {
904
1010
  await takeSnapshot(userText.length > 60 ? userText.slice(0, 60) + '…' : userText);
905
1011
  } 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
- // дополнительным блоком — модель видит что у юзера сейчас в фокусе.
1012
+ // Контекст + system-prompt: формируем тут (на клиенте) и отдаём серверу.
913
1013
  const ctxSnap = buildContextSnapshot();
914
1014
  const system = buildSystemPrompt() + '\n\n' + buildContextBlock(ctxSnap);
1015
+ appendStatus('KingKont думает…');
915
1016
  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} итераций`);
1017
+ const r = await fetch('/api/chat/send', {
1018
+ method: 'POST',
1019
+ headers: { 'Content-Type': 'application/json' },
1020
+ body: JSON.stringify({ key, text: userText, system }),
1021
+ });
1022
+ if (!r.ok) {
1023
+ const err = await r.json().catch(() => ({}));
1024
+ throw new Error(err.error || 'HTTP ' + r.status);
968
1025
  }
1026
+ const snap = await r.json();
1027
+ applyServerState(snap);
1028
+ startPolling(key);
969
1029
  } catch (e) {
970
- status.textContent = '⚠ ' + (e?.message || e);
971
- status.classList.add('error');
972
- } finally {
973
- busy = false;
1030
+ appendStatus('⚠ ' + (e?.message || e), true);
974
1031
  }
975
1032
  }
1033
+ function appendStatus(text, isError) {
1034
+ const list = $('chatList');
1035
+ if (!list) return null;
1036
+ const div = document.createElement('div');
1037
+ div.className = 'chat-status' + (isError ? ' error' : '');
1038
+ div.textContent = text;
1039
+ list.appendChild(div);
1040
+ list.scrollTop = list.scrollHeight;
1041
+ return div;
1042
+ }
976
1043
 
977
1044
  // Скрываем tool_result-сообщения из UI (они нужны только модели).
978
1045
  // renderHistory вызывает renderHistory — оставлю как есть, плюс добавлю
@@ -1180,13 +1247,25 @@
1180
1247
  open: () => { ensureUI(); $('chatPanel').classList.remove('hidden'); setTimeout(() => $('chatInput')?.focus(), 50); },
1181
1248
  close: () => $('chatPanel')?.classList.add('hidden'),
1182
1249
  send,
1183
- // User-clear: чистит И на диске тоже (юзер явно нажал ⌫).
1184
- clear: () => { history = []; snapshots = []; renderHistory(); renderSnapshotBar(); persistNow().catch(() => {}); },
1185
- // Reset-on-close: ФЛАШИМ pending-дебаунс В ТЕКУЩИЙ filmHandle (чтобы
1186
- // история не утекла в следующий проект), потом чистим in-memory.
1187
- // На диске история того проекта остаётся — re-open подгрузит.
1250
+ // User-clear: стираем И на сервере (юзер явно нажал ⌫).
1251
+ clear: async () => {
1252
+ const key = sessionKey();
1253
+ if (key) {
1254
+ try {
1255
+ await fetch('/api/chat/clear', {
1256
+ method: 'POST',
1257
+ headers: { 'Content-Type': 'application/json' },
1258
+ body: JSON.stringify({ key }),
1259
+ });
1260
+ } catch {}
1261
+ }
1262
+ history = []; snapshots = [];
1263
+ renderHistory(); renderSnapshotBar();
1264
+ },
1265
+ // Reset-on-close: останавливаем polling, чистим UI. История на сервере
1266
+ // остаётся (server-side persistence). re-open того же проекта подгрузит.
1188
1267
  resetInMemory: async () => {
1189
- try { await flushPersist(); } catch {}
1268
+ stopPolling();
1190
1269
  history = []; snapshots = [];
1191
1270
  renderHistory(); renderSnapshotBar();
1192
1271
  },
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>