kingkont 0.17.3 → 0.17.5

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/lib/jobsHub.js CHANGED
@@ -32,7 +32,7 @@ function _emit(projectKey, extra) {
32
32
  wsHub.publish('jobs:all', { ...evt, projectKey });
33
33
  }
34
34
 
35
- function start({ projectKey, jobId, kind, name, type, taskId }) {
35
+ function start({ projectKey, jobId, kind, name, type, taskId, boardKey }) {
36
36
  if (!projectKey || !jobId) return;
37
37
  let m = jobsByProject.get(projectKey);
38
38
  if (!m) { m = new Map(); jobsByProject.set(projectKey, m); }
@@ -46,6 +46,7 @@ function start({ projectKey, jobId, kind, name, type, taskId }) {
46
46
  name: name ?? existing.name ?? null,
47
47
  type: type ?? existing.type ?? null,
48
48
  taskId: taskId ?? existing.taskId ?? null,
49
+ boardKey: boardKey ?? existing.boardKey ?? null,
49
50
  startedAt: existing.startedAt || Date.now(),
50
51
  };
51
52
  m.set(jobId, merged);
@@ -93,16 +94,15 @@ function _startPoller({ projectKey, jobId, taskId, kind }) {
93
94
  if (r.status === 'done') {
94
95
  console.log('[jobsHub] DONE', jobId, '→', r.url);
95
96
  _stopPoller(jobId);
96
- // Push WS event с completion-info. Renderer (если жив) скачает
97
- // через /api/proxy и запишет в FSAH. Если renderer закрыт — event
98
- // пропадёт; при возврате scanAllBoardsForPendingJobs восстановит
99
- // (там status='generating' + taskId в scene.json).
100
- wsHub.publish('jobs:' + projectKey, {
101
- kind: 'done', jobId, taskId, url: r.url, cost: r.cost ?? null, kindOf: kind, provider: r.provider,
102
- });
103
- wsHub.publish('jobs:all', {
104
- kind: 'done', projectKey, jobId, taskId, url: r.url, cost: r.cost ?? null, kindOf: kind, provider: r.provider,
105
- });
97
+ // Извлекаем boardKey + name из jobsByProject для navigation.
98
+ const info = jobsByProject.get(projectKey)?.get(jobId) || {};
99
+ const evt = {
100
+ kind: 'done', jobId, taskId, url: r.url, cost: r.cost ?? null,
101
+ kindOf: kind, provider: r.provider,
102
+ boardKey: info.boardKey || null, nodeName: info.name || null,
103
+ };
104
+ wsHub.publish('jobs:' + projectKey, evt);
105
+ wsHub.publish('jobs:all', { ...evt, projectKey });
106
106
  // Job-end ОТЛОЖЕН — ждём ack от клиента (он скачает файл).
107
107
  // Но если за 60s никто не пришёл — auto-end (чтобы счётчики не зависали).
108
108
  setTimeout(() => {
@@ -110,12 +110,13 @@ function _startPoller({ projectKey, jobId, taskId, kind }) {
110
110
  }, 60000);
111
111
  } else if (r.status === 'error') {
112
112
  _stopPoller(jobId);
113
- wsHub.publish('jobs:' + projectKey, {
113
+ const info = jobsByProject.get(projectKey)?.get(jobId) || {};
114
+ const evt = {
114
115
  kind: 'failed', jobId, taskId, error: r.error, kindOf: kind,
115
- });
116
- wsHub.publish('jobs:all', {
117
- kind: 'failed', projectKey, jobId, taskId, error: r.error, kindOf: kind,
118
- });
116
+ boardKey: info.boardKey || null, nodeName: info.name || null,
117
+ };
118
+ wsHub.publish('jobs:' + projectKey, evt);
119
+ wsHub.publish('jobs:all', { ...evt, projectKey });
119
120
  // На ошибке тоже auto-end через 60s.
120
121
  setTimeout(() => {
121
122
  if (!pollers.has(jobId)) end({ projectKey, jobId });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kingkont",
3
- "version": "0.17.3",
3
+ "version": "0.17.5",
4
4
  "description": "KingKont · Chatium — нод-редактор сцен с AI-генерацией (картинки/видео/голос/SFX/музыка/текст)",
5
5
  "main": "main.js",
6
6
  "bin": {
package/renderer/board.js CHANGED
@@ -454,7 +454,22 @@ function fmtRelativeTime(ts) {
454
454
  return new Date(ts).toLocaleDateString();
455
455
  }
456
456
 
457
+ // Дедуп: на Cmd+R / WS-event burst несколько вызовов могли race'иться
458
+ // (clear grid → await getRecents → append → второй call в это же время →
459
+ // дубли карточек / наезжают). Если уже рендерим — отметим что нужен
460
+ // follow-up render и выйдем; запустим его после текущего.
461
+ let _wrInFlight = false;
462
+ let _wrPending = false;
457
463
  async function renderWelcomeRecents() {
464
+ if (_wrInFlight) { _wrPending = true; return; }
465
+ _wrInFlight = true;
466
+ try { await _renderWelcomeRecentsInner(); }
467
+ finally {
468
+ _wrInFlight = false;
469
+ if (_wrPending) { _wrPending = false; renderWelcomeRecents().catch(() => {}); }
470
+ }
471
+ }
472
+ async function _renderWelcomeRecentsInner() {
458
473
  const grid = $('welcomeRecentGrid');
459
474
  const wrap = $('welcomeRecent');
460
475
  const titleEl = $('welcomeRecentTitle');
@@ -1733,7 +1733,7 @@ async function startGenerationJob(node, kind, prompt, mediaRefs, boardHandle, bK
1733
1733
  state.jobs.set(node.id, job);
1734
1734
  updateJobsBadge();
1735
1735
  // Track в global bg-jobs (для welcome-индикаторов и system-notif при завершении).
1736
- if (typeof bgJobStart === 'function') bgJobStart({ nodeId: node.id, kind, name: node.name, projectKey });
1736
+ if (typeof bgJobStart === 'function') bgJobStart({ nodeId: node.id, kind, name: node.name, projectKey, boardKey: bKey });
1737
1737
  logJob(node.id, `gen start kind=${kind} model=${modelKey || '—'} refs=${mediaRefs?.length || 0} prompt="${(prompt||'').slice(0,200)}"`);
1738
1738
  // Подробный дамп всех refs со всеми полями
1739
1739
  logJob(node.id, `refs dump: ${logSafe((mediaRefs || []).map(r => ({
@@ -21,11 +21,12 @@
21
21
 
22
22
  function _ensureUI() {
23
23
  if ($('notifyBtn')) return;
24
- // Кнопка-колокольчик в верхнем тулбаре справа.
24
+ // Кнопка-колокольчик в нижнем-левом углу — там есть свободное место,
25
+ // не перекрывает sidebar/canvas-toolbar/welcome-status.
25
26
  const btn = document.createElement('button');
26
27
  btn.id = 'notifyBtn';
27
28
  btn.title = 'События (генерации, чат, ошибки)';
28
- btn.style.cssText = 'position:fixed; top:12px; right:24px; z-index:9998; background:rgba(30,30,40,0.85); border:1px solid #444; color:#ccc; font-size:14px; width:32px; height:32px; border-radius:50%; cursor:pointer; backdrop-filter:blur(4px); display:flex; align-items:center; justify-content:center;';
29
+ btn.style.cssText = 'position:fixed; bottom:12px; left:12px; z-index:9998; background:rgba(30,30,40,0.85); border:1px solid #444; color:#ccc; font-size:14px; width:32px; height:32px; border-radius:50%; cursor:pointer; backdrop-filter:blur(4px); display:flex; align-items:center; justify-content:center;';
29
30
  btn.innerHTML = '🔔';
30
31
  document.body.appendChild(btn);
31
32
  btn.addEventListener('click', toggle);
@@ -35,10 +36,10 @@
35
36
  badge.style.cssText = 'position:absolute; top:-4px; right:-4px; background:#e33377; color:#fff; font-size:9px; min-width:14px; height:14px; line-height:14px; text-align:center; border-radius:8px; padding:0 4px; display:none; pointer-events:none;';
36
37
  btn.appendChild(badge);
37
38
 
38
- // Панель.
39
+ // Панель — bottom-left как и кнопка.
39
40
  const panel = document.createElement('div');
40
41
  panel.id = 'notifyPanel';
41
- panel.style.cssText = 'position:fixed; top:50px; right:16px; width:380px; max-height:75vh; background:#1a1a1a; border:1px solid #333; border-radius:8px; z-index:9999; display:none; flex-direction:column; box-shadow:0 8px 32px rgba(0,0,0,0.55); overflow:hidden;';
42
+ panel.style.cssText = 'position:fixed; bottom:50px; left:12px; width:380px; max-height:60vh; background:#1a1a1a; border:1px solid #333; border-radius:8px; z-index:9999; display:none; flex-direction:column; box-shadow:0 8px 32px rgba(0,0,0,0.55); overflow:hidden;';
42
43
  panel.innerHTML = `
43
44
  <div style="padding:10px 12px; border-bottom:1px solid #2a2a2a; display:flex; align-items:center; gap:8px;">
44
45
  <strong style="font-size:13px; color:#ddd;">События</strong>
@@ -87,20 +88,46 @@
87
88
  const c = colors[e.kind] || colors.info;
88
89
  const dt = new Date(e.ts);
89
90
  const time = `${String(dt.getHours()).padStart(2,'0')}:${String(dt.getMinutes()).padStart(2,'0')}:${String(dt.getSeconds()).padStart(2,'0')}`;
90
- row.style.cssText = `padding:8px 10px; border-radius:4px; margin-bottom:4px; background:${c}22; border-left:3px solid ${c}; cursor:default;`;
91
- row.innerHTML = `<div style="display:flex; justify-content:space-between; gap:8px; align-items:flex-start;">
91
+ const clickable = !!e.target;
92
+ row.style.cssText = `position:relative; padding:8px 10px 8px 10px; border-radius:4px; margin-bottom:4px; background:${c}22; border-left:3px solid ${c}; cursor:${clickable ? 'pointer' : 'default'}; transition:background 0.1s;`;
93
+ if (clickable) row.title = 'Перейти к объекту';
94
+ const targetHint = clickable ? `<span style="color:#9ab; font-size:10px; margin-left:6px;">↗</span>` : '';
95
+ row.innerHTML = `<div style="display:flex; justify-content:space-between; gap:8px; align-items:flex-start; padding-right:18px;">
92
96
  <div style="color:#e0e0e0; font-size:12px; line-height:1.4; word-break:break-word; flex:1;"></div>
93
- <span style="color:#777; font-size:10px; font-family:ui-monospace,monospace; flex-shrink:0;">${time}</span>
97
+ <span style="color:#777; font-size:10px; font-family:ui-monospace,monospace; flex-shrink:0;">${time}${targetHint}</span>
94
98
  </div>`;
95
99
  row.querySelector('div > div').textContent = e.text;
100
+ // Кнопка × для удаления конкретного event'a (не блокирует клик по row).
101
+ const delBtn = document.createElement('button');
102
+ delBtn.textContent = '×';
103
+ delBtn.title = 'Удалить';
104
+ delBtn.style.cssText = 'position:absolute; top:2px; right:4px; background:transparent; border:none; color:#888; cursor:pointer; font-size:14px; line-height:1; padding:2px 6px; opacity:0.5; transition:opacity 0.1s;';
105
+ delBtn.addEventListener('mouseenter', () => delBtn.style.opacity = '1');
106
+ delBtn.addEventListener('mouseleave', () => delBtn.style.opacity = '0.5');
107
+ delBtn.addEventListener('click', (ev) => {
108
+ ev.stopPropagation();
109
+ // Find by ts (events shifted by render, индекс i может измениться к click'у).
110
+ const idx = events.findIndex(x => x.ts === e.ts && x.text === e.text);
111
+ if (idx >= 0) events.splice(idx, 1);
112
+ render();
113
+ });
114
+ row.appendChild(delBtn);
115
+ if (clickable) {
116
+ row.addEventListener('mouseenter', () => row.style.background = `${c}44`);
117
+ row.addEventListener('mouseleave', () => row.style.background = `${c}22`);
118
+ row.addEventListener('click', (ev) => {
119
+ if (ev.target === delBtn) return;
120
+ navigateToTarget(e.target);
121
+ });
122
+ }
96
123
  list.appendChild(row);
97
124
  }
98
125
  }
99
126
 
100
127
  // Public API.
101
- function addEvent({ kind, text, raw }) {
128
+ function addEvent({ kind, text, raw, target }) {
102
129
  if (!text) return;
103
- events.push({ ts: Date.now(), kind: kind || 'info', text: String(text).slice(0, 300), raw });
130
+ events.push({ ts: Date.now(), kind: kind || 'info', text: String(text).slice(0, 300), raw, target: target || null });
104
131
  if (events.length > MAX_EVENTS) events.shift();
105
132
  if (!panelOpen) {
106
133
  unread++;
@@ -109,6 +136,71 @@
109
136
  render();
110
137
  }
111
138
  }
139
+
140
+ // Navigate from notification to scene/node.
141
+ // target: {projectKey, boardKey, nodeId?}
142
+ async function navigateToTarget(target) {
143
+ if (!target?.projectKey) return;
144
+ setOpen(false);
145
+ const [pkType, pkId] = String(target.projectKey).split(':', 2);
146
+ const currentKey = state.cloudProjectId ? 'cloud:' + state.cloudProjectId
147
+ : state.filmHandle?.name ? 'folder:' + state.filmHandle.name : null;
148
+ // Если уже в нужном проекте — просто переключаем доску.
149
+ if (currentKey === target.projectKey) {
150
+ if (target.boardKey) await _selectBoardByKey(target.boardKey);
151
+ if (target.nodeId) _highlightNode(target.nodeId);
152
+ return;
153
+ }
154
+ // Иначе открываем проект.
155
+ if (pkType === 'cloud') {
156
+ if (window.cloudProjects?.open) await window.cloudProjects.open(pkId);
157
+ else { alert('Облачный проект недоступен'); return; }
158
+ } else if (pkType === 'folder') {
159
+ // Folder-проект: ищем в recents.
160
+ try {
161
+ const recents = await getRecents();
162
+ const r = recents.find(x => x.name === pkId);
163
+ if (r?.handle) {
164
+ let g = (await r.handle.queryPermission({ mode: 'readwrite' })) === 'granted';
165
+ if (!g) g = (await r.handle.requestPermission({ mode: 'readwrite' })) === 'granted';
166
+ if (g) await openFilm(r.handle);
167
+ } else {
168
+ alert(`Проект «${pkId}» не найден в недавних. Открой его вручную.`);
169
+ return;
170
+ }
171
+ } catch (e) { console.warn('navigate to folder failed:', e); return; }
172
+ }
173
+ // После openFilm/cloudProjects.open — выбираем доску.
174
+ if (target.boardKey) {
175
+ // openFilm вызывает selectBoard через lastBoard, может перебить. Wait чуть.
176
+ setTimeout(() => _selectBoardByKey(target.boardKey).then(() => {
177
+ if (target.nodeId) _highlightNode(target.nodeId);
178
+ }), 600);
179
+ }
180
+ }
181
+ async function _selectBoardByKey(boardKey) {
182
+ if (!state.filmHandle) return;
183
+ // boardKey формат: 'episode/Name' / 'character/Name' / 'location/Name'.
184
+ const [kind, ...nameParts] = String(boardKey).split('/');
185
+ const name = nameParts.join('/');
186
+ if (!kind || !name) return;
187
+ try {
188
+ const list = kind === 'character' ? await listCharacters(state.filmHandle)
189
+ : kind === 'location' ? await listLocations(state.filmHandle)
190
+ : await listEpisodes(state.filmHandle);
191
+ const found = list.find(b => b.name === name);
192
+ if (found) await selectBoard({ kind, ...found });
193
+ } catch (e) { console.warn('select board failed:', e); }
194
+ }
195
+ function _highlightNode(nodeId) {
196
+ setTimeout(() => {
197
+ const el = document.querySelector(`.node[data-id="${nodeId}"]`);
198
+ if (!el) return;
199
+ el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'smooth' });
200
+ el.style.boxShadow = '0 0 0 3px #e33377';
201
+ setTimeout(() => { el.style.boxShadow = ''; }, 2500);
202
+ }, 200);
203
+ }
112
204
  window.notifyPanel = { open: () => setOpen(true), close: () => setOpen(false), toggle, addEvent, render };
113
205
 
114
206
  // Init: ensure UI exists ASAP, hook into showToast и WS-events.
@@ -138,13 +230,23 @@
138
230
  const e = m.event || {};
139
231
  const ch = m.channel || '';
140
232
  if (ch === 'jobs:all') {
233
+ // target позволяет click'нуть и перейти к ноде на доске.
234
+ const target = (e.projectKey && e.boardKey) ? {
235
+ projectKey: e.projectKey, boardKey: e.boardKey, nodeId: e.jobId,
236
+ } : null;
141
237
  if (e.kind === 'done') {
142
- addEvent({ kind: 'ok', text: `✓ ${e.kindOf || 'gen'} готов в ${e.projectKey || ''} (jobId=${(e.jobId||'').slice(0,8)})` });
238
+ const nameLabel = e.nodeName ? `«${e.nodeName}»` : `(jobId=${(e.jobId||'').slice(0,8)})`;
239
+ addEvent({
240
+ kind: 'ok',
241
+ text: `✓ ${e.kindOf || 'gen'} ${nameLabel} готов в ${e.projectKey || ''}`,
242
+ target,
243
+ });
143
244
  } else if (e.kind === 'failed') {
144
- addEvent({ kind: 'error', text: `⚠ ${e.kindOf || 'gen'} провалился: ${(e.error || '').slice(0, 100)}` });
145
- } else if (e.kind === 'changed' && Array.isArray(e.list)) {
146
- // Только при изменении (start/end) добавим краткую инфу.
147
- // Без шума — addEvent только если что-то стало активно или закончилось.
245
+ addEvent({
246
+ kind: 'error',
247
+ text: `⚠ ${e.kindOf || 'gen'} ${e.nodeName ? '«'+e.nodeName+'»' : ''} провалился: ${(e.error || '').slice(0, 100)}`,
248
+ target,
249
+ });
148
250
  }
149
251
  }
150
252
  };
package/renderer/state.js CHANGED
@@ -82,15 +82,16 @@ function _projectKeyForCurrent() {
82
82
  return null;
83
83
  }
84
84
  function bgJobStart(info) {
85
- // info: {nodeId, kind, name?, projectKey?, taskId?}
85
+ // info: {nodeId, kind, name?, projectKey?, taskId?, boardKey?}
86
86
  // taskId важен — server-side poller использует его чтобы опрашивать
87
87
  // провайдера сам (Chatium/KIE) даже когда renderer закрыт.
88
+ // boardKey — нужен для navigation из 🔔 уведомления (jump to scene).
88
89
  const pk = info.projectKey || _projectKeyForCurrent();
89
90
  if (!pk) { console.warn('bgJobStart: no projectKey, skipping'); return; }
90
91
  try {
91
92
  const list = JSON.parse(localStorage.getItem(bgJobsKey(pk)) || '[]');
92
93
  if (!list.some(j => j.nodeId === info.nodeId)) {
93
- list.push({ nodeId: info.nodeId, kind: info.kind, name: info.name || null, startedAt: Date.now() });
94
+ list.push({ nodeId: info.nodeId, kind: info.kind, name: info.name || null, boardKey: info.boardKey || null, startedAt: Date.now() });
94
95
  localStorage.setItem(bgJobsKey(pk), JSON.stringify(list));
95
96
  window.dispatchEvent(new CustomEvent('bgjobs:changed'));
96
97
  }
@@ -103,6 +104,7 @@ function bgJobStart(info) {
103
104
  jobId: info.nodeId,
104
105
  kind: info.kind,
105
106
  name: info.name,
107
+ boardKey: info.boardKey || null,
106
108
  taskId: info.taskId || null, // ← server-side poller цепляется по taskId
107
109
  }),
108
110
  }).catch(() => {});