@kolbo/mcp 1.57.1 → 1.59.0
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/package.json +3 -2
- package/src/apps/index.js +48 -4
- package/src/apps/theme.js +2 -1
- package/src/apps/widgets/generation.js +112 -17
- package/src/auth.js +155 -156
- package/src/tools/_shared.js +23 -7
- package/src/tools/generate.js +68 -9
- package/src/tools/voices.js +19 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolbo/mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.59.0",
|
|
4
4
|
"description": "Kolbo AI MCP Server - Generate images, videos, music, speech, and sound effects from Claude Code",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
"start": "node src/index.js",
|
|
11
11
|
"smoke": "node scripts/smoke.js",
|
|
12
12
|
"check-parity": "node scripts/check-parity.js",
|
|
13
|
-
"prepublishOnly": "node scripts/smoke.js && node scripts/check-parity.js && node scripts/check-widget-fields.js && node scripts/check-skill-tools.js && node scripts/check-install.js",
|
|
13
|
+
"prepublishOnly": "node scripts/smoke.js && node scripts/check-parity.js && node scripts/check-widget-fields.js && node scripts/check-widget-render.js && node scripts/check-skill-tools.js && node scripts/check-install.js",
|
|
14
14
|
"check-widget-fields": "node scripts/check-widget-fields.js",
|
|
15
|
+
"check-widget-render": "node scripts/check-widget-render.js",
|
|
15
16
|
"check-skill-tools": "node scripts/check-skill-tools.js",
|
|
16
17
|
"check-install": "node scripts/check-install.js"
|
|
17
18
|
},
|
package/src/apps/index.js
CHANGED
|
@@ -203,7 +203,10 @@ async function modelInfoMap(client) {
|
|
|
203
203
|
// Real p75 wall-clock estimate mined from production creditUsages —
|
|
204
204
|
// the same source the in-app countdowns use. No estimate → no ETA shown.
|
|
205
205
|
const eta = Number(m.estimatedDurationSeconds || m.estimated_duration_seconds) || null;
|
|
206
|
-
|
|
206
|
+
// `name` is the CLEAN display name ("Google TTS"); it is what widgets show.
|
|
207
|
+
// Without it the model chip fell back to whatever raw string the caller or
|
|
208
|
+
// the status endpoint supplied ("google_tts", "fal-ai/bytedance/omnihuman/v1.5").
|
|
209
|
+
const info = { icon, eta, id: m.identifier || null, name: m.name || null };
|
|
207
210
|
// Display names collide across variants ("Nano Banana 2" names both the
|
|
208
211
|
// t2i model and its editing sibling) — on collision keep the model with
|
|
209
212
|
// the SHORTEST identifier (the base model), deterministically.
|
|
@@ -224,11 +227,51 @@ async function modelInfoMap(client) {
|
|
|
224
227
|
return byKey;
|
|
225
228
|
}
|
|
226
229
|
|
|
227
|
-
/** Resolve one model's { icon, eta }; missing →
|
|
230
|
+
/** Resolve one model's { icon, eta, name }; missing → all null. */
|
|
228
231
|
async function modelInfo(client, modelName) {
|
|
229
|
-
if (!modelName) return { icon: null, eta: null };
|
|
232
|
+
if (!modelName) return { icon: null, eta: null, name: null };
|
|
230
233
|
const map = await modelInfoMap(client);
|
|
231
|
-
return map.get(String(modelName).toLowerCase()) || { icon: null, eta: null };
|
|
234
|
+
return map.get(String(modelName).toLowerCase()) || { icon: null, eta: null, name: null };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/* ------------------------------------------------------------------ */
|
|
238
|
+
/* Voice lookup (voice_id / display name → { id, name, thumbnail }) */
|
|
239
|
+
/* ------------------------------------------------------------------ */
|
|
240
|
+
|
|
241
|
+
// Same shape and TTL as the model catalog above: the voice catalog is stable,
|
|
242
|
+
// and a per-generation /v1/voices round trip would be paid on every speech card.
|
|
243
|
+
const voiceCache = new Map(); // apiBase → { at, byKey }
|
|
244
|
+
|
|
245
|
+
async function voiceInfoMap(client) {
|
|
246
|
+
const cacheKey = client.apiBase || 'default';
|
|
247
|
+
const hit = voiceCache.get(cacheKey);
|
|
248
|
+
if (hit && Date.now() - hit.at < ICON_TTL_MS) return hit.byKey;
|
|
249
|
+
const byKey = new Map();
|
|
250
|
+
try {
|
|
251
|
+
const res = await client.request('GET', '/v1/voices');
|
|
252
|
+
for (const v of res?.voices || []) {
|
|
253
|
+
if (!v || !v.voice_id) continue;
|
|
254
|
+
// thumbnail/preview come from the catalog record — NEVER templated from
|
|
255
|
+
// the id, so a change to the CDN path scheme cannot silently 404 the card.
|
|
256
|
+
const info = { id: v.voice_id, name: v.name || v.voice_id, thumbnail: v.thumbnail || null };
|
|
257
|
+
byKey.set(String(v.voice_id).toLowerCase(), info);
|
|
258
|
+
// Display names are not unique across locales (the same Gemini voice is
|
|
259
|
+
// catalogued per language). First one wins; the id lookup above is exact.
|
|
260
|
+
const nameKey = String(info.name).toLowerCase();
|
|
261
|
+
if (v.name && !byKey.has(nameKey)) byKey.set(nameKey, info);
|
|
262
|
+
}
|
|
263
|
+
} catch (_) {
|
|
264
|
+
/* fail open — cards fall back to the raw voice string */
|
|
265
|
+
}
|
|
266
|
+
if (byKey.size > 0) voiceCache.set(cacheKey, { at: Date.now(), byKey });
|
|
267
|
+
return byKey;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Resolve a voice id OR display name to { id, name, thumbnail }; null if unknown. */
|
|
271
|
+
async function voiceInfo(client, voice) {
|
|
272
|
+
if (!voice) return null;
|
|
273
|
+
const map = await voiceInfoMap(client);
|
|
274
|
+
return map.get(String(voice).toLowerCase().trim()) || null;
|
|
232
275
|
}
|
|
233
276
|
|
|
234
277
|
/** Back-compat shim (used by uiCompleted and older call sites). */
|
|
@@ -341,6 +384,7 @@ module.exports = {
|
|
|
341
384
|
modelIcon,
|
|
342
385
|
modelInfo,
|
|
343
386
|
modelInfoMap,
|
|
387
|
+
voiceInfo,
|
|
344
388
|
canonicalModelId,
|
|
345
389
|
resolveAvatarUrl,
|
|
346
390
|
widgetHtml, // exported for smoke tests
|
package/src/apps/theme.js
CHANGED
|
@@ -111,6 +111,7 @@ body {
|
|
|
111
111
|
color: #fff; font-size: 9px; font-weight: 700; display: inline-flex;
|
|
112
112
|
align-items: center; justify-content: center; font-family: 'Inter', sans-serif;
|
|
113
113
|
}
|
|
114
|
+
.k-chip img.k-voice-thumb { width: 18px; height: 18px; border-radius: 999px; margin-left: -3px; }
|
|
114
115
|
.k-ref-thumb { width: 26px; height: 26px; border-radius: 6px; object-fit: cover; border: 1px solid var(--border-strong); }
|
|
115
116
|
|
|
116
117
|
/* ---- Generating state ---- */
|
|
@@ -180,7 +181,7 @@ body {
|
|
|
180
181
|
display: inline-flex; align-items: center; justify-content: center;
|
|
181
182
|
cursor: pointer; opacity: 0; transition: opacity 150ms var(--smooth), background 150ms var(--smooth);
|
|
182
183
|
}
|
|
183
|
-
.k-media:hover .k-dl, .k-viewer:hover .k-dl { opacity: 1; }
|
|
184
|
+
.k-media:hover .k-dl, .k-viewer:hover .k-dl, .k-skel:hover .k-dl { opacity: 1; }
|
|
184
185
|
.k-dl:hover { background: var(--brand); border-color: var(--brand); }
|
|
185
186
|
.k-viewer { position: relative; }
|
|
186
187
|
|
|
@@ -14,7 +14,8 @@ const { widgetPage } = require('../html');
|
|
|
14
14
|
* status_args, // extra args for the poll tool (optional)
|
|
15
15
|
|
|
16
16
|
* model, model_icon, prompt, count,
|
|
17
|
-
* settings: { duration, resolution, aspect_ratio, audio, voice, mode
|
|
17
|
+
* settings: { duration, resolution, aspect_ratio, quality, audio, voice, mode,
|
|
18
|
+
* enhance_prompt, web_search, visual_dna, moodboard, preset, cinematic },
|
|
18
19
|
* reference_image, // thumbnail URL (optional)
|
|
19
20
|
* urls, thumbnail_url, title, duration, credits_used,
|
|
20
21
|
* tracks: [{ title, duration, thumbnail_url, model }], // optional audio metadata by URL index
|
|
@@ -104,18 +105,37 @@ function boot(sc) {
|
|
|
104
105
|
window.kolbo.notifySize();
|
|
105
106
|
}
|
|
106
107
|
|
|
108
|
+
// model_name / voice_name are the CLEAN catalog names, resolved server-side
|
|
109
|
+
// (src/tools/_shared.js on submit, get_generation_status on completion). The raw
|
|
110
|
+
// ids stay in sc.model / sc.voice for Recreate + model context — never for display.
|
|
111
|
+
function modelLabel(sc) { return sc.model_name || sc.model; }
|
|
112
|
+
function voiceLabel(sc) { return sc.voice_name || sc.voice || (sc.settings || {}).voice; }
|
|
113
|
+
|
|
107
114
|
function renderChips(sc) {
|
|
108
|
-
var h = modelChipHTML(sc
|
|
115
|
+
var h = modelChipHTML(modelLabel(sc), sc.model_icon);
|
|
109
116
|
var s = sc.settings || {};
|
|
110
117
|
if (sc.kind) h += chip(iconFor(sc.kind) + ' ' + sc.kind);
|
|
111
118
|
if (s.duration) h += chip(ICONS.clock + ' ' + fmtDur(s.duration));
|
|
112
119
|
if (s.resolution) h += chip(esc(s.resolution));
|
|
113
120
|
if (s.aspect_ratio) h += chip(esc(s.aspect_ratio));
|
|
121
|
+
if (s.quality) h += chip(esc(s.quality) + ' quality');
|
|
122
|
+
if (s.enhance_prompt) h += chip(ICONS.sparkle + ' enhanced');
|
|
123
|
+
if (s.web_search) h += chip('web search');
|
|
124
|
+
if (s.visual_dna) h += chip(s.visual_dna + ' Visual DNA');
|
|
125
|
+
if (s.moodboard) h += chip('moodboard');
|
|
126
|
+
if (s.preset) h += chip('preset');
|
|
127
|
+
if (s.cinematic) h += chip('cinematic');
|
|
114
128
|
if (s.audio) h += chip(ICONS.sound + ' audio');
|
|
115
|
-
|
|
129
|
+
var voice = voiceLabel(sc);
|
|
130
|
+
if (voice) {
|
|
131
|
+
var face = sc.voice_thumbnail
|
|
132
|
+
? '<img class="k-voice-thumb" src="' + esc(sc.voice_thumbnail) + '" alt="" loading="lazy" onerror="this.style.display=\\'none\\'">'
|
|
133
|
+
: ICONS.mic;
|
|
134
|
+
h += chip(face + ' ' + esc(voice));
|
|
135
|
+
}
|
|
116
136
|
if (s.mode) h += chip(esc(s.mode));
|
|
117
137
|
if (sc.count > 1) h += chip('×' + sc.count);
|
|
118
|
-
if (sc.reference_image) h += '<img class="k-ref-thumb" src="' + esc(sc.reference_image) + '" alt="" title="Reference image" onerror="this.style.display=\\'none\\'">';
|
|
138
|
+
if (sc.reference_image) h += '<img class="k-ref-thumb" src="' + esc(sc.reference_image) + '" alt="" loading="lazy" title="Reference image" onerror="this.style.display=\\'none\\'">';
|
|
119
139
|
el('chips').innerHTML = h;
|
|
120
140
|
}
|
|
121
141
|
function chip(inner) { return '<span class="k-chip">' + inner + '</span>'; }
|
|
@@ -237,13 +257,48 @@ var MAX_POLL_ERRORS = 30;
|
|
|
237
257
|
var pollStart = 0, pollErrors = 0;
|
|
238
258
|
var cancelRequested = false; // set by the Stop button; freezes the poll loop
|
|
239
259
|
|
|
260
|
+
/* ---------- offscreen gate ----------
|
|
261
|
+
The host mounts one of these iframes per generation, and re-delivers the
|
|
262
|
+
ORIGINAL "submitted" (phase:generating) result on every conversation open —
|
|
263
|
+
so a 50-generation session used to fire 50 status tools/call round trips plus
|
|
264
|
+
50+ full-resolution media downloads before the user had scrolled to any of
|
|
265
|
+
them. Hold the FIRST poll (and therefore every media request the result
|
|
266
|
+
produces) until the card is actually on screen. Once a card has been seen it
|
|
267
|
+
polls normally forever — a live generation the user scrolls away from still
|
|
268
|
+
finishes and still reports back. */
|
|
269
|
+
var seen = false, whenSeenFns = [];
|
|
270
|
+
function releaseSeen() {
|
|
271
|
+
if (seen) return;
|
|
272
|
+
seen = true;
|
|
273
|
+
var fns = whenSeenFns; whenSeenFns = [];
|
|
274
|
+
fns.forEach(function (f) { try { f(); } catch (e) {} });
|
|
275
|
+
}
|
|
276
|
+
(function () {
|
|
277
|
+
var card = document.querySelector('.k-card');
|
|
278
|
+
if (!window.IntersectionObserver || !card) return releaseSeen();
|
|
279
|
+
var fired = false;
|
|
280
|
+
var io = new IntersectionObserver(function (entries) {
|
|
281
|
+
fired = true;
|
|
282
|
+
if (!entries.some(function (e) { return e.isIntersecting; })) return;
|
|
283
|
+
io.disconnect();
|
|
284
|
+
releaseSeen();
|
|
285
|
+
// IO clips against ancestor frames, so this is true parent-viewport
|
|
286
|
+
// visibility. rootMargin starts the work just before the card scrolls in.
|
|
287
|
+
}, { rootMargin: '400px' });
|
|
288
|
+
io.observe(card);
|
|
289
|
+
// A host where IO never reports at all must not strand the card forever.
|
|
290
|
+
setTimeout(function () { if (!fired) releaseSeen(); }, 8000);
|
|
291
|
+
})();
|
|
292
|
+
|
|
240
293
|
function schedulePoll(sc) {
|
|
241
294
|
if (cancelRequested) return;
|
|
295
|
+
if (!seen) { whenSeenFns.push(function () { schedulePoll(sc); }); return; }
|
|
296
|
+
// The call itself long-waits server-side (normally up to three minutes).
|
|
297
|
+
// This short pause only separates successive wait windows — the FIRST call
|
|
298
|
+
// goes out immediately, so a card revealed by scrolling resolves at once.
|
|
299
|
+
var delay = pollStart ? 1500 : 0;
|
|
242
300
|
if (!pollStart) pollStart = Date.now();
|
|
243
301
|
clearTimeout(pollTimer);
|
|
244
|
-
// The call itself long-waits server-side (normally up to three minutes).
|
|
245
|
-
// This short pause only separates successive wait windows.
|
|
246
|
-
var delay = 1500;
|
|
247
302
|
pollTimer = setTimeout(function () { poll(sc); }, delay);
|
|
248
303
|
}
|
|
249
304
|
function poll(sc) {
|
|
@@ -304,7 +359,10 @@ function poll(sc) {
|
|
|
304
359
|
/* ---------- batch (prompts[] fan-out) ---------- */
|
|
305
360
|
// Each poll round resolves when every id has completed or its wait window
|
|
306
361
|
// closed (~3 min), so finished cells fill in per round while the rest keep
|
|
307
|
-
// their skeleton. When all_done
|
|
362
|
+
// their skeleton. When all_done the SAME grid is re-rendered from the resolved
|
|
363
|
+
// set — a batch is one grouped card end to end. It must NOT fall through to the
|
|
364
|
+
// scenes carousel: that collapses eight tiles into one big image plus a thumb
|
|
365
|
+
// strip, which is where the grouping (and the per-tile prompt caption) was lost.
|
|
308
366
|
function handleBatchStatus(sc, st) {
|
|
309
367
|
pollErrors = 0;
|
|
310
368
|
var gens = st.generations || [];
|
|
@@ -336,8 +394,9 @@ function handleBatchStatus(sc, st) {
|
|
|
336
394
|
});
|
|
337
395
|
state = done;
|
|
338
396
|
el('credits').textContent = done.credits_used != null ? fmtCredits(done.credits_used) : '';
|
|
339
|
-
if (failedCount) setPhaseChip(failedCount + ' failed', false);
|
|
340
397
|
renderResult(done);
|
|
398
|
+
// After renderResult — it resets the chip, so setting this first erased it.
|
|
399
|
+
if (failedCount) setPhaseChip(failedCount + ' failed', false);
|
|
341
400
|
try {
|
|
342
401
|
window.kolbo.updateModelContext(
|
|
343
402
|
'Batch generation completed (' + (sc.tool || '') + '): ' + scenes.length + ' of ' + gens.length + ' succeeded.' +
|
|
@@ -367,7 +426,12 @@ function fillBatchCell(sc, i, g) {
|
|
|
367
426
|
function renderResult(sc) {
|
|
368
427
|
clearTimeout(pollTimer);
|
|
369
428
|
|
|
429
|
+
// Repaint the chips: the generating phase only knew what the CALLER asked for
|
|
430
|
+
// (often nothing → "Smart Select"). The completed status carries the model and
|
|
431
|
+
// voice that actually ran, so the finished card must not keep the guess.
|
|
432
|
+
renderChips(sc);
|
|
370
433
|
setPhaseChip('', false);
|
|
434
|
+
if (sc.batch && sc.scenes && sc.scenes.length) return renderBatchGrid(sc);
|
|
371
435
|
if (sc.kind === 'scenes' && sc.scenes && sc.scenes.length) return renderScenes(sc);
|
|
372
436
|
var urls = sc.urls || [];
|
|
373
437
|
if (!urls.length) return renderError('No output received');
|
|
@@ -396,7 +460,7 @@ function renderImages(sc, urls) {
|
|
|
396
460
|
var thumbs = '';
|
|
397
461
|
if (urls.length > 1) {
|
|
398
462
|
thumbs = '<div class="k-thumbs">' + urls.map(function (u, i) {
|
|
399
|
-
return '<div class="k-thumb' + (i === selected ? ' active' : '') + '" data-i="' + i + '"><img src="' + esc(u) + '" alt=""></div>';
|
|
463
|
+
return '<div class="k-thumb' + (i === selected ? ' active' : '') + '" data-i="' + i + '"><img src="' + esc(u) + '" alt="" loading="lazy"></div>';
|
|
400
464
|
}).join('') + '</div>';
|
|
401
465
|
}
|
|
402
466
|
el('stage').innerHTML = viewer + thumbs;
|
|
@@ -415,8 +479,10 @@ function renderImages(sc, urls) {
|
|
|
415
479
|
}
|
|
416
480
|
|
|
417
481
|
function renderVideo(sc, urls) {
|
|
482
|
+
// preload="none" behind a poster: the card shows the still until the user
|
|
483
|
+
// hits play, instead of pulling the video header on mount.
|
|
418
484
|
el('stage').innerHTML = '<div class="k-viewer"><video id="main-video" src="' + esc(urls[0]) + '"' +
|
|
419
|
-
(sc.thumbnail_url ? ' poster="' + esc(sc.thumbnail_url) + '"' : '') + ' controls playsinline></video>' +
|
|
485
|
+
(sc.thumbnail_url ? ' poster="' + esc(sc.thumbnail_url) + '" preload="none"' : ' preload="metadata"') + ' controls playsinline></video>' +
|
|
420
486
|
dlBtnHTML(urls[0]) + '</div>';
|
|
421
487
|
wireDlButtons(el('stage'));
|
|
422
488
|
}
|
|
@@ -429,14 +495,16 @@ function renderAudio(sc, urls) {
|
|
|
429
495
|
var duration = track.duration != null ? track.duration : sc.duration;
|
|
430
496
|
var artwork = track.thumbnail_url || sc.thumbnail_url;
|
|
431
497
|
return '<div class="k-audio-row k-generated-audio">' +
|
|
432
|
-
(artwork ? '<img class="k-audio-art" src="' + esc(artwork) + '" alt="">' :
|
|
498
|
+
(artwork ? '<img class="k-audio-art" src="' + esc(artwork) + '" alt="" loading="lazy">' :
|
|
433
499
|
'<div class="k-audio-art k-audio-placeholder">' + ICONS.audio + '</div>') +
|
|
434
500
|
'<div class="k-audio-meta"><div class="k-audio-title">' + esc(title) + '</div>' +
|
|
435
|
-
|
|
501
|
+
// Resolved name first: a per-track model field is the raw id, and every
|
|
502
|
+
// track in one generation came from the same model anyway.
|
|
503
|
+
'<div class="k-audio-sub">' + esc(modelLabel(sc) || track.model || '') +
|
|
436
504
|
(duration ? ' · ' + fmtDur(duration) : '') + '</div></div>' +
|
|
437
505
|
'<button class="k-btn k-audio-download" data-audio-download="' + esc(u) +
|
|
438
506
|
'" aria-label="Download ' + esc(title) + '">' + ICONS.download + ' Download</button>' +
|
|
439
|
-
'<audio class="k-audio-player" src="' + esc(u) + '" controls preload="
|
|
507
|
+
'<audio class="k-audio-player" src="' + esc(u) + '" controls preload="none" aria-label="Play ' +
|
|
440
508
|
esc(title) + '"></audio></div>';
|
|
441
509
|
}).join('');
|
|
442
510
|
Array.prototype.forEach.call(el('stage').querySelectorAll('[data-audio-download]'), function (b) {
|
|
@@ -456,7 +524,7 @@ function renderAudio(sc, urls) {
|
|
|
456
524
|
|
|
457
525
|
function render3d(sc, urls) {
|
|
458
526
|
el('stage').innerHTML = (sc.thumbnail_url
|
|
459
|
-
? '<div class="k-viewer"><img src="' + esc(sc.thumbnail_url) + '" alt=""></div>' : '') +
|
|
527
|
+
? '<div class="k-viewer"><img src="' + esc(sc.thumbnail_url) + '" alt="" loading="lazy"></div>' : '') +
|
|
460
528
|
urls.map(function (u) {
|
|
461
529
|
var extMatch = u.split('?')[0].match(/\\.(\\w+)$/);
|
|
462
530
|
var ext = extMatch ? extMatch[1].toUpperCase() : 'FILE';
|
|
@@ -508,6 +576,32 @@ function sceneItems(sc) {
|
|
|
508
576
|
return items;
|
|
509
577
|
}
|
|
510
578
|
|
|
579
|
+
// Batch (prompts[] fan-out) result: the SAME tile grid the generating phase
|
|
580
|
+
// showed, each tile still captioned with the prompt that produced it. Downloads
|
|
581
|
+
// are per-tile (a batch has no single "current" url); click a tile to focus it.
|
|
582
|
+
function renderBatchGrid(sc) {
|
|
583
|
+
var items = sceneItems(sc);
|
|
584
|
+
if (!items.length) return renderError('No completed results received');
|
|
585
|
+
var shape = items[0].type === 'video' ? 'video' : 'square';
|
|
586
|
+
el('stage').innerHTML = '<div class="k-gen-grid n' + Math.min(items.length, 4) + '">' +
|
|
587
|
+
items.map(function (it, i) {
|
|
588
|
+
return '<div class="k-skel done ' + shape + '" data-focus="' + i + '">' +
|
|
589
|
+
(it.type === 'video'
|
|
590
|
+
? '<video class="k-cell-fill" src="' + esc(it.url) + '" controls playsinline preload="metadata"></video>'
|
|
591
|
+
: '<img class="k-cell-fill" src="' + esc(it.url) + '" alt="" loading="lazy" style="cursor:zoom-in">') +
|
|
592
|
+
(it.label ? '<span class="k-skel-cap" title="' + esc(it.label) + '">' + esc(it.label) + '</span>' : '') +
|
|
593
|
+
dlBtnHTML(it.url) + '</div>';
|
|
594
|
+
}).join('') + '</div>';
|
|
595
|
+
wireDlButtons(el('stage'));
|
|
596
|
+
Array.prototype.forEach.call(el('stage').querySelectorAll('[data-focus]'), function (cell) {
|
|
597
|
+
var it = items[+cell.getAttribute('data-focus')];
|
|
598
|
+
if (it.type !== 'image') return; // <video controls> owns its own clicks
|
|
599
|
+
cell.onclick = function () { focusMedia(it.url); };
|
|
600
|
+
});
|
|
601
|
+
renderActions(sc);
|
|
602
|
+
window.kolbo.notifySize();
|
|
603
|
+
}
|
|
604
|
+
|
|
511
605
|
function renderScenes(sc) {
|
|
512
606
|
var items = sceneItems(sc);
|
|
513
607
|
if (!items.length) return renderError('No completed scenes received');
|
|
@@ -555,7 +649,7 @@ function exitFocus() {
|
|
|
555
649
|
window.kolbo.requestDisplayMode('inline').catch(function () {});
|
|
556
650
|
isFullscreen = false;
|
|
557
651
|
applyFullscreen(false);
|
|
558
|
-
|
|
652
|
+
renderResult(state); // restore whichever multi-item view we came from
|
|
559
653
|
window.kolbo.notifySize();
|
|
560
654
|
}
|
|
561
655
|
|
|
@@ -740,7 +834,8 @@ function completedFromPlain(sc) {
|
|
|
740
834
|
settings: {
|
|
741
835
|
duration: sc.duration || originArgs.duration,
|
|
742
836
|
resolution: originArgs.resolution,
|
|
743
|
-
aspect_ratio: originArgs.aspect_ratio
|
|
837
|
+
aspect_ratio: originArgs.aspect_ratio,
|
|
838
|
+
quality: originArgs.quality
|
|
744
839
|
},
|
|
745
840
|
urls: sc.urls || []
|
|
746
841
|
});
|
package/src/auth.js
CHANGED
|
@@ -1,156 +1,155 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Keyless browser login for the LOCAL (stdio) Kolbo MCP server.
|
|
3
|
-
*
|
|
4
|
-
* When the server runs on the user's machine with no KOLBO_API_KEY and no
|
|
5
|
-
* stored credential, the first tool call triggers this: we open the browser to
|
|
6
|
-
* Kolbo's OAuth login (the same server that powers the remote connector), the
|
|
7
|
-
* user clicks Allow, and we capture a token via a loopback redirect — no API
|
|
8
|
-
* key to create or paste. The token is cached so every later run is silent.
|
|
9
|
-
*
|
|
10
|
-
* Standard "native app" OAuth: authorization-code + PKCE with a
|
|
11
|
-
* http://localhost:<port>/callback redirect (already allow-listed by the Kolbo
|
|
12
|
-
* OAuth server). This path is NOT used by the remote connector (it always
|
|
13
|
-
* injects the caller's key, and passes allowBrowserLogin:false).
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
const http = require('http');
|
|
17
|
-
const crypto = require('crypto');
|
|
18
|
-
const { exec } = require('child_process');
|
|
19
|
-
const fs = require('fs');
|
|
20
|
-
const path = require('path');
|
|
21
|
-
const os = require('os');
|
|
22
|
-
|
|
23
|
-
function b64url(buf) {
|
|
24
|
-
return Buffer.from(buf).toString('base64url');
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function openBrowser(url) {
|
|
28
|
-
const cmd =
|
|
29
|
-
process.platform === 'win32' ? `start "" "${url}"`
|
|
30
|
-
: process.platform === 'darwin' ? `open "${url}"`
|
|
31
|
-
: `xdg-open "${url}"`;
|
|
32
|
-
try { exec(cmd, () => {}); } catch (_) { /* best effort */ }
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// Where we cache the token — same location + shape that client.js reads back
|
|
36
|
-
// (`<xdg-data>/kolbo/auth.json` → { "kolbo@<host>": { type: 'api', key } }).
|
|
37
|
-
function authStorePath() {
|
|
38
|
-
const dataDir =
|
|
39
|
-
process.env.XDG_DATA_HOME ||
|
|
40
|
-
(process.platform === 'win32'
|
|
41
|
-
? (process.env.LOCALAPPDATA || path.join(os.homedir(), '.local', 'share'))
|
|
42
|
-
: process.platform === 'darwin'
|
|
43
|
-
? path.join(os.homedir(), 'Library', 'Application Support')
|
|
44
|
-
: path.join(os.homedir(), '.local', 'share'));
|
|
45
|
-
return path.join(dataDir, 'kolbo', 'auth.json');
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function storeKey(apiHost, key) {
|
|
49
|
-
try {
|
|
50
|
-
const file = authStorePath();
|
|
51
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
52
|
-
let store = {};
|
|
53
|
-
try { store = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (_) {}
|
|
54
|
-
store[`kolbo@${apiHost}`] = { type: 'api', key, savedAt: new Date().toISOString() };
|
|
55
|
-
fs.writeFileSync(file, JSON.stringify(store, null, 2), { mode: 0o600 });
|
|
56
|
-
} catch (_) { /* non-fatal — the key still works for this process */ }
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function donePage(ok) {
|
|
60
|
-
const title = ok ? 'Connected to Kolbo' : 'Connection cancelled';
|
|
61
|
-
const sub = ok ? 'You can close this tab and return to your app.' : 'You can close this tab.';
|
|
62
|
-
const mark = ok ? '✓' : '✕';
|
|
63
|
-
return `<!doctype html><meta charset="utf-8"><title>${title}</title>` +
|
|
64
|
-
`<body style="margin:0;font-family:Inter,system-ui,sans-serif;background:#05050f;color:#fff;` +
|
|
65
|
-
`display:flex;align-items:center;justify-content:center;height:100vh">` +
|
|
66
|
-
`<div style="text-align:center"><div style="font-size:42px;color:#8B5CF6;margin-bottom:8px">${mark}</div>` +
|
|
67
|
-
`<h2 style="margin:0 0 6px">${title}</h2><p style="opacity:.55;font-size:14px">${sub}</p></div></body>`;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Run the interactive browser login. Resolves with the kolbo_live_ key.
|
|
72
|
-
* @param {object} opts
|
|
73
|
-
* @param {string} opts.apiBase e.g. https://api.kolbo.ai/api
|
|
74
|
-
*/
|
|
75
|
-
async function browserLogin({ apiBase }) {
|
|
76
|
-
// The OAuth endpoints live at the host root, not under /api.
|
|
77
|
-
const oauthBase = apiBase.replace(/\/api\/?$/, '');
|
|
78
|
-
let apiHost = 'api.kolbo.ai';
|
|
79
|
-
try { apiHost = new URL(apiBase).host; } catch (_) {}
|
|
80
|
-
|
|
81
|
-
const verifier = b64url(crypto.randomBytes(32));
|
|
82
|
-
const challenge = b64url(crypto.createHash('sha256').update(verifier).digest());
|
|
83
|
-
const state = b64url(crypto.randomBytes(16));
|
|
84
|
-
|
|
85
|
-
// Loopback callback server on a random free port.
|
|
86
|
-
const server = http.createServer();
|
|
87
|
-
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
88
|
-
const port = server.address().port;
|
|
89
|
-
const redirectUri = `http://localhost:${port}/callback`;
|
|
90
|
-
|
|
91
|
-
try {
|
|
92
|
-
// 1. Dynamic client registration (public + PKCE).
|
|
93
|
-
const regRes = await fetch(`${oauthBase}/oauth/register`, {
|
|
94
|
-
method: 'POST',
|
|
95
|
-
headers: { 'Content-Type': 'application/json' },
|
|
96
|
-
body: JSON.stringify({ client_name: 'Kolbo MCP (local)', redirect_uris: [redirectUri] }),
|
|
97
|
-
});
|
|
98
|
-
if (!regRes.ok) throw new Error(`client registration failed (${regRes.status})`);
|
|
99
|
-
const { client_id } = await regRes.json();
|
|
100
|
-
|
|
101
|
-
// 2. Wait for the browser redirect to hit our loopback server.
|
|
102
|
-
const codePromise = new Promise((resolve, reject) => {
|
|
103
|
-
const timer = setTimeout(() => reject(new Error('login timed out (5 min)')), 5 * 60 * 1000);
|
|
104
|
-
server.on('request', (req, resp) => {
|
|
105
|
-
let u;
|
|
106
|
-
try { u = new URL(req.url, redirectUri); } catch (_) { resp.writeHead(400); resp.end(); return; }
|
|
107
|
-
if (u.pathname !== '/callback') { resp.writeHead(404); resp.end(); return; }
|
|
108
|
-
clearTimeout(timer);
|
|
109
|
-
const code = u.searchParams.get('code');
|
|
110
|
-
const st = u.searchParams.get('state');
|
|
111
|
-
const err = u.searchParams.get('error');
|
|
112
|
-
resp.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
113
|
-
resp.end(donePage(!err && !!code));
|
|
114
|
-
if (err) return reject(new Error(`login denied: ${err}`));
|
|
115
|
-
if (!code || st !== state) return reject(new Error('login: invalid callback'));
|
|
116
|
-
resolve(code);
|
|
117
|
-
});
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
// 3. Open the consent/login page.
|
|
121
|
-
const authUrl =
|
|
122
|
-
`${oauthBase}/oauth/authorize?response_type=code&client_id=${encodeURIComponent(client_id)}` +
|
|
123
|
-
`&redirect_uri=${encodeURIComponent(redirectUri)}&code_challenge=${challenge}` +
|
|
124
|
-
`&code_challenge_method=S256&state=${state}&scope=kolbo`;
|
|
125
|
-
openBrowser(authUrl);
|
|
126
|
-
process.stderr.write(
|
|
127
|
-
`\n[kolbo] Connect your Kolbo account in the browser. If it didn't open, visit:\n${authUrl}\n\n`
|
|
128
|
-
);
|
|
129
|
-
|
|
130
|
-
const code = await codePromise;
|
|
131
|
-
|
|
132
|
-
// 4. Exchange the code (with the PKCE verifier) for the token.
|
|
133
|
-
const tokRes = await fetch(`${oauthBase}/oauth/token`, {
|
|
134
|
-
method: 'POST',
|
|
135
|
-
headers: { 'Content-Type': 'application/json' },
|
|
136
|
-
body: JSON.stringify({
|
|
137
|
-
grant_type: 'authorization_code',
|
|
138
|
-
code,
|
|
139
|
-
code_verifier: verifier,
|
|
140
|
-
redirect_uri: redirectUri,
|
|
141
|
-
client_id,
|
|
142
|
-
}),
|
|
143
|
-
});
|
|
144
|
-
if (!tokRes.ok) throw new Error(`token exchange failed (${tokRes.status})`);
|
|
145
|
-
const tok = await tokRes.json();
|
|
146
|
-
if (!tok.access_token) throw new Error('login: no access_token returned');
|
|
147
|
-
|
|
148
|
-
storeKey(apiHost, tok.access_token);
|
|
149
|
-
return tok.access_token;
|
|
150
|
-
} finally {
|
|
151
|
-
try { server.close(); } catch (_) {}
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
module.exports = { browserLogin };
|
|
156
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Keyless browser login for the LOCAL (stdio) Kolbo MCP server.
|
|
3
|
+
*
|
|
4
|
+
* When the server runs on the user's machine with no KOLBO_API_KEY and no
|
|
5
|
+
* stored credential, the first tool call triggers this: we open the browser to
|
|
6
|
+
* Kolbo's OAuth login (the same server that powers the remote connector), the
|
|
7
|
+
* user clicks Allow, and we capture a token via a loopback redirect — no API
|
|
8
|
+
* key to create or paste. The token is cached so every later run is silent.
|
|
9
|
+
*
|
|
10
|
+
* Standard "native app" OAuth: authorization-code + PKCE with a
|
|
11
|
+
* http://localhost:<port>/callback redirect (already allow-listed by the Kolbo
|
|
12
|
+
* OAuth server). This path is NOT used by the remote connector (it always
|
|
13
|
+
* injects the caller's key, and passes allowBrowserLogin:false).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const http = require('http');
|
|
17
|
+
const crypto = require('crypto');
|
|
18
|
+
const { exec } = require('child_process');
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const os = require('os');
|
|
22
|
+
|
|
23
|
+
function b64url(buf) {
|
|
24
|
+
return Buffer.from(buf).toString('base64url');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function openBrowser(url) {
|
|
28
|
+
const cmd =
|
|
29
|
+
process.platform === 'win32' ? `start "" "${url}"`
|
|
30
|
+
: process.platform === 'darwin' ? `open "${url}"`
|
|
31
|
+
: `xdg-open "${url}"`;
|
|
32
|
+
try { exec(cmd, () => {}); } catch (_) { /* best effort */ }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Where we cache the token — same location + shape that client.js reads back
|
|
36
|
+
// (`<xdg-data>/kolbo/auth.json` → { "kolbo@<host>": { type: 'api', key } }).
|
|
37
|
+
function authStorePath() {
|
|
38
|
+
const dataDir =
|
|
39
|
+
process.env.XDG_DATA_HOME ||
|
|
40
|
+
(process.platform === 'win32'
|
|
41
|
+
? (process.env.LOCALAPPDATA || path.join(os.homedir(), '.local', 'share'))
|
|
42
|
+
: process.platform === 'darwin'
|
|
43
|
+
? path.join(os.homedir(), 'Library', 'Application Support')
|
|
44
|
+
: path.join(os.homedir(), '.local', 'share'));
|
|
45
|
+
return path.join(dataDir, 'kolbo', 'auth.json');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function storeKey(apiHost, key) {
|
|
49
|
+
try {
|
|
50
|
+
const file = authStorePath();
|
|
51
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
52
|
+
let store = {};
|
|
53
|
+
try { store = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (_) {}
|
|
54
|
+
store[`kolbo@${apiHost}`] = { type: 'api', key, savedAt: new Date().toISOString() };
|
|
55
|
+
fs.writeFileSync(file, JSON.stringify(store, null, 2), { mode: 0o600 });
|
|
56
|
+
} catch (_) { /* non-fatal — the key still works for this process */ }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function donePage(ok) {
|
|
60
|
+
const title = ok ? 'Connected to Kolbo' : 'Connection cancelled';
|
|
61
|
+
const sub = ok ? 'You can close this tab and return to your app.' : 'You can close this tab.';
|
|
62
|
+
const mark = ok ? '✓' : '✕';
|
|
63
|
+
return `<!doctype html><meta charset="utf-8"><title>${title}</title>` +
|
|
64
|
+
`<body style="margin:0;font-family:Inter,system-ui,sans-serif;background:#05050f;color:#fff;` +
|
|
65
|
+
`display:flex;align-items:center;justify-content:center;height:100vh">` +
|
|
66
|
+
`<div style="text-align:center"><div style="font-size:42px;color:#8B5CF6;margin-bottom:8px">${mark}</div>` +
|
|
67
|
+
`<h2 style="margin:0 0 6px">${title}</h2><p style="opacity:.55;font-size:14px">${sub}</p></div></body>`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Run the interactive browser login. Resolves with the kolbo_live_ key.
|
|
72
|
+
* @param {object} opts
|
|
73
|
+
* @param {string} opts.apiBase e.g. https://api.kolbo.ai/api
|
|
74
|
+
*/
|
|
75
|
+
async function browserLogin({ apiBase }) {
|
|
76
|
+
// The OAuth endpoints live at the host root, not under /api.
|
|
77
|
+
const oauthBase = apiBase.replace(/\/api\/?$/, '');
|
|
78
|
+
let apiHost = 'api.kolbo.ai';
|
|
79
|
+
try { apiHost = new URL(apiBase).host; } catch (_) {}
|
|
80
|
+
|
|
81
|
+
const verifier = b64url(crypto.randomBytes(32));
|
|
82
|
+
const challenge = b64url(crypto.createHash('sha256').update(verifier).digest());
|
|
83
|
+
const state = b64url(crypto.randomBytes(16));
|
|
84
|
+
|
|
85
|
+
// Loopback callback server on a random free port.
|
|
86
|
+
const server = http.createServer();
|
|
87
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
88
|
+
const port = server.address().port;
|
|
89
|
+
const redirectUri = `http://localhost:${port}/callback`;
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
// 1. Dynamic client registration (public + PKCE).
|
|
93
|
+
const regRes = await fetch(`${oauthBase}/oauth/register`, {
|
|
94
|
+
method: 'POST',
|
|
95
|
+
headers: { 'Content-Type': 'application/json' },
|
|
96
|
+
body: JSON.stringify({ client_name: 'Kolbo MCP (local)', redirect_uris: [redirectUri] }),
|
|
97
|
+
});
|
|
98
|
+
if (!regRes.ok) throw new Error(`client registration failed (${regRes.status})`);
|
|
99
|
+
const { client_id } = await regRes.json();
|
|
100
|
+
|
|
101
|
+
// 2. Wait for the browser redirect to hit our loopback server.
|
|
102
|
+
const codePromise = new Promise((resolve, reject) => {
|
|
103
|
+
const timer = setTimeout(() => reject(new Error('login timed out (5 min)')), 5 * 60 * 1000);
|
|
104
|
+
server.on('request', (req, resp) => {
|
|
105
|
+
let u;
|
|
106
|
+
try { u = new URL(req.url, redirectUri); } catch (_) { resp.writeHead(400); resp.end(); return; }
|
|
107
|
+
if (u.pathname !== '/callback') { resp.writeHead(404); resp.end(); return; }
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
const code = u.searchParams.get('code');
|
|
110
|
+
const st = u.searchParams.get('state');
|
|
111
|
+
const err = u.searchParams.get('error');
|
|
112
|
+
resp.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
113
|
+
resp.end(donePage(!err && !!code));
|
|
114
|
+
if (err) return reject(new Error(`login denied: ${err}`));
|
|
115
|
+
if (!code || st !== state) return reject(new Error('login: invalid callback'));
|
|
116
|
+
resolve(code);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// 3. Open the consent/login page.
|
|
121
|
+
const authUrl =
|
|
122
|
+
`${oauthBase}/oauth/authorize?response_type=code&client_id=${encodeURIComponent(client_id)}` +
|
|
123
|
+
`&redirect_uri=${encodeURIComponent(redirectUri)}&code_challenge=${challenge}` +
|
|
124
|
+
`&code_challenge_method=S256&state=${state}&scope=kolbo`;
|
|
125
|
+
openBrowser(authUrl);
|
|
126
|
+
process.stderr.write(
|
|
127
|
+
`\n[kolbo] Connect your Kolbo account in the browser. If it didn't open, visit:\n${authUrl}\n\n`
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const code = await codePromise;
|
|
131
|
+
|
|
132
|
+
// 4. Exchange the code (with the PKCE verifier) for the token.
|
|
133
|
+
const tokRes = await fetch(`${oauthBase}/oauth/token`, {
|
|
134
|
+
method: 'POST',
|
|
135
|
+
headers: { 'Content-Type': 'application/json' },
|
|
136
|
+
body: JSON.stringify({
|
|
137
|
+
grant_type: 'authorization_code',
|
|
138
|
+
code,
|
|
139
|
+
code_verifier: verifier,
|
|
140
|
+
redirect_uri: redirectUri,
|
|
141
|
+
client_id,
|
|
142
|
+
}),
|
|
143
|
+
});
|
|
144
|
+
if (!tokRes.ok) throw new Error(`token exchange failed (${tokRes.status})`);
|
|
145
|
+
const tok = await tokRes.json();
|
|
146
|
+
if (!tok.access_token) throw new Error('login: no access_token returned');
|
|
147
|
+
|
|
148
|
+
storeKey(apiHost, tok.access_token);
|
|
149
|
+
return tok.access_token;
|
|
150
|
+
} finally {
|
|
151
|
+
try { server.close(); } catch (_) {}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = { browserLogin };
|
package/src/tools/_shared.js
CHANGED
|
@@ -484,7 +484,22 @@ function buildProjectUrl(projectId, opts = {}) {
|
|
|
484
484
|
// ui://kolbo/generation.html widget takes over: live progress, inline result,
|
|
485
485
|
// action buttons. Text-only hosts never enter this path — their blocking
|
|
486
486
|
// behavior and response bytes are UNCHANGED.
|
|
487
|
-
const { UI, uiResult, appsEnabled,
|
|
487
|
+
const { UI, uiResult, appsEnabled, modelInfo } = require('../apps');
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Chip identity for a model: the CLEAN display name + its icon, resolved from
|
|
491
|
+
* the same /v1/models catalog `list_models` renders. Callers pass whatever the
|
|
492
|
+
* user/LLM supplied (an identifier like `google_tts`, `fal-ai/…/omnihuman/v1.5`,
|
|
493
|
+
* or a display name) — the card must never show the raw id.
|
|
494
|
+
*/
|
|
495
|
+
async function modelChipFields(client, model) {
|
|
496
|
+
const info = await modelInfo(client, model).catch(() => null);
|
|
497
|
+
return {
|
|
498
|
+
model: model || 'Smart Select',
|
|
499
|
+
model_name: (info && info.name) || model || 'Smart Select',
|
|
500
|
+
model_icon: (info && info.icon) || null,
|
|
501
|
+
};
|
|
502
|
+
}
|
|
488
503
|
|
|
489
504
|
/**
|
|
490
505
|
* Build the "submitted — widget is live" tool result for a UI host.
|
|
@@ -494,12 +509,13 @@ const { UI, uiResult, appsEnabled, modelIcon } = require('../apps');
|
|
|
494
509
|
* gen the submit response ({ generation_id, poll_interval_hint })
|
|
495
510
|
* client KolboClient (for model icon lookup)
|
|
496
511
|
* model, prompt, count, settings, reference_image, estimated_seconds
|
|
512
|
+
* voice resolved voice record { name, thumbnail } (speech only)
|
|
497
513
|
* poll_tool widget-side status tool (default 'get_generation_status')
|
|
498
514
|
* status_args args for poll_tool (default { generation_id, wait: true })
|
|
499
515
|
*/
|
|
500
516
|
async function uiGenerating(p) {
|
|
501
517
|
// No ETAs anywhere — just a spinner until the poll flips to completed.
|
|
502
|
-
const
|
|
518
|
+
const chip = await modelChipFields(p.client, p.model);
|
|
503
519
|
const structured = {
|
|
504
520
|
phase: 'generating',
|
|
505
521
|
widget: 'generation',
|
|
@@ -511,8 +527,8 @@ async function uiGenerating(p) {
|
|
|
511
527
|
// wait=true, every open card calls tools/call every few seconds, flooding
|
|
512
528
|
// the host's global progress/context stream and API rate limits.
|
|
513
529
|
status_args: p.status_args || { generation_id: p.gen.generation_id, wait: true },
|
|
514
|
-
|
|
515
|
-
|
|
530
|
+
...chip,
|
|
531
|
+
...(p.voice ? { voice_name: p.voice.name, voice_thumbnail: p.voice.thumbnail } : {}),
|
|
516
532
|
prompt: p.prompt,
|
|
517
533
|
count: p.count || 1,
|
|
518
534
|
settings: p.settings || {},
|
|
@@ -531,6 +547,7 @@ async function uiGenerating(p) {
|
|
|
531
547
|
? { batch: true, generation_ids: p.generation_ids } : {}),
|
|
532
548
|
...(p.failed_submissions && p.failed_submissions.length
|
|
533
549
|
? { failed_submissions: p.failed_submissions } : {}),
|
|
550
|
+
...(p.warning ? { _warning: p.warning } : {}),
|
|
534
551
|
_widget_note: 'A live Kolbo widget is rendering this generation for the user (progress + final result + action buttons). Tell the user it is generating and the card above will update — do NOT poll in a loop. If you need the output URLs (e.g. for a follow-up edit or a report), call get_generation_status ONCE with wait=true — it blocks until done. Tracking several generations? Pass ALL their ids in generation_ids in that one call.',
|
|
535
552
|
}, null, 2);
|
|
536
553
|
return uiResult(UI.generation, text, structured);
|
|
@@ -541,14 +558,13 @@ async function uiGenerating(p) {
|
|
|
541
558
|
* that stay blocking even on UI hosts, e.g. creative director).
|
|
542
559
|
*/
|
|
543
560
|
async function uiCompleted(p, textPayload) {
|
|
544
|
-
const
|
|
561
|
+
const chip = await modelChipFields(p.client, p.model);
|
|
545
562
|
const structured = {
|
|
546
563
|
phase: 'completed',
|
|
547
564
|
widget: 'generation',
|
|
548
565
|
kind: p.kind,
|
|
549
566
|
tool: p.tool,
|
|
550
|
-
|
|
551
|
-
model_icon: icon,
|
|
567
|
+
...chip,
|
|
552
568
|
prompt: p.prompt,
|
|
553
569
|
count: p.count || 1,
|
|
554
570
|
settings: p.settings || {},
|
package/src/tools/generate.js
CHANGED
|
@@ -7,7 +7,7 @@ const { z } = require('zod');
|
|
|
7
7
|
const FormData = require('form-data');
|
|
8
8
|
const { pollUntilDone } = require('../polling');
|
|
9
9
|
const { resolveToBuffer, pollOrTimedOut, creditFields, projectIdField, inlineImageBlocks, buildOpenUrl, uiGenerating, appsEnabled } = require('./_shared');
|
|
10
|
-
const { UI, uiResult, canonicalModelId } = require('../apps');
|
|
10
|
+
const { UI, uiResult, canonicalModelId, modelInfo, voiceInfo } = require('../apps');
|
|
11
11
|
|
|
12
12
|
// ─── Cinematic Dimensions schema (shared by generate_image + generate_image_edit) ───
|
|
13
13
|
// Kolbo's "Cinema mode": eight independent photographic dimensions, each an OPTIONAL
|
|
@@ -73,6 +73,24 @@ async function pollBatch(client, batch, { interval, timeout }) {
|
|
|
73
73
|
};
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
// ─── Widget settings block ──────────────────────────────────────────────────
|
|
77
|
+
// What the CALLER actually asked for, for the generation card AND for the model
|
|
78
|
+
// reading the tool result. Undefined/false keys are dropped by JSON.stringify, so
|
|
79
|
+
// only values that were really supplied ever surface. This used to be
|
|
80
|
+
// `{ resolution, aspect_ratio }` only — `quality` (and every knob below it) was
|
|
81
|
+
// silently dropped, so three calls at low/medium/high rendered identical cards.
|
|
82
|
+
const imageSettings = (a = {}) => ({
|
|
83
|
+
resolution: a.resolution,
|
|
84
|
+
aspect_ratio: a.aspect_ratio,
|
|
85
|
+
quality: a.quality,
|
|
86
|
+
enhance_prompt: a.enhance_prompt || undefined,
|
|
87
|
+
web_search: a.enable_web_search || undefined,
|
|
88
|
+
visual_dna: (a.visual_dna_ids && a.visual_dna_ids.length) || undefined,
|
|
89
|
+
moodboard: a.moodboard_id ? true : undefined,
|
|
90
|
+
preset: a.preset_id ? true : undefined,
|
|
91
|
+
cinematic: a.cinematic ? true : undefined,
|
|
92
|
+
});
|
|
93
|
+
|
|
76
94
|
const promptsField = (what) => z.array(z.string()).optional().describe(
|
|
77
95
|
`BATCH MODE — several DIFFERENT prompts (2–${MAX_BATCH_PROMPTS}) generated concurrently in ONE call and rendered together in ONE combined widget. Whenever the user wants multiple distinct ${what} with their own prompts, ALWAYS pass them all here instead of making several separate calls — separate calls clutter the chat with stacked widgets. All prompts share the same model/settings. When set, \`prompt\` is ignored. For N variations of a SINGLE prompt use num_images (image tools); for an AI-planned coherent scene set use generate_creative_director.`
|
|
78
96
|
);
|
|
@@ -121,7 +139,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
121
139
|
const batch = await submitBatch(prompts, (p) => client.post('/v1/generate/image', { ...shared, prompt: p }));
|
|
122
140
|
if (ui()) return uiGenerating({
|
|
123
141
|
tool: 'generate_image', kind: 'image', gen: batch.ok[0].gen, client, model,
|
|
124
|
-
count: batch.ids.length, settings:
|
|
142
|
+
count: batch.ids.length, settings: imageSettings(shared),
|
|
125
143
|
generation_ids: batch.ids, prompts: batch.ok.map((o) => o.prompt),
|
|
126
144
|
failed_submissions: batch.failed,
|
|
127
145
|
status_args: { generation_ids: batch.ids, wait: true },
|
|
@@ -134,7 +152,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
134
152
|
|
|
135
153
|
if (ui()) return uiGenerating({
|
|
136
154
|
tool: 'generate_image', kind: 'image', gen, client, model, prompt,
|
|
137
|
-
count: num_images, settings:
|
|
155
|
+
count: num_images, settings: imageSettings(shared),
|
|
138
156
|
reference_image: reference_images?.[0]
|
|
139
157
|
});
|
|
140
158
|
|
|
@@ -189,7 +207,8 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
189
207
|
|
|
190
208
|
if (ui()) return uiGenerating({
|
|
191
209
|
tool: 'generate_image_edit', kind: 'image', gen, client, model, prompt,
|
|
192
|
-
count: num_images,
|
|
210
|
+
count: num_images,
|
|
211
|
+
settings: imageSettings({ resolution, aspect_ratio, enhance_prompt, enable_web_search, visual_dna_ids, moodboard_id, cinematic }),
|
|
193
212
|
reference_image: source_images?.[0]
|
|
194
213
|
});
|
|
195
214
|
|
|
@@ -569,7 +588,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
569
588
|
'Convert text to speech using Kolbo AI. Default provider is ElevenLabs. To pick a specific voice by language/gender, call list_voices first and pass the returned voice_id (or a voice display name — both work). Every voice belongs to a provider (ElevenLabs, DeepDub, MiniMax, Google/Gemini, OpenAI, Zonos) and each provider exposes its own expressive/style controls below — the engine ignores any control that does not apply to the chosen voice\'s provider, so it is safe to pass only what you need. Returns the final audio URL when complete.',
|
|
570
589
|
{
|
|
571
590
|
text: z.string().describe('The text to convert to speech'),
|
|
572
|
-
voice: z.string().optional().describe('Voice ID
|
|
591
|
+
voice: z.string().optional().describe('Voice ID or display name — MUST come from a `list_voices` result, never constructed. Google/Gemini ids in particular are not validated provider-side: an id that is not in the catalog is silently mapped to another voice (or a default one) and the audio comes back in a voice nobody asked for. Do not pattern-match a locale onto an id you saw for another language. Default: "Rachel"'),
|
|
573
592
|
model: z.string().optional().describe('Model identifier. Use list_models type="text_to_speech" to see options. Default: eleven_v3'),
|
|
574
593
|
language: z.string().optional().describe('Language code (e.g., "en-US", "he-IL", "es-ES"). Default: "en-US"'),
|
|
575
594
|
// ── Expressive style / emotion (provider-specific) ──
|
|
@@ -601,6 +620,16 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
601
620
|
},
|
|
602
621
|
async ({ text, voice, model, language, style_instructions, selected_style, emotion, speaking_speed, similarity_boost, style, use_speaker_boost, variance, tempo, promptBoost, seed, accentControl, voiceTitle, minimax_pitch, minimax_vol, minimax_intensity, minimax_timbre, project_id }) => {
|
|
603
622
|
model = await canonicalModelId(client, model); // lenient id resolution ("z-image" → "z-image/turbo")
|
|
623
|
+
// Resolve the requested voice against the REAL catalog (cached) so the card
|
|
624
|
+
// can show its display name + portrait instead of a raw id, and so an id
|
|
625
|
+
// that does not exist is reported instead of rendering silently: Google
|
|
626
|
+
// voice ids collapse to their last segment provider-side, so a made-up
|
|
627
|
+
// "en-US-Chirp3-HD-<Name>" either speaks as some other catalog entry's
|
|
628
|
+
// voice or falls back to a default one — with the card naming neither.
|
|
629
|
+
const voiceRecord = await voiceInfo(client, voice).catch(() => null);
|
|
630
|
+
const unknownVoice = voice && !voiceRecord && !/^custom_/i.test(voice)
|
|
631
|
+
? `Voice "${voice}" is not in the Kolbo voice catalog. Call list_voices and pass a voice_id it returns — an unrecognised id is NOT rejected, it is silently mapped to a different voice, so the audio will not be the voice you named.`
|
|
632
|
+
: null;
|
|
604
633
|
const gen = await client.post('/v1/generate/speech', {
|
|
605
634
|
text, voice, model, language,
|
|
606
635
|
style_instructions, selected_style, emotion, speaking_speed,
|
|
@@ -612,7 +641,9 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
612
641
|
|
|
613
642
|
if (ui()) return uiGenerating({
|
|
614
643
|
tool: 'generate_speech', kind: 'audio', gen, client, model, prompt: text,
|
|
615
|
-
|
|
644
|
+
voice: voiceRecord,
|
|
645
|
+
settings: { voice: voice || 'Rachel', style: selected_style || emotion || style_instructions },
|
|
646
|
+
warning: unknownVoice
|
|
616
647
|
});
|
|
617
648
|
|
|
618
649
|
const poll = await pollOrTimedOut(client, gen.generation_id, {
|
|
@@ -629,7 +660,8 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
629
660
|
...creditFields(result),
|
|
630
661
|
urls: result.result.urls,
|
|
631
662
|
voice: result.result.voice,
|
|
632
|
-
duration: result.result.duration
|
|
663
|
+
duration: result.result.duration,
|
|
664
|
+
...(unknownVoice ? { _warning: unknownVoice } : {})
|
|
633
665
|
}, null, 2)
|
|
634
666
|
}]
|
|
635
667
|
};
|
|
@@ -694,6 +726,32 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
694
726
|
}
|
|
695
727
|
);
|
|
696
728
|
|
|
729
|
+
// The status endpoint reports RAW ids — `model: "google_tts"`, `voice:
|
|
730
|
+
// "he-IL-Chirp3-HD-Kore"`. That is the model/voice that ACTUALLY ran (for
|
|
731
|
+
// Smart Select it is the only place the choice surfaces), so the card must
|
|
732
|
+
// show it — but with the clean name + icon everything else uses. Resolved
|
|
733
|
+
// here, off the same cached catalogs `list_models` / `list_voices` serve, so
|
|
734
|
+
// the widget adds no round trip per generation. Written INTO `result` because
|
|
735
|
+
// that is the object the widget merges over its generating-phase state.
|
|
736
|
+
async function addDisplayNames(status) {
|
|
737
|
+
const r = status && status.result;
|
|
738
|
+
if (!r || typeof r !== 'object') return;
|
|
739
|
+
// Always WRITE both keys, even on a catalog miss: the widget merges this
|
|
740
|
+
// over its generating-phase state, so a missing key silently keeps the
|
|
741
|
+
// pre-submit guess ("Smart Select") for a model that is not what ran. An
|
|
742
|
+
// unresolvable id is at least honest.
|
|
743
|
+
if (r.model) {
|
|
744
|
+
const info = await modelInfo(client, r.model).catch(() => null);
|
|
745
|
+
r.model_name = (info && info.name) || r.model;
|
|
746
|
+
r.model_icon = (info && info.icon) || null;
|
|
747
|
+
}
|
|
748
|
+
if (r.voice) {
|
|
749
|
+
const v = await voiceInfo(client, r.voice).catch(() => null);
|
|
750
|
+
r.voice_name = (v && v.name) || r.voice;
|
|
751
|
+
r.voice_thumbnail = (v && v.thumbnail) || null;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
697
755
|
// ─── get_generation_status ─────────────────────────────────
|
|
698
756
|
server.tool(
|
|
699
757
|
'get_generation_status',
|
|
@@ -734,6 +792,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
734
792
|
};
|
|
735
793
|
|
|
736
794
|
const results = await Promise.all(ids.map(checkOne));
|
|
795
|
+
await Promise.all(results.map(addDisplayNames));
|
|
737
796
|
|
|
738
797
|
const pending = results.filter(r => r.state !== 'completed' && r.state !== 'failed' && r.state !== 'cancelled');
|
|
739
798
|
const doneHint = 'ALL generations are in a final state — do NOT poll again. Report the results to the user.';
|
|
@@ -1434,13 +1493,13 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1434
1493
|
.describe('Output quality preset (e.g. "high", "standard"). Applies where the underlying model supports quality tiers.'),
|
|
1435
1494
|
|
|
1436
1495
|
ai_optimize: z.boolean().optional()
|
|
1437
|
-
.describe('Whether to let Kolbo AI enhance your prompt before sending to the model. Default:
|
|
1496
|
+
.describe('Whether to let Kolbo AI enhance your prompt before sending to the model. Default: false — your prompt reaches the model exactly as written. Only pass true if the user explicitly asks to enhance/improve the prompt.'),
|
|
1438
1497
|
|
|
1439
1498
|
project_id: projectIdField
|
|
1440
1499
|
},
|
|
1441
1500
|
async ({
|
|
1442
1501
|
image_url, operation, model, scale, aspect_ratio, skin_strength, prompt,
|
|
1443
|
-
mask_image_url, additional_images, generate_all_angles, resolution, quality, ai_optimize,
|
|
1502
|
+
mask_image_url, additional_images, generate_all_angles, resolution, quality, ai_optimize = false,
|
|
1444
1503
|
zoom_out_percentage, expand_left, expand_right, expand_top, expand_bottom,
|
|
1445
1504
|
project_id
|
|
1446
1505
|
}) => {
|
package/src/tools/voices.js
CHANGED
|
@@ -19,14 +19,29 @@ function registerVoiceTools(server, client, options = {}) {
|
|
|
19
19
|
},
|
|
20
20
|
async ({ language, gender, provider }) => {
|
|
21
21
|
const params = new URLSearchParams();
|
|
22
|
-
if (language) params.set('language', language);
|
|
23
22
|
if (gender) params.set('gender', gender);
|
|
24
23
|
if (provider) params.set('provider', provider);
|
|
24
|
+
const withoutLanguage = `/v1/voices${params.toString() ? '?' + params.toString() : ''}`;
|
|
25
25
|
|
|
26
|
-
const
|
|
27
|
-
|
|
26
|
+
const qs = new URLSearchParams(params);
|
|
27
|
+
if (language) qs.set('language', language);
|
|
28
|
+
const result = await client.get(`/v1/voices${qs.toString() ? '?' + qs.toString() : ''}`);
|
|
28
29
|
|
|
29
|
-
|
|
30
|
+
let voices = result.voices || [];
|
|
31
|
+
// The API matches `language` EXACTLY against the voice's language name or
|
|
32
|
+
// its locale code — "hebrew" and "he-IL" both hit, but "he" / "heb" /
|
|
33
|
+
// "Hebrew (Israel)" return nothing. This arg is documented as a partial
|
|
34
|
+
// match, and an empty list reads to the model as "Kolbo has no Google
|
|
35
|
+
// Hebrew voices at all", which is how a whole provider goes missing.
|
|
36
|
+
// Retry ONCE, unfiltered, and match locally — only on the empty path, so
|
|
37
|
+
// the normal call still costs one request.
|
|
38
|
+
if (voices.length === 0 && language) {
|
|
39
|
+
const l = language.toLowerCase();
|
|
40
|
+
const all = await client.get(withoutLanguage);
|
|
41
|
+
voices = (all.voices || []).filter(v =>
|
|
42
|
+
[v.language, v.language_code, v.languageCode]
|
|
43
|
+
.some(x => String(x || '').toLowerCase().includes(l)));
|
|
44
|
+
}
|
|
30
45
|
if (voices.length === 0) {
|
|
31
46
|
return {
|
|
32
47
|
content: [{ type: 'text', text: 'No voices found matching those filters.' }]
|