kingkont 0.17.3 → 0.17.4

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.4",
4
4
  "description": "KingKont · Chatium — нод-редактор сцен с AI-генерацией (картинки/видео/голос/SFX/музыка/текст)",
5
5
  "main": "main.js",
6
6
  "bin": {
@@ -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,28 @@
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
+ const clickable = !!e.target;
92
+ row.style.cssText = `padding: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>` : '';
91
95
  row.innerHTML = `<div style="display:flex; justify-content:space-between; gap:8px; align-items:flex-start;">
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
+ if (clickable) {
101
+ row.addEventListener('mouseenter', () => row.style.background = `${c}44`);
102
+ row.addEventListener('mouseleave', () => row.style.background = `${c}22`);
103
+ row.addEventListener('click', () => navigateToTarget(e.target));
104
+ }
96
105
  list.appendChild(row);
97
106
  }
98
107
  }
99
108
 
100
109
  // Public API.
101
- function addEvent({ kind, text, raw }) {
110
+ function addEvent({ kind, text, raw, target }) {
102
111
  if (!text) return;
103
- events.push({ ts: Date.now(), kind: kind || 'info', text: String(text).slice(0, 300), raw });
112
+ events.push({ ts: Date.now(), kind: kind || 'info', text: String(text).slice(0, 300), raw, target: target || null });
104
113
  if (events.length > MAX_EVENTS) events.shift();
105
114
  if (!panelOpen) {
106
115
  unread++;
@@ -109,6 +118,71 @@
109
118
  render();
110
119
  }
111
120
  }
121
+
122
+ // Navigate from notification to scene/node.
123
+ // target: {projectKey, boardKey, nodeId?}
124
+ async function navigateToTarget(target) {
125
+ if (!target?.projectKey) return;
126
+ setOpen(false);
127
+ const [pkType, pkId] = String(target.projectKey).split(':', 2);
128
+ const currentKey = state.cloudProjectId ? 'cloud:' + state.cloudProjectId
129
+ : state.filmHandle?.name ? 'folder:' + state.filmHandle.name : null;
130
+ // Если уже в нужном проекте — просто переключаем доску.
131
+ if (currentKey === target.projectKey) {
132
+ if (target.boardKey) await _selectBoardByKey(target.boardKey);
133
+ if (target.nodeId) _highlightNode(target.nodeId);
134
+ return;
135
+ }
136
+ // Иначе открываем проект.
137
+ if (pkType === 'cloud') {
138
+ if (window.cloudProjects?.open) await window.cloudProjects.open(pkId);
139
+ else { alert('Облачный проект недоступен'); return; }
140
+ } else if (pkType === 'folder') {
141
+ // Folder-проект: ищем в recents.
142
+ try {
143
+ const recents = await getRecents();
144
+ const r = recents.find(x => x.name === pkId);
145
+ if (r?.handle) {
146
+ let g = (await r.handle.queryPermission({ mode: 'readwrite' })) === 'granted';
147
+ if (!g) g = (await r.handle.requestPermission({ mode: 'readwrite' })) === 'granted';
148
+ if (g) await openFilm(r.handle);
149
+ } else {
150
+ alert(`Проект «${pkId}» не найден в недавних. Открой его вручную.`);
151
+ return;
152
+ }
153
+ } catch (e) { console.warn('navigate to folder failed:', e); return; }
154
+ }
155
+ // После openFilm/cloudProjects.open — выбираем доску.
156
+ if (target.boardKey) {
157
+ // openFilm вызывает selectBoard через lastBoard, может перебить. Wait чуть.
158
+ setTimeout(() => _selectBoardByKey(target.boardKey).then(() => {
159
+ if (target.nodeId) _highlightNode(target.nodeId);
160
+ }), 600);
161
+ }
162
+ }
163
+ async function _selectBoardByKey(boardKey) {
164
+ if (!state.filmHandle) return;
165
+ // boardKey формат: 'episode/Name' / 'character/Name' / 'location/Name'.
166
+ const [kind, ...nameParts] = String(boardKey).split('/');
167
+ const name = nameParts.join('/');
168
+ if (!kind || !name) return;
169
+ try {
170
+ const list = kind === 'character' ? await listCharacters(state.filmHandle)
171
+ : kind === 'location' ? await listLocations(state.filmHandle)
172
+ : await listEpisodes(state.filmHandle);
173
+ const found = list.find(b => b.name === name);
174
+ if (found) await selectBoard({ kind, ...found });
175
+ } catch (e) { console.warn('select board failed:', e); }
176
+ }
177
+ function _highlightNode(nodeId) {
178
+ setTimeout(() => {
179
+ const el = document.querySelector(`.node[data-id="${nodeId}"]`);
180
+ if (!el) return;
181
+ el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'smooth' });
182
+ el.style.boxShadow = '0 0 0 3px #e33377';
183
+ setTimeout(() => { el.style.boxShadow = ''; }, 2500);
184
+ }, 200);
185
+ }
112
186
  window.notifyPanel = { open: () => setOpen(true), close: () => setOpen(false), toggle, addEvent, render };
113
187
 
114
188
  // Init: ensure UI exists ASAP, hook into showToast и WS-events.
@@ -138,13 +212,23 @@
138
212
  const e = m.event || {};
139
213
  const ch = m.channel || '';
140
214
  if (ch === 'jobs:all') {
215
+ // target позволяет click'нуть и перейти к ноде на доске.
216
+ const target = (e.projectKey && e.boardKey) ? {
217
+ projectKey: e.projectKey, boardKey: e.boardKey, nodeId: e.jobId,
218
+ } : null;
141
219
  if (e.kind === 'done') {
142
- addEvent({ kind: 'ok', text: `✓ ${e.kindOf || 'gen'} готов в ${e.projectKey || ''} (jobId=${(e.jobId||'').slice(0,8)})` });
220
+ const nameLabel = e.nodeName ? `«${e.nodeName}»` : `(jobId=${(e.jobId||'').slice(0,8)})`;
221
+ addEvent({
222
+ kind: 'ok',
223
+ text: `✓ ${e.kindOf || 'gen'} ${nameLabel} готов в ${e.projectKey || ''}`,
224
+ target,
225
+ });
143
226
  } 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 только если что-то стало активно или закончилось.
227
+ addEvent({
228
+ kind: 'error',
229
+ text: `⚠ ${e.kindOf || 'gen'} ${e.nodeName ? '«'+e.nodeName+'»' : ''} провалился: ${(e.error || '').slice(0, 100)}`,
230
+ target,
231
+ });
148
232
  }
149
233
  }
150
234
  };
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(() => {});