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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kingkont",
3
- "version": "0.18.13",
3
+ "version": "0.18.14",
4
4
  "description": "KingKont · Chatium — нод-редактор сцен с AI-генерацией (картинки/видео/голос/SFX/музыка/текст)",
5
5
  "main": "main.js",
6
6
  "bin": {
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
- wrap.innerHTML = '';
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();
@@ -100,8 +100,41 @@
100
100
  `;
101
101
  document.body.appendChild(panel);
102
102
  $('notifyClose').addEventListener('click', () => setOpen(false));
103
- $('notifyClear').addEventListener('click', () => { events.length = 0; unread = 0; render(); });
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
- events.push({ ts: Date.now(), kind: k, text: String(text).slice(0, 300), raw, target: target || null, _dk: dk });
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
- _pulseBell();
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
- // Авто-открытие панели на 3.5s — юзер видит событие сразу, потом
245
- // панель прячется. Если юзер сам открыл не трогаем (проверка в
246
- // openAuto). Каждое новое событие сбрасывает таймер.
247
- _scheduleAutoFlash();
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);