kingkont 0.18.14 → 0.19.0

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/renderer/board.js CHANGED
@@ -44,23 +44,43 @@ window.addEventListener('DOMContentLoaded', async () => {
44
44
  // - logout → чистим кэш + перерисовываем (cloud-карточки исчезнут,
45
45
  // fetchListCached не дёрнет /api/projects без логина).
46
46
  // setCloudButtonsVisibility пересоберёт sidebar-кнопки (☁ Новый и пр.).
47
+ // Универсальный handler смены auth (login/logout) — общий для Electron-IPC
48
+ // и web WS-event 'auth:changed'.
49
+ async function _onAuthChanged(state) {
50
+ try {
51
+ localStorage.removeItem('cloudProjectsCache');
52
+ localStorage.removeItem('cloudProjectsLastOpened');
53
+ localStorage.removeItem('chatiumStatusCache');
54
+ renderWelcomeIdentity({ force: true }).catch(() => {});
55
+ refreshBalance().catch(() => {});
56
+ if (!window.cloudProjects) return;
57
+ if (window.cloudProjects.setVisibility) window.cloudProjects.setVisibility();
58
+ if (!document.body.classList.contains('no-project')) return;
59
+ await renderWelcomeRecents();
60
+ } catch (e) { vlog('err', 'auth-changed handler: ' + (e?.message || e)); }
61
+ }
47
62
  if (window.appChatium?.onAuthChanged) {
48
- window.appChatium.onAuthChanged(async (state) => {
49
- try {
50
- // Сбрасываем кэш он содержал данные старого юзера.
51
- localStorage.removeItem('cloudProjectsCache');
52
- localStorage.removeItem('cloudProjectsLastOpened');
53
- // Welcome top-right identity-pill + balances перерисовываем сразу
54
- // (даже если открыт проект и welcome скрыт — обновится для следующего показа).
55
- renderWelcomeIdentity().catch(() => {});
56
- refreshBalance().catch(() => {});
57
- // Если открыт welcome (нет проекта) — перерисовать grid.
58
- if (!state || !window.cloudProjects) return;
59
- if (window.cloudProjects.setVisibility) window.cloudProjects.setVisibility();
60
- if (!document.body.classList.contains('no-project')) return;
61
- await renderWelcomeRecents();
62
- } catch (e) { vlog('err', 'auth-changed handler: ' + (e?.message || e)); }
63
- });
63
+ window.appChatium.onAuthChanged(_onAuthChanged);
64
+ } else {
65
+ // Web-mode: подписка на WS-канал auth:changed (server publish'ит
66
+ // после web-login flow / logout).
67
+ try {
68
+ const proto = location.protocol === 'https:' ? 'wss' : 'ws';
69
+ const ws = new WebSocket(`${proto}://${location.host}/ws`);
70
+ ws.onopen = () => {
71
+ try { ws.send(JSON.stringify({ type: 'subscribe', channel: 'auth:changed' })); } catch {}
72
+ };
73
+ ws.onmessage = (e) => {
74
+ let m; try { m = JSON.parse(e.data); } catch { return; }
75
+ if (m?.type === 'event' && m.channel === 'auth:changed') {
76
+ _onAuthChanged(m.event?.kind || 'login');
77
+ }
78
+ };
79
+ // Если WS отвалится — потеряем live-уведомления auth-changed,
80
+ // но identity всё равно подтянется при следующем render'е через
81
+ // 10-минутный refresh-window. Не reconnect'имся аггресивно.
82
+ ws.onerror = () => {};
83
+ } catch (e) { vlog('warn', 'auth WS subscribe failed: ' + e?.message); }
64
84
  }
65
85
  // Восстановить состояние панелей таймлайна/превью/реплик
66
86
  const tlOpen = localStorage.getItem('timelineOpen') === '1';
@@ -100,18 +120,25 @@ window.addEventListener('DOMContentLoaded', async () => {
100
120
  refreshBalance().catch(() => {});
101
121
  setInterval(() => { refreshBalance().catch(() => {}); }, 60_000);
102
122
 
103
- // Тихий autoload: первый recent с granted-permission (если есть).
104
- // skipAutoload работает по двум флагам:
105
- // - welcomeOnNextStart (одноразовый, ставится в closeProject)
106
- // - lastLocation === 'welcome' (постоянный, ставится при показе welcome)
107
- // Без второго: после Cmd+R на welcome flag сбрасывался, и второй Cmd+R
108
- // открывал последний проект. Теперь lastLocation сохраняется пока юзер
109
- // не открыл проект.
110
- const skipAutoload = localStorage.getItem('welcomeOnNextStart') === '1'
123
+ // Autoload-стратегия: открыть последний проект ТОЛЬКО при Cmd+R (reload),
124
+ // когда юзер был в проекте. На cold start (полное закрытие → запуск)
125
+ // показываем welcome. Юзер: «при запуске я вижу страницу проекта (а не
126
+ // стартовую страницу)».
127
+ //
128
+ // Differentiation: main-process IPC `app:consume-cold-start` возвращает
129
+ // true ровно один раз за app-launch. Cmd+R → false (main продолжает
130
+ // жить, renderer перезагружается). Полный quit+restart → новый main →
131
+ // флаг снова true. Это надёжнее sessionStorage (которое в Electron
132
+ // может переживать quit при persistent partition).
133
+ let isColdStart = false;
134
+ try { isColdStart = await window.appProject?.consumeColdStart?.(); } catch {}
135
+ // skipAutoload: cold start ИЛИ explicit welcome-флаги.
136
+ const skipAutoload = isColdStart
137
+ || localStorage.getItem('welcomeOnNextStart') === '1'
111
138
  || localStorage.getItem('lastLocation') === 'welcome';
112
139
  if (skipAutoload) {
113
140
  localStorage.removeItem('welcomeOnNextStart');
114
- vlog('info', 'restore: skipped (welcome state persisted)');
141
+ vlog('info', `restore: skipped (cold-start=${isColdStart}, welcome state persisted)`);
115
142
  } else {
116
143
  try {
117
144
  const recents = await getRecents();
@@ -135,8 +162,27 @@ window.addEventListener('DOMContentLoaded', async () => {
135
162
  const openSettingsFromLogo = () => {
136
163
  if (window.appSettings?.openSettingsWindow) window.appSettings.openSettingsWindow();
137
164
  };
138
- document.getElementById('brandLogo')?.addEventListener('dblclick', openSettingsFromLogo);
139
165
  document.getElementById('welcomeLogo')?.addEventListener('dblclick', openSettingsFromLogo);
166
+ // Brand-logo в шапке проекта: single-click → возврат на welcome,
167
+ // dblclick → настройки. Юзер: «на странице проекта сделай клик по
168
+ // левому-верхнему логотипу возвратом в список проектов».
169
+ // Single-click задерживаем на 250ms — за это время dblclick отменит таймер.
170
+ const brandLogo = document.getElementById('brandLogo');
171
+ if (brandLogo) {
172
+ brandLogo.style.cursor = 'pointer';
173
+ brandLogo.title = 'Клик — на главную; дабл-клик — настройки';
174
+ brandLogo.addEventListener('click', () => {
175
+ if (brandLogo._dblTimer) { clearTimeout(brandLogo._dblTimer); brandLogo._dblTimer = null; }
176
+ brandLogo._dblTimer = setTimeout(() => {
177
+ brandLogo._dblTimer = null;
178
+ if (state.filmHandle && typeof closeProject === 'function') closeProject();
179
+ }, 250);
180
+ });
181
+ brandLogo.addEventListener('dblclick', () => {
182
+ if (brandLogo._dblTimer) { clearTimeout(brandLogo._dblTimer); brandLogo._dblTimer = null; }
183
+ openSettingsFromLogo();
184
+ });
185
+ }
140
186
  // ПКМ на brand-area (sidebar header) — действия проектного уровня
141
187
  // (сохранить как шаблон / обновить шаблон).
142
188
  document.querySelector('.brand')?.addEventListener('contextmenu', e => {
@@ -202,6 +248,7 @@ async function refreshBalance() {
202
248
  }
203
249
  }
204
250
 
251
+ function _renderBalancesInto(data) {
205
252
  // Один pill на провайдер. Если у провайдера нет данных (выключен или
206
253
  // API не дал баланс) — pill не рендерим.
207
254
  const pills = [
@@ -210,10 +257,13 @@ async function refreshBalance() {
210
257
  { key: 'openrouter', label: 'OpenRouter', low: 0.5, fmt: (a) => `<b>$${a.toFixed(2)}</b>` },
211
258
  { key: 'elevenlabs', label: 'ElevenLabs', low: 1000, fmt: (a) => `<b>${a.toLocaleString('ru-RU')}</b>&nbsp;chars` },
212
259
  ];
213
- function renderInto(wrap) {
260
+ function renderInto(wrap, opts = {}) {
214
261
  if (!wrap) return;
215
262
  wrap.innerHTML = '';
216
263
  for (const p of pills) {
264
+ // На welcome (welcomeStatusBalances) kingkont-кредиты показываются
265
+ // ВНУТРИ identity-pill (см. _renderIdentity) — здесь не дублируем.
266
+ if (opts.skipKingkont && p.key === 'kingkont') continue;
217
267
  const d = data[p.key];
218
268
  if (!d || typeof d.amount !== 'number') continue;
219
269
  const pill = document.createElement('span');
@@ -229,10 +279,15 @@ async function refreshBalance() {
229
279
  wrap.appendChild(pill);
230
280
  }
231
281
  }
232
- // Sidebar-footer (когда открыт проект).
282
+ // Sidebar-footer (когда открыт проект) — все балансы.
233
283
  renderInto(document.getElementById('balancesAll'));
234
- // Welcome top-right (когда no-project).
235
- renderInto(document.getElementById('welcomeStatusBalances'));
284
+ // Welcome top-right — БЕЗ kingkont (он встроен в identity-pill).
285
+ renderInto(document.getElementById('welcomeStatusBalances'), { skipKingkont: true });
286
+ // refreshBalance мог обновить cache → identity-pill должен показать
287
+ // новое значение. Зовём только если уже на welcome (не лезть в проект).
288
+ if (document.body.classList.contains('no-project')) {
289
+ renderWelcomeIdentity().catch(() => {});
290
+ }
236
291
  }
237
292
  window.refreshBalance = refreshBalance;
238
293
 
@@ -240,13 +295,19 @@ window.refreshBalance = refreshBalance;
240
295
  // при возврате на welcome (без flicker «пусто → потом запрос → потом текст»).
241
296
  // На фоне дёргаем настоящий status и обновляем pill.
242
297
  const _STATUS_CACHE_KEY = 'chatiumStatusCache';
298
+ // Refresh-окно: фоновый запрос профиля делаем не чаще раз в 10 минут.
299
+ // Юзер: «перестань запрашивать профиль каждый раз когда я возвращаюсь
300
+ // на главную страницу». Раньше каждый show welcome → новый HTTP-запрос
301
+ // к Chatium /auth~me — лишний трафик, мигание pill'а.
302
+ const _STATUS_REFRESH_WINDOW_MS = 10 * 60 * 1000;
303
+ const _STATUS_HARD_TTL_MS = 24 * 3600 * 1000;
243
304
  function _readStatusCache() {
244
305
  try {
245
306
  const raw = localStorage.getItem(_STATUS_CACHE_KEY);
246
307
  if (!raw) return null;
247
308
  const obj = JSON.parse(raw);
248
- if (Date.now() - (obj.ts || 0) > 24 * 3600 * 1000) return null; // 24h max
249
- return obj.status || null;
309
+ if (Date.now() - (obj.ts || 0) > _STATUS_HARD_TTL_MS) return null; // 24h hard max
310
+ return { status: obj.status || null, ts: obj.ts || 0 };
250
311
  } catch { return null; }
251
312
  }
252
313
  function _writeStatusCache(status) {
@@ -255,19 +316,31 @@ function _writeStatusCache(status) {
255
316
 
256
317
  // Identity-pill в правом верхнем углу welcome-экрана.
257
318
  // Показывает кто залогинен в KingKont (или предлагает войти).
258
- async function renderWelcomeIdentity() {
319
+ // opts.force=true — игнорирует rate-limit, всё равно фетчит (login/logout flow).
320
+ async function renderWelcomeIdentity(opts = {}) {
259
321
  const wrap = document.getElementById('welcomeStatusIdentity');
260
322
  if (!wrap) return;
261
- // 1) Сначала рендерим из кэша (если есть) — мгновенно.
262
323
  const cached = _readStatusCache();
263
- if (cached) _renderIdentity(wrap, cached);
264
- // 2) В фоне дёргаем свежий status и перерисовываем + обновляем кэш.
324
+ if (cached?.status) _renderIdentity(wrap, cached.status);
325
+ // Re-fetch только если: кэш отсутствует ИЛИ старше 10 мин ИЛИ force.
326
+ const ageMs = cached ? (Date.now() - cached.ts) : Infinity;
327
+ const needFetch = opts.force || !cached || ageMs > _STATUS_REFRESH_WINDOW_MS;
328
+ if (!needFetch) return;
265
329
  let status = null;
330
+ // Electron preload — preferred (читает свой settings + дёргает auth~me).
266
331
  try { status = await window.appChatium?.status?.(); } catch {}
332
+ // Browser-fallback: тот же ответ, но через server endpoint.
333
+ // appChatium в web undefined → status здесь будет null.
334
+ if (!status) {
335
+ try {
336
+ const r = await fetch('/api/auth/status');
337
+ if (r.ok) status = await r.json();
338
+ } catch {}
339
+ }
267
340
  if (status) {
268
341
  _writeStatusCache(status);
269
342
  _renderIdentity(wrap, status);
270
- } else if (!cached) {
343
+ } else if (!cached?.status) {
271
344
  // Ни кэша, ни ответа — рисуем «не залогинен» по дефолту.
272
345
  _renderIdentity(wrap, { connected: false });
273
346
  }
@@ -275,33 +348,75 @@ async function renderWelcomeIdentity() {
275
348
  function _renderIdentity(wrap, status) {
276
349
  wrap.innerHTML = '';
277
350
  if (status?.connected) {
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
287
- || status.userId || 'KingKont';
288
- const sub = status.userId && status.userId !== name ? `· ${status.userId.slice(0, 8)}` : '';
351
+ // Chatium-профиль: всё в `userInfo` (displayName/firstName/lastName/
352
+ // confirmedEmail/confirmedPhone/imageUrl). Также проверяем legacy-
353
+ // имена (user/profile/account) на случай других версий API.
354
+ const u = status.userInfo || status.user || status.profile || status.account || {};
355
+ let name = status.displayName || status.name || status.fullName
356
+ || status.login || status.username || status.userName
357
+ || u.displayName || u.name || u.fullName
358
+ || (u.firstName && u.lastName ? `${u.firstName} ${u.lastName}` : null)
359
+ || u.firstName || u.lastName
360
+ || u.login || u.username
361
+ || status.confirmedEmail || status.email
362
+ || u.confirmedEmail || u.email
363
+ || u.confirmedPhone || u.phone
364
+ || null;
365
+ let sub = '';
366
+ // sub: email если основное имя — НЕ email; иначе userId-префикс.
367
+ const subEmail = u.confirmedEmail || u.email || status.confirmedEmail || status.email;
368
+ if (name && subEmail && subEmail !== name) {
369
+ sub = `· ${subEmail}`;
370
+ } else if (name && status.userId && status.userId !== name) {
371
+ sub = `· ${status.userId.slice(0, 8)}`;
372
+ }
373
+ // Avatar — слева от имени, если imageUrl есть. Через server proxy
374
+ // (CDN-картинка может требовать referer).
375
+ const avatarUrl = u.imageUrl || u.avatarUrl || u.photoUrl || null;
376
+ if (!name) {
377
+ // Fallback на userId-prefix.
378
+ name = '👤 ' + (status.userId || 'KingKont').slice(0, 10);
379
+ sub = status.userId ? `…${status.userId.slice(-4)}` : '';
380
+ }
289
381
  // Диагностика: если ни одного «человеческого» поля не нашлось —
290
382
  // громкий лог в консоль с полным дампом, чтобы видеть что прислал
291
383
  // сервер (см. main.js [chatium:status] логи тоже).
292
384
  const noHumanField = !status.displayName && !status.name && !status.fullName
293
385
  && !status.login && !status.confirmedEmail && !status.email
294
- && !u.displayName && !u.name && !u.email;
386
+ && !u.displayName && !u.firstName && !u.lastName && !u.confirmedEmail;
295
387
  if (noHumanField) {
296
388
  console.warn('[chat-identity] No human-readable name field. Status object:', status);
297
389
  console.warn('[chat-identity] _allKeys:', status._allKeys);
298
390
  console.warn('[chat-identity] _raw:', status._raw);
299
391
  }
392
+ // Avatar (если есть imageUrl) — слева. Прокси через server, чтобы CDN
393
+ // не требовал referer. Lazy-error → скрыть img при failed load.
394
+ const avatarHtml = avatarUrl
395
+ ? `<img src="/api/proxy?url=${encodeURIComponent(avatarUrl)}" alt="" class="who-avatar" onerror="this.remove()">`
396
+ : `<span style="color:#5c5; font-size:13px; line-height:1;">●</span>`;
300
397
  wrap.innerHTML = `
301
- <span style="color:#5c5; font-size:13px; line-height:1;">●</span>
398
+ ${avatarHtml}
302
399
  <span class="who">${escapeHtml(name)}</span>
303
400
  <span class="who-sub">${escapeHtml(sub)}</span>
304
401
  `;
402
+ // KingKont-кредиты — встраиваем chip ВНУТРИ identity-pill (юзер
403
+ // попросил «кредиты kingkont внеси туда же где профиль»). Берём
404
+ // из balances-кэша (refreshBalance его наполняет). Из общего
405
+ // welcomeStatusBalances kingkont теперь убирается (см. ниже).
406
+ const balances = _readBalancesCache();
407
+ const kk = balances?.kingkont;
408
+ if (kk && typeof kk.amount === 'number') {
409
+ const credits = document.createElement('span');
410
+ credits.className = 'who-credits';
411
+ const low = kk.amount > 0 && kk.amount < 100;
412
+ const empty = kk.amount <= 0;
413
+ if (low) credits.classList.add('low');
414
+ if (empty) credits.classList.add('empty');
415
+ credits.title = 'Баланс KingKont · клик — лог списаний';
416
+ credits.innerHTML = `<b>${kk.amount.toLocaleString('ru-RU')}</b>&nbsp;credits`;
417
+ credits.addEventListener('click', () => window.openTxLog?.());
418
+ wrap.appendChild(credits);
419
+ }
305
420
  // Если имя не найдено — добавляем visible debug-pill с available keys,
306
421
  // чтобы юзер мог тебе показать что отдаёт Chatium без копания в консоли.
307
422
  if (noHumanField && Array.isArray(status._allKeys) && status._allKeys.length) {
@@ -316,7 +431,14 @@ function _renderIdentity(wrap, status) {
316
431
  logoutBtn.title = 'Logout из KingKont';
317
432
  logoutBtn.addEventListener('click', async () => {
318
433
  if (!confirm('Выйти из KingKont?')) return;
319
- try { await window.appChatium?.logout?.(); } catch {}
434
+ // Electron: preload IPC. Web: server endpoint.
435
+ try {
436
+ if (window.appChatium?.logout) await window.appChatium.logout();
437
+ else await fetch('/api/auth/logout', { method: 'POST' });
438
+ } catch {}
439
+ // Очистим status-кэш и форсим re-render.
440
+ try { localStorage.removeItem('chatiumStatusCache'); } catch {}
441
+ renderWelcomeIdentity({ force: true }).catch(() => {});
320
442
  });
321
443
  wrap.appendChild(logoutBtn);
322
444
  } else {
@@ -328,7 +450,21 @@ function _renderIdentity(wrap, status) {
328
450
  loginBtn.textContent = 'Войти';
329
451
  loginBtn.title = 'Войти в KingKont';
330
452
  loginBtn.addEventListener('click', async () => {
331
- try { await window.appChatium?.login?.(); } catch (e) { alert('Login failed: ' + (e?.message || e)); }
453
+ // Electron: preload IPC (откроет внешний браузер, дождётся callback'а
454
+ // на random localhost-port). Web: server endpoint (этот же сервер
455
+ // принимает callback на /api/auth/login/callback).
456
+ if (window.appChatium?.login) {
457
+ try { await window.appChatium.login(); } catch (e) { alert('Login failed: ' + (e?.message || e)); }
458
+ return;
459
+ }
460
+ try {
461
+ const r = await fetch('/api/auth/login/start', { method: 'POST' });
462
+ const d = await r.json();
463
+ if (!r.ok || !d?.loginUrl) throw new Error(d?.error || 'login start failed');
464
+ // Открываем в новой вкладке. После успеха она auto-close,
465
+ // а наша WS-подписка (см. _wireAuthWS) подтянет новый status.
466
+ window.open(d.loginUrl, '_blank', 'noopener');
467
+ } catch (e) { alert('Login failed: ' + (e?.message || e)); }
332
468
  });
333
469
  wrap.appendChild(loginBtn);
334
470
  }
@@ -595,10 +731,18 @@ async function _renderWelcomeRecentsInner() {
595
731
 
596
732
  // ☁ Облачные проекты — видна только если залогинен в Chatium.
597
733
  // Создаёт серверную запись и открывает её как новый проект.
734
+ // Web fallback: если preload appSettings нет — спрашиваем сервер
735
+ // через /api/auth/status (тот же chatium-токен из settings.json).
598
736
  let isLoggedIn = false;
599
737
  try {
600
- const s = await window.appSettings?.get();
601
- isLoggedIn = !!(s?.useChatium && s?.chatium?.token);
738
+ if (window.appSettings?.get) {
739
+ const s = await window.appSettings.get();
740
+ isLoggedIn = !!(s?.useChatium && s?.chatium?.token);
741
+ } else {
742
+ const r = await fetch('/api/auth/status');
743
+ const st = await r.json();
744
+ isLoggedIn = !!st?.connected;
745
+ }
602
746
  } catch {}
603
747
  if (isLoggedIn) {
604
748
  const cloudCard = document.createElement('div');
@@ -1393,65 +1537,77 @@ async function openFilm(handle) {
1393
1537
  // Закрыть текущий проект: выгрузить state, скрыть секции, восстановить
1394
1538
  // шапку, оставить запись в idb (чтобы Recent работал).
1395
1539
  async function closeProject() {
1396
- // Помечаем что юзер вышел явно на следующем старте autoload пропускается.
1397
- // Cmd+R после close = welcome, а не реоткрытие.
1398
- try {
1540
+ // Каждый шаг wrap'нут try/catchраньше любой throw ломал весь close
1541
+ // (юзер видел «приложение падает», на самом деле renderer вис в полузакрытом
1542
+ // состоянии). Логируем что упало, но продолжаем дальше.
1543
+ const safe = (label, fn) => {
1544
+ try { return fn(); }
1545
+ catch (e) { console.warn(`[closeProject] ${label} failed:`, e?.message || e); }
1546
+ };
1547
+ const safeAwait = async (label, fn) => {
1548
+ try { await fn(); }
1549
+ catch (e) { console.warn(`[closeProject] ${label} failed:`, e?.message || e); }
1550
+ };
1551
+
1552
+ safe('persist welcome flag', () => {
1399
1553
  localStorage.setItem('welcomeOnNextStart', '1');
1400
1554
  localStorage.setItem('lastLocation', 'welcome');
1401
- } catch {}
1402
- // Чат привязан к одному проекту — flush pending-persist В ТЕКУЩИЙ
1403
- // filmHandle, потом сбрасываем in-memory. Без flush'а debounced-write
1404
- // мог бы попасть в новый проект (race на 600ms окно).
1405
- // Прячем панель на welcome без проекта чат не имеет смысла.
1406
- if (window.kingChat?.resetInMemory) await window.kingChat.resetInMemory();
1407
- if (window.kingChat?.close) window.kingChat.close();
1408
- stopExternalWatcher();
1409
- // Сбрасываем UI таймлайна/превью — иначе при возврате через welcome
1410
- // в новый проект остаётся фрейм/дорожки прошлого.
1411
- if (typeof resetTimelineUI === 'function') resetTimelineUI();
1412
- if (state.currentBoard?.urls) {
1413
- for (const u of Object.values(state.currentBoard.urls)) URL.revokeObjectURL(u);
1414
- }
1415
- // Освобождаем blob URL'ы для миниатюр @-popup'а — они грузились лазиво
1416
- // при открытии mention-popup, кэш накапливался.
1417
- if (typeof revokeMentionThumbCache === 'function') revokeMentionThumbCache();
1555
+ });
1556
+ await safeAwait('chat resetInMemory', async () => {
1557
+ if (window.kingChat?.resetInMemory) await window.kingChat.resetInMemory();
1558
+ });
1559
+ safe('chat close', () => { window.kingChat?.close?.(); });
1560
+ safe('stopExternalWatcher', () => { if (typeof stopExternalWatcher === 'function') stopExternalWatcher(); });
1561
+ safe('resetTimelineUI', () => { if (typeof resetTimelineUI === 'function') resetTimelineUI(); });
1562
+ safe('revoke board urls', () => {
1563
+ if (state.currentBoard?.urls) {
1564
+ for (const u of Object.values(state.currentBoard.urls)) URL.revokeObjectURL(u);
1565
+ }
1566
+ });
1567
+ safe('revokeMentionThumbCache', () => {
1568
+ if (typeof revokeMentionThumbCache === 'function') revokeMentionThumbCache();
1569
+ });
1570
+ // Reset state.
1418
1571
  state.filmHandle = null;
1419
1572
  state.currentBoard = null;
1420
- // Сбрасываем cloud-маркеры (если был открыт облачный проект).
1421
1573
  state.cloudProjectId = null;
1422
1574
  state.cloudDirty = false;
1423
- window.appProject?.notifyState(false);
1424
- // notifyPanel перезагружает events (теперь global — все проекты).
1425
- window.dispatchEvent(new CustomEvent('project-changed'));
1575
+ safe('notifyState', () => { window.appProject?.notifyState(false); });
1576
+ safe('dispatch project-changed', () => {
1577
+ window.dispatchEvent(new CustomEvent('project-changed'));
1578
+ });
1426
1579
  state.charactersInfo = [];
1427
1580
  state.locationsInfo = [];
1428
1581
  state.selectedNodeIds.clear();
1429
1582
  state.selectedClipIds.clear();
1430
1583
  state.selectedTrackIds.clear();
1431
1584
  document.body.classList.add('no-project');
1432
- // Видимость cloud-кнопок зависит от наличия открытого проекта — переключаем.
1433
- if (window.cloudProjects?.setVisibility) window.cloudProjects.setVisibility();
1434
- const sub = $('brandSub');
1435
- if (sub) { sub.textContent = 'Видео-редактор'; sub.classList.remove('has-project'); }
1436
- const boardEl = $('brandBoard');
1437
- if (boardEl) { boardEl.style.display = 'none'; boardEl.innerHTML = ''; }
1438
- $('rootInfo').textContent = '';
1439
- // Очистить sidebar-списки
1440
- for (const id of ['characterList','locationList','episodeList']) {
1441
- const el = $(id); if (el) el.innerHTML = '';
1442
- }
1443
- $('newEpisode').disabled = true;
1444
- $('newCharacter').disabled = true;
1445
- $('newLocation').disabled = true;
1446
- $('repliquesBtn').disabled = true;
1447
- $('boardBadge').style.display = 'none';
1448
- $('charSettingsBtn').style.display = 'none';
1449
- // Очистить холст
1450
- if (typeof clearCanvasKeepSvg === 'function') clearCanvasKeepSvg();
1451
- $('addText').disabled = true; $('genText').disabled = true; $('genAudio').disabled = true; $('genImage').disabled = true; $('genVideo').disabled = true; $('genSfx').disabled = true; $('genMusic').disabled = true;
1452
-
1453
- await renderWelcomeRecents();
1454
- showEmpty();
1585
+ safe('cloud setVisibility', () => { window.cloudProjects?.setVisibility?.(); });
1586
+ safe('reset sidebar header', () => {
1587
+ const sub = $('brandSub');
1588
+ if (sub) { sub.textContent = 'Видео-редактор'; sub.classList.remove('has-project'); }
1589
+ const boardEl = $('brandBoard');
1590
+ if (boardEl) { boardEl.style.display = 'none'; boardEl.innerHTML = ''; }
1591
+ const rootInfo = $('rootInfo'); if (rootInfo) rootInfo.textContent = '';
1592
+ });
1593
+ safe('clear sidebar lists', () => {
1594
+ for (const id of ['characterList','locationList','episodeList']) {
1595
+ const el = $(id); if (el) el.innerHTML = '';
1596
+ }
1597
+ });
1598
+ safe('disable buttons', () => {
1599
+ for (const id of ['newEpisode','newCharacter','newLocation','repliquesBtn',
1600
+ 'addText','genText','genAudio','genImage','genVideo','genSfx','genMusic']) {
1601
+ const b = $(id); if (b) b.disabled = true;
1602
+ }
1603
+ const bb = $('boardBadge'); if (bb) bb.style.display = 'none';
1604
+ const cs = $('charSettingsBtn'); if (cs) cs.style.display = 'none';
1605
+ });
1606
+ safe('clearCanvasKeepSvg', () => {
1607
+ if (typeof clearCanvasKeepSvg === 'function') clearCanvasKeepSvg();
1608
+ });
1609
+ await safeAwait('renderWelcomeRecents', () => renderWelcomeRecents());
1610
+ safe('showEmpty', () => { if (typeof showEmpty === 'function') showEmpty(); });
1455
1611
  }
1456
1612
 
1457
1613
  // =================== Sidebar ===================
@@ -2756,7 +2912,16 @@ function showNodeContextMenu(node, clientX, clientY) {
2756
2912
  if (node.generated) add('⚙ Параметры', () => showNodeSettings(node));
2757
2913
  if (node.status === 'generating') {
2758
2914
  add('⏹ Остановить', () => stopJob(node.id));
2759
- add('↻ Перезапустить', () => restartJob(node.id));
2915
+ // «Перезапустить» открывает модалку с предзаполненными параметрами
2916
+ // (как «Перегенерировать» для готовой ноды). Раньше тут был
2917
+ // restartJob(id) — рестартил без модалки. Юзер: «нужно чтобы показывал
2918
+ // как раньше». Сначала стопим текущую генерацию, чтобы regenerateNode
2919
+ // пропустил guard `if (status==='generating') return`.
2920
+ add('↻ Перезапустить', async () => {
2921
+ await stopJob(node.id);
2922
+ // status уже сброшен на 'error' в stopJob → regenerateNode пройдёт.
2923
+ regenerateNode(node);
2924
+ });
2760
2925
  } else if (node.status === 'draft') {
2761
2926
  add('▶ Запустить генерацию', () => regenerateNode(node));
2762
2927
  add('✎ Изменить и запустить', () => regenerateNode(node));
@@ -288,5 +288,13 @@
288
288
  getCloudProjectId,
289
289
  // exposed для save-to-server: пройтись по всем файлам in-memory map'а.
290
290
  _memProject,
291
+ // Web-only helper: прямой write в backend этого проекта. Используется
292
+ // в cloudProjects.openCloudProject для скачивания manifest+файлов с
293
+ // сервера в память (window.cloudFs только в Electron preload).
294
+ async writeFile(projectId, relPath, data) {
295
+ const backend = makeBackend(projectId);
296
+ await backend.ensure();
297
+ return backend.write(relPath, data);
298
+ },
291
299
  };
292
300
  })();
@@ -25,7 +25,29 @@
25
25
  // -------------- helpers --------------
26
26
  function $(id) { return document.getElementById(id); }
27
27
  function isLoggedIn(s) { return !!(s?.useChatium && s?.chatium?.token); }
28
- async function getSettings() { try { return await window.appSettings?.get(); } catch { return {}; } }
28
+ // Electron: preload IPC возвращает полный settings.json. В web preload
29
+ // недоступен → опрашиваем /api/auth/status и эмулируем settings-shape
30
+ // (с token-плейсхолдером — реальный токен на сервере, renderer'у он
31
+ // не нужен напрямую, HTTP-запросы идут на наш же сервер с auth-header'ом).
32
+ async function getSettings() {
33
+ if (window.appSettings?.get) {
34
+ try { return await window.appSettings.get(); } catch { return {}; }
35
+ }
36
+ try {
37
+ const r = await fetch('/api/auth/status');
38
+ const status = await r.json();
39
+ if (status?.connected) {
40
+ return {
41
+ useChatium: true,
42
+ useOpenrouter: true,
43
+ useElevenlabs: true,
44
+ useKie: true,
45
+ chatium: { token: 'present', userId: status.userId, base: status.base },
46
+ };
47
+ }
48
+ } catch {}
49
+ return {};
50
+ }
29
51
 
30
52
  // Прогресс — переиспользуем существующий tplProgress из templates.js.
31
53
  const PROGRESS = (typeof TPL_PROGRESS !== 'undefined') ? TPL_PROGRESS : {
@@ -360,8 +382,11 @@
360
382
 
361
383
  async function writeCloudFile(projectId, relPath, data) {
362
384
  if (window.cloudFs) return window.cloudFs.write(projectId, relPath, data);
363
- // web fallback пишем в backend in-memory.
364
- // (cloudFsShim сам разруливает; этот path для main loop, не используется напрямую)
385
+ // Web fallback: пишем в shared in-memory backend, тот же что
386
+ // используют cloudFsShim handle'ы (т.е. файлы появятся в filmHandle
387
+ // через FSAH-like API). Без этого openCloudProject в браузере
388
+ // download'ил manifest+файлы и просто терял их → sidebar пустой.
389
+ if (window.cloudFsShim?.writeFile) return window.cloudFsShim.writeFile(projectId, relPath, data);
365
390
  return false;
366
391
  }
367
392
 
@@ -2065,7 +2065,10 @@ async function pollJob(job, nodeId, bKey, boardHandle, kind) {
2065
2065
  }
2066
2066
  if (pd.status === 'done') {
2067
2067
  logJob(nodeId, `done, downloading ${pd.url?.slice(0,80)}`);
2068
- const blob = await (await fetch(`/api/proxy?url=${encodeURIComponent(pd.url)}`)).blob();
2068
+ // data: URLs (OpenRouter image-gen возвращает inline base64) — берём
2069
+ // напрямую без proxy. Браузерный fetch их понимает natively.
2070
+ const fetchUrl = pd.url.startsWith('data:') ? pd.url : `/api/proxy?url=${encodeURIComponent(pd.url)}`;
2071
+ const blob = await (await fetch(fetchUrl)).blob();
2069
2072
  const ext = kind === 'image' ? 'jpg' : 'mp4';
2070
2073
  const sub = kind === 'image' ? 'frames' : 'clips';
2071
2074
  const dir = await getOrCreateBoardSubdir(boardHandle, sub);