kingkont 0.16.1 → 0.16.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kingkont",
3
- "version": "0.16.1",
3
+ "version": "0.16.2",
4
4
  "description": "KingKont · Chatium — нод-редактор сцен с AI-генерацией (картинки/видео/голос/SFX/музыка/текст)",
5
5
  "main": "main.js",
6
6
  "bin": {
package/renderer/board.js CHANGED
@@ -101,14 +101,17 @@ window.addEventListener('DOMContentLoaded', async () => {
101
101
  setInterval(() => { refreshBalance().catch(() => {}); }, 60_000);
102
102
 
103
103
  // Тихий autoload: первый recent с granted-permission (если есть).
104
- // НО если юзер только что вышел из проекта (closeProject поставил
105
- // флаг welcomeOnNextStart), пропускаем autoload и показываем welcome.
106
- // Это нужно чтобы Cmd+R после явного выхода не реоткрывал проект.
107
- // (При обычном перезапуске после crash'а флага нет автоload работает.)
108
- const skipAutoload = localStorage.getItem('welcomeOnNextStart') === '1';
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'
111
+ || localStorage.getItem('lastLocation') === 'welcome';
109
112
  if (skipAutoload) {
110
113
  localStorage.removeItem('welcomeOnNextStart');
111
- vlog('info', 'restore: skipped (user explicitly closed last project)');
114
+ vlog('info', 'restore: skipped (welcome state persisted)');
112
115
  } else {
113
116
  try {
114
117
  const recents = await getRecents();
@@ -458,8 +461,21 @@ async function renderWelcomeRecents() {
458
461
  if (!grid || !wrap) return;
459
462
  grid.innerHTML = '';
460
463
  wrap.style.display = '';
464
+ // Запоминаем что юзер на welcome — Cmd+R должен оставить здесь.
465
+ try { localStorage.setItem('lastLocation', 'welcome'); } catch {}
461
466
  // Identity-pill в правом верхнем — обновляем при каждом показе welcome.
462
467
  renderWelcomeIdentity().catch(() => {});
468
+ // Server-side bg-jobs cache: обновим в фоне (если изменится — снова
469
+ // re-render через bgjobs:changed event). Без infinite loop: проверяем
470
+ // что состояние реально поменялось.
471
+ (async () => {
472
+ const before = JSON.stringify(_bgServerCache);
473
+ await refreshBgServerCache();
474
+ const after = JSON.stringify(_bgServerCache);
475
+ if (before !== after && !state.filmHandle) {
476
+ window.dispatchEvent(new CustomEvent('bgjobs:changed'));
477
+ }
478
+ })().catch(() => {});
463
479
  const list = await getRecents();
464
480
 
465
481
  // Первой картой — «Открыть проект». Кликается → дёргает скрытый
@@ -603,10 +619,25 @@ function _ru_jobs(n) {
603
619
  if (n10 >= 2 && n10 <= 4) return 'генерации';
604
620
  return 'генераций';
605
621
  }
622
+ // Server-side cache всех bg-jobs. Source of truth — server jobsHub
623
+ // (через GET /api/jobs или WS push). localStorage остаётся только как
624
+ // fallback и для оптимистичных обновлений на самом клиенте.
625
+ let _bgServerCache = {}; // projectKey → [{jobId, kind, name, startedAt}]
626
+ async function refreshBgServerCache() {
627
+ try {
628
+ const r = await fetch('/api/jobs');
629
+ if (!r.ok) return;
630
+ const d = await r.json();
631
+ _bgServerCache = d?.all || {};
632
+ } catch {}
633
+ }
634
+
606
635
  // Live-update welcome когда bg-jobs меняются (start/end). Перерисовываем
607
636
  // только если открыт welcome (нет проекта), иначе UI не виден.
608
637
  window.addEventListener('bgjobs:changed', () => {
609
- if (!state.filmHandle) renderWelcomeRecents().catch(() => {});
638
+ if (!state.filmHandle) {
639
+ refreshBgServerCache().then(() => renderWelcomeRecents().catch(() => {}));
640
+ }
610
641
  });
611
642
 
612
643
  // WebSocket подписка на jobs:all — server push'ит при изменениях ЛЮБЫХ
@@ -625,7 +656,11 @@ function _connectWelcomeJobsWS() {
625
656
  _welcomeWS.onmessage = (e) => {
626
657
  let m; try { m = JSON.parse(e.data); } catch { return; }
627
658
  if (m?.type === 'event' && m.channel === 'jobs:all') {
628
- if (!state.filmHandle) renderWelcomeRecents().catch(() => {});
659
+ // Refresh cache from server first (m.event.list содержит свежий
660
+ // список для конкретного projectKey, но проще сделать GET /api/jobs).
661
+ refreshBgServerCache().then(() => {
662
+ if (!state.filmHandle) renderWelcomeRecents().catch(() => {});
663
+ });
629
664
  }
630
665
  };
631
666
  _welcomeWS.onclose = () => { _welcomeWS = null; setTimeout(_connectWelcomeJobsWS, 5000); };
@@ -647,9 +682,8 @@ function makeRecentWelcomeCard(r) {
647
682
  img.onload = () => setTimeout(() => URL.revokeObjectURL(img.src), 60_000);
648
683
  thumb.appendChild(img);
649
684
  } else thumb.textContent = '🎬';
650
- // Бейдж «N в фоне» если в этом проекте крутятся генерации.
651
- // Map проектов → projectKey: для folder-проектов = 'folder:' + r.name.
652
- const bgList = (typeof bgJobsAll === 'function' ? bgJobsAll() : {})['folder:' + r.name];
685
+ // Бейдж «N в фоне» server-side jobsHub source-of-truth (см. _bgServerCache).
686
+ const bgList = _bgServerCache['folder:' + r.name];
653
687
  if (bgList?.length) thumb.appendChild(_makeBgBadge(bgList.length));
654
688
  const meta = document.createElement('div');
655
689
  meta.className = 'welcome-card-meta';
@@ -804,8 +838,8 @@ function makeCloudWelcomeCard(p) {
804
838
  cloudBadge.textContent = '☁';
805
839
  cloudBadge.title = 'Облачный проект';
806
840
  thumb.appendChild(cloudBadge);
807
- // Бейдж «N в фоне» если в этом проекте крутятся генерации.
808
- const bgList = (typeof bgJobsAll === 'function' ? bgJobsAll() : {})['cloud:' + p.id];
841
+ // Бейдж «N в фоне» server-side jobsHub source-of-truth.
842
+ const bgList = _bgServerCache['cloud:' + p.id];
809
843
  if (bgList?.length) thumb.appendChild(_makeBgBadge(bgList.length));
810
844
 
811
845
  const meta = document.createElement('div');
@@ -1060,6 +1094,8 @@ async function openFilm(handle) {
1060
1094
  state.filmHandle = handle;
1061
1095
  state.currentBoard = null;
1062
1096
  document.body.classList.remove('no-project');
1097
+ // Запоминаем что юзер сейчас в проекте → Cmd+R откроет его снова.
1098
+ try { localStorage.setItem('lastLocation', 'project'); } catch {}
1063
1099
  window.appProject?.notifyState(true);
1064
1100
  ensureClaudeMd(handle).catch(() => {});
1065
1101
  // Подзаголовок шапки = имя открытого проекта (вместо «Видео-редактор»).
@@ -1133,7 +1169,10 @@ async function openFilm(handle) {
1133
1169
  async function closeProject() {
1134
1170
  // Помечаем что юзер вышел явно — на следующем старте autoload пропускается.
1135
1171
  // Cmd+R после close = welcome, а не реоткрытие.
1136
- try { localStorage.setItem('welcomeOnNextStart', '1'); } catch {}
1172
+ try {
1173
+ localStorage.setItem('welcomeOnNextStart', '1');
1174
+ localStorage.setItem('lastLocation', 'welcome');
1175
+ } catch {}
1137
1176
  // Чат привязан к одному проекту — flush pending-persist В ТЕКУЩИЙ
1138
1177
  // filmHandle, потом сбрасываем in-memory. Без flush'а debounced-write
1139
1178
  // мог бы попасть в новый проект (race на 600ms окно).
@@ -1725,11 +1725,15 @@ async function runTTSJob(node, text, boardHandle, bKey, voiceId) {
1725
1725
  }
1726
1726
 
1727
1727
  async function startGenerationJob(node, kind, prompt, mediaRefs, boardHandle, bKey, modelKey) {
1728
- const job = { boardKey: bKey, boardHandle, kind, taskId: null, nodeId: node.id };
1728
+ // КЛЮЧЕВОЕ: captures projectKey НА МОМЕНТ старта иначе если юзер
1729
+ // позже закроет проект, на completion мы не знаем чьё это «end».
1730
+ const projectKey = (state.cloudProjectId ? 'cloud:' + state.cloudProjectId
1731
+ : state.filmHandle?.name ? 'folder:' + state.filmHandle.name : null);
1732
+ const job = { boardKey: bKey, boardHandle, kind, taskId: null, nodeId: node.id, projectKey };
1729
1733
  state.jobs.set(node.id, job);
1730
1734
  updateJobsBadge();
1731
1735
  // Track в global bg-jobs (для welcome-индикаторов и system-notif при завершении).
1732
- if (typeof bgJobStart === 'function') bgJobStart({ nodeId: node.id, kind, name: node.name });
1736
+ if (typeof bgJobStart === 'function') bgJobStart({ nodeId: node.id, kind, name: node.name, projectKey });
1733
1737
  logJob(node.id, `gen start kind=${kind} model=${modelKey || '—'} refs=${mediaRefs?.length || 0} prompt="${(prompt||'').slice(0,200)}"`);
1734
1738
  // Подробный дамп всех refs со всеми полями
1735
1739
  logJob(node.id, `refs dump: ${logSafe((mediaRefs || []).map(r => ({
@@ -1856,9 +1860,10 @@ async function startGenerationJob(node, kind, prompt, mediaRefs, boardHandle, bK
1856
1860
  await mutateNode(bKey, boardHandle, node.id, n => {
1857
1861
  n.status = 'error'; n.error = e.message;
1858
1862
  });
1863
+ const projectKeyOnError = state.jobs.get(node.id)?.projectKey;
1859
1864
  state.jobs.delete(node.id);
1860
1865
  updateJobsBadge();
1861
- if (typeof bgJobEnd === 'function') bgJobEnd(node.id);
1866
+ if (typeof bgJobEnd === 'function') bgJobEnd(node.id, projectKeyOnError);
1862
1867
  if (typeof showToast === 'function') showToast(`⚠ ${kind || 'gen'}: ${e.message?.slice(0, 80)}`, 'error');
1863
1868
  if (typeof systemNotify === 'function' && document.hidden) {
1864
1869
  systemNotify('KingKont: ошибка генерации', e.message?.slice(0, 100), { tag: 'gen-err-' + node.id }).catch(() => {});
@@ -1986,10 +1991,12 @@ async function pollJob(job, nodeId, bKey, boardHandle, kind) {
1986
1991
  n.generated = { ...(n.generated || {}), creditsCharged: cost };
1987
1992
  }
1988
1993
  });
1994
+ const projectKeyAtCompletion = state.jobs.get(nodeId)?.projectKey;
1989
1995
  state.jobs.delete(nodeId);
1990
1996
  updateJobsBadge();
1991
- // bgJob убираем (welcome перечитает счётчики через event).
1992
- if (typeof bgJobEnd === 'function') bgJobEnd(nodeId);
1997
+ // bgJob убираем — projectKey берём из job (captured at start), а не
1998
+ // _projectKeyForCurrent (юзер мог переключить проект, тогда было бы null).
1999
+ if (typeof bgJobEnd === 'function') bgJobEnd(nodeId, projectKeyAtCompletion);
1993
2000
  if (typeof window.refreshBalance === 'function') window.refreshBalance();
1994
2001
  // Toast — ВСЕГДА (юзер хочет видеть когда что-то сделалось).
1995
2002
  // Стиль 'ok' для current-board, 'info' для другой доски.
package/renderer/state.js CHANGED
@@ -83,24 +83,31 @@ function _projectKeyForCurrent() {
83
83
  }
84
84
  function bgJobStart(info) {
85
85
  // info: {nodeId, kind, name?, projectKey?}
86
+ // ВАЖНО: projectKey обязателен — иначе job висит на сервере вечно
87
+ // (если юзер закроет проект до bgJobEnd, _projectKeyForCurrent вернёт
88
+ // null, и end-call станет no-op).
86
89
  const pk = info.projectKey || _projectKeyForCurrent();
87
- if (!pk) return;
90
+ if (!pk) { console.warn('bgJobStart: no projectKey, skipping'); return; }
88
91
  try {
89
92
  const list = JSON.parse(localStorage.getItem(bgJobsKey(pk)) || '[]');
90
- if (list.some(j => j.nodeId === info.nodeId)) return; // уже трекается
91
- list.push({ nodeId: info.nodeId, kind: info.kind, name: info.name || null, startedAt: Date.now() });
92
- localStorage.setItem(bgJobsKey(pk), JSON.stringify(list));
93
- window.dispatchEvent(new CustomEvent('bgjobs:changed'));
93
+ if (!list.some(j => j.nodeId === info.nodeId)) {
94
+ list.push({ nodeId: info.nodeId, kind: info.kind, name: info.name || null, startedAt: Date.now() });
95
+ localStorage.setItem(bgJobsKey(pk), JSON.stringify(list));
96
+ window.dispatchEvent(new CustomEvent('bgjobs:changed'));
97
+ }
94
98
  } catch {}
95
- // Также шлём на сервер — чтобы welcome (любой клиент) видел через WS push.
96
99
  fetch('/api/jobs/track', {
97
100
  method: 'POST', headers: { 'Content-Type': 'application/json' },
98
101
  body: JSON.stringify({ action: 'start', projectKey: pk, jobId: info.nodeId, kind: info.kind, name: info.name }),
99
102
  }).catch(() => {});
100
103
  }
101
104
  function bgJobEnd(nodeId, projectKey) {
105
+ // projectKey ОБЯЗАТЕЛЕН — без него юзер на welcome теряет уведомление
106
+ // и счётчик зависает. Раньше фоллбэк на _projectKeyForCurrent давал
107
+ // null (welcome) → no-op. Теперь шлём end даже если localStorage не
108
+ // знает проект (важно для server jobsHub).
102
109
  const pk = projectKey || _projectKeyForCurrent();
103
- if (!pk) return null;
110
+ if (!pk) { console.warn('bgJobEnd: no projectKey for', nodeId); return null; }
104
111
  let job = null;
105
112
  try {
106
113
  const list = JSON.parse(localStorage.getItem(bgJobsKey(pk)) || '[]');
@@ -133,8 +140,16 @@ window.bgJobEnd = bgJobEnd;
133
140
  window.bgJobsAll = bgJobsAll;
134
141
 
135
142
  // === SYSTEM NOTIFICATION (HTML5 Notification API) ===
136
- // Просим разрешение лениво на первом успешном вызове.
143
+ // Просим разрешение eagerly при старте приложения чтобы первое уведомление
144
+ // уже работало (без race с первой генерацией).
137
145
  let _notifPermAsked = false;
146
+ if (typeof Notification !== 'undefined' && Notification.permission === 'default') {
147
+ // Откладываем чуть-чуть — иначе диалог появляется до того как юзер увидел UI.
148
+ setTimeout(() => {
149
+ _notifPermAsked = true;
150
+ Notification.requestPermission().catch(() => {});
151
+ }, 2000);
152
+ }
138
153
  async function systemNotify(title, body, opts = {}) {
139
154
  if (typeof Notification === 'undefined') return null;
140
155
  let perm = Notification.permission;
@@ -142,7 +157,11 @@ async function systemNotify(title, body, opts = {}) {
142
157
  _notifPermAsked = true;
143
158
  try { perm = await Notification.requestPermission(); } catch {}
144
159
  }
145
- if (perm !== 'granted') return null;
160
+ if (perm !== 'granted') {
161
+ // Не вышло — fallback на toast (юзер хоть так увидит).
162
+ if (typeof showToast === 'function') showToast('🔔 ' + title + (body ? ': ' + body : ''), 'info');
163
+ return null;
164
+ }
146
165
  try {
147
166
  const n = new Notification(title, {
148
167
  body: body || '',
@@ -160,26 +179,30 @@ async function systemNotify(title, body, opts = {}) {
160
179
  window.systemNotify = systemNotify;
161
180
 
162
181
  // Глобальный toast для информативных уведомлений (генерация завершилась,
163
- // resume сработал, ...). Стек справа сверху, auto-dismiss через 5s.
182
+ // resume сработал, ...). Стек справа сверху. Auto-dismiss:
183
+ // - ok/info — 10s (юзер должен заметить успех)
184
+ // - error — 30s (важно, не пропустить)
164
185
  function showToast(text, kind) {
165
186
  let host = document.getElementById('toastHost');
166
187
  if (!host) {
167
188
  host = document.createElement('div');
168
189
  host.id = 'toastHost';
169
- host.style.cssText = 'position:fixed; right:16px; top:64px; z-index:9999; display:flex; flex-direction:column; gap:6px; pointer-events:none;';
190
+ host.style.cssText = 'position:fixed; right:16px; top:64px; z-index:9999; display:flex; flex-direction:column; gap:8px; pointer-events:none; max-width:340px;';
170
191
  document.body.appendChild(host);
171
192
  }
172
193
  const t = document.createElement('div');
173
194
  const colors = {
174
- ok: 'background:#1a3a1a; border:1px solid #2a6a3a; color:#9efa9e;',
175
- error: 'background:#3a1a1a; border:1px solid #8a2a2a; color:#fa9e9e;',
176
- info: 'background:#1a2a3a; border:1px solid #2a4a6a; color:#9ecdfa;',
195
+ ok: 'background:#1f4a1f; border:1px solid #4cc44c; color:#dcffdc;',
196
+ error: 'background:#4a1f1f; border:1px solid #c44c4c; color:#ffdcdc;',
197
+ info: 'background:#1f2f4a; border:1px solid #4c7cc4; color:#dceaff;',
177
198
  };
178
- t.style.cssText = `${colors[kind] || colors.info} padding:8px 14px; border-radius:6px; font-size:12px; pointer-events:auto; box-shadow:0 4px 16px rgba(0,0,0,0.4); animation:toastIn 0.18s ease-out;`;
199
+ t.style.cssText = `${colors[kind] || colors.info} padding:10px 14px; border-radius:8px; font-size:13px; font-weight:500; pointer-events:auto; box-shadow:0 6px 20px rgba(0,0,0,0.5); cursor:pointer; line-height:1.4; word-break:break-word;`;
179
200
  t.textContent = text;
201
+ t.title = 'Кликни чтобы закрыть';
180
202
  t.addEventListener('click', () => t.remove());
181
203
  host.appendChild(t);
182
- setTimeout(() => t.remove(), 5000);
204
+ const ttl = kind === 'error' ? 30000 : 10000;
205
+ setTimeout(() => t.remove(), ttl);
183
206
  return t;
184
207
  }
185
208
  window.showToast = showToast;
@@ -497,9 +497,11 @@
497
497
  обложка заполняет всю карту (вертикальная картинка кроп'ится по верху/
498
498
  низу через object-fit:cover, не растягивает карту бесконечно вверх).
499
499
  Meta (название + дата) — absolute поверх обложки внизу с тёмным
500
- градиентом, читается на ЛЮБОЙ обложке. */
500
+ градиентом, читается на ЛЮБОЙ обложке.
501
+ 4:3 (а не 16:9) — карты выше, лучше выглядят на стандартных и
502
+ вертикальных обложках, занимают меньше горизонтального места. */
501
503
  .welcome-card {
502
- aspect-ratio: 16 / 9;
504
+ aspect-ratio: 4 / 3;
503
505
  overflow: hidden;
504
506
  }
505
507
  .welcome-card-thumb {