kingkont 0.18.15 → 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,24 +348,33 @@ 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 || {};
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 || {};
283
355
  let name = status.displayName || status.name || status.fullName
284
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
285
361
  || status.confirmedEmail || status.email
286
- || u.displayName || u.name || u.fullName || u.login || u.email
362
+ || u.confirmedEmail || u.email
363
+ || u.confirmedPhone || u.phone
287
364
  || null;
288
365
  let sub = '';
289
- if (name && status.userId && status.userId !== name) {
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) {
290
371
  sub = `· ${status.userId.slice(0, 8)}`;
291
372
  }
373
+ // Avatar — слева от имени, если imageUrl есть. Через server proxy
374
+ // (CDN-картинка может требовать referer).
375
+ const avatarUrl = u.imageUrl || u.avatarUrl || u.photoUrl || null;
292
376
  if (!name) {
293
- // Chatium /auth~me возвращает только userId+appName+createdAt —
294
- // нет ни email, ни displayName. Показываем userId-префикс
295
- // компактно вместо некрасивого 24-символьного hash'а целиком.
377
+ // Fallback на userId-prefix.
296
378
  name = '👤 ' + (status.userId || 'KingKont').slice(0, 10);
297
379
  sub = status.userId ? `…${status.userId.slice(-4)}` : '';
298
380
  }
@@ -301,17 +383,40 @@ function _renderIdentity(wrap, status) {
301
383
  // сервер (см. main.js [chatium:status] логи тоже).
302
384
  const noHumanField = !status.displayName && !status.name && !status.fullName
303
385
  && !status.login && !status.confirmedEmail && !status.email
304
- && !u.displayName && !u.name && !u.email;
386
+ && !u.displayName && !u.firstName && !u.lastName && !u.confirmedEmail;
305
387
  if (noHumanField) {
306
388
  console.warn('[chat-identity] No human-readable name field. Status object:', status);
307
389
  console.warn('[chat-identity] _allKeys:', status._allKeys);
308
390
  console.warn('[chat-identity] _raw:', status._raw);
309
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>`;
310
397
  wrap.innerHTML = `
311
- <span style="color:#5c5; font-size:13px; line-height:1;">●</span>
398
+ ${avatarHtml}
312
399
  <span class="who">${escapeHtml(name)}</span>
313
400
  <span class="who-sub">${escapeHtml(sub)}</span>
314
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
+ }
315
420
  // Если имя не найдено — добавляем visible debug-pill с available keys,
316
421
  // чтобы юзер мог тебе показать что отдаёт Chatium без копания в консоли.
317
422
  if (noHumanField && Array.isArray(status._allKeys) && status._allKeys.length) {
@@ -326,7 +431,14 @@ function _renderIdentity(wrap, status) {
326
431
  logoutBtn.title = 'Logout из KingKont';
327
432
  logoutBtn.addEventListener('click', async () => {
328
433
  if (!confirm('Выйти из KingKont?')) return;
329
- 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(() => {});
330
442
  });
331
443
  wrap.appendChild(logoutBtn);
332
444
  } else {
@@ -338,7 +450,21 @@ function _renderIdentity(wrap, status) {
338
450
  loginBtn.textContent = 'Войти';
339
451
  loginBtn.title = 'Войти в KingKont';
340
452
  loginBtn.addEventListener('click', async () => {
341
- 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)); }
342
468
  });
343
469
  wrap.appendChild(loginBtn);
344
470
  }
@@ -605,10 +731,18 @@ async function _renderWelcomeRecentsInner() {
605
731
 
606
732
  // ☁ Облачные проекты — видна только если залогинен в Chatium.
607
733
  // Создаёт серверную запись и открывает её как новый проект.
734
+ // Web fallback: если preload appSettings нет — спрашиваем сервер
735
+ // через /api/auth/status (тот же chatium-токен из settings.json).
608
736
  let isLoggedIn = false;
609
737
  try {
610
- const s = await window.appSettings?.get();
611
- 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
+ }
612
746
  } catch {}
613
747
  if (isLoggedIn) {
614
748
  const cloudCard = document.createElement('div');
@@ -1403,65 +1537,77 @@ async function openFilm(handle) {
1403
1537
  // Закрыть текущий проект: выгрузить state, скрыть секции, восстановить
1404
1538
  // шапку, оставить запись в idb (чтобы Recent работал).
1405
1539
  async function closeProject() {
1406
- // Помечаем что юзер вышел явно на следующем старте autoload пропускается.
1407
- // Cmd+R после close = welcome, а не реоткрытие.
1408
- 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', () => {
1409
1553
  localStorage.setItem('welcomeOnNextStart', '1');
1410
1554
  localStorage.setItem('lastLocation', 'welcome');
1411
- } catch {}
1412
- // Чат привязан к одному проекту — flush pending-persist В ТЕКУЩИЙ
1413
- // filmHandle, потом сбрасываем in-memory. Без flush'а debounced-write
1414
- // мог бы попасть в новый проект (race на 600ms окно).
1415
- // Прячем панель на welcome без проекта чат не имеет смысла.
1416
- if (window.kingChat?.resetInMemory) await window.kingChat.resetInMemory();
1417
- if (window.kingChat?.close) window.kingChat.close();
1418
- stopExternalWatcher();
1419
- // Сбрасываем UI таймлайна/превью — иначе при возврате через welcome
1420
- // в новый проект остаётся фрейм/дорожки прошлого.
1421
- if (typeof resetTimelineUI === 'function') resetTimelineUI();
1422
- if (state.currentBoard?.urls) {
1423
- for (const u of Object.values(state.currentBoard.urls)) URL.revokeObjectURL(u);
1424
- }
1425
- // Освобождаем blob URL'ы для миниатюр @-popup'а — они грузились лазиво
1426
- // при открытии mention-popup, кэш накапливался.
1427
- 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.
1428
1571
  state.filmHandle = null;
1429
1572
  state.currentBoard = null;
1430
- // Сбрасываем cloud-маркеры (если был открыт облачный проект).
1431
1573
  state.cloudProjectId = null;
1432
1574
  state.cloudDirty = false;
1433
- window.appProject?.notifyState(false);
1434
- // notifyPanel перезагружает events (теперь global — все проекты).
1435
- 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
+ });
1436
1579
  state.charactersInfo = [];
1437
1580
  state.locationsInfo = [];
1438
1581
  state.selectedNodeIds.clear();
1439
1582
  state.selectedClipIds.clear();
1440
1583
  state.selectedTrackIds.clear();
1441
1584
  document.body.classList.add('no-project');
1442
- // Видимость cloud-кнопок зависит от наличия открытого проекта — переключаем.
1443
- if (window.cloudProjects?.setVisibility) window.cloudProjects.setVisibility();
1444
- const sub = $('brandSub');
1445
- if (sub) { sub.textContent = 'Видео-редактор'; sub.classList.remove('has-project'); }
1446
- const boardEl = $('brandBoard');
1447
- if (boardEl) { boardEl.style.display = 'none'; boardEl.innerHTML = ''; }
1448
- $('rootInfo').textContent = '';
1449
- // Очистить sidebar-списки
1450
- for (const id of ['characterList','locationList','episodeList']) {
1451
- const el = $(id); if (el) el.innerHTML = '';
1452
- }
1453
- $('newEpisode').disabled = true;
1454
- $('newCharacter').disabled = true;
1455
- $('newLocation').disabled = true;
1456
- $('repliquesBtn').disabled = true;
1457
- $('boardBadge').style.display = 'none';
1458
- $('charSettingsBtn').style.display = 'none';
1459
- // Очистить холст
1460
- if (typeof clearCanvasKeepSvg === 'function') clearCanvasKeepSvg();
1461
- $('addText').disabled = true; $('genText').disabled = true; $('genAudio').disabled = true; $('genImage').disabled = true; $('genVideo').disabled = true; $('genSfx').disabled = true; $('genMusic').disabled = true;
1462
-
1463
- await renderWelcomeRecents();
1464
- 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(); });
1465
1611
  }
1466
1612
 
1467
1613
  // =================== Sidebar ===================
@@ -2766,7 +2912,16 @@ function showNodeContextMenu(node, clientX, clientY) {
2766
2912
  if (node.generated) add('⚙ Параметры', () => showNodeSettings(node));
2767
2913
  if (node.status === 'generating') {
2768
2914
  add('⏹ Остановить', () => stopJob(node.id));
2769
- 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
+ });
2770
2925
  } else if (node.status === 'draft') {
2771
2926
  add('▶ Запустить генерацию', () => regenerateNode(node));
2772
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);