kingkont 0.13.0 → 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.
- package/lib/chatSession.js +310 -0
- package/main.js +4 -1
- package/package.json +3 -2
- package/renderer/chat.js +153 -74
- package/renderer/media.js +37 -9
- package/server.js +54 -0
- package/web.html +105 -0
|
@@ -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 = {
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
901
|
-
|
|
902
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
const
|
|
923
|
-
|
|
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
|
-
|
|
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:
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
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
|
-
|
|
1268
|
+
stopPolling();
|
|
1190
1269
|
history = []; snapshots = [];
|
|
1191
1270
|
renderHistory(); renderSnapshotBar();
|
|
1192
1271
|
},
|
package/renderer/media.js
CHANGED
|
@@ -14,31 +14,59 @@ const _canvasFrame = $('canvasFrame');
|
|
|
14
14
|
// layout-affecting свойство, его смена на каждом wheel-tick форсила 751ms Layout.
|
|
15
15
|
// Транформ на canvas идёт через GPU (will-change: transform → composited),
|
|
16
16
|
// scroll-bounds во время burst могут быть слегка off, на idle подстраиваются.
|
|
17
|
+
// Канвас-frame теперь с padding'ом вокруг .canvas (см. styles.css
|
|
18
|
+
// .canvas-frame --canvas-pad-x/y). Координаты:
|
|
19
|
+
// - frame-coords: 0..(6000*z + 2*padX). Лево-верх frame = (0,0).
|
|
20
|
+
// - canvas-coords: 0..6000 (до scale). Канвас расположен в frame'е
|
|
21
|
+
// по offset (padX, padY) и масштабирован transform: scale(z).
|
|
22
|
+
// - mouseInFrame = scrollLeft + (mouseClient - wrapClient)
|
|
23
|
+
// - contentCoord = (mouseInFrame - padX) / z
|
|
24
|
+
function _getFramePadding() {
|
|
25
|
+
const cs = getComputedStyle(_canvasFrame);
|
|
26
|
+
return {
|
|
27
|
+
padX: parseInt(cs.getPropertyValue('--canvas-pad-x')) || 0,
|
|
28
|
+
padY: parseInt(cs.getPropertyValue('--canvas-pad-y')) || 0,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
17
31
|
let _frameSizeTimer = null;
|
|
18
32
|
function applyZoomStyles(z) {
|
|
19
33
|
canvas.style.transform = `scale(${z})`;
|
|
20
34
|
clearTimeout(_frameSizeTimer);
|
|
21
35
|
_frameSizeTimer = setTimeout(() => {
|
|
22
36
|
_frameSizeTimer = null;
|
|
23
|
-
|
|
24
|
-
|
|
37
|
+
const { padX, padY } = _getFramePadding();
|
|
38
|
+
// Frame-размер = scaled-canvas + padding с обеих сторон.
|
|
39
|
+
// Раньше padding не учитывался → frame ужимался при zoom-out → весь
|
|
40
|
+
// padding-skill терялся, ноды с отрицательными coord'ами становились
|
|
41
|
+
// неперехватываемыми скроллом.
|
|
42
|
+
_canvasFrame.style.width = (6000 * z + 2 * padX) + 'px';
|
|
43
|
+
_canvasFrame.style.height = (4000 * z + 2 * padY) + 'px';
|
|
25
44
|
}, 200);
|
|
26
45
|
}
|
|
27
46
|
function applyZoom(nextZoom, anchorClientX, anchorClientY) {
|
|
28
47
|
nextZoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, nextZoom));
|
|
29
48
|
if (nextZoom === state.zoom) return;
|
|
30
49
|
const rect = canvasWrap.getBoundingClientRect();
|
|
31
|
-
|
|
50
|
+
const { padX, padY } = _getFramePadding();
|
|
51
|
+
// Якорь = mouse client coord (или центр если не задан).
|
|
32
52
|
const ax = anchorClientX ?? (rect.left + rect.width / 2);
|
|
33
53
|
const ay = anchorClientY ?? (rect.top + rect.height / 2);
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
const
|
|
37
|
-
|
|
54
|
+
// mouse внутри wrap (без учёта scroll).
|
|
55
|
+
const mouseWrapX = ax - rect.left;
|
|
56
|
+
const mouseWrapY = ay - rect.top;
|
|
57
|
+
// mouse в frame-coords (со scroll).
|
|
58
|
+
const mouseFrameX = canvasWrap.scrollLeft + mouseWrapX;
|
|
59
|
+
const mouseFrameY = canvasWrap.scrollTop + mouseWrapY;
|
|
60
|
+
// mouse в canvas content-coords (вычитаем padding и делим на zoom).
|
|
61
|
+
const contentX = (mouseFrameX - padX) / state.zoom;
|
|
62
|
+
const contentY = (mouseFrameY - padY) / state.zoom;
|
|
38
63
|
state.zoom = nextZoom;
|
|
39
64
|
applyZoomStyles(nextZoom);
|
|
40
|
-
|
|
41
|
-
|
|
65
|
+
// После нового zoom: contentCoord должен оказаться там же где был курсор.
|
|
66
|
+
// newMouseFrame = contentCoord * nextZoom + padX
|
|
67
|
+
// newScrollLeft = newMouseFrame - mouseWrapX
|
|
68
|
+
canvasWrap.scrollLeft = contentX * nextZoom + padX - mouseWrapX;
|
|
69
|
+
canvasWrap.scrollTop = contentY * nextZoom + padY - mouseWrapY;
|
|
42
70
|
$('zoomLabel').textContent = Math.round(nextZoom * 100) + '%';
|
|
43
71
|
scheduleViewSave();
|
|
44
72
|
}
|
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>
|