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 +1 -1
- package/lib/chatSession.js +310 -0
- package/main.js +4 -1
- package/package.json +3 -2
- package/renderer/chat.js +266 -105
- package/renderer/styles.css +4 -1
- package/server.js +54 -0
- package/web.html +105 -0
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="
|
|
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 = {
|
|
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.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
|
-
//
|
|
108
|
-
//
|
|
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
|
-
|
|
329
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
901
|
-
|
|
902
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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} итераций`);
|
|
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
|
-
|
|
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
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
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
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
if (tc.
|
|
1033
|
-
if (
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
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:
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
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
|
-
|
|
1350
|
+
stopPolling();
|
|
1190
1351
|
history = []; snapshots = [];
|
|
1191
1352
|
renderHistory(); renderSnapshotBar();
|
|
1192
1353
|
},
|
package/renderer/styles.css
CHANGED
|
@@ -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
|
|
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>
|