kingkont 0.18.1 → 0.18.3

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/providers.js CHANGED
@@ -515,7 +515,20 @@ async function generateText({ prompt, messages: msgsIn, model = 'anthropic/claud
515
515
  });
516
516
  const text = await r.text();
517
517
  let data; try { data = JSON.parse(text); } catch { data = { raw: text }; }
518
- if (!r.ok) throw new Error(data?.error?.message || data?.raw || `HTTP ${r.status}`);
518
+ if (!r.ok) {
519
+ // OpenRouter заворачивает upstream-ошибки в {error:{message, metadata:{provider_name, raw}}}.
520
+ // Без metadata.raw юзер видит generic «Provider returned error» — бесполезно.
521
+ const baseMsg = data?.error?.message || data?.raw || `HTTP ${r.status}`;
522
+ const meta = data?.error?.metadata || {};
523
+ const provName = meta.provider_name ? ` [${meta.provider_name}]` : '';
524
+ let extra = '';
525
+ if (meta.raw) {
526
+ const rawStr = typeof meta.raw === 'string' ? meta.raw : JSON.stringify(meta.raw);
527
+ extra = ` — ${rawStr.slice(0, 400)}`;
528
+ }
529
+ console.error('[OpenRouter error]', JSON.stringify(data, null, 2).slice(0, 2000));
530
+ throw new Error(`${baseMsg}${provName}${extra}`);
531
+ }
519
532
  return {
520
533
  text: data?.choices?.[0]?.message?.content || '',
521
534
  model: data?.model || model,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kingkont",
3
- "version": "0.18.1",
3
+ "version": "0.18.3",
4
4
  "description": "KingKont · Chatium — нод-редактор сцен с AI-генерацией (картинки/видео/голос/SFX/музыка/текст)",
5
5
  "main": "main.js",
6
6
  "bin": {
@@ -53,6 +53,24 @@
53
53
  const i = n.lastIndexOf('/');
54
54
  return i < 0 ? { dir: '', name: n } : { dir: n.slice(0, i), name: n.slice(i + 1) };
55
55
  }
56
+ // MIME-detection по расширению. Нужен в getFile() — нативный FSAH ставит
57
+ // type из расширения, наш shim строил Blob без type → readAsDataURL давал
58
+ // битый `data:base64,...` → Anthropic/OpenRouter reject image_url.
59
+ const _MIME_MAP = {
60
+ jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp',
61
+ gif: 'image/gif', avif: 'image/avif', heic: 'image/heic', heif: 'image/heif',
62
+ bmp: 'image/bmp', svg: 'image/svg+xml',
63
+ mp4: 'video/mp4', mov: 'video/quicktime', webm: 'video/webm', mkv: 'video/x-matroska',
64
+ mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg', m4a: 'audio/mp4',
65
+ flac: 'audio/flac', aac: 'audio/aac',
66
+ json: 'application/json', md: 'text/markdown', txt: 'text/plain',
67
+ pdf: 'application/pdf',
68
+ };
69
+ function _mimeFromName(name) {
70
+ const m = String(name || '').match(/\.([a-zA-Z0-9]+)$/);
71
+ if (!m) return '';
72
+ return _MIME_MAP[m[1].toLowerCase()] || '';
73
+ }
56
74
 
57
75
  // === Backend factories. =====================================================
58
76
  // Возвращает объект с минимальным API над хранилищем (диск или память).
@@ -209,8 +227,11 @@
209
227
  const buf = await backend.read(relPath);
210
228
  const u8 = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
211
229
  const stat = await backend.stat(relPath);
212
- // Имитация Web File: (.name, .size, .type, .arrayBuffer(), .text(), .stream()).
213
- const blob = new Blob([u8]);
230
+ // КРИТИЧНО: Blob БЕЗ type file.type='' FileReader.readAsDataURL
231
+ // даёт `data:base64,...` без MIME → Anthropic/OpenRouter reject
232
+ // image_url. Нативный FSAH ставит type из расширения, мимикрируем.
233
+ const type = _mimeFromName(_name);
234
+ const blob = new Blob([u8], type ? { type } : undefined);
214
235
  return Object.assign(blob, {
215
236
  name: _name,
216
237
  lastModified: stat?.mtimeMs ?? Date.now(),
@@ -492,6 +492,20 @@ $('textGenSubmit').addEventListener('click', async () => {
492
492
  runTextJob(node, resolvedPrompt, model, state.currentBoard.handle, state.currentBoard.key, imageRefs);
493
493
  });
494
494
 
495
+ // Лёгкий fallback MIME-detector по расширению — на случай когда
496
+ // Blob-обёртка пришла без `type` (некоторые пути могут потерять).
497
+ // Anthropic/OpenRouter rejects data URL без MIME → обязательный fix.
498
+ function _mimeFromFilename(name) {
499
+ const m = String(name || '').match(/\.([a-zA-Z0-9]+)$/);
500
+ if (!m) return 'image/jpeg'; // дефолт для картинок без расширения
501
+ const ext = m[1].toLowerCase();
502
+ return ({
503
+ jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png',
504
+ webp: 'image/webp', gif: 'image/gif', avif: 'image/avif',
505
+ heic: 'image/heic', bmp: 'image/bmp',
506
+ })[ext] || 'image/jpeg';
507
+ }
508
+
495
509
  async function _imageRefToDataUrl(ref) {
496
510
  // Возвращает {url, size, mime} или {error}. Раньше тихо возвращал null
497
511
  // на любую ошибку → дебаг был невозможен (юзер видел «картинка не
@@ -503,13 +517,34 @@ async function _imageRefToDataUrl(ref) {
503
517
  }
504
518
  const fh = await resolveBoardFile(ref.boardHandle, ref.file);
505
519
  const file = await fh.getFile();
506
- const url = await new Promise((res, rej) => {
507
- const r = new FileReader();
508
- r.onload = () => res(r.result);
509
- r.onerror = () => rej(r.error);
510
- r.readAsDataURL(file);
511
- });
512
- return { url, size: file.size, mime: file.type || 'image/*' };
520
+ // Гарантируем правильный MIME иначе data URL получается как
521
+ // `data:base64,...` (без MIME) → Anthropic возвращает 502 «Provider
522
+ // returned error» через OpenRouter. Bug был в cloudFs shim'е (Blob
523
+ // без type), но защищаемся здесь тоже на случай других source'ов.
524
+ const mime = file.type && file.type !== 'application/octet-stream'
525
+ ? file.type
526
+ : _mimeFromFilename(ref.file);
527
+ let url;
528
+ if (file.type === mime) {
529
+ // type уже правильный — FileReader даст правильный data URL.
530
+ url = await new Promise((res, rej) => {
531
+ const r = new FileReader();
532
+ r.onload = () => res(r.result);
533
+ r.onerror = () => rej(r.error);
534
+ r.readAsDataURL(file);
535
+ });
536
+ } else {
537
+ // type пустой/неправильный — пересобираем Blob с явным type.
538
+ const buf = await file.arrayBuffer();
539
+ const blob = new Blob([buf], { type: mime });
540
+ url = await new Promise((res, rej) => {
541
+ const r = new FileReader();
542
+ r.onload = () => res(r.result);
543
+ r.onerror = () => rej(r.error);
544
+ r.readAsDataURL(blob);
545
+ });
546
+ }
547
+ return { url, size: file.size, mime };
513
548
  } catch (e) {
514
549
  return { error: e?.message || String(e) };
515
550
  }
@@ -54,13 +54,19 @@
54
54
  $('notifyClear').addEventListener('click', () => { events.length = 0; unread = 0; render(); });
55
55
  }
56
56
 
57
- function setOpen(open) {
57
+ // openMode: 'manual' (юзер сам нажал на 🔔 — НЕ автозакрываем),
58
+ // 'auto' (открыли по событию — закроется через таймер),
59
+ // null (закрыто).
60
+ let openMode = null;
61
+ function setOpen(open, mode) {
58
62
  _ensureUI();
59
63
  panelOpen = open;
64
+ openMode = open ? (mode || 'manual') : null;
60
65
  $('notifyPanel').style.display = open ? 'flex' : 'none';
61
66
  if (open) { unread = 0; updateBadge(); render(); }
62
67
  }
63
- function toggle() { setOpen(!panelOpen); }
68
+ function toggle() { setOpen(!panelOpen, 'manual'); }
69
+ function isManuallyOpen() { return panelOpen && openMode === 'manual'; }
64
70
 
65
71
  function updateBadge() {
66
72
  const b = $('notifyBadge');
@@ -158,6 +164,22 @@
158
164
  } else {
159
165
  render();
160
166
  }
167
+ // Авто-открытие панели на 3.5s — юзер видит событие сразу, потом
168
+ // панель прячется. Если юзер сам открыл — не трогаем (проверка в
169
+ // openAuto). Каждое новое событие сбрасывает таймер.
170
+ _scheduleAutoFlash();
171
+ }
172
+ // Auto-flash: открыть в auto-режиме и закрыть через таймер. Срабатывает
173
+ // на КАЖДОЕ addEvent. Если юзер уже manual'но открыл — открытие no-op'ится.
174
+ let _autoFlashT = null;
175
+ function _scheduleAutoFlash() {
176
+ if (panelOpen && openMode === 'manual') return; // юзер сам открыл — не лезем
177
+ if (!panelOpen) setOpen(true, 'auto');
178
+ if (_autoFlashT) clearTimeout(_autoFlashT);
179
+ _autoFlashT = setTimeout(() => {
180
+ _autoFlashT = null;
181
+ if (openMode === 'auto') setOpen(false);
182
+ }, 3500);
161
183
  }
162
184
 
163
185
  // Navigate from notification to scene/node.
@@ -224,20 +246,30 @@
224
246
  setTimeout(() => { el.style.boxShadow = ''; }, 2500);
225
247
  }, 200);
226
248
  }
227
- window.notifyPanel = { open: () => setOpen(true), close: () => setOpen(false), toggle, addEvent, render };
249
+ // openAuto для showToast: открывает в режиме auto (закроется по таймеру
250
+ // если showToast.close('auto') не отменён). open() без аргумента — manual.
251
+ window.notifyPanel = {
252
+ open: (mode) => setOpen(true, mode || 'manual'),
253
+ openAuto: () => setOpen(true, 'auto'),
254
+ closeAuto: () => { if (openMode === 'auto') setOpen(false); },
255
+ close: () => setOpen(false),
256
+ toggle, addEvent, render,
257
+ isManuallyOpen,
258
+ };
228
259
 
229
- // Init: ensure UI exists ASAP, hook into showToast и WS-events.
260
+ // Init: ensure UI exists ASAP, drain pending toast queue, subscribe WS.
230
261
  document.addEventListener('DOMContentLoaded', () => {
231
262
  _ensureUI();
232
- // Wrap showToast чтобы дублировать в панель + forward target для
233
- // click-навигации (showToast(text, kind, {target: {...}})).
234
- if (typeof window.showToast === 'function') {
235
- const orig = window.showToast;
236
- window.showToast = function (text, kind, opts) {
237
- try { addEvent({ kind: kind || 'info', text, target: opts?.target || null }); } catch {}
238
- return orig.apply(this, arguments);
239
- };
240
- }
263
+ // Дренируем showToast которые вызвались до загрузки notifyPanel.
264
+ // (state.js:showToast копит в __pendingToastQueue если addEvent
265
+ // ещё не доступен.)
266
+ try {
267
+ const q = window.__pendingToastQueue;
268
+ if (Array.isArray(q)) {
269
+ for (const ev of q) addEvent(ev);
270
+ q.length = 0;
271
+ }
272
+ } catch {}
241
273
  // WS-канал jobs:all для start/end/done/failed events. Connect к /ws.
242
274
  function _connect() {
243
275
  let ws;
package/renderer/state.js CHANGED
@@ -177,8 +177,10 @@ async function systemNotify(title, body, opts = {}) {
177
177
  try { perm = await Notification.requestPermission(); } catch {}
178
178
  }
179
179
  if (perm !== 'granted') {
180
- // Не вышлоfallback на toast (юзер хоть так увидит).
181
- if (typeof showToast === 'function') showToast('🔔 ' + title + (body ? ': ' + body : ''), 'info');
180
+ // Без OS-нотификациисобытие УЖЕ добавлено в notifyPanel через
181
+ // showToast-wrapper (single-source-of-truth). Раньше тут был fallback
182
+ // на showToast → дубль события с префиксом «🔔 KingKont:» БЕЗ target,
183
+ // поэтому без клика. Теперь молча пропускаем.
182
184
  return null;
183
185
  }
184
186
  try {
@@ -197,36 +199,27 @@ async function systemNotify(title, body, opts = {}) {
197
199
  }
198
200
  window.systemNotify = systemNotify;
199
201
 
200
- // Глобальный toast для информативных уведомлений (генерация завершилась,
201
- // resume сработал, ...). Стек справа сверху. Auto-dismiss:
202
- // - ok/info 10s (юзер должен заметить успех)
203
- // - error — 30s (важно, не пропустить)
202
+ // Глобальная сигналка событий. Раньше показывался bottom-left toast,
203
+ // но дубль с notifyPanel'ом юзера утомил («toast не нужен»). Теперь:
204
+ // - событие добавляется в notifyPanel (через wrapper в notifyPanel.js)
205
+ // - панель открывается АВТОМАТИЧЕСКИ ненадолго (3s) юзер видит
206
+ // событие сразу, потом панель скрывается, бейдж остаётся.
207
+ // Возвращаем фейковый «токен» для совместимости с местами которые ждут
208
+ // результат showToast (там ничего с ним не делают, но на всякий случай).
204
209
  function showToast(text, kind, opts) {
205
- // opts.target: {projectKey, boardKey, nodeId} toast становится кликабельным.
206
- let host = document.getElementById('toastHost');
207
- if (!host) {
208
- host = document.createElement('div');
209
- host.id = 'toastHost';
210
- // Bottom-left стек — рядом с 🔔 кнопкой нотификаций. Новые toast'ы
211
- // появляются СНИЗУ (flex-direction: column-reverse), так что они
212
- // «всплывают» вверх от кнопки.
213
- host.style.cssText = 'position:fixed; left:52px; bottom:12px; z-index:9999; display:flex; flex-direction:column-reverse; gap:8px; pointer-events:none; max-width:340px;';
214
- document.body.appendChild(host);
215
- }
216
- const t = document.createElement('div');
217
- const colors = {
218
- ok: 'background:#1f4a1f; border:1px solid #4cc44c; color:#dcffdc;',
219
- error: 'background:#4a1f1f; border:1px solid #c44c4c; color:#ffdcdc;',
220
- info: 'background:#1f2f4a; border:1px solid #4c7cc4; color:#dceaff;',
221
- };
222
- 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;`;
223
- t.textContent = text;
224
- t.title = 'Кликни чтобы закрыть';
225
- t.addEventListener('click', () => t.remove());
226
- host.appendChild(t);
227
- const ttl = kind === 'error' ? 30000 : 10000;
228
- setTimeout(() => t.remove(), ttl);
229
- return t;
210
+ // Bottom-left toast убрансобытие напрямую идёт в notifyPanel,
211
+ // панель сама подсветится (см. _scheduleAutoFlash в notifyPanel.js).
212
+ // Если notifyPanel ещё не загружен (DOM-readiness race) — копим
213
+ // в очередь, она дренируется при создании панели.
214
+ try {
215
+ if (window.notifyPanel?.addEvent) {
216
+ window.notifyPanel.addEvent({ kind: kind || 'info', text, target: opts?.target || null });
217
+ } else {
218
+ (window.__pendingToastQueue = window.__pendingToastQueue || [])
219
+ .push({ kind: kind || 'info', text, target: opts?.target || null });
220
+ }
221
+ } catch {}
222
+ return { _stub: true };
230
223
  }
231
224
  window.showToast = showToast;
232
225
 
@@ -669,10 +662,14 @@ async function plannedProvider(kind) {
669
662
  let s;
670
663
  try { s = await window.appSettings.get(); } catch { s = {}; }
671
664
  const hasChatium = !!(s.useChatium && s.chatium?.token && s.chatium?.base);
665
+ const hasOpenrouter = !!(s.useOpenrouter && s.openrouterKey);
672
666
  switch (kind) {
673
667
  case 'text':
668
+ // Зеркалит логику в lib/providers.js:generateText — directOpenrouter
669
+ // имеет приоритет над Chatium (юзер явно включил → значит хочет
670
+ // не тратить kingkont-кредиты).
671
+ if (hasOpenrouter) return 'openrouter';
674
672
  if (hasChatium) return 'kingkont';
675
- if (s.useOpenrouter === true) return 'openrouter';
676
673
  return 'none';
677
674
  case 'audio': case 'tts': case 'sfx': case 'music':
678
675
  // Если ElevenLabs включён С ключом — приоритет прямого ElevenLabs
@@ -124,8 +124,11 @@
124
124
  display: flex; flex-direction: column; gap: 6px;
125
125
  font-size: 11px; color: #777;
126
126
  }
127
- .sidebar-footer .hint { color: #777; font-size: 11px; line-height: 1.4; }
128
- .sidebar-footer .jobs-info { color: #aaccdd; font-size: 11px; }
127
+ /* «В фоне: N» и hint «Перетаскивай файлы…» отступаем влево на ширину
128
+ 🔔-кнопки (32px + 12px gap слева от sidebar = 44px), чтобы кнопка
129
+ событий (position:fixed; left:12px; bottom:12px) их не перекрывала. */
130
+ .sidebar-footer .hint { color: #777; font-size: 11px; line-height: 1.4; padding-left: 44px; }
131
+ .sidebar-footer .jobs-info { color: #aaccdd; font-size: 11px; padding-left: 44px; }
129
132
  .sidebar-footer .balance-info {
130
133
  display: flex; align-items: center; gap: 6px; font-size: 11px;
131
134
  color: #c4c4c4; padding: 4px 8px; background: #2a2a2a;