kingkont 0.18.12 → 0.18.14
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 +19 -0
- package/lib/eventStore.js +179 -0
- package/lib/jobsHub.js +14 -0
- package/main.js +70 -13
- package/package.json +1 -1
- package/renderer/board.js +92 -8
- package/renderer/notifyPanel.js +78 -8
- package/server.js +46 -0
package/lib/chatSession.js
CHANGED
|
@@ -297,6 +297,25 @@ async function _runLoop(session, system, settingsGetter) {
|
|
|
297
297
|
text: (lastFinalText || '').slice(0, 240),
|
|
298
298
|
error: session.lastError || null,
|
|
299
299
|
});
|
|
300
|
+
// Persistent log: чат-final → notifyPanel.event переживает рестарт.
|
|
301
|
+
// session.key — projectKey ('cloud:..'/'folder:..').
|
|
302
|
+
try {
|
|
303
|
+
const eventStore = require('./eventStore');
|
|
304
|
+
const preview = (lastFinalText || '').slice(0, 100);
|
|
305
|
+
if (session.lastError) {
|
|
306
|
+
eventStore.append(session.key, {
|
|
307
|
+
kind: 'error',
|
|
308
|
+
text: `💬 KingKont: ⚠ ${session.lastError.slice(0, 80)}`,
|
|
309
|
+
target: { chat: true },
|
|
310
|
+
});
|
|
311
|
+
} else if (preview) {
|
|
312
|
+
eventStore.append(session.key, {
|
|
313
|
+
kind: 'ok',
|
|
314
|
+
text: `💬 KingKont: ${preview}${preview.length === 100 ? '…' : ''}`,
|
|
315
|
+
target: { chat: true },
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
} catch {}
|
|
300
319
|
}
|
|
301
320
|
}
|
|
302
321
|
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// lib/eventStore.js — Persistent event log для notifyPanel.
|
|
2
|
+
//
|
|
3
|
+
// Зачем: юзер хочет видеть уведомления (gen-completion, chat-final,
|
|
4
|
+
// errors) ПОСЛЕ рестарта приложения. Если в процессе фоновых генераций
|
|
5
|
+
// клиент был закрыт — на старте подтянет события которые произошли
|
|
6
|
+
// пока его не было.
|
|
7
|
+
//
|
|
8
|
+
// Источник событий — те же wsHub.publish('jobs:all', ...) и chat:<key>
|
|
9
|
+
// что уже шлют WS-push'и (см. lib/jobsHub.js, lib/chatSession.js).
|
|
10
|
+
// Дополнительно зовём `eventStore.append(projectKey, evt)` рядом.
|
|
11
|
+
//
|
|
12
|
+
// Хранение: per-project JSON файл `<userDataDir>/events/<projectKey>.json`.
|
|
13
|
+
// Cap 200 событий, debounced write 500ms.
|
|
14
|
+
//
|
|
15
|
+
// API:
|
|
16
|
+
// init({userDataDir}) — выставить путь хранения (из main.js)
|
|
17
|
+
// append(projectKey, event) — добавить событие
|
|
18
|
+
// list(projectKey, {limit?}) — вернуть массив событий (новые в конце)
|
|
19
|
+
// listAll({limit?}) — вернуть события из ВСЕХ проектов (для welcome-экрана)
|
|
20
|
+
// clear(projectKey) — стереть лог проекта (юзер нажал ⌫)
|
|
21
|
+
// delete(projectKey, ts) — удалить одно событие по ts
|
|
22
|
+
//
|
|
23
|
+
// Event shape: { ts, kind, text, target?, raw? }
|
|
24
|
+
// projectKey: 'cloud:<id>' | 'folder:<name>' | '_global' для не-проектных событий
|
|
25
|
+
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
const path = require('path');
|
|
29
|
+
const fs = require('fs');
|
|
30
|
+
const fsp = require('fs/promises');
|
|
31
|
+
|
|
32
|
+
const MAX_PER_PROJECT = 200;
|
|
33
|
+
const PERSIST_DEBOUNCE_MS = 500;
|
|
34
|
+
|
|
35
|
+
let _userDataDir = null;
|
|
36
|
+
const _cache = new Map(); // projectKey → events[]
|
|
37
|
+
const _persistTimers = new Map(); // projectKey → setTimeout handle
|
|
38
|
+
|
|
39
|
+
function init({ userDataDir }) {
|
|
40
|
+
_userDataDir = userDataDir;
|
|
41
|
+
if (_userDataDir) {
|
|
42
|
+
const dir = path.join(_userDataDir, 'events');
|
|
43
|
+
try { fs.mkdirSync(dir, { recursive: true }); } catch {}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function _filePath(projectKey) {
|
|
48
|
+
if (!_userDataDir) return null;
|
|
49
|
+
// projectKey может содержать /, : и т.п. — encode.
|
|
50
|
+
const safe = encodeURIComponent(projectKey || '_global');
|
|
51
|
+
return path.join(_userDataDir, 'events', safe + '.json');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function _loadSync(projectKey) {
|
|
55
|
+
const p = _filePath(projectKey);
|
|
56
|
+
if (!p) return [];
|
|
57
|
+
try {
|
|
58
|
+
const text = fs.readFileSync(p, 'utf-8');
|
|
59
|
+
const parsed = JSON.parse(text);
|
|
60
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
61
|
+
} catch { return []; }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function _ensureLoaded(projectKey) {
|
|
65
|
+
if (_cache.has(projectKey)) return _cache.get(projectKey);
|
|
66
|
+
const list = _loadSync(projectKey);
|
|
67
|
+
_cache.set(projectKey, list);
|
|
68
|
+
return list;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function _schedulePersist(projectKey) {
|
|
72
|
+
if (!_userDataDir) return;
|
|
73
|
+
if (_persistTimers.has(projectKey)) clearTimeout(_persistTimers.get(projectKey));
|
|
74
|
+
_persistTimers.set(projectKey, setTimeout(async () => {
|
|
75
|
+
_persistTimers.delete(projectKey);
|
|
76
|
+
const p = _filePath(projectKey);
|
|
77
|
+
if (!p) return;
|
|
78
|
+
const list = _cache.get(projectKey) || [];
|
|
79
|
+
try {
|
|
80
|
+
await fsp.writeFile(p, JSON.stringify(list, null, 2), 'utf-8');
|
|
81
|
+
} catch (e) {
|
|
82
|
+
console.warn('[eventStore] persist failed for', projectKey, e?.message);
|
|
83
|
+
}
|
|
84
|
+
}, PERSIST_DEBOUNCE_MS));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function append(projectKey, event) {
|
|
88
|
+
if (!event || !event.text) return;
|
|
89
|
+
const pk = projectKey || '_global';
|
|
90
|
+
const list = _ensureLoaded(pk);
|
|
91
|
+
const ts = event.ts || Date.now();
|
|
92
|
+
// Дедуп (mirror'ит клиентскую логику в notifyPanel.js):
|
|
93
|
+
// - completion-event с nodeId — ключ `node:<nodeId>` (gen завершён)
|
|
94
|
+
// - chat-final event — ключ `chat:<first 60 chars text>` (chat-final
|
|
95
|
+
// приходит И от server-side chatSession, И от client showToast)
|
|
96
|
+
// - всё остальное — ключ `text:<first 80 chars>` (короткое окно)
|
|
97
|
+
// Окно дедупа — 30 секунд.
|
|
98
|
+
let dk = null;
|
|
99
|
+
if (event.target?.nodeId && (event.kind === 'ok' || event.kind === 'error' || event.kind === 'info' || event.kind === 'warn')) {
|
|
100
|
+
dk = `node:${event.target.nodeId}`;
|
|
101
|
+
} else if (event.target?.chat) {
|
|
102
|
+
dk = `chat:${String(event.text).slice(0, 60)}`;
|
|
103
|
+
} else {
|
|
104
|
+
dk = `text:${String(event.text).slice(0, 80)}`;
|
|
105
|
+
}
|
|
106
|
+
if (dk) {
|
|
107
|
+
const cutoff = ts - 30 * 1000;
|
|
108
|
+
for (let i = list.length - 1; i >= 0; i--) {
|
|
109
|
+
if (list[i].ts < cutoff) break;
|
|
110
|
+
if (list[i]._dk === dk) return; // already have it
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const dkNode = dk; // legacy var name used below
|
|
114
|
+
list.push({
|
|
115
|
+
ts,
|
|
116
|
+
kind: event.kind || 'info',
|
|
117
|
+
text: String(event.text).slice(0, 500),
|
|
118
|
+
target: event.target || null,
|
|
119
|
+
raw: event.raw || null,
|
|
120
|
+
_dk: dkNode,
|
|
121
|
+
});
|
|
122
|
+
// Обрезаем до MAX_PER_PROJECT (отбрасываем самые старые).
|
|
123
|
+
if (list.length > MAX_PER_PROJECT) list.splice(0, list.length - MAX_PER_PROJECT);
|
|
124
|
+
_schedulePersist(pk);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function list(projectKey, { limit = MAX_PER_PROJECT } = {}) {
|
|
128
|
+
const pk = projectKey || '_global';
|
|
129
|
+
const all = _ensureLoaded(pk);
|
|
130
|
+
return all.slice(-limit);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Для welcome — все события из всех проектов, отсортированные по ts.
|
|
134
|
+
// Загружаем все .json в events/ если ещё не в кэше.
|
|
135
|
+
function listAll({ limit = 200 } = {}) {
|
|
136
|
+
if (_userDataDir) {
|
|
137
|
+
const dir = path.join(_userDataDir, 'events');
|
|
138
|
+
let names;
|
|
139
|
+
try { names = fs.readdirSync(dir); } catch { names = []; }
|
|
140
|
+
for (const n of names) {
|
|
141
|
+
if (!n.endsWith('.json')) continue;
|
|
142
|
+
const pk = decodeURIComponent(n.replace(/\.json$/, ''));
|
|
143
|
+
if (!_cache.has(pk)) _ensureLoaded(pk);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const merged = [];
|
|
147
|
+
for (const [pk, evts] of _cache.entries()) {
|
|
148
|
+
for (const e of evts) merged.push({ ...e, projectKey: pk });
|
|
149
|
+
}
|
|
150
|
+
merged.sort((a, b) => a.ts - b.ts);
|
|
151
|
+
return merged.slice(-limit);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function clear(projectKey) {
|
|
155
|
+
const pk = projectKey || '_global';
|
|
156
|
+
_cache.set(pk, []);
|
|
157
|
+
_schedulePersist(pk);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function deleteOne(projectKey, ts) {
|
|
161
|
+
const pk = projectKey || '_global';
|
|
162
|
+
const list = _ensureLoaded(pk);
|
|
163
|
+
const idx = list.findIndex(e => e.ts === ts);
|
|
164
|
+
if (idx >= 0) {
|
|
165
|
+
list.splice(idx, 1);
|
|
166
|
+
_schedulePersist(pk);
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
module.exports = {
|
|
173
|
+
init,
|
|
174
|
+
append,
|
|
175
|
+
list,
|
|
176
|
+
listAll,
|
|
177
|
+
clear,
|
|
178
|
+
deleteOne,
|
|
179
|
+
};
|
package/lib/jobsHub.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
'use strict';
|
|
13
13
|
|
|
14
14
|
const wsHub = require('./wsHub');
|
|
15
|
+
const eventStore = require('./eventStore');
|
|
15
16
|
|
|
16
17
|
const jobsByProject = new Map(); // projectKey → Map<jobId, jobInfo>
|
|
17
18
|
|
|
@@ -103,6 +104,14 @@ function _startPoller({ projectKey, jobId, taskId, kind }) {
|
|
|
103
104
|
};
|
|
104
105
|
wsHub.publish('jobs:' + projectKey, evt);
|
|
105
106
|
wsHub.publish('jobs:all', { ...evt, projectKey });
|
|
107
|
+
// Persistent log: пишем в eventStore чтобы клиент после рестарта
|
|
108
|
+
// увидел этот completion-event (раньше WS event терялся).
|
|
109
|
+
const nameLabel = info.name ? `«${info.name}»` : `(jobId=${(jobId||'').slice(0,8)})`;
|
|
110
|
+
eventStore.append(projectKey, {
|
|
111
|
+
kind: 'ok',
|
|
112
|
+
text: `✓ ${kind || 'gen'} ${nameLabel} готов`,
|
|
113
|
+
target: { projectKey, boardKey: info.boardKey || null, nodeId: jobId },
|
|
114
|
+
});
|
|
106
115
|
// Job-end ОТЛОЖЕН — ждём ack от клиента (он скачает файл).
|
|
107
116
|
// Но если за 60s никто не пришёл — auto-end (чтобы счётчики не зависали).
|
|
108
117
|
setTimeout(() => {
|
|
@@ -117,6 +126,11 @@ function _startPoller({ projectKey, jobId, taskId, kind }) {
|
|
|
117
126
|
};
|
|
118
127
|
wsHub.publish('jobs:' + projectKey, evt);
|
|
119
128
|
wsHub.publish('jobs:all', { ...evt, projectKey });
|
|
129
|
+
eventStore.append(projectKey, {
|
|
130
|
+
kind: 'error',
|
|
131
|
+
text: `⚠ ${kind || 'gen'}${info.name ? ' «'+info.name+'»' : ''} провалился: ${(r.error || '').slice(0,100)}`,
|
|
132
|
+
target: { projectKey, boardKey: info.boardKey || null, nodeId: jobId },
|
|
133
|
+
});
|
|
120
134
|
// На ошибке тоже auto-end через 60s.
|
|
121
135
|
setTimeout(() => {
|
|
122
136
|
if (!pollers.has(jobId)) end({ projectKey, jobId });
|
package/main.js
CHANGED
|
@@ -273,12 +273,26 @@ ipcMain.handle('chatium:status', async () => {
|
|
|
273
273
|
const base = s?.chatium?.base || CHATIUM_BASE;
|
|
274
274
|
if (!token) return { connected: false };
|
|
275
275
|
try {
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
276
|
+
const url = `${base}/app/spaces/server/api/auth~me`;
|
|
277
|
+
const r = await fetch(url, { headers: { 'Authorization': `Bearer ${token}` } });
|
|
278
|
+
const rawText = await r.text();
|
|
279
|
+
let me; try { me = JSON.parse(rawText); } catch { me = null; }
|
|
280
|
+
// Диагностика: логируем что Chatium вообще отдаёт. Юзер жалуется
|
|
281
|
+
// что displayName/email не показываются — нужно увидеть какие
|
|
282
|
+
// поля приходят в реальности.
|
|
283
|
+
console.log(`[chatium:status] ${r.status} ${url}`);
|
|
284
|
+
console.log(`[chatium:status] response keys: ${me ? Object.keys(me).join(', ') : '(non-json)'}`);
|
|
285
|
+
console.log(`[chatium:status] response body: ${rawText.slice(0, 800)}`);
|
|
279
286
|
if (r.ok) {
|
|
280
|
-
|
|
281
|
-
|
|
287
|
+
// _allKeys и _raw добавлены для UI-debug — renderer покажет их
|
|
288
|
+
// в console если displayName/email/login пусто.
|
|
289
|
+
return {
|
|
290
|
+
connected: true,
|
|
291
|
+
...(me || {}),
|
|
292
|
+
base,
|
|
293
|
+
_allKeys: me ? Object.keys(me) : [],
|
|
294
|
+
_raw: rawText.slice(0, 800),
|
|
295
|
+
};
|
|
282
296
|
}
|
|
283
297
|
if (r.status === 401) {
|
|
284
298
|
// Токен протух/отозван — чистим.
|
|
@@ -287,8 +301,9 @@ ipcMain.handle('chatium:status', async () => {
|
|
|
287
301
|
writeSettings(next);
|
|
288
302
|
return { connected: false, reason: 'expired' };
|
|
289
303
|
}
|
|
290
|
-
return { connected: false, reason: `http_${r.status}
|
|
304
|
+
return { connected: false, reason: `http_${r.status}`, _raw: rawText.slice(0, 400) };
|
|
291
305
|
} catch (e) {
|
|
306
|
+
console.warn('[chatium:status] network error:', e?.message || e);
|
|
292
307
|
return { connected: false, reason: 'network_error', error: String(e?.message || e) };
|
|
293
308
|
}
|
|
294
309
|
});
|
|
@@ -296,18 +311,43 @@ ipcMain.handle('chatium:status', async () => {
|
|
|
296
311
|
function runChatiumLoginFlow() {
|
|
297
312
|
return new Promise((resolveOk, rejectErr) => {
|
|
298
313
|
const state = crypto.randomBytes(16).toString('hex');
|
|
314
|
+
// Кэш отрендеренной success-страницы: после первого успешного /cb
|
|
315
|
+
// ВСЕ следующие запросы (refresh, navigation back, favicon retry,
|
|
316
|
+
// Chatium повторно редиректит и т.д.) отдают ту же страницу.
|
|
317
|
+
// Иначе юзер мог увидеть «localhost не грузится» если refresh'нул
|
|
318
|
+
// вкладку после автозакрытия server'а. Listener живёт ещё 30s
|
|
319
|
+
// после успеха для этих случаев.
|
|
320
|
+
let cachedSuccessPage = null;
|
|
321
|
+
let alreadyResolved = false;
|
|
299
322
|
const server = http.createServer((req, res) => {
|
|
300
323
|
const url = new URL(req.url, `http://localhost`);
|
|
324
|
+
console.log(`[chatium-login] ← ${req.method} ${req.url}`);
|
|
325
|
+
// Уже зарезолвили — отдаём ту же success-страницу повторно
|
|
326
|
+
// (юзер refresh'нул, или Chatium шлёт callback дважды).
|
|
327
|
+
if (alreadyResolved && cachedSuccessPage) {
|
|
328
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
329
|
+
res.end(cachedSuccessPage);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (url.pathname === '/favicon.ico') {
|
|
333
|
+
res.writeHead(204); res.end();
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
301
336
|
if (url.pathname !== '/cb') {
|
|
302
|
-
|
|
337
|
+
console.log(`[chatium-login] 404 (unexpected path): ${url.pathname}`);
|
|
338
|
+
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
339
|
+
res.end('Not found. Ожидался /cb от Chatium-callback.');
|
|
303
340
|
return;
|
|
304
341
|
}
|
|
305
342
|
const token = url.searchParams.get('token');
|
|
306
343
|
const recvState = url.searchParams.get('state');
|
|
344
|
+
const allParams = Array.from(url.searchParams.keys());
|
|
345
|
+
console.log(`[chatium-login] callback params: ${allParams.join(', ')}`);
|
|
307
346
|
|
|
308
347
|
if (!token || recvState !== state) {
|
|
348
|
+
console.warn(`[chatium-login] BAD callback: token=${!!token} stateMatch=${recvState === state}`);
|
|
309
349
|
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
310
|
-
res.end(authResultPage('error',
|
|
350
|
+
res.end(authResultPage('error', `Авторизация не удалась. token=${token ? 'есть' : 'нет'}, state=${recvState === state ? 'ok' : 'mismatch'}. Попробуйте снова.`));
|
|
311
351
|
cleanup();
|
|
312
352
|
rejectErr(new Error('State mismatch или token пуст'));
|
|
313
353
|
return;
|
|
@@ -318,12 +358,25 @@ function runChatiumLoginFlow() {
|
|
|
318
358
|
fetch(`${CHATIUM_BASE}/app/spaces/server/api/auth~me`, {
|
|
319
359
|
headers: { 'Authorization': `Bearer ${token}` },
|
|
320
360
|
})
|
|
321
|
-
.then(r => r.ok
|
|
322
|
-
.catch(
|
|
323
|
-
.then(
|
|
361
|
+
.then(r => r.text().then(t => ({ ok: r.ok, status: r.status, text: t })))
|
|
362
|
+
.catch(e => ({ ok: false, status: 0, text: '', err: e?.message || String(e) }))
|
|
363
|
+
.then(({ ok, status, text, err }) => {
|
|
364
|
+
let me = null;
|
|
365
|
+
try { me = JSON.parse(text); } catch {}
|
|
366
|
+
console.log(`[chatium-login] /me → ${status} keys=${me ? Object.keys(me).join(',') : '(non-json)'}`);
|
|
367
|
+
if (err) console.warn(`[chatium-login] /me fetch failed: ${err}`);
|
|
368
|
+
cachedSuccessPage = authResultPage('ok', 'Готово! Можно закрыть эту вкладку и вернуться в приложение.');
|
|
369
|
+
alreadyResolved = true;
|
|
324
370
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
325
|
-
res.end(
|
|
326
|
-
|
|
371
|
+
res.end(cachedSuccessPage);
|
|
372
|
+
// НЕ закрываем server сразу — даём юзеру 30s на refresh / повторный
|
|
373
|
+
// hit (Chatium иногда шлёт callback дважды; или юзер случайно
|
|
374
|
+
// нажал refresh на этой вкладке).
|
|
375
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
376
|
+
timeoutId = setTimeout(() => {
|
|
377
|
+
console.log('[chatium-login] graceful close after 30s grace period');
|
|
378
|
+
cleanup();
|
|
379
|
+
}, 30 * 1000);
|
|
327
380
|
resolveOk({
|
|
328
381
|
token,
|
|
329
382
|
userId: me?.userId || null,
|
|
@@ -342,6 +395,7 @@ function runChatiumLoginFlow() {
|
|
|
342
395
|
}
|
|
343
396
|
|
|
344
397
|
server.on('error', (err) => {
|
|
398
|
+
console.warn('[chatium-login] server error:', err.message);
|
|
345
399
|
cleanup();
|
|
346
400
|
rejectErr(err);
|
|
347
401
|
});
|
|
@@ -349,12 +403,14 @@ function runChatiumLoginFlow() {
|
|
|
349
403
|
server.listen(0, '127.0.0.1', () => {
|
|
350
404
|
const addr = server.address();
|
|
351
405
|
const callback = `http://localhost:${addr.port}/cb`;
|
|
406
|
+
console.log(`[chatium-login] listener up on ${callback}`);
|
|
352
407
|
// На стороне Chatium роут авторизации привязан к корню workspace (/),
|
|
353
408
|
// т.е. /app/spaces/server. После успеха он редиректит на наш callback с token.
|
|
354
409
|
const url = new URL(`${CHATIUM_BASE}/app/spaces/server`);
|
|
355
410
|
url.searchParams.set('callback', callback);
|
|
356
411
|
url.searchParams.set('state', state);
|
|
357
412
|
url.searchParams.set('app', 'KingKont');
|
|
413
|
+
console.log(`[chatium-login] opening browser → ${url.toString()}`);
|
|
358
414
|
|
|
359
415
|
shell.openExternal(url.toString()).catch((e) => {
|
|
360
416
|
cleanup();
|
|
@@ -362,6 +418,7 @@ function runChatiumLoginFlow() {
|
|
|
362
418
|
});
|
|
363
419
|
|
|
364
420
|
timeoutId = setTimeout(() => {
|
|
421
|
+
console.warn('[chatium-login] timeout 5min — abort');
|
|
365
422
|
cleanup();
|
|
366
423
|
rejectErr(new Error('Таймаут: авторизация не завершена за 5 минут'));
|
|
367
424
|
}, CHATIUM_AUTH_TIMEOUT_MS);
|
package/package.json
CHANGED
package/renderer/board.js
CHANGED
|
@@ -167,12 +167,41 @@ window.addEventListener('DOMContentLoaded', async () => {
|
|
|
167
167
|
// Цвет dot'а меняется в зависимости от баланса: green / yellow (<100) /
|
|
168
168
|
// red (≤0). Также экспортирована глобально как window.refreshBalance —
|
|
169
169
|
// чтобы settings-окно могло триггерить обновление после login/logout.
|
|
170
|
+
// Кэш балансов в localStorage — чтобы при возврате на welcome пилюли
|
|
171
|
+
// мгновенно появлялись из старых значений (а не «пустота → fetch → flash»).
|
|
172
|
+
// На фоне грузим свежие данные и перерисовываем.
|
|
173
|
+
const _BALANCES_CACHE_KEY = 'balancesCache';
|
|
174
|
+
function _readBalancesCache() {
|
|
175
|
+
try {
|
|
176
|
+
const raw = localStorage.getItem(_BALANCES_CACHE_KEY);
|
|
177
|
+
if (!raw) return null;
|
|
178
|
+
const obj = JSON.parse(raw);
|
|
179
|
+
// Используем кэш только если ему меньше 12 часов — иначе слишком стейл.
|
|
180
|
+
if (Date.now() - (obj.ts || 0) > 12 * 3600 * 1000) return null;
|
|
181
|
+
return obj.data || null;
|
|
182
|
+
} catch { return null; }
|
|
183
|
+
}
|
|
184
|
+
function _writeBalancesCache(data) {
|
|
185
|
+
try { localStorage.setItem(_BALANCES_CACHE_KEY, JSON.stringify({ ts: Date.now(), data })); } catch {}
|
|
186
|
+
}
|
|
187
|
+
|
|
170
188
|
async function refreshBalance() {
|
|
189
|
+
// Сначала рендерим из кэша (мгновенно), потом fetch + update.
|
|
190
|
+
// Юзер видит знакомые цифры пока приходит свежее значение.
|
|
191
|
+
const cached = _readBalancesCache();
|
|
192
|
+
if (cached) _renderBalancesInto(cached);
|
|
193
|
+
|
|
171
194
|
let data = {};
|
|
172
195
|
try {
|
|
173
196
|
const r = await fetch('/api/balance/all');
|
|
174
197
|
if (r.ok) data = await r.json();
|
|
175
198
|
} catch {}
|
|
199
|
+
if (data && Object.keys(data).length) {
|
|
200
|
+
_writeBalancesCache(data);
|
|
201
|
+
_renderBalancesInto(data);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
176
205
|
// Один pill на провайдер. Если у провайдера нет данных (выключен или
|
|
177
206
|
// API не дал баланс) — pill не рендерим.
|
|
178
207
|
const pills = [
|
|
@@ -207,30 +236,81 @@ async function refreshBalance() {
|
|
|
207
236
|
}
|
|
208
237
|
window.refreshBalance = refreshBalance;
|
|
209
238
|
|
|
239
|
+
// Кэш chatium-status в localStorage — для мгновенного отображения identity
|
|
240
|
+
// при возврате на welcome (без flicker «пусто → потом запрос → потом текст»).
|
|
241
|
+
// На фоне дёргаем настоящий status и обновляем pill.
|
|
242
|
+
const _STATUS_CACHE_KEY = 'chatiumStatusCache';
|
|
243
|
+
function _readStatusCache() {
|
|
244
|
+
try {
|
|
245
|
+
const raw = localStorage.getItem(_STATUS_CACHE_KEY);
|
|
246
|
+
if (!raw) return null;
|
|
247
|
+
const obj = JSON.parse(raw);
|
|
248
|
+
if (Date.now() - (obj.ts || 0) > 24 * 3600 * 1000) return null; // 24h max
|
|
249
|
+
return obj.status || null;
|
|
250
|
+
} catch { return null; }
|
|
251
|
+
}
|
|
252
|
+
function _writeStatusCache(status) {
|
|
253
|
+
try { localStorage.setItem(_STATUS_CACHE_KEY, JSON.stringify({ ts: Date.now(), status })); } catch {}
|
|
254
|
+
}
|
|
255
|
+
|
|
210
256
|
// Identity-pill в правом верхнем углу welcome-экрана.
|
|
211
257
|
// Показывает кто залогинен в KingKont (или предлагает войти).
|
|
212
258
|
async function renderWelcomeIdentity() {
|
|
213
259
|
const wrap = document.getElementById('welcomeStatusIdentity');
|
|
214
260
|
if (!wrap) return;
|
|
215
|
-
|
|
261
|
+
// 1) Сначала рендерим из кэша (если есть) — мгновенно.
|
|
262
|
+
const cached = _readStatusCache();
|
|
263
|
+
if (cached) _renderIdentity(wrap, cached);
|
|
264
|
+
// 2) В фоне дёргаем свежий status и перерисовываем + обновляем кэш.
|
|
216
265
|
let status = null;
|
|
217
266
|
try { status = await window.appChatium?.status?.(); } catch {}
|
|
267
|
+
if (status) {
|
|
268
|
+
_writeStatusCache(status);
|
|
269
|
+
_renderIdentity(wrap, status);
|
|
270
|
+
} else if (!cached) {
|
|
271
|
+
// Ни кэша, ни ответа — рисуем «не залогинен» по дефолту.
|
|
272
|
+
_renderIdentity(wrap, { connected: false });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
function _renderIdentity(wrap, status) {
|
|
276
|
+
wrap.innerHTML = '';
|
|
218
277
|
if (status?.connected) {
|
|
219
|
-
// Приоритет имени: displayName
|
|
220
|
-
//
|
|
221
|
-
|
|
222
|
-
|
|
278
|
+
// Приоритет имени: displayName → name → fullName → login → confirmedEmail
|
|
279
|
+
// → email → user.* (если nested) → userId. Раньше displayName был
|
|
280
|
+
// first, но Chatium может возвращать поле под другим именем —
|
|
281
|
+
// проверяем все известные варианты + nested user-объект.
|
|
282
|
+
const u = status.user || status.profile || status.account || {};
|
|
283
|
+
const name = status.displayName || status.name || status.fullName
|
|
284
|
+
|| status.login || status.username || status.userName
|
|
285
|
+
|| status.confirmedEmail || status.email
|
|
286
|
+
|| u.displayName || u.name || u.fullName || u.login || u.email
|
|
223
287
|
|| status.userId || 'KingKont';
|
|
224
288
|
const sub = status.userId && status.userId !== name ? `· ${status.userId.slice(0, 8)}` : '';
|
|
225
|
-
// Диагностика: если
|
|
226
|
-
|
|
227
|
-
|
|
289
|
+
// Диагностика: если ни одного «человеческого» поля не нашлось —
|
|
290
|
+
// громкий лог в консоль с полным дампом, чтобы видеть что прислал
|
|
291
|
+
// сервер (см. main.js [chatium:status] логи тоже).
|
|
292
|
+
const noHumanField = !status.displayName && !status.name && !status.fullName
|
|
293
|
+
&& !status.login && !status.confirmedEmail && !status.email
|
|
294
|
+
&& !u.displayName && !u.name && !u.email;
|
|
295
|
+
if (noHumanField) {
|
|
296
|
+
console.warn('[chat-identity] No human-readable name field. Status object:', status);
|
|
297
|
+
console.warn('[chat-identity] _allKeys:', status._allKeys);
|
|
298
|
+
console.warn('[chat-identity] _raw:', status._raw);
|
|
228
299
|
}
|
|
229
300
|
wrap.innerHTML = `
|
|
230
301
|
<span style="color:#5c5; font-size:13px; line-height:1;">●</span>
|
|
231
302
|
<span class="who">${escapeHtml(name)}</span>
|
|
232
303
|
<span class="who-sub">${escapeHtml(sub)}</span>
|
|
233
304
|
`;
|
|
305
|
+
// Если имя не найдено — добавляем visible debug-pill с available keys,
|
|
306
|
+
// чтобы юзер мог тебе показать что отдаёт Chatium без копания в консоли.
|
|
307
|
+
if (noHumanField && Array.isArray(status._allKeys) && status._allKeys.length) {
|
|
308
|
+
const dbg = document.createElement('span');
|
|
309
|
+
dbg.style.cssText = 'background:#3a2a4a; color:#dcb; font-size:10px; padding:2px 6px; border-radius:4px; font-family:ui-monospace,monospace; cursor:help;';
|
|
310
|
+
dbg.textContent = `keys: ${status._allKeys.join(',').slice(0, 60)}`;
|
|
311
|
+
dbg.title = `Полный ответ:\n${status._raw || '(пусто)'}\n\nИ скопируй это сообщение разработчику.`;
|
|
312
|
+
wrap.appendChild(dbg);
|
|
313
|
+
}
|
|
234
314
|
const logoutBtn = document.createElement('button');
|
|
235
315
|
logoutBtn.textContent = 'Выйти';
|
|
236
316
|
logoutBtn.title = 'Logout из KingKont';
|
|
@@ -1221,6 +1301,8 @@ async function openFilm(handle) {
|
|
|
1221
1301
|
// Запоминаем что юзер сейчас в проекте → Cmd+R откроет его снова.
|
|
1222
1302
|
try { localStorage.setItem('lastLocation', 'project'); } catch {}
|
|
1223
1303
|
window.appProject?.notifyState(true);
|
|
1304
|
+
// Notify notifyPanel reload persisted events для нового projectKey.
|
|
1305
|
+
window.dispatchEvent(new CustomEvent('project-changed'));
|
|
1224
1306
|
// Register абс-путь folder-проекта на сервере — так server-side задачи
|
|
1225
1307
|
// (jobsHub poller, future server-tools) могут писать файлы напрямую без
|
|
1226
1308
|
// FSAH. Cloud не регистрируем (path derivable из cloudProjectId).
|
|
@@ -1339,6 +1421,8 @@ async function closeProject() {
|
|
|
1339
1421
|
state.cloudProjectId = null;
|
|
1340
1422
|
state.cloudDirty = false;
|
|
1341
1423
|
window.appProject?.notifyState(false);
|
|
1424
|
+
// notifyPanel перезагружает events (теперь global — все проекты).
|
|
1425
|
+
window.dispatchEvent(new CustomEvent('project-changed'));
|
|
1342
1426
|
state.charactersInfo = [];
|
|
1343
1427
|
state.locationsInfo = [];
|
|
1344
1428
|
state.selectedNodeIds.clear();
|
package/renderer/notifyPanel.js
CHANGED
|
@@ -100,8 +100,41 @@
|
|
|
100
100
|
`;
|
|
101
101
|
document.body.appendChild(panel);
|
|
102
102
|
$('notifyClose').addEventListener('click', () => setOpen(false));
|
|
103
|
-
$('notifyClear').addEventListener('click', () => {
|
|
103
|
+
$('notifyClear').addEventListener('click', () => {
|
|
104
|
+
events.length = 0; unread = 0; render();
|
|
105
|
+
// Стираем и на сервере для текущего projectKey (или _global если на welcome).
|
|
106
|
+
const pk = _currentProjectKey() || null;
|
|
107
|
+
fetch('/api/events/clear', {
|
|
108
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
109
|
+
body: JSON.stringify({ projectKey: pk }),
|
|
110
|
+
}).catch(() => {});
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Подгружаем persisted events с сервера. В контексте проекта — только
|
|
115
|
+
// его события; на welcome (нет filmHandle) — все из всех проектов.
|
|
116
|
+
// Восстановленные events помечаем _persisted+_silent чтобы не запускать
|
|
117
|
+
// bell-pulse и auto-flash для каждого старого ивента.
|
|
118
|
+
async function _loadPersistedEvents() {
|
|
119
|
+
try {
|
|
120
|
+
const pk = _currentProjectKey();
|
|
121
|
+
const url = pk ? `/api/events?projectKey=${encodeURIComponent(pk)}` : '/api/events';
|
|
122
|
+
const r = await fetch(url);
|
|
123
|
+
if (!r.ok) return;
|
|
124
|
+
const data = await r.json();
|
|
125
|
+
const list = data?.events || [];
|
|
126
|
+
// Чистим in-memory и заливаем восстановленное (без триггера UI-сигналок).
|
|
127
|
+
events.length = 0;
|
|
128
|
+
for (const e of list) {
|
|
129
|
+
addEvent({ ...e, _persisted: true, _silent: true });
|
|
130
|
+
}
|
|
131
|
+
// После восстановления — render если панель открыта.
|
|
132
|
+
if (panelOpen) render();
|
|
133
|
+
_renderBellState();
|
|
134
|
+
} catch (e) { console.warn('[notifyPanel] load persisted events failed:', e?.message); }
|
|
104
135
|
}
|
|
136
|
+
// Public: переподгрузить (board.js зовёт после openFilm/closeProject).
|
|
137
|
+
window.addEventListener('project-changed', _loadPersistedEvents);
|
|
105
138
|
|
|
106
139
|
// openMode: 'manual' (юзер сам нажал на 🔔 — НЕ автозакрываем),
|
|
107
140
|
// 'auto' (открыли по событию — закроется через таймер),
|
|
@@ -183,6 +216,12 @@
|
|
|
183
216
|
const idx = events.findIndex(x => x.ts === e.ts && x.text === e.text);
|
|
184
217
|
if (idx >= 0) events.splice(idx, 1);
|
|
185
218
|
render();
|
|
219
|
+
// Удаляем и из persisted log на сервере.
|
|
220
|
+
const pk = _currentProjectKey() || null;
|
|
221
|
+
fetch('/api/events/delete', {
|
|
222
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
223
|
+
body: JSON.stringify({ projectKey: pk, ts: e.ts }),
|
|
224
|
+
}).catch(() => {});
|
|
186
225
|
});
|
|
187
226
|
row.appendChild(delBtn);
|
|
188
227
|
if (clickable) {
|
|
@@ -217,7 +256,7 @@
|
|
|
217
256
|
}
|
|
218
257
|
return null; // chat-события (target.chat) не дедупим — нет nodeId
|
|
219
258
|
}
|
|
220
|
-
function addEvent({ kind, text, raw, target }) {
|
|
259
|
+
function addEvent({ kind, text, raw, target, ts, _persisted, _silent }) {
|
|
221
260
|
if (!text) return;
|
|
222
261
|
const k = kind || 'info';
|
|
223
262
|
const dk = _dedupKey(k, text, target);
|
|
@@ -231,7 +270,11 @@
|
|
|
231
270
|
}
|
|
232
271
|
}
|
|
233
272
|
}
|
|
234
|
-
|
|
273
|
+
const evt = { ts: ts || Date.now(), kind: k, text: String(text).slice(0, 300), raw, target: target || null, _dk: dk };
|
|
274
|
+
events.push(evt);
|
|
275
|
+
// Сортируем по ts — на случай если приходят restored-события
|
|
276
|
+
// вперемешку с live (restored ts < Date.now()).
|
|
277
|
+
events.sort((a, b) => a.ts - b.ts);
|
|
235
278
|
if (events.length > MAX_EVENTS) events.shift();
|
|
236
279
|
if (!panelOpen) {
|
|
237
280
|
unread++;
|
|
@@ -239,12 +282,35 @@
|
|
|
239
282
|
} else {
|
|
240
283
|
render();
|
|
241
284
|
}
|
|
242
|
-
|
|
285
|
+
// Persist на сервер — если это НЕ restored-event (иначе бесконечный loop).
|
|
286
|
+
// Server сам пишет события из jobsHub/chatSession; client только пишет
|
|
287
|
+
// ad-hoc info-toast'ы (resume notifications, errors из renderer).
|
|
288
|
+
if (!_persisted) _persistEvent(evt);
|
|
289
|
+
// Silent — для restored events: не показываем визуальные сигналки
|
|
290
|
+
// (pulse, auto-flash). Иначе при загрузке десятка старых событий
|
|
291
|
+
// 🔔 будет дико мигать.
|
|
292
|
+
if (!_silent) {
|
|
293
|
+
_pulseBell();
|
|
294
|
+
_scheduleAutoFlash();
|
|
295
|
+
}
|
|
243
296
|
_renderBellState();
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
297
|
+
}
|
|
298
|
+
// Pers'им event на сервер. Только локальные (showToast и WS-events с
|
|
299
|
+
// клиента) — server-driven (jobsHub completion) сервер пишет сам.
|
|
300
|
+
// Дедуп на сервере зеркально клиентский → safe.
|
|
301
|
+
async function _persistEvent(evt) {
|
|
302
|
+
try {
|
|
303
|
+
const pk = _currentProjectKey() || null;
|
|
304
|
+
// Если нет projectKey (welcome без открытого проекта) — пишем в _global.
|
|
305
|
+
await fetch('/api/events/append', {
|
|
306
|
+
method: 'POST',
|
|
307
|
+
headers: { 'Content-Type': 'application/json' },
|
|
308
|
+
body: JSON.stringify({
|
|
309
|
+
projectKey: pk,
|
|
310
|
+
event: { ts: evt.ts, kind: evt.kind, text: evt.text, target: evt.target },
|
|
311
|
+
}),
|
|
312
|
+
});
|
|
313
|
+
} catch {}
|
|
248
314
|
}
|
|
249
315
|
// Bell-pulse: одна короткая анимация. Перезапускаем класс через
|
|
250
316
|
// void offsetWidth — иначе подряд идущие события не «пульсируют»
|
|
@@ -462,6 +528,10 @@
|
|
|
462
528
|
// даёт мгновенное название ноды («когда state.jobs обновился, но
|
|
463
529
|
// bgjobs:changed не прилетел»).
|
|
464
530
|
setInterval(_renderBellState, 1500);
|
|
531
|
+
// Загрузить persisted events с сервера. На welcome — все из всех
|
|
532
|
+
// проектов; в проекте — только этого. При смене проекта/welcome
|
|
533
|
+
// перезагружаем (см. window.notifyPanel.reloadPersisted ниже).
|
|
534
|
+
_loadPersistedEvents();
|
|
465
535
|
// WS-канал jobs:all для start/end/done/failed events. Connect к /ws.
|
|
466
536
|
function _connect() {
|
|
467
537
|
let ws;
|
package/server.js
CHANGED
|
@@ -14,6 +14,7 @@ const { extname, join, normalize, resolve } = require('node:path');
|
|
|
14
14
|
|
|
15
15
|
const providers = require('./lib/providers');
|
|
16
16
|
const chatSession = require('./lib/chatSession');
|
|
17
|
+
const eventStore = require('./lib/eventStore');
|
|
17
18
|
const jobsHub = require('./lib/jobsHub');
|
|
18
19
|
const wsHub = require('./lib/wsHub');
|
|
19
20
|
const projectPaths = require('./lib/projectPaths');
|
|
@@ -372,6 +373,44 @@ async function handleJobsList(res, url) {
|
|
|
372
373
|
} catch (e) { sendError(res, e, 500); }
|
|
373
374
|
}
|
|
374
375
|
|
|
376
|
+
// =============================================================================
|
|
377
|
+
// Persistent event log — notifyPanel.events переживают рестарт. Сервер хранит
|
|
378
|
+
// per-project JSON в `<userDataDir>/events/<projectKey>.json` (cap 200/проект).
|
|
379
|
+
// =============================================================================
|
|
380
|
+
async function handleEventsList(res, url) {
|
|
381
|
+
try {
|
|
382
|
+
const projectKey = url.searchParams.get('projectKey');
|
|
383
|
+
const limitRaw = parseInt(url.searchParams.get('limit') || '', 10);
|
|
384
|
+
const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(500, limitRaw)) : 200;
|
|
385
|
+
if (projectKey) send(res, 200, { events: eventStore.list(projectKey, { limit }) });
|
|
386
|
+
else send(res, 200, { events: eventStore.listAll({ limit }) });
|
|
387
|
+
} catch (e) { sendError(res, e, 500); }
|
|
388
|
+
}
|
|
389
|
+
async function handleEventsAppend(req, res) {
|
|
390
|
+
try {
|
|
391
|
+
const body = await readJson(req);
|
|
392
|
+
const { projectKey, event } = body || {};
|
|
393
|
+
if (!event || !event.text) return send(res, 400, { error: 'event.text обязателен' });
|
|
394
|
+
eventStore.append(projectKey || null, event);
|
|
395
|
+
send(res, 200, { ok: true });
|
|
396
|
+
} catch (e) { sendError(res, e, 500); }
|
|
397
|
+
}
|
|
398
|
+
async function handleEventsClear(req, res) {
|
|
399
|
+
try {
|
|
400
|
+
const body = await readJson(req);
|
|
401
|
+
eventStore.clear(body?.projectKey || null);
|
|
402
|
+
send(res, 200, { ok: true });
|
|
403
|
+
} catch (e) { sendError(res, e, 500); }
|
|
404
|
+
}
|
|
405
|
+
async function handleEventsDelete(req, res) {
|
|
406
|
+
try {
|
|
407
|
+
const body = await readJson(req);
|
|
408
|
+
if (!body?.ts) return send(res, 400, { error: 'ts обязателен' });
|
|
409
|
+
const ok = eventStore.deleteOne(body.projectKey || null, body.ts);
|
|
410
|
+
send(res, 200, { ok });
|
|
411
|
+
} catch (e) { sendError(res, e, 500); }
|
|
412
|
+
}
|
|
413
|
+
|
|
375
414
|
// =============================================================================
|
|
376
415
|
// Projects: regission абсолютного пути folder-проекта (renderer достаёт через
|
|
377
416
|
// webUtils.getPathForFile). Cloud-проекты регистрировать не нужно — путь
|
|
@@ -453,6 +492,11 @@ const server = createServer(async (req, res) => {
|
|
|
453
492
|
// Jobs hub.
|
|
454
493
|
if (req.method === 'POST' && url.pathname === '/api/jobs/track') return handleJobsTrack(req, res);
|
|
455
494
|
if (req.method === 'GET' && url.pathname === '/api/jobs') return handleJobsList(res, url);
|
|
495
|
+
// Persistent event log (notifyPanel).
|
|
496
|
+
if (req.method === 'GET' && url.pathname === '/api/events') return handleEventsList(res, url);
|
|
497
|
+
if (req.method === 'POST' && url.pathname === '/api/events/append') return handleEventsAppend(req, res);
|
|
498
|
+
if (req.method === 'POST' && url.pathname === '/api/events/clear') return handleEventsClear(req, res);
|
|
499
|
+
if (req.method === 'POST' && url.pathname === '/api/events/delete') return handleEventsDelete(req, res);
|
|
456
500
|
// Projects: register абс-путя для server-side fs-доступа.
|
|
457
501
|
if (req.method === 'POST' && url.pathname === '/api/project/register') return handleProjectRegister(req, res);
|
|
458
502
|
if (req.method === 'GET' && url.pathname === '/api/project/resolve') return handleProjectResolve(res, url);
|
|
@@ -484,6 +528,8 @@ function start(port = PORT, opts = {}) {
|
|
|
484
528
|
// Без opts.userDataDir чат живёт только in-memory (CLI-режим).
|
|
485
529
|
chatSession.init({ userDataDir: opts.userDataDir || null });
|
|
486
530
|
projectPaths.init({ userDataDir: opts.userDataDir || null });
|
|
531
|
+
// Persistent event log (notifyPanel events переживают рестарт).
|
|
532
|
+
eventStore.init({ userDataDir: opts.userDataDir || null });
|
|
487
533
|
// jobsHub нужен settingsGetter чтобы поллить провайдеров (Chatium token,
|
|
488
534
|
// KIE_API_KEY и т.п.).
|
|
489
535
|
jobsHub.setSettingsGetter(getSettings);
|