kingkont 0.18.13 → 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/package.json +1 -1
- package/renderer/board.js +64 -1
- 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/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,14 +236,44 @@ 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
278
|
// Приоритет имени: displayName → name → fullName → login → confirmedEmail
|
|
220
279
|
// → email → user.* (если nested) → userId. Раньше displayName был
|
|
@@ -1242,6 +1301,8 @@ async function openFilm(handle) {
|
|
|
1242
1301
|
// Запоминаем что юзер сейчас в проекте → Cmd+R откроет его снова.
|
|
1243
1302
|
try { localStorage.setItem('lastLocation', 'project'); } catch {}
|
|
1244
1303
|
window.appProject?.notifyState(true);
|
|
1304
|
+
// Notify notifyPanel reload persisted events для нового projectKey.
|
|
1305
|
+
window.dispatchEvent(new CustomEvent('project-changed'));
|
|
1245
1306
|
// Register абс-путь folder-проекта на сервере — так server-side задачи
|
|
1246
1307
|
// (jobsHub poller, future server-tools) могут писать файлы напрямую без
|
|
1247
1308
|
// FSAH. Cloud не регистрируем (path derivable из cloudProjectId).
|
|
@@ -1360,6 +1421,8 @@ async function closeProject() {
|
|
|
1360
1421
|
state.cloudProjectId = null;
|
|
1361
1422
|
state.cloudDirty = false;
|
|
1362
1423
|
window.appProject?.notifyState(false);
|
|
1424
|
+
// notifyPanel перезагружает events (теперь global — все проекты).
|
|
1425
|
+
window.dispatchEvent(new CustomEvent('project-changed'));
|
|
1363
1426
|
state.charactersInfo = [];
|
|
1364
1427
|
state.locationsInfo = [];
|
|
1365
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);
|