@kolbo/mcp 1.37.1 → 1.39.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/README.md +2 -1
- package/package.json +1 -1
- package/skill/GENERATED.md +1 -1
- package/skill/SKILL.md +1 -4
- package/skill/VERSION +1 -1
- package/src/apps/html.js +43 -0
- package/src/apps/index.js +14 -1
- package/src/apps/theme.js +2 -0
- package/src/apps/widgets/generation.js +24 -17
- package/src/apps/widgets/mediaGrid.js +7 -7
- package/src/apps/widgets/transcript.js +6 -6
- package/src/apps/widgets/upload.js +268 -0
- package/src/index.js +3 -1
- package/src/tools/generate.js +7 -7
- package/src/tools/media.js +80 -3
package/README.md
CHANGED
|
@@ -163,7 +163,8 @@ Every generation tool also accepts an optional `project_id` arg that routes the
|
|
|
163
163
|
**Media Library**
|
|
164
164
|
| Tool | Description |
|
|
165
165
|
|------|-------------|
|
|
166
|
-
| `
|
|
166
|
+
| `media_upload_widget` | Open an in-chat upload card so claude.ai users can upload LOCAL files (image / video / audio / document) — chat attachments are unreachable from remote MCP, so this is the way to bring them in. Returns stable CDN URLs |
|
|
167
|
+
| `upload_media` | Upload a local file (path or URL), or inline `source_base64` + `filename`, → stable Kolbo CDN URL for reuse |
|
|
167
168
|
| `list_media` | Browse media library — filter by `project_id`, `folder_id`, `type`, `category` (ai / uploaded / edited / favorites / training-lab), `source_type`, `sort`, `search`, pagination |
|
|
168
169
|
| `list_media_folders` | List the user's media folders (owned + shared) — discover `folder_id` values to pass to `list_media` |
|
|
169
170
|
| `create_media_folder` | Create a new folder (name, optional description / color / icon) |
|
package/package.json
CHANGED
package/skill/GENERATED.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# AUTO-GENERATED — do not edit
|
|
2
2
|
|
|
3
3
|
This skill/ tree is mirrored from kolbo-code (the single source of truth)
|
|
4
|
-
by .github/workflows/sync-skill-to-plugin.yml — synced from kolbo-code@
|
|
4
|
+
by .github/workflows/sync-skill-to-plugin.yml — synced from kolbo-code@d70e982.
|
|
5
5
|
|
|
6
6
|
It is the skill that 'npx @kolbo/mcp install' deploys into the user's agent.
|
|
7
7
|
To change it, edit packages/opencode/skills/kolbo/ in kolbo-code and push;
|
package/skill/SKILL.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
version: 0.7.
|
|
2
|
+
version: 0.7.2
|
|
3
3
|
name: kolbo
|
|
4
4
|
description: |
|
|
5
5
|
Generate, edit, or analyze creative media via the Kolbo AI MCP server:
|
|
@@ -275,9 +275,6 @@ Chat renders markdown natively. `` = inline image. `[label](url)` = l
|
|
|
275
275
|
|
|
276
276
|
Avoid bare URL dumps and HTML `<table>` grids — canvas already provides a gallery.
|
|
277
277
|
|
|
278
|
-
- **3D models (`generate_3d`)**: NEVER paste the raw GLB/FBX/OBJ/USDZ file URLs as a link list. The result renders as a card with a live 3D preview + Download buttons ("View in Canvas"). Just say the model is ready and describe it — the user grabs formats from the card.
|
|
279
|
-
- **NEVER surface a raw provider domain** (e.g. `*.fal.media`, `replicate.delivery`) in chat or `.kolbo/production.md`. All Kolbo outputs are Kolbo-hosted; if you ever see a provider URL in a result, treat it as a bug — report it, don't echo it.
|
|
280
|
-
|
|
281
278
|
**After `generate_creative_director` completes** — share results as individual URLs, one per scene. Do NOT create an HTML grid artifact.
|
|
282
279
|
|
|
283
280
|
**Always** record every URL in `.kolbo/production.md` — see `references/workflows/production-log.md`.
|
package/skill/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.7.
|
|
1
|
+
0.7.2
|
package/src/apps/html.js
CHANGED
|
@@ -27,6 +27,49 @@ ${body}
|
|
|
27
27
|
// ---- shared widget runtime ----
|
|
28
28
|
var KOLBO_LOGO_FALLBACK = ${JSON.stringify(KOLBO_LOGO_SVG)};
|
|
29
29
|
var KOLBO_LOGO = ${JSON.stringify(KOLBO_LOGO_IMG)};
|
|
30
|
+
// ---- shared inline SVG icon set (currentColor, 1em; replaces emoji so glyphs
|
|
31
|
+
// render identically in every host iframe instead of tofu boxes) ----
|
|
32
|
+
function _svg(inner, o) {
|
|
33
|
+
o = o || {};
|
|
34
|
+
var f = o.fill ? o.fill : 'none';
|
|
35
|
+
var s = o.fill ? 'none' : 'currentColor';
|
|
36
|
+
return '<svg class="k-ic" viewBox="0 0 24 24" width="1em" height="1em" fill="' + f + '" stroke="' + s +
|
|
37
|
+
'" stroke-width="' + (o.w || 2) + '" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + inner + '</svg>';
|
|
38
|
+
}
|
|
39
|
+
var ICONS = {
|
|
40
|
+
upload: _svg('<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="M17 8l-5-5-5 5"/><path d="M12 3v12"/>'),
|
|
41
|
+
download: _svg('<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/>'),
|
|
42
|
+
play: _svg('<path d="M8 5v14l11-7z"/>', { fill: 'currentColor', w: 1 }),
|
|
43
|
+
pause: _svg('<path d="M6 4h4v16H6zM14 4h4v16h-4z"/>', { fill: 'currentColor', w: 1 }),
|
|
44
|
+
check: _svg('<path d="M20 6 9 17l-5-5"/>', { w: 2.4 }),
|
|
45
|
+
x: _svg('<path d="M18 6 6 18M6 6l12 12"/>', { w: 2.4 }),
|
|
46
|
+
warn: _svg('<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><path d="M12 9v4"/><path d="M12 17h.01"/>'),
|
|
47
|
+
retry: _svg('<path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0 1 15-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 0 1-15 6.7L3 16"/>'),
|
|
48
|
+
edit: _svg('<path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4z"/>'),
|
|
49
|
+
open: _svg('<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><path d="M15 3h6v6"/><path d="M10 14 21 3"/>'),
|
|
50
|
+
arrowRight: _svg('<path d="M5 12h14"/><path d="M12 5l7 7-7 7"/>'),
|
|
51
|
+
sparkle: _svg('<path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10z"/>', { fill: 'currentColor', w: 1 }),
|
|
52
|
+
clock: _svg('<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>'),
|
|
53
|
+
sound: _svg('<path d="M11 5 6 9H2v6h4l5 4z"/><path d="M15.5 8.5a5 5 0 0 1 0 7"/><path d="M19 5a9 9 0 0 1 0 14"/>'),
|
|
54
|
+
mic: _svg('<path d="M12 2a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z"/><path d="M19 10a7 7 0 0 1-14 0"/><path d="M12 19v3"/>'),
|
|
55
|
+
image: _svg('<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/>'),
|
|
56
|
+
video: _svg('<path d="M23 7l-7 5 7 5z"/><rect x="1" y="5" width="15" height="14" rx="2"/>'),
|
|
57
|
+
audio: _svg('<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>'),
|
|
58
|
+
document: _svg('<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8"/><path d="M8 17h8"/>'),
|
|
59
|
+
cube: _svg('<path d="M21 8l-9-5-9 5 9 5z"/><path d="M3 8v8l9 5 9-5V8"/><path d="M12 13v8"/>'),
|
|
60
|
+
file: _svg('<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/>')
|
|
61
|
+
};
|
|
62
|
+
// Media-kind → icon (accepts model kind / media_type strings).
|
|
63
|
+
function kindIcon(kind) {
|
|
64
|
+
switch (String(kind || '').toLowerCase()) {
|
|
65
|
+
case 'image': return ICONS.image;
|
|
66
|
+
case 'video': return ICONS.video;
|
|
67
|
+
case 'audio': case 'music': case 'sound': case 'speech': return ICONS.audio;
|
|
68
|
+
case 'document': case 'doc': return ICONS.document;
|
|
69
|
+
case '3d': case 'three_d': case 'model': return ICONS.cube;
|
|
70
|
+
default: return ICONS.file;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
30
73
|
function esc(s) {
|
|
31
74
|
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
|
|
32
75
|
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
package/src/apps/index.js
CHANGED
|
@@ -20,12 +20,14 @@ const { generationWidgetHtml } = require('./widgets/generation');
|
|
|
20
20
|
const { mediaGridWidgetHtml } = require('./widgets/mediaGrid');
|
|
21
21
|
const { catalogWidgetHtml } = require('./widgets/catalog');
|
|
22
22
|
const { transcriptWidgetHtml } = require('./widgets/transcript');
|
|
23
|
+
const { uploadWidgetHtml } = require('./widgets/upload');
|
|
23
24
|
|
|
24
25
|
const UI = {
|
|
25
26
|
generation: 'ui://kolbo/generation.html',
|
|
26
27
|
mediaGrid: 'ui://kolbo/media-grid.html',
|
|
27
28
|
catalog: 'ui://kolbo/catalog.html',
|
|
28
29
|
transcript: 'ui://kolbo/transcript.html',
|
|
30
|
+
upload: 'ui://kolbo/upload.html',
|
|
29
31
|
};
|
|
30
32
|
|
|
31
33
|
const WIDGET_BUILDERS = {
|
|
@@ -33,6 +35,7 @@ const WIDGET_BUILDERS = {
|
|
|
33
35
|
[UI.mediaGrid]: mediaGridWidgetHtml,
|
|
34
36
|
[UI.catalog]: catalogWidgetHtml,
|
|
35
37
|
[UI.transcript]: transcriptWidgetHtml,
|
|
38
|
+
[UI.upload]: uploadWidgetHtml,
|
|
36
39
|
};
|
|
37
40
|
|
|
38
41
|
// Widgets are pure functions of source — build once per process.
|
|
@@ -72,7 +75,14 @@ const WIDGET_CSP = {
|
|
|
72
75
|
'https://*.sketchfab.com',
|
|
73
76
|
'https://*.cloudfront.net',
|
|
74
77
|
],
|
|
75
|
-
|
|
78
|
+
// connect-src — XHR/fetch FROM widget iframes. Used by the upload widget to
|
|
79
|
+
// POST files to /mcp/upload with its short-lived ticket.
|
|
80
|
+
connectDomains: [
|
|
81
|
+
'https://api.kolbo.ai',
|
|
82
|
+
'https://api-staging.kolbo.ai',
|
|
83
|
+
'https://api-dev.kolbo.ai',
|
|
84
|
+
'https://*.kolbo.ai',
|
|
85
|
+
],
|
|
76
86
|
};
|
|
77
87
|
|
|
78
88
|
/** Register all Kolbo widget resources on an McpServer. */
|
|
@@ -82,6 +92,7 @@ function registerApps(server) {
|
|
|
82
92
|
[UI.mediaGrid, 'Kolbo Library Widget'],
|
|
83
93
|
[UI.catalog, 'Kolbo Model Catalog Widget'],
|
|
84
94
|
[UI.transcript, 'Kolbo Transcription Widget'],
|
|
95
|
+
[UI.upload, 'Kolbo Upload Widget'],
|
|
85
96
|
]) {
|
|
86
97
|
registerAppResource(
|
|
87
98
|
server, name, uri,
|
|
@@ -269,6 +280,8 @@ const TOOL_WIDGETS = {
|
|
|
269
280
|
list_visual_dnas: UI.mediaGrid,
|
|
270
281
|
list_moodboards: UI.mediaGrid,
|
|
271
282
|
shorts_analyze: UI.mediaGrid,
|
|
283
|
+
// upload widget
|
|
284
|
+
media_upload_widget: UI.upload,
|
|
272
285
|
};
|
|
273
286
|
|
|
274
287
|
function attachToolWidgetMeta(server) {
|
package/src/apps/theme.js
CHANGED
|
@@ -222,6 +222,8 @@ html.k-fullscreen .k-actions { flex: none; padding-top: 8px; }
|
|
|
222
222
|
.k-btn.ghost { background: transparent; box-shadow: none; border-color: transparent; color: var(--text-muted); }
|
|
223
223
|
.k-btn.ghost:hover { color: var(--text); background: var(--surface-2); }
|
|
224
224
|
.k-btn svg { width: 13px; height: 13px; }
|
|
225
|
+
/* Inline SVG icons (replace emoji) — align with text, inherit color, never shrink. */
|
|
226
|
+
.k-ic { width: 1em; height: 1em; vertical-align: -0.14em; flex: none; }
|
|
225
227
|
.k-btn:disabled { opacity: 0.5; cursor: default; }
|
|
226
228
|
|
|
227
229
|
/* ---- Inline prompt input (Animate / Edit flows) ---- */
|
|
@@ -38,7 +38,7 @@ const BODY = `
|
|
|
38
38
|
<div class="k-prompt-row" id="prompt-row">
|
|
39
39
|
<input class="k-input" id="action-input" placeholder="">
|
|
40
40
|
<button class="k-btn primary" id="action-send">Send</button>
|
|
41
|
-
<button class="k-btn ghost" id="action-cancel"
|
|
41
|
+
<button class="k-btn ghost" id="action-cancel" aria-label="Cancel"></button>
|
|
42
42
|
</div>
|
|
43
43
|
<div class="k-actions" id="actions"></div>
|
|
44
44
|
</div>
|
|
@@ -55,6 +55,7 @@ var selected = 0; // selected result index
|
|
|
55
55
|
var pollTimer = null;
|
|
56
56
|
|
|
57
57
|
el('logo').innerHTML = KOLBO_LOGO + '<span>Kolbo</span>';
|
|
58
|
+
el('action-cancel').innerHTML = ICONS.x;
|
|
58
59
|
el('kolbo-link').onclick = function (e) { e.preventDefault(); window.kolbo.openLink('https://app.kolbo.ai'); };
|
|
59
60
|
|
|
60
61
|
var TOOL_TITLES = {
|
|
@@ -85,11 +86,11 @@ function renderChips(sc) {
|
|
|
85
86
|
var h = modelChipHTML(sc.model, sc.model_icon);
|
|
86
87
|
var s = sc.settings || {};
|
|
87
88
|
if (sc.kind) h += chip(iconFor(sc.kind) + ' ' + sc.kind);
|
|
88
|
-
if (s.duration) h += chip('
|
|
89
|
+
if (s.duration) h += chip(ICONS.clock + ' ' + fmtDur(s.duration));
|
|
89
90
|
if (s.resolution) h += chip(esc(s.resolution));
|
|
90
91
|
if (s.aspect_ratio) h += chip(esc(s.aspect_ratio));
|
|
91
|
-
if (s.audio) h += chip('
|
|
92
|
-
if (s.voice) h += chip('
|
|
92
|
+
if (s.audio) h += chip(ICONS.sound + ' audio');
|
|
93
|
+
if (s.voice) h += chip(ICONS.mic + ' ' + esc(s.voice));
|
|
93
94
|
if (s.mode) h += chip(esc(s.mode));
|
|
94
95
|
if (sc.count > 1) h += chip('×' + sc.count);
|
|
95
96
|
if (sc.reference_image) h += '<img class="k-ref-thumb" src="' + esc(sc.reference_image) + '" alt="" title="Reference image" onerror="this.style.display=\\'none\\'">';
|
|
@@ -97,7 +98,13 @@ function renderChips(sc) {
|
|
|
97
98
|
}
|
|
98
99
|
function chip(inner) { return '<span class="k-chip">' + inner + '</span>'; }
|
|
99
100
|
function iconFor(kind) {
|
|
100
|
-
|
|
101
|
+
switch (kind) {
|
|
102
|
+
case 'image': return ICONS.image;
|
|
103
|
+
case 'video': case 'scenes': return ICONS.video;
|
|
104
|
+
case 'audio': return ICONS.audio;
|
|
105
|
+
case '3d': return ICONS.cube;
|
|
106
|
+
default: return ICONS.sparkle;
|
|
107
|
+
}
|
|
101
108
|
}
|
|
102
109
|
|
|
103
110
|
/* ---------- generating ---------- */
|
|
@@ -247,7 +254,7 @@ function renderLinks(urls) {
|
|
|
247
254
|
// Small hover download button attached to a media cell (per-item downloads —
|
|
248
255
|
// batch grids and CD scenes have no single "current" url for the action row).
|
|
249
256
|
function dlBtnHTML(u) {
|
|
250
|
-
return '<button class="k-dl" data-dl="' + esc(u) + '" title="Download" aria-label="Download"
|
|
257
|
+
return '<button class="k-dl" data-dl="' + esc(u) + '" title="Download" aria-label="Download">' + ICONS.download + '</button>';
|
|
251
258
|
}
|
|
252
259
|
function wireDlButtons(root) {
|
|
253
260
|
Array.prototype.forEach.call((root || document).querySelectorAll('.k-dl[data-dl]'), function (b) {
|
|
@@ -280,7 +287,7 @@ function renderScenes(sc) {
|
|
|
280
287
|
: '<img id="scene-main" src="' + esc(it.url) + '" alt="" style="cursor:zoom-in">';
|
|
281
288
|
var thumbs = '<div class="k-thumbs">' + items.map(function (t, i) {
|
|
282
289
|
var inner = t.type === 'video'
|
|
283
|
-
? '<span style="display:flex;align-items:center;justify-content:center;width:100%;height:100%;background:rgba(255,255,255,0.06);font-size:
|
|
290
|
+
? '<span style="display:flex;align-items:center;justify-content:center;width:100%;height:100%;background:rgba(255,255,255,0.06);font-size:22px">' + ICONS.play + '</span>'
|
|
284
291
|
: '<img src="' + esc(t.url) + '" alt="" loading="lazy">';
|
|
285
292
|
return '<div class="k-thumb' + (i === selected ? ' active' : '') + '" data-i="' + i + '" title="' + esc(t.label) + '">' + inner + '</div>';
|
|
286
293
|
}).join('') + '</div>';
|
|
@@ -324,8 +331,8 @@ function renderError(msg) {
|
|
|
324
331
|
clearTimeout(pollTimer);
|
|
325
332
|
|
|
326
333
|
setPhaseChip('Failed', false);
|
|
327
|
-
el('stage').innerHTML = '<div class="k-error"
|
|
328
|
-
el('actions').innerHTML = '<button class="k-btn" id="retry-btn"
|
|
334
|
+
el('stage').innerHTML = '<div class="k-error">' + ICONS.warn + ' ' + esc(msg) + '</div>';
|
|
335
|
+
el('actions').innerHTML = '<button class="k-btn" id="retry-btn">' + ICONS.retry + ' Try Again</button>';
|
|
329
336
|
el('retry-btn').onclick = function () {
|
|
330
337
|
var what = (state && TOOL_TITLES[state.tool]) || 'generation';
|
|
331
338
|
window.kolbo.sendMessage('Please retry that ' + what.toLowerCase() + ' — it failed with: ' + msg);
|
|
@@ -355,7 +362,7 @@ function applyFullscreen(on, exitHandler) {
|
|
|
355
362
|
var c = el('phase-chip');
|
|
356
363
|
if (on) {
|
|
357
364
|
c.style.display = '';
|
|
358
|
-
c.innerHTML = '
|
|
365
|
+
c.innerHTML = ICONS.x + ' ' + esc('Exit');
|
|
359
366
|
c.style.cursor = 'pointer';
|
|
360
367
|
c.onclick = exitHandler || toggleFullscreen;
|
|
361
368
|
} else {
|
|
@@ -382,18 +389,18 @@ function renderActions(sc) {
|
|
|
382
389
|
var a = [];
|
|
383
390
|
var hasSingleUrl = !!(sc.urls && sc.urls.length);
|
|
384
391
|
if (sc.kind === 'image') {
|
|
385
|
-
a.push('<button class="k-btn primary" id="btn-animate"
|
|
386
|
-
a.push('<button class="k-btn" id="btn-edit"
|
|
392
|
+
a.push('<button class="k-btn primary" id="btn-animate">' + ICONS.video + ' Animate</button>');
|
|
393
|
+
a.push('<button class="k-btn" id="btn-edit">' + ICONS.edit + ' Edit</button>');
|
|
387
394
|
}
|
|
388
395
|
if (sc.kind === 'video') {
|
|
389
|
-
a.push('<button class="k-btn primary" id="btn-download"
|
|
396
|
+
a.push('<button class="k-btn primary" id="btn-download">' + ICONS.download + ' Download</button>');
|
|
390
397
|
} else if (hasSingleUrl) {
|
|
391
398
|
// Scenes (Creative Director) have no single "current" url — per-item hover
|
|
392
399
|
// download buttons cover them instead.
|
|
393
|
-
a.push('<button class="k-btn" id="btn-download"
|
|
400
|
+
a.push('<button class="k-btn" id="btn-download">' + ICONS.download + ' Download</button>');
|
|
394
401
|
}
|
|
395
|
-
a.push('<button class="k-btn" id="btn-recreate"
|
|
396
|
-
a.push('<button class="k-btn ghost" id="btn-open">Open in Kolbo
|
|
402
|
+
a.push('<button class="k-btn" id="btn-recreate">' + ICONS.retry + ' Recreate</button>');
|
|
403
|
+
a.push('<button class="k-btn ghost" id="btn-open">Open in Kolbo ' + ICONS.open + '</button>');
|
|
397
404
|
el('actions').innerHTML = a.join('');
|
|
398
405
|
|
|
399
406
|
bind('btn-download', function () { window.kolbo.openLink(downloadUrl(currentUrl())); });
|
|
@@ -405,7 +412,7 @@ function renderActions(sc) {
|
|
|
405
412
|
'\\n(from the ' + (TOOL_TITLES[state.tool] || 'generation') + ' widget)');
|
|
406
413
|
});
|
|
407
414
|
bind('btn-animate', function () {
|
|
408
|
-
openPromptRow('Describe the motion (optional
|
|
415
|
+
openPromptRow('Describe the motion (optional)…', function (text) {
|
|
409
416
|
window.kolbo.sendMessage('Animate this image into a short video' +
|
|
410
417
|
'\\n🎬 Reference image: ' + currentUrl() +
|
|
411
418
|
'\\nModel: smart select — pick the best image-to-video model' +
|
|
@@ -65,8 +65,8 @@ function cellHTML(item, i) {
|
|
|
65
65
|
var idx = state.items.indexOf(item);
|
|
66
66
|
var media = item.thumbnail
|
|
67
67
|
? '<img src="' + esc(item.thumbnail) + '" loading="lazy" alt="">'
|
|
68
|
-
: '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-faint);font-size:
|
|
69
|
-
(
|
|
68
|
+
: '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-faint);font-size:22px">' +
|
|
69
|
+
kindIcon(item.media_type) + '</div>';
|
|
70
70
|
return '<div class="k-cell" data-i="' + idx + '">' +
|
|
71
71
|
'<div class="k-cell-media">' + media + '</div>' +
|
|
72
72
|
'<div class="k-cell-label">' + esc(item.title || '') + '</div>' +
|
|
@@ -81,7 +81,7 @@ function audioRowHTML(item) {
|
|
|
81
81
|
'<div class="k-audio-meta"><div class="k-audio-title">' + esc(item.title || '') + '</div>' +
|
|
82
82
|
'<div class="k-audio-sub">' + esc(item.subtitle || '') + '</div></div>' +
|
|
83
83
|
(item.preview_audio || item.url
|
|
84
|
-
? '<button class="k-play" data-play="' + esc(item.preview_audio || item.url) + '"
|
|
84
|
+
? '<button class="k-play" data-play="' + esc(item.preview_audio || item.url) + '">' + ICONS.play + '</button>' : '') +
|
|
85
85
|
'<button class="k-btn" data-use="' + idx + '">Use</button></div>';
|
|
86
86
|
}
|
|
87
87
|
|
|
@@ -96,13 +96,13 @@ function wire() {
|
|
|
96
96
|
b.onclick = function (e) {
|
|
97
97
|
e.stopPropagation();
|
|
98
98
|
var url = b.getAttribute('data-play');
|
|
99
|
-
if (playing && playing.src === url && !playing.paused) { playing.pause(); b.
|
|
99
|
+
if (playing && playing.src === url && !playing.paused) { playing.pause(); b.innerHTML = ICONS.play; return; }
|
|
100
100
|
if (playing) playing.pause();
|
|
101
|
-
Array.prototype.forEach.call(document.querySelectorAll('[data-play]'), function (x) { x.
|
|
101
|
+
Array.prototype.forEach.call(document.querySelectorAll('[data-play]'), function (x) { x.innerHTML = ICONS.play; });
|
|
102
102
|
playing = new Audio(url);
|
|
103
103
|
playing.play();
|
|
104
|
-
b.
|
|
105
|
-
playing.onended = function () { b.
|
|
104
|
+
b.innerHTML = ICONS.pause;
|
|
105
|
+
playing.onended = function () { b.innerHTML = ICONS.play; };
|
|
106
106
|
};
|
|
107
107
|
});
|
|
108
108
|
}
|
|
@@ -54,7 +54,7 @@ function boot(sc) {
|
|
|
54
54
|
}
|
|
55
55
|
if (sc.phase === 'failed') {
|
|
56
56
|
el('phase-chip').style.display = 'none';
|
|
57
|
-
el('stage').innerHTML = '<div class="k-error"
|
|
57
|
+
el('stage').innerHTML = '<div class="k-error">' + ICONS.warn + ' ' + esc(sc.error || 'Transcription failed') + '</div>';
|
|
58
58
|
return;
|
|
59
59
|
}
|
|
60
60
|
el('phase-chip').style.display = '';
|
|
@@ -67,9 +67,9 @@ function boot(sc) {
|
|
|
67
67
|
'background:var(--surface);border:1px solid var(--border);font-size:12.5px;color:var(--text-muted);white-space:pre-wrap">' +
|
|
68
68
|
esc(sc.text || '(empty transcript)') + '</div>';
|
|
69
69
|
var a = [];
|
|
70
|
-
if (sc.srt_url) a.push('<button class="k-btn primary" data-url="' + esc(sc.srt_url) + '"
|
|
71
|
-
if (sc.word_by_word_srt_url) a.push('<button class="k-btn" data-url="' + esc(sc.word_by_word_srt_url) + '"
|
|
72
|
-
if (sc.txt_url) a.push('<button class="k-btn" data-url="' + esc(sc.txt_url) + '"
|
|
70
|
+
if (sc.srt_url) a.push('<button class="k-btn primary" data-url="' + esc(sc.srt_url) + '">' + ICONS.download + ' SRT</button>');
|
|
71
|
+
if (sc.word_by_word_srt_url) a.push('<button class="k-btn" data-url="' + esc(sc.word_by_word_srt_url) + '">' + ICONS.download + ' Word-by-word SRT</button>');
|
|
72
|
+
if (sc.txt_url) a.push('<button class="k-btn" data-url="' + esc(sc.txt_url) + '">' + ICONS.download + ' TXT</button>');
|
|
73
73
|
a.push('<button class="k-btn ghost" id="btn-copy">Copy text</button>');
|
|
74
74
|
el('actions').innerHTML = a.join('');
|
|
75
75
|
Array.prototype.forEach.call(el('actions').querySelectorAll('[data-url]'), function (b) {
|
|
@@ -77,7 +77,7 @@ function boot(sc) {
|
|
|
77
77
|
});
|
|
78
78
|
var copyBtn = el('btn-copy');
|
|
79
79
|
if (copyBtn) copyBtn.onclick = function () {
|
|
80
|
-
try { navigator.clipboard.writeText(state.text || ''); copyBtn.
|
|
80
|
+
try { navigator.clipboard.writeText(state.text || ''); copyBtn.innerHTML = 'Copied ' + ICONS.check; } catch (e) {}
|
|
81
81
|
};
|
|
82
82
|
window.kolbo.notifySize();
|
|
83
83
|
}
|
|
@@ -107,7 +107,7 @@ window.kolbo.onToolResult(function (result) {
|
|
|
107
107
|
if (result.isError || /error|failed/i.test(txt)) {
|
|
108
108
|
el('phase-chip').style.display = 'none';
|
|
109
109
|
el('stage').classList.remove('k-empty');
|
|
110
|
-
el('stage').innerHTML = '<div class="k-error"
|
|
110
|
+
el('stage').innerHTML = '<div class="k-error">' + ICONS.warn + ' ' + esc((txt || 'Transcription failed').slice(0, 300)) + '</div>';
|
|
111
111
|
window.kolbo.notifySize();
|
|
112
112
|
return;
|
|
113
113
|
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { widgetPage } = require('../html');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Upload widget — media_upload_widget tool.
|
|
7
|
+
*
|
|
8
|
+
* Lets claude.ai users upload LOCAL files (images / video / audio / documents)
|
|
9
|
+
* into their Kolbo media library from inside the chat. Chat attachments are
|
|
10
|
+
* unreachable from remote MCP servers — this widget is the bridge: the file is
|
|
11
|
+
* POSTed straight from the iframe to api.kolbo.ai/mcp/upload with a
|
|
12
|
+
* short-lived, upload-only ticket (never the user's API key).
|
|
13
|
+
*
|
|
14
|
+
* structuredContent: {
|
|
15
|
+
* widget: 'upload', title, upload_url, token, expires_at (epoch ms),
|
|
16
|
+
* accept (input accept attr), max_files, max_mb: {image,video,audio,document},
|
|
17
|
+
* project_id?
|
|
18
|
+
* }
|
|
19
|
+
*
|
|
20
|
+
* Flow: pick/drop files -> client-side type+size validation -> XHR upload
|
|
21
|
+
* (2 concurrent, per-file progress) -> per-file CDN URL. Every completed file
|
|
22
|
+
* is pushed into the model context silently; the "Use these files" button
|
|
23
|
+
* sends one chat message with all URLs so the model continues the task.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const BODY = `
|
|
27
|
+
<div class="k-card">
|
|
28
|
+
<div class="k-head">
|
|
29
|
+
<span class="k-logo" id="logo"></span>
|
|
30
|
+
<span class="k-title" id="title">Upload media</span>
|
|
31
|
+
<span class="k-spacer"></span>
|
|
32
|
+
<span class="k-chip" id="count-chip" style="display:none"></span>
|
|
33
|
+
</div>
|
|
34
|
+
<div class="k-body">
|
|
35
|
+
<div id="drop" style="border:1.5px dashed var(--border);border-radius:12px;padding:26px 16px;text-align:center;cursor:pointer;transition:border-color .15s,background .15s">
|
|
36
|
+
<div id="drop-icon" style="font-size:26px;line-height:1;margin-bottom:8px;color:var(--text-muted)"></div>
|
|
37
|
+
<div style="font-size:13px;font-weight:600">Click or drop files here</div>
|
|
38
|
+
<div id="accept-hint" style="font-size:11.5px;color:var(--text-muted);margin-top:4px"></div>
|
|
39
|
+
</div>
|
|
40
|
+
<input type="file" id="picker" multiple style="display:none">
|
|
41
|
+
<div id="rows" style="margin-top:10px"></div>
|
|
42
|
+
<div class="k-actions" id="actions" style="display:none"></div>
|
|
43
|
+
<div id="notice" style="display:none;margin-top:8px;font-size:12px;color:var(--text-muted)"></div>
|
|
44
|
+
</div>
|
|
45
|
+
<div class="k-footer">
|
|
46
|
+
<span><a href="#" id="kolbo-link">Kolbo.AI</a> Media Library</span>
|
|
47
|
+
<span class="k-credits">free</span>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
`;
|
|
51
|
+
|
|
52
|
+
const SCRIPT = `
|
|
53
|
+
el('logo').innerHTML = KOLBO_LOGO + '<span>Kolbo</span>';
|
|
54
|
+
el('drop-icon').innerHTML = ICONS.upload;
|
|
55
|
+
el('kolbo-link').onclick = function (e) { e.preventDefault(); window.kolbo.openLink('https://app.kolbo.ai/media-library'); };
|
|
56
|
+
|
|
57
|
+
var state = null;
|
|
58
|
+
var items = []; // {file, kind, status, pct, url, err, id}
|
|
59
|
+
var nextItemId = 1;
|
|
60
|
+
var CONCURRENCY = 2;
|
|
61
|
+
var active = 0;
|
|
62
|
+
var sent = false;
|
|
63
|
+
|
|
64
|
+
var KINDS = {
|
|
65
|
+
image: { exts: ['jpg','jpeg','png','webp','gif','heic','heif','avif','bmp','tif','tiff'], icon: ICONS.image },
|
|
66
|
+
video: { exts: ['mp4','mov','webm','m4v','mkv','avi'], icon: ICONS.video },
|
|
67
|
+
audio: { exts: ['mp3','wav','m4a','aac','ogg','flac'], icon: ICONS.audio },
|
|
68
|
+
document: { exts: ['pdf','txt','md','csv','json','docx','xlsx','pptx','doc','xls'], icon: ICONS.document }
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
function classify(name) {
|
|
72
|
+
var ext = String(name || '').split('.').pop().toLowerCase();
|
|
73
|
+
for (var k in KINDS) { if (KINDS[k].exts.indexOf(ext) !== -1) return k; }
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function fmtSize(b) {
|
|
78
|
+
if (b == null) return '';
|
|
79
|
+
if (b > 1024 * 1024) return (Math.round(b / 1024 / 102.4) / 10) + 'MB';
|
|
80
|
+
return Math.max(1, Math.round(b / 1024)) + 'KB';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function expired() { return state && state.expires_at && Date.now() > state.expires_at; }
|
|
84
|
+
|
|
85
|
+
function boot(sc) {
|
|
86
|
+
if (!sc || sc.widget !== 'upload') return;
|
|
87
|
+
state = sc;
|
|
88
|
+
if (sc.title) el('title').textContent = sc.title;
|
|
89
|
+
var kinds = sc.kinds && sc.kinds.length ? sc.kinds : ['image','video','audio','document'];
|
|
90
|
+
var exts = [];
|
|
91
|
+
kinds.forEach(function (k) { if (KINDS[k]) exts = exts.concat(KINDS[k].exts); });
|
|
92
|
+
el('picker').setAttribute('accept', exts.map(function (e) { return '.' + e; }).join(','));
|
|
93
|
+
el('accept-hint').textContent = kinds.join(' · ') + ' — up to ' + (sc.max_files || 10) + ' files';
|
|
94
|
+
if (expired()) return showExpired();
|
|
95
|
+
window.kolbo.notifySize();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function showExpired() {
|
|
99
|
+
el('drop').style.pointerEvents = 'none';
|
|
100
|
+
el('drop').style.opacity = '0.5';
|
|
101
|
+
el('notice').style.display = '';
|
|
102
|
+
el('notice').innerHTML = ICONS.clock + ' This upload window expired. Ask Claude to open a new upload widget.';
|
|
103
|
+
window.kolbo.notifySize();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---- picking ----
|
|
107
|
+
el('drop').onclick = function () { el('picker').click(); };
|
|
108
|
+
el('drop').ondragover = function (e) { e.preventDefault(); el('drop').style.borderColor = 'var(--accent, #7c6cff)'; };
|
|
109
|
+
el('drop').ondragleave = function () { el('drop').style.borderColor = 'var(--border)'; };
|
|
110
|
+
el('drop').ondrop = function (e) {
|
|
111
|
+
e.preventDefault();
|
|
112
|
+
el('drop').style.borderColor = 'var(--border)';
|
|
113
|
+
addFiles(e.dataTransfer && e.dataTransfer.files);
|
|
114
|
+
};
|
|
115
|
+
el('picker').onchange = function () { addFiles(el('picker').files); el('picker').value = ''; };
|
|
116
|
+
|
|
117
|
+
function addFiles(list) {
|
|
118
|
+
if (!list || !state) return;
|
|
119
|
+
if (expired()) return showExpired();
|
|
120
|
+
var maxFiles = state.max_files || 10;
|
|
121
|
+
for (var i = 0; i < list.length; i++) {
|
|
122
|
+
if (items.length >= maxFiles) break;
|
|
123
|
+
var f = list[i];
|
|
124
|
+
var kind = classify(f.name);
|
|
125
|
+
var it = { file: f, kind: kind, status: 'queued', pct: 0, url: null, err: null, id: nextItemId++ };
|
|
126
|
+
var allowedKinds = state.kinds && state.kinds.length ? state.kinds : ['image','video','audio','document'];
|
|
127
|
+
if (!kind || allowedKinds.indexOf(kind) === -1) {
|
|
128
|
+
it.status = 'error'; it.err = 'Unsupported file type';
|
|
129
|
+
} else {
|
|
130
|
+
var capMb = (state.max_mb && state.max_mb[kind]) || 50;
|
|
131
|
+
if (f.size > capMb * 1024 * 1024) { it.status = 'error'; it.err = kind + ' files are limited to ' + capMb + 'MB'; }
|
|
132
|
+
}
|
|
133
|
+
items.push(it);
|
|
134
|
+
}
|
|
135
|
+
render();
|
|
136
|
+
pump();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---- upload queue ----
|
|
140
|
+
function pump() {
|
|
141
|
+
if (expired()) return showExpired();
|
|
142
|
+
while (active < CONCURRENCY) {
|
|
143
|
+
var next = null;
|
|
144
|
+
for (var i = 0; i < items.length; i++) { if (items[i].status === 'queued') { next = items[i]; break; } }
|
|
145
|
+
if (!next) break;
|
|
146
|
+
upload(next);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function upload(it) {
|
|
151
|
+
it.status = 'uploading';
|
|
152
|
+
active++;
|
|
153
|
+
render();
|
|
154
|
+
var fd = new FormData();
|
|
155
|
+
fd.append('file', it.file, it.file.name);
|
|
156
|
+
if (state.project_id) fd.append('project_id', state.project_id);
|
|
157
|
+
var xhr = new XMLHttpRequest();
|
|
158
|
+
xhr.open('POST', state.upload_url, true);
|
|
159
|
+
xhr.setRequestHeader('Authorization', 'Bearer ' + state.token);
|
|
160
|
+
xhr.upload.onprogress = function (e) {
|
|
161
|
+
if (e.lengthComputable) { it.pct = Math.round((e.loaded / e.total) * 100); renderRow(it); }
|
|
162
|
+
};
|
|
163
|
+
xhr.onload = function () {
|
|
164
|
+
active--;
|
|
165
|
+
var res = null;
|
|
166
|
+
try { res = JSON.parse(xhr.responseText); } catch (e) {}
|
|
167
|
+
if (xhr.status >= 200 && xhr.status < 300 && res && res.success && res.media && res.media.url) {
|
|
168
|
+
it.status = 'done';
|
|
169
|
+
it.url = res.media.url;
|
|
170
|
+
// Silent context update — the model learns the URL even before the
|
|
171
|
+
// user clicks "Use these files".
|
|
172
|
+
try {
|
|
173
|
+
window.kolbo.updateModelContext('Upload widget: "' + it.file.name + '" (' + it.kind + ') uploaded to the Kolbo media library. URL: ' + it.url);
|
|
174
|
+
} catch (e) {}
|
|
175
|
+
} else {
|
|
176
|
+
it.status = 'error';
|
|
177
|
+
it.err = (res && res.error) || ('Upload failed (' + xhr.status + ')');
|
|
178
|
+
}
|
|
179
|
+
render();
|
|
180
|
+
pump();
|
|
181
|
+
};
|
|
182
|
+
xhr.onerror = function () {
|
|
183
|
+
active--;
|
|
184
|
+
it.status = 'error';
|
|
185
|
+
it.err = 'Network error — try again';
|
|
186
|
+
render();
|
|
187
|
+
pump();
|
|
188
|
+
};
|
|
189
|
+
xhr.send(fd);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---- rendering ----
|
|
193
|
+
function rowHtml(it) {
|
|
194
|
+
var icon = it.kind && KINDS[it.kind] ? KINDS[it.kind].icon : ICONS.file;
|
|
195
|
+
var right = '';
|
|
196
|
+
if (it.status === 'queued') right = '<span style="color:var(--text-muted)">queued</span>';
|
|
197
|
+
else if (it.status === 'uploading') right = '<span style="color:var(--text-muted)">' + it.pct + '%</span>';
|
|
198
|
+
else if (it.status === 'done') right = '<span style="color:#4ade80">' + ICONS.check + ' uploaded</span>';
|
|
199
|
+
else right = '<span class="k-error" style="padding:0;border:0;background:none">' + ICONS.x + ' ' + esc(it.err || 'failed') + '</span> <a href="#" data-retry="' + it.id + '" style="font-size:11px">retry</a>';
|
|
200
|
+
var bar = it.status === 'uploading'
|
|
201
|
+
? '<div style="height:3px;border-radius:2px;background:var(--surface);margin-top:5px;overflow:hidden"><div id="bar-' + it.id + '" style="height:100%;width:' + it.pct + '%;background:var(--accent,#7c6cff);transition:width .2s"></div></div>'
|
|
202
|
+
: '';
|
|
203
|
+
return '<div id="row-' + it.id + '" style="padding:8px 10px;border:1px solid var(--border);border-radius:10px;margin-bottom:6px;background:var(--surface)">' +
|
|
204
|
+
'<div style="display:flex;align-items:center;gap:8px;font-size:12.5px">' +
|
|
205
|
+
'<span>' + icon + '</span>' +
|
|
206
|
+
'<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + esc(it.file.name) + '">' + esc(it.file.name) + '</span>' +
|
|
207
|
+
'<span style="color:var(--text-muted);font-size:11px">' + fmtSize(it.file.size) + '</span>' +
|
|
208
|
+
'<span id="status-' + it.id + '" style="font-size:11.5px">' + right + '</span>' +
|
|
209
|
+
'</div>' + bar + '</div>';
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function renderRow(it) {
|
|
213
|
+
var s = el('status-' + it.id);
|
|
214
|
+
if (s && it.status === 'uploading') s.innerHTML = '<span style="color:var(--text-muted)">' + it.pct + '%</span>';
|
|
215
|
+
var b = el('bar-' + it.id);
|
|
216
|
+
if (b) b.style.width = it.pct + '%';
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function render() {
|
|
220
|
+
el('rows').innerHTML = items.map(rowHtml).join('');
|
|
221
|
+
Array.prototype.forEach.call(el('rows').querySelectorAll('[data-retry]'), function (a) {
|
|
222
|
+
a.onclick = function (e) {
|
|
223
|
+
e.preventDefault();
|
|
224
|
+
var id = Number(a.getAttribute('data-retry'));
|
|
225
|
+
for (var i = 0; i < items.length; i++) {
|
|
226
|
+
if (items[i].id === id) { items[i].status = 'queued'; items[i].err = null; items[i].pct = 0; }
|
|
227
|
+
}
|
|
228
|
+
render();
|
|
229
|
+
pump();
|
|
230
|
+
};
|
|
231
|
+
});
|
|
232
|
+
var done = items.filter(function (i) { return i.status === 'done'; });
|
|
233
|
+
var busy = items.some(function (i) { return i.status === 'uploading' || i.status === 'queued'; });
|
|
234
|
+
el('count-chip').style.display = items.length ? '' : 'none';
|
|
235
|
+
el('count-chip').textContent = done.length + '/' + items.length + ' uploaded';
|
|
236
|
+
if (done.length && !busy && !sent) {
|
|
237
|
+
el('actions').style.display = '';
|
|
238
|
+
el('actions').innerHTML = '<button class="k-btn primary" id="btn-use">Use ' + (done.length === 1 ? 'this file' : 'these ' + done.length + ' files') + '</button>' +
|
|
239
|
+
'<button class="k-btn ghost" id="btn-more">Add more</button>';
|
|
240
|
+
el('btn-use').onclick = function () {
|
|
241
|
+
if (sent) return;
|
|
242
|
+
sent = true;
|
|
243
|
+
var lines = done.map(function (i, idx) { return (idx + 1) + '. ' + i.file.name + ' (' + i.kind + '): ' + i.url; });
|
|
244
|
+
window.kolbo.sendMessage('I uploaded ' + done.length + ' file(s) to my Kolbo media library:\\n' + lines.join('\\n') + '\\nContinue with these files.');
|
|
245
|
+
el('actions').innerHTML = '<span style="font-size:12px;color:var(--text-muted)">' + ICONS.check + ' Sent to Claude — continuing…</span>';
|
|
246
|
+
window.kolbo.notifySize();
|
|
247
|
+
};
|
|
248
|
+
el('btn-more').onclick = function () { el('picker').click(); };
|
|
249
|
+
} else if (!done.length || busy) {
|
|
250
|
+
el('actions').style.display = 'none';
|
|
251
|
+
}
|
|
252
|
+
window.kolbo.notifySize();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
window.kolbo.onToolResult(function (result) {
|
|
256
|
+
var sc = result.structuredContent || structured(result);
|
|
257
|
+
if (sc && sc.widget === 'upload') return boot(sc);
|
|
258
|
+
var card = document.querySelector('.k-card');
|
|
259
|
+
if (card && !state) card.style.display = 'none';
|
|
260
|
+
window.kolbo.notifySize();
|
|
261
|
+
});
|
|
262
|
+
`;
|
|
263
|
+
|
|
264
|
+
function uploadWidgetHtml() {
|
|
265
|
+
return widgetPage({ title: 'Kolbo Upload', body: BODY, script: SCRIPT });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
module.exports = { uploadWidgetHtml };
|
package/src/index.js
CHANGED
|
@@ -118,7 +118,9 @@ function createServer(opts = {}) {
|
|
|
118
118
|
'4. If the user has not mentioned any project, omit `project_id` — the default bucket is correct in that case. Do not ask which project to use unless the user\'s intent is ambiguous.',
|
|
119
119
|
'5. Written deliverables (plans, briefs, scripts, research summaries) can live in Kolbo too: author them as AI Docs with `create_doc` (project-scoped, editable in the app, shareable via `share_doc`).',
|
|
120
120
|
'6. DIRECTOR / BATCH JOBS: generate_creative_director runs its scenes (image OR video) in parallel and only reports state="completed" once EVERY scene is terminal. Video batches can take many minutes. If the tool returns `_timed_out:true`, the batch is STILL RUNNING on the server — call `get_creative_director_status` with the returned generation_id and keep checking until state="completed" to collect all scene outputs. NEVER conclude a Director run failed and fall back to plain generate_image/generate_video without first checking status — doing so wastes the user\'s credits by paying twice. If scenes already carry image_urls/video_urls, they are done; do not regenerate.',
|
|
121
|
-
'7. SESSION CONTINUITY: keep one workflow in ONE session. chat_send_message and the generation tools return a `session_id` — for follow-ups, refinements, retries, or additional steps on the SAME task/theme, pass that same `session_id` back on the next call instead of starting fresh. Only OMIT session_id (start a new session) when the user genuinely switches to an unrelated task. Do not open a new conversation/session for every message of the same workflow — it fragments the user\'s history and loses context.'
|
|
121
|
+
'7. SESSION CONTINUITY: keep one workflow in ONE session. chat_send_message and the generation tools return a `session_id` — for follow-ups, refinements, retries, or additional steps on the SAME task/theme, pass that same `session_id` back on the next call instead of starting fresh. Only OMIT session_id (start a new session) when the user genuinely switches to an unrelated task. Do not open a new conversation/session for every message of the same workflow — it fragments the user\'s history and loses context.',
|
|
122
|
+
'8. LOCAL FILES / CHAT ATTACHMENTS: remote MCP tools CANNOT read files the user attached to the chat. When a claude.ai (browser/mobile) user has a local image/video/audio/document to use as a generation input, IMMEDIATELY call `media_upload_widget` — an upload card appears in the chat, they upload, and stable Kolbo CDN URLs come back in a follow-up message. Never ask them to re-attach the file in chat and never invent a URL. On Claude Desktop/Code with filesystem access, use `upload_media` with the absolute local path instead.',
|
|
123
|
+
'9. MODEL SELECTION: ALWAYS pass a specific `model` on every generation tool — do NOT omit it. Omitting falls back to "Smart Select" auto-routing, which we deliberately avoid because it hides the model choice from the user and often picks a generic default. Choose the model that best fits the task and the user\'s intent (quality, speed, style, capability). If you are unsure which model to use for a given type, call `list_models` with the matching `type` and pick the recommended/flagship one, then pass its `identifier`. Only use Smart Select (omit `model`) if the user EXPLICITLY asks you to auto-pick.'
|
|
122
124
|
].join('\n')
|
|
123
125
|
});
|
|
124
126
|
|
package/src/tools/generate.js
CHANGED
|
@@ -52,7 +52,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
52
52
|
'Generate image(s) from a text prompt using Kolbo AI. Supports Visual DNA profiles (for character/style/product consistency), moodboards (for style direction), reference images (for composition guidance), batch generation (num_images), and web-search grounding. For EDITING an existing image, use generate_image_edit instead. For a coordinated multi-scene set (storyboard, ad campaign), use generate_creative_director. Returns the final image URL(s) when complete.',
|
|
53
53
|
{
|
|
54
54
|
prompt: z.string().describe('Text description of the image to generate'),
|
|
55
|
-
model: z.string().optional().describe('Model identifier.
|
|
55
|
+
model: z.string().optional().describe('Model identifier — REQUIRED in practice: pick a specific model, do NOT omit (omitting = Smart Select auto-pick, which we avoid). Strong current defaults: "nano-banana-2" (versatile, text rendering, multilingual) or "gpt-image-2" (photoreal, infographics). Call list_models type="text_to_img" to see all options and pick per the user\'s intent.'),
|
|
56
56
|
aspect_ratio: z.string().optional().describe('Aspect ratio (e.g., "1:1", "16:9", "9:16"). Must be a value present in the model\'s `supported_aspect_ratios` from list_models — pass an unsupported value and the API rejects. Default: "1:1"'),
|
|
57
57
|
enhance_prompt: z.boolean().optional().describe('Enhance the prompt for better results. Default: true'),
|
|
58
58
|
num_images: z.number().optional().describe('Number of images to generate in one call. Default: 1. Note: some models (Midjourney etc.) have a fixed `images_per_request` and ignore this — check list_models.'),
|
|
@@ -105,7 +105,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
105
105
|
'Edit or transform an existing image using AI. Provide the source image URL(s) in `source_images` and describe the edit in `prompt` (e.g., "remove the background", "change the car color to red", "add sunglasses to the person"). Supports Visual DNA profiles and moodboards for style-consistent edits. For creating a brand new image from scratch, use generate_image. Returns the edited image URL(s) when complete.',
|
|
106
106
|
{
|
|
107
107
|
prompt: z.string().describe('Description of the edit to apply (e.g., "remove the background", "change the sky to sunset")'),
|
|
108
|
-
model: z.string().optional().describe('Model identifier.
|
|
108
|
+
model: z.string().optional().describe('Model identifier — REQUIRED in practice: pick a specific editing model, do NOT omit (omitting = Smart Select auto-pick, which we avoid). Strong current default: "nano-banana-2" (best general prompt-driven editor) or "gpt-image-2" for photoreal edits. Call list_models type="image_editing" to see all options and pick per the user\'s intent.'),
|
|
109
109
|
source_images: z.array(z.string()).describe('PIXEL-ACCURATE compositing. Array of source image URLs whose pixel content is composited into the output. **Cap: pass at most `max_reference_images` URLs from list_models for the chosen model — exceeding it is a deterministic 400.** Three modes the model auto-detects from input shape: (1) Single image → edit/transform that image. (2) Multiple images, one base + others → composite the others into the base. (3) Multiple images with no clear base → generate a new scene that pixel-accurately embeds the supplied images at positions described in the prompt. Mode 3 is the canonical pattern for thumbnails / branded compositions where exact-pixel logo + face fidelity matter. Refer to source images in the prompt by ordinal position ("FIRST source image", "SECOND source image") or use @image1/@image2 tags. Add "composite AS-IS, do not redraw or restyle" to lock pixels.'),
|
|
110
110
|
aspect_ratio: z.string().optional().describe('Output aspect ratio (e.g., "1:1", "16:9", "9:16"). Must be in the chosen model\'s `supported_aspect_ratios` from list_models. Default: "1:1"'),
|
|
111
111
|
enhance_prompt: z.boolean().optional().describe('Enhance the prompt for better results. Default: true'),
|
|
@@ -162,7 +162,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
162
162
|
{
|
|
163
163
|
prompt: z.string().describe('Creative brief or concept describing the full set of scenes to generate'),
|
|
164
164
|
scene_count: z.number().optional().describe('Number of scenes/images to generate, 1–8. Default: 4. Use this — NOT num_images — to control how many outputs are created.'),
|
|
165
|
-
model: z.string().optional().describe('Model identifier applied to every scene.
|
|
165
|
+
model: z.string().optional().describe('Model identifier applied to every scene. Pick a SPECIFIC model — do NOT omit (omitting = Smart Select auto-pick, which we avoid); call list_models for this type and choose the model that best fits the user\'s intent.'),
|
|
166
166
|
aspect_ratio: z.string().optional().describe('Aspect ratio applied to every scene (e.g., "1:1", "16:9", "9:16"). Must be in the chosen model\'s `supported_aspect_ratios` from list_models. Default: "1:1"'),
|
|
167
167
|
workflow_type: z.string().optional().describe('"image" (default) or "video"'),
|
|
168
168
|
duration: z.number().optional().describe('Duration in seconds per scene (video mode only). Must be a value in `supported_durations` from list_models, OR within `min_output_duration`-`max_output_duration`. E.g., 5 or 10.'),
|
|
@@ -598,7 +598,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
598
598
|
'Generate a video from reference elements (images, videos, and/or audio) + a text prompt. Use when the user wants to animate specific uploaded/referenced assets — e.g. "animate this product", "put these 3 characters into a scene". IMPORTANT: different models accept different numbers of inputs — call list_models type="elements" and read elements_max_images / elements_max_videos / elements_max_audio on the chosen model before generating. For text-only → video use generate_video instead. For animating a single still image use generate_video_from_image. Returns the final video URL when complete.',
|
|
599
599
|
{
|
|
600
600
|
prompt: z.string().describe('Text description of the desired video / animation'),
|
|
601
|
-
model: z.string().optional().describe('Model identifier. Use list_models type="elements" to see options (Seedance 2, Kling O3 Reference, Grok Imagine, Veo 3.1, etc.). Check elements_max_images / elements_max_videos / elements_max_audio on the model.
|
|
601
|
+
model: z.string().optional().describe('Model identifier. Use list_models type="elements" to see options (Seedance 2, Kling O3 Reference, Grok Imagine, Veo 3.1, etc.). Check elements_max_images / elements_max_videos / elements_max_audio on the model. Pick a SPECIFIC model — do NOT omit (omitting = Smart Select auto-pick, which we avoid); call list_models for this type and choose the model that best fits the user\'s intent.'),
|
|
602
602
|
reference_images: z.array(z.string()).optional().describe('Array of public image URLs used as reference elements (product shots, character references, etc.). **Cap: pass at most `elements_max_images` URLs from list_models for the chosen model — exceeding it is a deterministic 400.**'),
|
|
603
603
|
reference_videos: z.array(z.string()).optional().describe('Array of reference video URLs for models that accept video inputs. **Cap: pass at most `elements_max_videos` URLs from list_models — if the cap is 0 the model rejects videos.**'),
|
|
604
604
|
audio_url: z.string().optional().describe('URL of a reference audio track. **Audio constraints: `elements_max_audio` from list_models gates whether audio is accepted at all; audio duration must fall within `min_audio_duration`-`max_audio_duration`; format must be in `supported_audio_formats` (if specified).**'),
|
|
@@ -681,7 +681,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
681
681
|
first_frame: z.string().optional().describe('URL or absolute local path to the first frame (file mode — alternative to first_frame_url)'),
|
|
682
682
|
last_frame: z.string().optional().describe('URL or absolute local path to the last frame (file mode — alternative to last_frame_url)'),
|
|
683
683
|
prompt: z.string().optional().describe('Optional description of the desired motion between the two frames (e.g. "smooth camera dolly in")'),
|
|
684
|
-
model: z.string().optional().describe('Model identifier. Use list_models type="firstlastgenerations" to see options.
|
|
684
|
+
model: z.string().optional().describe('Model identifier. Use list_models type="firstlastgenerations" to see options. Pick a SPECIFIC model — do NOT omit (omitting = Smart Select auto-pick, which we avoid); call list_models for this type and choose the model that best fits the user\'s intent.'),
|
|
685
685
|
duration: z.number().optional().describe('Duration in seconds. Must be in `supported_durations` from list_models, OR within `min_output_duration`-`max_output_duration`. Default: 5'),
|
|
686
686
|
aspect_ratio: z.string().optional().describe('Aspect ratio (auto-detected from first frame if not provided). Must be in `supported_aspect_ratios` from list_models when set. Default: "16:9"'),
|
|
687
687
|
enhance_prompt: z.boolean().optional().describe('Enhance the prompt. Default: true'),
|
|
@@ -758,7 +758,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
758
758
|
source: z.string().describe('URL or absolute local path to the source image or video (the face to animate). For lipsync-video: duration must fall within `min_video_duration`-`max_video_duration` from list_models.'),
|
|
759
759
|
audio: z.string().describe('URL or absolute local path to the audio track (the voice to sync to). Duration must fall within `min_audio_duration`-`max_audio_duration` from list_models; format must be in `supported_audio_formats` (when set).'),
|
|
760
760
|
text_prompt: z.string().optional().describe('Optional text prompt (for performance-capable models). For Sync-3 this is the free-text emotion/acting prompt, e.g. "speaking with excitement, calm and serious".'),
|
|
761
|
-
model: z.string().optional().describe('Model identifier. Use list_models type="lipsync-image" or type="lipsync-video" to see options.
|
|
761
|
+
model: z.string().optional().describe('Model identifier. Use list_models type="lipsync-image" or type="lipsync-video" to see options. Pick a SPECIFIC model — do NOT omit (omitting = Smart Select auto-pick, which we avoid); call list_models for this type and choose the model that best fits the user\'s intent.'),
|
|
762
762
|
bounding_box_target: z.array(z.number()).optional().describe('Optional bounding box [x, y, w, h] for multi-face inputs (Hedra Character3 style). Leave empty for single-face.'),
|
|
763
763
|
// Sync-3 (fal-ai/sync-lipsync/v3) only; ignored by other models.
|
|
764
764
|
sync_mode: z.enum(['cut_off', 'loop', 'bounce', 'silence', 'remap']).optional().describe('Sync-3 / sync-lipsync family: how to reconcile an audio/video length mismatch. Default cut_off.'),
|
|
@@ -867,7 +867,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
867
867
|
{
|
|
868
868
|
source_video: z.string().describe('URL or absolute local path to the primary source video to restyle. **Source duration must fall within `min_video_duration`-`max_video_duration` from list_models for the chosen model** — videos outside that range are rejected (or silently truncated by some upstream providers). For models that use reference_videos as their primary input (e.g. WAN 2.6 reference-to-video), pass the first reference video here and also include it in reference_videos.'),
|
|
869
869
|
prompt: z.string().optional().describe('Text description of the desired restyle / transformation. Required by most video-to-video models; omit for prompt-less models (VEED Subtitles, Act Two, Wan Animate, Kling Motion Control).'),
|
|
870
|
-
model: z.string().optional().describe('Model identifier. Use list_models type="video_to_video" to see options and check max_images / max_videos / max_elements / max_video_duration per model.
|
|
870
|
+
model: z.string().optional().describe('Model identifier. Use list_models type="video_to_video" to see options and check max_images / max_videos / max_elements / max_video_duration per model. Pick a SPECIFIC model — do NOT omit (omitting = Smart Select auto-pick, which we avoid); call list_models for this type and choose the model that best fits the user\'s intent.'),
|
|
871
871
|
aspect_ratio: z.string().optional().describe('Output aspect ratio. Must be in `supported_aspect_ratios` from list_models when set. Default: matches source'),
|
|
872
872
|
duration: z.number().optional().describe('Output duration in seconds. Must be in `supported_durations` from list_models, OR within `min_output_duration`-`max_output_duration`. Default: matches source'),
|
|
873
873
|
enhance_prompt: z.boolean().optional().describe('Enhance the prompt. Default: true'),
|
package/src/tools/media.js
CHANGED
|
@@ -10,17 +10,94 @@ const { UI, uiResult, appsEnabled } = require('../apps');
|
|
|
10
10
|
|
|
11
11
|
function registerMediaTools(server, client, options = {}) {
|
|
12
12
|
const ui = () => appsEnabled(server, options);
|
|
13
|
+
|
|
14
|
+
// ─── media_upload_widget ───────────────────────────────────
|
|
15
|
+
server.tool(
|
|
16
|
+
'media_upload_widget',
|
|
17
|
+
'Open an interactive file-upload card in the chat so the user can upload LOCAL files (images, videos, audio, documents) into their Kolbo media library. USE THIS IMMEDIATELY whenever a claude.ai (browser/mobile) user wants to use a local file, or references a file they attached to the chat — remote MCP tools CANNOT read chat attachments, so the user must re-upload through this widget; do not ask them to re-attach the file in chat. Each uploaded file gets a stable Kolbo CDN URL that arrives in a follow-up user message — then pass those URLs to generation tools (generate_image_edit, generate_video_from_image, generate_lipsync, transcribe_audio, visual DNA, etc.). On Claude Desktop / Claude Code with filesystem access, prefer `upload_media` with the local path instead.',
|
|
18
|
+
{
|
|
19
|
+
purpose: z.string().optional().describe('Short title shown on the card, e.g. "Upload the photo to animate". Helps the user know what to drop.'),
|
|
20
|
+
media_types: z.array(z.enum(['image', 'video', 'audio', 'document'])).optional().describe('Restrict which file kinds the widget accepts. Omit to accept all types.'),
|
|
21
|
+
max_files: z.number().optional().describe('Maximum number of files (default 10, max 20).'),
|
|
22
|
+
project_id: z.string().optional().describe('Project ObjectId to file the uploads into (resolve names via `list_projects`).')
|
|
23
|
+
},
|
|
24
|
+
async ({ purpose, media_types, max_files, project_id }) => {
|
|
25
|
+
const ticket = await client.post('/v1/media/upload-ticket', {});
|
|
26
|
+
if (!ticket || !ticket.token) throw new Error('Could not create an upload ticket — try again.');
|
|
27
|
+
|
|
28
|
+
const info = {
|
|
29
|
+
status: 'upload_widget_opened',
|
|
30
|
+
instructions: 'An upload card is now shown to the user. WAIT for them to upload — the uploaded file URLs will arrive in a follow-up message (or in the model context). Do not guess URLs.',
|
|
31
|
+
accepted: ticket.accepted,
|
|
32
|
+
expires_in_seconds: ticket.expires_in,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
if (ui()) {
|
|
36
|
+
return uiResult(UI.upload, JSON.stringify(info, null, 2), {
|
|
37
|
+
widget: 'upload',
|
|
38
|
+
title: purpose || 'Upload media',
|
|
39
|
+
upload_url: ticket.upload_url,
|
|
40
|
+
token: ticket.token,
|
|
41
|
+
expires_at: Date.now() + (ticket.expires_in || 900) * 1000,
|
|
42
|
+
kinds: media_types && media_types.length ? media_types : undefined,
|
|
43
|
+
max_files: Math.min(Math.max(Number(max_files) || 10, 1), 20),
|
|
44
|
+
max_mb: ticket.max_file_mb || { image: 50, video: 500, audio: 200, document: 50 },
|
|
45
|
+
...(project_id ? { project_id } : {}),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Text-only host: no iframe to render — steer to upload_media.
|
|
50
|
+
return {
|
|
51
|
+
content: [{
|
|
52
|
+
type: 'text',
|
|
53
|
+
text: JSON.stringify({
|
|
54
|
+
status: 'widget_unavailable',
|
|
55
|
+
hint: 'This host cannot render the upload widget. Use the upload_media tool with a URL or absolute local file path instead.'
|
|
56
|
+
}, null, 2)
|
|
57
|
+
}]
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
);
|
|
61
|
+
|
|
13
62
|
// ─── upload_media ──────────────────────────────────────────
|
|
14
63
|
server.tool(
|
|
15
64
|
'upload_media',
|
|
16
65
|
'Upload a local file (or remote URL) to the user\'s Kolbo media library and get back a stable Kolbo CDN URL. Use this when the user wants to reference a local file in multiple subsequent generation calls — upload once, then pass the returned URL to generate_image / generate_video / visual_dna / etc. Auto-detects media type (image / video / audio) from the file extension. For a single-use reference where you already have a public URL, you can skip this and pass the URL directly to the generation tool.',
|
|
17
66
|
{
|
|
18
|
-
source: z.string().describe('URL or absolute local path to the file to upload. For local files this is the primary mode; for URLs, this re-hosts the file on Kolbo CDN for stability.'),
|
|
67
|
+
source: z.string().optional().describe('URL or absolute local path to the file to upload. For local files this is the primary mode; for URLs, this re-hosts the file on Kolbo CDN for stability. Provide this OR source_base64.'),
|
|
68
|
+
source_base64: z.string().optional().describe('Raw file content as base64 (no data: prefix) — fallback for hosts with no filesystem or public URL (e.g. small images on claude.ai when the upload widget is unavailable). Requires `filename`. Keep under ~10MB; for larger files use media_upload_widget.'),
|
|
69
|
+
filename: z.string().optional().describe('Original filename WITH extension (e.g. photo.png) — required with source_base64; the extension determines the media type.'),
|
|
19
70
|
description: z.string().optional().describe('Optional description / caption for the uploaded media'),
|
|
20
71
|
project_id: z.string().optional().describe('Project ObjectId to file the upload into. Call `list_projects` to resolve a name → id. When the user is working in a named project, pass it here too — omitting it files the upload outside that project.')
|
|
21
72
|
},
|
|
22
|
-
async ({ source, description, project_id }) => {
|
|
23
|
-
if (!source) throw new Error('source
|
|
73
|
+
async ({ source, source_base64, filename, description, project_id }) => {
|
|
74
|
+
if (!source && !source_base64) throw new Error('Provide source (URL or absolute local path) OR source_base64 (+ filename)');
|
|
75
|
+
|
|
76
|
+
if (source_base64) {
|
|
77
|
+
if (!filename || !/\.[a-z0-9]{2,5}$/i.test(filename)) {
|
|
78
|
+
throw new Error('source_base64 requires a `filename` with an extension (e.g. photo.png)');
|
|
79
|
+
}
|
|
80
|
+
const buffer = Buffer.from(source_base64, 'base64');
|
|
81
|
+
if (!buffer.length) throw new Error('source_base64 decoded to an empty file');
|
|
82
|
+
// Backend routes by mimetype (video/audio must NOT hit the image
|
|
83
|
+
// optimizer) — derive it from the extension, never octet-stream.
|
|
84
|
+
const ext = filename.split('.').pop().toLowerCase();
|
|
85
|
+
const MIME = {
|
|
86
|
+
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', gif: 'image/gif', heic: 'image/heic', avif: 'image/avif',
|
|
87
|
+
mp4: 'video/mp4', mov: 'video/quicktime', webm: 'video/webm', m4v: 'video/x-m4v', mkv: 'video/x-matroska',
|
|
88
|
+
mp3: 'audio/mpeg', wav: 'audio/wav', m4a: 'audio/mp4', aac: 'audio/aac', ogg: 'audio/ogg', flac: 'audio/flac',
|
|
89
|
+
pdf: 'application/pdf', txt: 'text/plain', md: 'text/markdown', csv: 'text/csv', json: 'application/json',
|
|
90
|
+
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
91
|
+
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
92
|
+
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
|
|
93
|
+
};
|
|
94
|
+
const form = new FormData();
|
|
95
|
+
form.append('file', buffer, { filename, contentType: MIME[ext] || 'application/octet-stream' });
|
|
96
|
+
if (description) form.append('description', description);
|
|
97
|
+
if (project_id) form.append('project_id', project_id);
|
|
98
|
+
const result = await client.postMultipart('/v1/media/upload', form);
|
|
99
|
+
return { content: [{ type: 'text', text: JSON.stringify(result.media || result, null, 2) }] };
|
|
100
|
+
}
|
|
24
101
|
|
|
25
102
|
// Even for URL input we download-and-reupload — that's the whole point
|
|
26
103
|
// of upload_media (getting a stable Kolbo-owned URL). For ephemeral
|