kingkont 0.18.0 → 0.18.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/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.0",
3
+ "version": "0.18.2",
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(),
@@ -475,7 +475,13 @@ $('textGenSubmit').addEventListener('click', async () => {
475
475
  type: 'text', file: `texts/${mdName}`, text: '',
476
476
  x: spot.x, y: spot.y,
477
477
  status: 'generating',
478
- generated: { kind: 'text', rawPrompt, prompt: resolvedPrompt, model, state: 'submitting' },
478
+ generated: {
479
+ kind: 'text', rawPrompt, prompt: resolvedPrompt, model, state: 'submitting',
480
+ // refs — для resume после reload. boardHandle не сериализуется,
481
+ // на resume re-resolve через state.currentBoard.handle (тот же
482
+ // board — мы только что в нём создали ноду).
483
+ refs: imageRefs.map(r => ({ name: r.name, key: r.key, type: r.type, file: r.file })),
484
+ },
479
485
  };
480
486
  state.currentBoard.metadata.nodes.push(node);
481
487
  canvas.appendChild(await createNodeEl(node));
@@ -486,31 +492,93 @@ $('textGenSubmit').addEventListener('click', async () => {
486
492
  runTextJob(node, resolvedPrompt, model, state.currentBoard.handle, state.currentBoard.key, imageRefs);
487
493
  });
488
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
+
489
509
  async function _imageRefToDataUrl(ref) {
510
+ // Возвращает {url, size, mime} или {error}. Раньше тихо возвращал null
511
+ // на любую ошибку → дебаг был невозможен (юзер видел «картинка не
512
+ // подаётся», но в логах ничего).
490
513
  try {
514
+ if (!ref || !ref.file) return { error: 'no file in ref' };
515
+ if (!ref.boardHandle || typeof ref.boardHandle.getDirectoryHandle !== 'function') {
516
+ return { error: 'no boardHandle in ref' };
517
+ }
491
518
  const fh = await resolveBoardFile(ref.boardHandle, ref.file);
492
519
  const file = await fh.getFile();
493
- return await new Promise((res, rej) => {
494
- const r = new FileReader();
495
- r.onload = () => res(r.result);
496
- r.onerror = () => rej(r.error);
497
- r.readAsDataURL(file);
498
- });
499
- } catch (e) { return null; }
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 };
548
+ } catch (e) {
549
+ return { error: e?.message || String(e) };
550
+ }
500
551
  }
501
552
 
502
553
  async function runTextJob(node, prompt, model, boardHandle, bKey, imageRefs) {
503
554
  state.jobs.set(node.id, { boardKey: bKey, kind: 'text', nodeId: node.id });
504
555
  if (typeof updateJobsBadge === 'function') updateJobsBadge();
505
- logJob(node.id, `text-gen start model=${model} prompt="${(prompt||'').slice(0,80)}" images=${imageRefs?.length||0}`);
556
+ logJob(node.id, `text-gen start model=${model} prompt="${(prompt||'').slice(0,200)}" images=${imageRefs?.length||0}`);
557
+ // Дамп refs ДО загрузки — видно сразу что именно собирается прикрутить
558
+ // к запросу (имя, файл, доска). Если refs.length=0 при @-mention в
559
+ // промпте — значит gatherMediaRefs не нашёл (имя не совпало / нода
560
+ // другого типа). Если refs есть, но загрузка падает — следующий лог
561
+ // покажет error по каждому.
562
+ if (imageRefs?.length) {
563
+ logJob(node.id, `image refs (${imageRefs.length}): ${imageRefs.map((r, i) => `[${i+1}] @${r.key||r.name} file=${r.file} board=${r.boardHandle?.name||'—'}`).join(' | ')}`);
564
+ } else {
565
+ logJob(node.id, 'image refs: НЕТ — модель получит только текст. Если ожидалась картинка, проверь @-mention (имя ноды должно совпадать) и тип ноды.');
566
+ }
506
567
  try {
507
568
  const images = [];
508
- for (const ref of (imageRefs || [])) {
509
- const url = await _imageRefToDataUrl(ref);
510
- if (url) images.push({ name: ref.name, url });
569
+ for (let i = 0; i < (imageRefs || []).length; i++) {
570
+ const ref = imageRefs[i];
571
+ const result = await _imageRefToDataUrl(ref);
572
+ if (result.url) {
573
+ images.push({ name: ref.name, url: result.url });
574
+ const sizeKb = (result.size / 1024).toFixed(1);
575
+ logJob(node.id, ` ✓ image [${i+1}] @${ref.key||ref.name}: ${result.mime}, ${sizeKb}KB → [image ${images.length}] в промпте`);
576
+ } else {
577
+ logJob(node.id, ` ✗ image [${i+1}] @${ref.key||ref.name} НЕ ЗАГРУЖЕНА: ${result.error || 'unknown'} (file=${ref.file})`);
578
+ }
511
579
  }
512
580
  const provider = await plannedProvider('text');
513
- logJob(node.id, `→ POST /api/text → ${provider} (model=${model})`);
581
+ logJob(node.id, `→ POST /api/text → ${provider} (model=${model}, images.length=${images.length}, prompt.length=${(prompt||'').length}ch)`);
514
582
  const r = await fetch('/api/text', {
515
583
  method: 'POST',
516
584
  headers: { 'Content-Type': 'application/json' },
@@ -1923,7 +1991,12 @@ async function resumeJob(node, bKey, boardHandle) {
1923
1991
  await runTTSJob(node, node.generated.prompt, boardHandle, bKey, node.generated.voiceId);
1924
1992
  }
1925
1993
  } else if (kind === 'text') {
1926
- const imageRefs = (node.generated.refs || []).filter(r => r.type === 'image' && r.file);
1994
+ // Re-attach boardHandle к refs JSON не сохраняет JS-handle.
1995
+ // Все refs изначально с одной (текущей) доски (см. textGenSubmit),
1996
+ // так что подставляем boardHandle resume-доски.
1997
+ const imageRefs = (node.generated.refs || [])
1998
+ .filter(r => r.type === 'image' && r.file)
1999
+ .map(r => ({ ...r, boardHandle }));
1927
2000
  const model = node.generated.model || node.generated.modelKey || 'anthropic/claude-sonnet-4';
1928
2001
  await runTextJob(node, node.generated.prompt, model, boardHandle, bKey, imageRefs);
1929
2002
  } else {
@@ -125,9 +125,32 @@
125
125
  }
126
126
 
127
127
  // Public API.
128
+ // Дедуп: gen-completion идёт двумя путями (local pollJob → showToast,
129
+ // и server _startPoller → WS jobs:all). Без дедупа юзер видит ДВА
130
+ // одинаковых события с разницей в секунды. Ключ дедупа — kind+nodeId
131
+ // (если есть target.nodeId) или kind+text (fallback). Окно — 30 сек.
132
+ const DEDUP_WINDOW_MS = 30 * 1000;
133
+ function _dedupKey(kind, text, target) {
134
+ if (target?.nodeId && (kind === 'ok' || kind === 'error')) {
135
+ return `${kind}:${target.nodeId}`;
136
+ }
137
+ return null; // не дедупим chat / info-сообщения, у них нет nodeId
138
+ }
128
139
  function addEvent({ kind, text, raw, target }) {
129
140
  if (!text) return;
130
- events.push({ ts: Date.now(), kind: kind || 'info', text: String(text).slice(0, 300), raw, target: target || null });
141
+ const k = kind || 'info';
142
+ const dk = _dedupKey(k, text, target);
143
+ if (dk) {
144
+ const cutoff = Date.now() - DEDUP_WINDOW_MS;
145
+ for (let i = events.length - 1; i >= 0; i--) {
146
+ if (events[i].ts < cutoff) break;
147
+ if (events[i]._dk === dk) {
148
+ // Уже было — игнорируем (первый источник победил).
149
+ return;
150
+ }
151
+ }
152
+ }
153
+ events.push({ ts: Date.now(), kind: k, text: String(text).slice(0, 300), raw, target: target || null, _dk: dk });
131
154
  if (events.length > MAX_EVENTS) events.shift();
132
155
  if (!panelOpen) {
133
156
  unread++;
package/renderer/state.js CHANGED
@@ -669,10 +669,14 @@ async function plannedProvider(kind) {
669
669
  let s;
670
670
  try { s = await window.appSettings.get(); } catch { s = {}; }
671
671
  const hasChatium = !!(s.useChatium && s.chatium?.token && s.chatium?.base);
672
+ const hasOpenrouter = !!(s.useOpenrouter && s.openrouterKey);
672
673
  switch (kind) {
673
674
  case 'text':
675
+ // Зеркалит логику в lib/providers.js:generateText — directOpenrouter
676
+ // имеет приоритет над Chatium (юзер явно включил → значит хочет
677
+ // не тратить kingkont-кредиты).
678
+ if (hasOpenrouter) return 'openrouter';
674
679
  if (hasChatium) return 'kingkont';
675
- if (s.useOpenrouter === true) return 'openrouter';
676
680
  return 'none';
677
681
  case 'audio': case 'tts': case 'sfx': case 'music':
678
682
  // Если ElevenLabs включён С ключом — приоритет прямого ElevenLabs