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 +14 -1
- package/package.json +1 -1
- package/renderer/cloudFs.js +23 -2
- package/renderer/generate.js +87 -14
- package/renderer/notifyPanel.js +24 -1
- package/renderer/state.js +5 -1
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)
|
|
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
package/renderer/cloudFs.js
CHANGED
|
@@ -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
|
-
//
|
|
213
|
-
|
|
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(),
|
package/renderer/generate.js
CHANGED
|
@@ -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: {
|
|
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
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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,
|
|
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 (
|
|
509
|
-
const
|
|
510
|
-
|
|
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
|
-
|
|
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 {
|
package/renderer/notifyPanel.js
CHANGED
|
@@ -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
|
-
|
|
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
|