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.
@@ -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 r = await fetch(`${base}/app/spaces/server/api/auth~me`, {
277
- headers: { 'Authorization': `Bearer ${token}` },
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
- const me = await r.json().catch(() => ({}));
281
- return { connected: true, ...me, base };
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
- res.writeHead(404); res.end('not found');
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', 'Авторизация не удалась (state mismatch или токен пуст). Попробуйте снова.'));
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 ? r.json() : null)
322
- .catch(() => null)
323
- .then(me => {
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(authResultPage('ok', 'Готово! Можно закрыть эту вкладку и вернуться в приложение.'));
326
- cleanup();
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kingkont",
3
- "version": "0.18.12",
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,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
- 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
- // Приоритет имени: displayName (UI Chatium)confirmedEmail
220
- // email loginfullName name → userId.
221
- const name = status.displayName || status.confirmedEmail || status.email
222
- || status.login || status.fullName || status.name
278
+ // Приоритет имени: displayName namefullName login → confirmedEmail
279
+ // → emailuser.* (если 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
- // Диагностика: если ничего кроме userId не пришло лог в консоль.
226
- if (!status.displayName && !status.confirmedEmail && !status.email) {
227
- console.log('[chat-identity] available fields:', status, 'allKeys:', status._allKeys);
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();
@@ -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);