@kolbo/mcp 1.65.1 → 1.67.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 +1 -1
- package/skill/SKILL.md +2 -1
- package/skill/references/workflows/media-library.md +7 -2
- package/src/apps/theme.js +12 -0
- package/src/apps/widgets/upload.js +177 -34
- package/src/index.js +2 -0
- package/src/tools/media.js +13 -1
- package/src/tools/review.js +338 -0
- package/src/tools/voices.js +33 -19
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -125,6 +125,7 @@ Each `references/models/*.md` mirrors the matching skill prompt in `kolbo-api/sr
|
|
|
125
125
|
| `clone_voice` / `import_elevenlabs_voice` / `delete_voice` | Custom voices (clone CHARGES CREDITS — confirm first; new voices show in `list_voices`) |
|
|
126
126
|
| `trim_video` | Frame-accurate trim of a Kolbo-hosted video (tool waits and returns the URL). `edit_video` also gained `remove_background`. |
|
|
127
127
|
| `create_doc` / `list_docs` / `get_doc` / `update_doc` / `share_doc` / `delete_doc` | AI Docs (Magic Pad): YOU author full HTML documents (plans, briefs, scripts, research) saved into the user's project, editable in the Kolbo app. `share_doc` returns a public link. `update_doc` content replaces the WHOLE doc — `get_doc` first. |
|
|
128
|
+
| `create_review_asset` / `list_review_assets` / `get_review_asset` / `update_review_asset` / `add_review_version` / `set_review_status` / `delete_review_asset` / `get_review_storage_usage` / review collection + comment + share-link tools | **Kolbo Review** (Frame.io-style): upload first via `upload_media` / ticket / widget → pass `media_id` to `create_review_asset`. Comments are text + optional timecodes. `create_review_share_link` returns a guest URL. |
|
|
128
129
|
| `chat_send_message` / `chat_list_conversations` / `chat_get_messages` | Kolbo chat with optional `media_urls` (up to 10 per call) |
|
|
129
130
|
| `publish_html_artifact` | Publish HTML / SVG / Mermaid to `sites.kolbo.ai`. Server dedupes by content hash. Strict CSP. |
|
|
130
131
|
|
|
@@ -225,7 +226,7 @@ You are NOT allowed to:
|
|
|
225
226
|
|
|
226
227
|
Existing video → modify → **single `generate_video_from_video` call** with source video URL + edit prompt.
|
|
227
228
|
|
|
228
|
-
**Use a TRUE video-to-video model.** Image-to-video models reject with `WRONG_MODEL_TYPE`. Valid: `wan/2-7-videoedit`, `happyhorse/video-edit`, `kling-video/o3-video-to-video`, or any model whose DB `type` includes `video_to_video` (use `list_models({ type: "video_to_video" })`).
|
|
229
|
+
**Use a TRUE video-to-video model.** Image-to-video models reject with `WRONG_MODEL_TYPE`. Valid: `wan/2-7-videoedit`, `happyhorse/video-edit`, `kling-video/o3-video-to-video`, `pika/pikadditions/video-to-video`, `pika/pikaswaps/video-to-video`, `pika/pikaffects/video-to-video`, or any model whose DB `type` includes `video_to_video` (use `list_models({ type: "video_to_video" })`).
|
|
229
230
|
|
|
230
231
|
**Motion-control / animate-move models invert the inputs**: `reference_images[0]` = the CHARACTER IMAGE to animate, `source_video` = the driving/reference video whose motion is transferred. Omitting the character image returns a `MOTION_CONTROL_INPUTS` error.
|
|
231
232
|
|
|
@@ -42,7 +42,7 @@ client share a filesystem. Choose by transport:
|
|
|
42
42
|
|---|---|
|
|
43
43
|
| **Local (stdio) install** — `npx @kolbo/mcp` on the same machine | `upload_media` with the absolute path |
|
|
44
44
|
| **Remote connector + you can run shell commands** (Claude Code, Codex, Cursor, CI) | `create_upload_ticket`, then POST each file to `upload_url` |
|
|
45
|
-
| **Remote connector, no filesystem** (claude.ai web/mobile) | `media_upload_widget` — the user picks the file |
|
|
45
|
+
| **Remote connector, no filesystem** (claude.ai web/mobile) | `media_upload_widget` — the user picks the file. On Claude iOS/Android the card opens a full-screen uploader (in-chat file pickers are dropped by WebKit); after upload the user pastes the copied CDN URLs back into chat. |
|
|
46
46
|
|
|
47
47
|
`create_upload_ticket` returns `upload_url` + a short-lived `token`. Upload with
|
|
48
48
|
multipart field `file` and `Authorization: Bearer <token>`; the stable CDN URL comes
|
|
@@ -50,9 +50,14 @@ back at `media.url`. One POST per file, ticket reusable for a batch. Then pass t
|
|
|
50
50
|
URLs to any generation tool.
|
|
51
51
|
|
|
52
52
|
```bash
|
|
53
|
-
curl -X POST "<upload_url>" -H "Authorization: Bearer <token>" -F "file=@/abs/path/clip.mp3"
|
|
53
|
+
curl -X POST "<upload_url>" -H "Authorization: Bearer <token>" -F "file=@/abs/path/clip.mp3;type=audio/mpeg"
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
+
**Declare the type.** `curl` labels the part from its own mime table and falls back to
|
|
57
|
+
`application/octet-stream` for anything missing from it — `.mp3` included — which the
|
|
58
|
+
endpoint can reject as an unsupported type. Append `;type=<mime>` (`audio/mpeg`,
|
|
59
|
+
`audio/wav`, `video/mp4`, `image/png`, `application/pdf`, …) and it never comes up.
|
|
60
|
+
|
|
56
61
|
**Pace a batch.** The upload endpoint is rate limited — the ticket response says by
|
|
57
62
|
how much in `rate_limit` (currently 40 uploads per 60s). Firing 55 files back to
|
|
58
63
|
back stalls at file 41. Sleep ~2s between files, or read the 429: it carries a
|
package/src/apps/theme.js
CHANGED
|
@@ -291,6 +291,18 @@ html.k-fullscreen .k-actions { flex: none; padding-top: 8px; }
|
|
|
291
291
|
.k-generated-audio .k-audio-art { width: 36px; height: 36px; }
|
|
292
292
|
.k-generated-audio .k-btn { grid-column: 2; justify-self: start; }
|
|
293
293
|
.k-generated-audio .k-audio-player { grid-column: 1 / -1; }
|
|
294
|
+
/* Touch-friendly MCP App layout (Claude iOS/Android iframe). */
|
|
295
|
+
.k-head { padding: 10px 12px; gap: 8px; }
|
|
296
|
+
.k-body { padding: 10px 12px 12px; }
|
|
297
|
+
.k-footer { padding: 8px 12px 12px; }
|
|
298
|
+
.k-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
|
|
299
|
+
.k-actions { flex-direction: column; align-items: stretch; }
|
|
300
|
+
.k-actions .k-btn { width: 100%; min-height: 44px; justify-content: center; }
|
|
301
|
+
.k-btn { min-height: 40px; padding: 10px 14px; }
|
|
302
|
+
.k-title { font-size: 13.5px; }
|
|
303
|
+
.k-prompt-row { flex-wrap: wrap; }
|
|
304
|
+
.k-prompt-row .k-input { min-width: 0; flex: 1 1 100%; }
|
|
305
|
+
.k-prompt-row .k-btn { flex: 1 1 auto; }
|
|
294
306
|
}
|
|
295
307
|
.k-play {
|
|
296
308
|
width: 32px; height: 32px; border-radius: 50%; flex: none; border: 1px solid rgba(255,255,255,0.18);
|
|
@@ -11,16 +11,21 @@ const { widgetPage } = require('../html');
|
|
|
11
11
|
* POSTed straight from the iframe to api.kolbo.ai/mcp/upload with a
|
|
12
12
|
* short-lived, upload-only ticket (never the user's API key).
|
|
13
13
|
*
|
|
14
|
+
* Mobile (Claude iOS/Android): WebKit drops <input type=file> selections inside
|
|
15
|
+
* cross-origin MCP App iframes, so the primary CTA openLinks a TOP-LEVEL
|
|
16
|
+
* upload page (/mcp/upload-ui) where the native picker works. Desktop keeps
|
|
17
|
+
* the in-chat picker; empty-selection after pick also offers the external page.
|
|
18
|
+
*
|
|
14
19
|
* structuredContent: {
|
|
15
|
-
* widget: 'upload', title, upload_url, token, expires_at (epoch ms),
|
|
20
|
+
* widget: 'upload', title, upload_url, upload_ui_url, token, expires_at (epoch ms),
|
|
16
21
|
* accept (input accept attr), max_files, max_mb: {image,video,audio,document},
|
|
17
22
|
* project_id?
|
|
18
23
|
* }
|
|
19
24
|
*
|
|
20
|
-
* Flow: pick/drop files -> client-side type+size validation -> XHR
|
|
21
|
-
* (2 concurrent, per-file progress) -> per-file CDN URL.
|
|
22
|
-
* is pushed into the model context silently; the
|
|
23
|
-
* sends one chat message with all URLs
|
|
25
|
+
* Flow (desktop): pick/drop files -> client-side type+size validation -> XHR
|
|
26
|
+
* upload (2 concurrent, per-file progress + thumbnails) -> per-file CDN URL.
|
|
27
|
+
* Every completed file is pushed into the model context silently; the
|
|
28
|
+
* "Use these files" button sends one chat message with all URLs.
|
|
24
29
|
*/
|
|
25
30
|
|
|
26
31
|
const BODY = `
|
|
@@ -32,15 +37,15 @@ const BODY = `
|
|
|
32
37
|
<span class="k-chip" id="count-chip" style="display:none"></span>
|
|
33
38
|
</div>
|
|
34
39
|
<div class="k-body">
|
|
35
|
-
<div id="drop" style="border:1.5px dashed var(--border);border-radius:12px;padding:
|
|
40
|
+
<div id="drop" style="border:1.5px dashed var(--border);border-radius:12px;padding:22px 14px;text-align:center;cursor:pointer;transition:border-color .15s,background .15s;-webkit-tap-highlight-color:transparent">
|
|
36
41
|
<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>
|
|
42
|
+
<div id="drop-title" style="font-size:13px;font-weight:600">Click or drop files here</div>
|
|
43
|
+
<div id="accept-hint" style="font-size:11.5px;color:var(--text-muted);margin-top:4px;line-height:1.35"></div>
|
|
39
44
|
</div>
|
|
40
|
-
<input type="file" id="picker" multiple style="
|
|
45
|
+
<input type="file" id="picker" multiple accept="*/*" style="position:absolute;width:1px;height:1px;opacity:0;overflow:hidden;clip:rect(0,0,0,0)">
|
|
41
46
|
<div id="rows" style="margin-top:10px"></div>
|
|
42
47
|
<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>
|
|
48
|
+
<div id="notice" style="display:none;margin-top:8px;font-size:12px;color:var(--text-muted);line-height:1.4"></div>
|
|
44
49
|
</div>
|
|
45
50
|
<div class="k-footer">
|
|
46
51
|
<span><a href="#" id="kolbo-link">Kolbo.AI</a> Media Library</span>
|
|
@@ -55,11 +60,12 @@ el('drop-icon').innerHTML = ICONS.upload;
|
|
|
55
60
|
el('kolbo-link').onclick = function (e) { e.preventDefault(); window.kolbo.openLink('https://app.kolbo.ai/media-library'); };
|
|
56
61
|
|
|
57
62
|
var state = null;
|
|
58
|
-
var items = []; // {file, kind, status, pct, url, err, id}
|
|
63
|
+
var items = []; // {file, kind, status, pct, url, err, id, thumb}
|
|
59
64
|
var nextItemId = 1;
|
|
60
65
|
var CONCURRENCY = 2;
|
|
61
66
|
var active = 0;
|
|
62
67
|
var sent = false;
|
|
68
|
+
var pickerArmed = false;
|
|
63
69
|
|
|
64
70
|
var KINDS = {
|
|
65
71
|
image: { exts: ['jpg','jpeg','png','webp','gif','heic','heif','avif','bmp','tif','tiff'], icon: ICONS.image },
|
|
@@ -68,12 +74,36 @@ var KINDS = {
|
|
|
68
74
|
document: { exts: ['pdf','txt','md','csv','json','docx','xlsx','pptx','doc','xls'], icon: ICONS.document }
|
|
69
75
|
};
|
|
70
76
|
|
|
71
|
-
function
|
|
72
|
-
var
|
|
73
|
-
|
|
77
|
+
function isMobileHost() {
|
|
78
|
+
var ua = navigator.userAgent || '';
|
|
79
|
+
if (/iPhone|iPad|iPod|Android/i.test(ua)) return true;
|
|
80
|
+
// iPadOS desktop-UA spoof
|
|
81
|
+
if (navigator.maxTouchPoints > 1 && /MacIntel/.test(navigator.platform || '')) return true;
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function classify(file) {
|
|
86
|
+
var name = (file && file.name) || '';
|
|
87
|
+
var ext = name.indexOf('.') >= 0 ? String(name).split('.').pop().toLowerCase() : '';
|
|
88
|
+
if (ext) {
|
|
89
|
+
for (var k in KINDS) { if (KINDS[k].exts.indexOf(ext) !== -1) return k; }
|
|
90
|
+
}
|
|
91
|
+
// Mobile Photos often yields a MIME with a weak/missing filename.
|
|
92
|
+
var mime = String((file && file.type) || '').toLowerCase();
|
|
93
|
+
if (mime.indexOf('image/') === 0) return 'image';
|
|
94
|
+
if (mime.indexOf('video/') === 0) return 'video';
|
|
95
|
+
if (mime.indexOf('audio/') === 0) return 'audio';
|
|
96
|
+
if (mime === 'application/pdf' || mime.indexOf('text/') === 0 || mime.indexOf('application/vnd.') === 0 || mime === 'application/msword' || mime === 'application/json') return 'document';
|
|
74
97
|
return null;
|
|
75
98
|
}
|
|
76
99
|
|
|
100
|
+
function filenameFor(it) {
|
|
101
|
+
var n = (it.file && it.file.name) || '';
|
|
102
|
+
if (n && n.indexOf('.') !== -1) return n;
|
|
103
|
+
var ext = { image: 'jpg', video: 'mp4', audio: 'mp3', document: 'pdf' }[it.kind] || 'bin';
|
|
104
|
+
return 'upload-' + it.id + '.' + ext;
|
|
105
|
+
}
|
|
106
|
+
|
|
77
107
|
function fmtSize(b) {
|
|
78
108
|
if (b == null) return '';
|
|
79
109
|
if (b > 1024 * 1024) return (Math.round(b / 1024 / 102.4) / 10) + 'MB';
|
|
@@ -82,6 +112,38 @@ function fmtSize(b) {
|
|
|
82
112
|
|
|
83
113
|
function expired() { return state && state.expires_at && Date.now() > state.expires_at; }
|
|
84
114
|
|
|
115
|
+
function uploadUiUrl() {
|
|
116
|
+
if (!state) return '';
|
|
117
|
+
var base = state.upload_ui_url || String(state.upload_url || '').replace(/\\/upload\\/?$/, '/upload-ui');
|
|
118
|
+
if (!base) return '';
|
|
119
|
+
var cfg = {
|
|
120
|
+
token: state.token,
|
|
121
|
+
upload_url: state.upload_url,
|
|
122
|
+
title: state.title || 'Upload media',
|
|
123
|
+
kinds: state.kinds,
|
|
124
|
+
max_files: state.max_files,
|
|
125
|
+
max_mb: state.max_mb,
|
|
126
|
+
project_id: state.project_id,
|
|
127
|
+
expires_at: state.expires_at
|
|
128
|
+
};
|
|
129
|
+
try {
|
|
130
|
+
var json = JSON.stringify(cfg);
|
|
131
|
+
var b64 = btoa(unescape(encodeURIComponent(json))).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');
|
|
132
|
+
return base + '#' + b64;
|
|
133
|
+
} catch (e) {
|
|
134
|
+
return base + '#' + encodeURIComponent(JSON.stringify(cfg));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function openExternalUploader() {
|
|
139
|
+
var url = uploadUiUrl();
|
|
140
|
+
if (!url) return;
|
|
141
|
+
window.kolbo.openLink(url);
|
|
142
|
+
el('notice').style.display = '';
|
|
143
|
+
el('notice').innerHTML = ICONS.open + ' Uploader opened. After files finish, return here and paste the copied message into Claude.';
|
|
144
|
+
window.kolbo.notifySize();
|
|
145
|
+
}
|
|
146
|
+
|
|
85
147
|
function boot(sc) {
|
|
86
148
|
if (!sc || sc.widget !== 'upload') return;
|
|
87
149
|
state = sc;
|
|
@@ -89,10 +151,27 @@ function boot(sc) {
|
|
|
89
151
|
var kinds = sc.kinds && sc.kinds.length ? sc.kinds : ['image','video','audio','document'];
|
|
90
152
|
var exts = [];
|
|
91
153
|
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(','));
|
|
154
|
+
el('picker').setAttribute('accept', exts.map(function (e) { return '.' + e; }).concat(kinds.map(function (k) { return k + '/*'; })).join(','));
|
|
93
155
|
var maxN = sc.max_files || 10;
|
|
94
|
-
|
|
156
|
+
var mobile = isMobileHost();
|
|
157
|
+
if (mobile) {
|
|
158
|
+
el('drop-title').textContent = 'Tap to open uploader';
|
|
159
|
+
el('accept-hint').textContent = kinds.join(' · ') + (maxN === 1 ? ' — one file' : ' — up to ' + maxN + ' files') + '. Opens a full-screen picker (required on iPhone & Android).';
|
|
160
|
+
} else {
|
|
161
|
+
el('drop-title').textContent = 'Click or drop files here';
|
|
162
|
+
el('accept-hint').textContent = kinds.join(' · ') + (maxN === 1 ? ' — one file' : ' — up to ' + maxN + ' files');
|
|
163
|
+
}
|
|
95
164
|
if (expired()) return showExpired();
|
|
165
|
+
// Always offer the external path — desktop users may be inside a host that
|
|
166
|
+
// also sandboxes file inputs; mobile uses it as the primary CTA.
|
|
167
|
+
el('actions').style.display = '';
|
|
168
|
+
el('actions').innerHTML = mobile
|
|
169
|
+
? '<button class="k-btn primary" id="btn-external" style="width:100%;min-height:44px">' + ICONS.upload + ' Open uploader</button>' +
|
|
170
|
+
'<button class="k-btn ghost" id="btn-inline">Try in-chat picker</button>'
|
|
171
|
+
: '<button class="k-btn ghost" id="btn-external">' + ICONS.open + ' Open full-screen uploader</button>';
|
|
172
|
+
el('btn-external').onclick = function (e) { e.preventDefault(); e.stopPropagation(); openExternalUploader(); };
|
|
173
|
+
var inline = el('btn-inline');
|
|
174
|
+
if (inline) inline.onclick = function (e) { e.preventDefault(); e.stopPropagation(); armInlinePicker(); };
|
|
96
175
|
window.kolbo.notifySize();
|
|
97
176
|
}
|
|
98
177
|
|
|
@@ -104,8 +183,18 @@ function showExpired() {
|
|
|
104
183
|
window.kolbo.notifySize();
|
|
105
184
|
}
|
|
106
185
|
|
|
186
|
+
function armInlinePicker() {
|
|
187
|
+
pickerArmed = true;
|
|
188
|
+
el('picker').click();
|
|
189
|
+
}
|
|
190
|
+
|
|
107
191
|
// ---- picking ----
|
|
108
|
-
el('drop').onclick = function () {
|
|
192
|
+
el('drop').onclick = function () {
|
|
193
|
+
if (!state) return;
|
|
194
|
+
if (expired()) return showExpired();
|
|
195
|
+
if (isMobileHost()) return openExternalUploader();
|
|
196
|
+
armInlinePicker();
|
|
197
|
+
};
|
|
109
198
|
el('drop').ondragover = function (e) { e.preventDefault(); el('drop').style.borderColor = 'var(--accent, #7c6cff)'; };
|
|
110
199
|
el('drop').ondragleave = function () { el('drop').style.borderColor = 'var(--border)'; };
|
|
111
200
|
el('drop').ondrop = function (e) {
|
|
@@ -113,7 +202,54 @@ el('drop').ondrop = function (e) {
|
|
|
113
202
|
el('drop').style.borderColor = 'var(--border)';
|
|
114
203
|
addFiles(e.dataTransfer && e.dataTransfer.files);
|
|
115
204
|
};
|
|
116
|
-
el('picker').onchange = function () {
|
|
205
|
+
el('picker').onchange = function () {
|
|
206
|
+
var files = el('picker').files;
|
|
207
|
+
// iOS/WebKit cross-origin iframe: picker UI runs, selection is dropped → empty FileList.
|
|
208
|
+
if (pickerArmed && (!files || !files.length)) {
|
|
209
|
+
pickerArmed = false;
|
|
210
|
+
el('notice').style.display = '';
|
|
211
|
+
el('notice').innerHTML = ICONS.warn + ' In-chat picker could not receive the file on this device. Use the full-screen uploader instead.';
|
|
212
|
+
el('actions').style.display = '';
|
|
213
|
+
el('actions').innerHTML = '<button class="k-btn primary" id="btn-external" style="width:100%;min-height:44px">' + ICONS.upload + ' Open uploader</button>';
|
|
214
|
+
el('btn-external').onclick = function (e) { e.preventDefault(); openExternalUploader(); };
|
|
215
|
+
window.kolbo.notifySize();
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
pickerArmed = false;
|
|
219
|
+
addFiles(files);
|
|
220
|
+
el('picker').value = '';
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
function makeThumb(it) {
|
|
224
|
+
if (it.kind === 'image') {
|
|
225
|
+
try { it.thumb = URL.createObjectURL(it.file); } catch (e) {}
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (it.kind !== 'video') return;
|
|
229
|
+
try {
|
|
230
|
+
var url = URL.createObjectURL(it.file);
|
|
231
|
+
var v = document.createElement('video');
|
|
232
|
+
v.muted = true; v.playsInline = true; v.preload = 'metadata'; v.src = url;
|
|
233
|
+
var done = false;
|
|
234
|
+
function snap() {
|
|
235
|
+
if (done) return;
|
|
236
|
+
try {
|
|
237
|
+
var c = document.createElement('canvas');
|
|
238
|
+
c.width = 68; c.height = 68;
|
|
239
|
+
c.getContext('2d').drawImage(v, 0, 0, 68, 68);
|
|
240
|
+
it.thumb = c.toDataURL('image/jpeg', 0.7);
|
|
241
|
+
done = true;
|
|
242
|
+
render();
|
|
243
|
+
} catch (e) {}
|
|
244
|
+
try { URL.revokeObjectURL(url); } catch (e2) {}
|
|
245
|
+
}
|
|
246
|
+
v.addEventListener('loadeddata', function () {
|
|
247
|
+
try { v.currentTime = Math.min(0.25, (v.duration || 1) * 0.1); } catch (e) { snap(); }
|
|
248
|
+
});
|
|
249
|
+
v.addEventListener('seeked', snap);
|
|
250
|
+
setTimeout(snap, 1500);
|
|
251
|
+
} catch (e) {}
|
|
252
|
+
}
|
|
117
253
|
|
|
118
254
|
function addFiles(list) {
|
|
119
255
|
if (!list || !state) return;
|
|
@@ -122,15 +258,15 @@ function addFiles(list) {
|
|
|
122
258
|
for (var i = 0; i < list.length; i++) {
|
|
123
259
|
if (items.length >= maxFiles) break;
|
|
124
260
|
var f = list[i];
|
|
125
|
-
var kind = classify(f
|
|
261
|
+
var kind = classify(f);
|
|
126
262
|
var it = { file: f, kind: kind, status: 'queued', pct: 0, url: null, err: null, id: nextItemId++, thumb: null };
|
|
127
|
-
if (kind === 'image') { try { it.thumb = URL.createObjectURL(f); } catch (e) {} }
|
|
128
263
|
var allowedKinds = state.kinds && state.kinds.length ? state.kinds : ['image','video','audio','document'];
|
|
129
264
|
if (!kind || allowedKinds.indexOf(kind) === -1) {
|
|
130
265
|
it.status = 'error'; it.err = 'Unsupported file type';
|
|
131
266
|
} else {
|
|
132
267
|
var capMb = (state.max_mb && state.max_mb[kind]) || 50;
|
|
133
268
|
if (f.size > capMb * 1024 * 1024) { it.status = 'error'; it.err = kind + ' files are limited to ' + capMb + 'MB'; }
|
|
269
|
+
else makeThumb(it);
|
|
134
270
|
}
|
|
135
271
|
items.push(it);
|
|
136
272
|
}
|
|
@@ -154,7 +290,7 @@ function upload(it) {
|
|
|
154
290
|
active++;
|
|
155
291
|
render();
|
|
156
292
|
var fd = new FormData();
|
|
157
|
-
fd.append('file', it.file, it
|
|
293
|
+
fd.append('file', it.file, filenameFor(it));
|
|
158
294
|
if (state.project_id) fd.append('project_id', state.project_id);
|
|
159
295
|
var xhr = new XMLHttpRequest();
|
|
160
296
|
xhr.open('POST', state.upload_url, true);
|
|
@@ -169,10 +305,9 @@ function upload(it) {
|
|
|
169
305
|
if (xhr.status >= 200 && xhr.status < 300 && res && res.success && res.media && res.media.url) {
|
|
170
306
|
it.status = 'done';
|
|
171
307
|
it.url = res.media.url;
|
|
172
|
-
|
|
173
|
-
// user clicks "Use these files".
|
|
308
|
+
if (!it.thumb && res.media.thumbnail_url) it.thumb = res.media.thumbnail_url;
|
|
174
309
|
try {
|
|
175
|
-
window.kolbo.updateModelContext('Upload widget: "' + it
|
|
310
|
+
window.kolbo.updateModelContext('Upload widget: "' + filenameFor(it) + '" (' + it.kind + ') uploaded to the Kolbo media library. URL: ' + it.url);
|
|
176
311
|
} catch (e) {}
|
|
177
312
|
} else {
|
|
178
313
|
it.status = 'error';
|
|
@@ -184,7 +319,7 @@ function upload(it) {
|
|
|
184
319
|
xhr.onerror = function () {
|
|
185
320
|
active--;
|
|
186
321
|
it.status = 'error';
|
|
187
|
-
it.err = 'Network error — try
|
|
322
|
+
it.err = 'Network error — try the full-screen uploader';
|
|
188
323
|
render();
|
|
189
324
|
pump();
|
|
190
325
|
};
|
|
@@ -204,11 +339,11 @@ function rowHtml(it) {
|
|
|
204
339
|
: '';
|
|
205
340
|
var left = it.thumb
|
|
206
341
|
? '<img data-thumb="' + it.id + '" src="' + it.thumb + '" alt="" style="width:34px;height:34px;object-fit:cover;border-radius:7px;flex:none;border:1px solid var(--border);background:var(--surface)">'
|
|
207
|
-
: '<span>' + icon + '</span>';
|
|
342
|
+
: '<span style="width:34px;height:34px;display:inline-flex;align-items:center;justify-content:center;border-radius:7px;background:var(--surface);border:1px solid var(--border);color:var(--text-muted)">' + icon + '</span>';
|
|
208
343
|
return '<div id="row-' + it.id + '" style="padding:8px 10px;border:1px solid var(--border);border-radius:10px;margin-bottom:6px;background:var(--surface)">' +
|
|
209
344
|
'<div style="display:flex;align-items:center;gap:8px;font-size:12.5px">' +
|
|
210
345
|
left +
|
|
211
|
-
'<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + esc(it
|
|
346
|
+
'<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + esc(filenameFor(it)) + '">' + esc(filenameFor(it)) + '</span>' +
|
|
212
347
|
'<span style="color:var(--text-muted);font-size:11px">' + fmtSize(it.file.size) + '</span>' +
|
|
213
348
|
'<span id="status-' + it.id + '" style="font-size:11.5px">' + right + '</span>' +
|
|
214
349
|
'</div>' + bar + '</div>';
|
|
@@ -223,8 +358,6 @@ function renderRow(it) {
|
|
|
223
358
|
|
|
224
359
|
function render() {
|
|
225
360
|
el('rows').innerHTML = items.map(rowHtml).join('');
|
|
226
|
-
// Thumbnail fallback chain: local blob preview -> CDN URL (allowlisted in the
|
|
227
|
-
// host CSP) once uploaded -> plain type icon.
|
|
228
361
|
Array.prototype.forEach.call(el('rows').querySelectorAll('[data-thumb]'), function (img) {
|
|
229
362
|
img.onerror = function () {
|
|
230
363
|
var id = Number(img.getAttribute('data-thumb'));
|
|
@@ -253,19 +386,29 @@ function render() {
|
|
|
253
386
|
el('count-chip').textContent = done.length + '/' + items.length + ' uploaded';
|
|
254
387
|
if (done.length && !busy && !sent) {
|
|
255
388
|
el('actions').style.display = '';
|
|
256
|
-
el('actions').innerHTML = '<button class="k-btn primary" id="btn-use">Use ' + (done.length === 1 ? 'this file' : 'these ' + done.length + ' files') + '</button>' +
|
|
257
|
-
'<button class="k-btn ghost" id="btn-more">Add more</button>'
|
|
389
|
+
el('actions').innerHTML = '<button class="k-btn primary" id="btn-use" style="min-height:44px">Use ' + (done.length === 1 ? 'this file' : 'these ' + done.length + ' files') + '</button>' +
|
|
390
|
+
'<button class="k-btn ghost" id="btn-more">Add more</button>' +
|
|
391
|
+
'<button class="k-btn ghost" id="btn-external">' + ICONS.open + ' Full-screen</button>';
|
|
258
392
|
el('btn-use').onclick = function () {
|
|
259
393
|
if (sent) return;
|
|
260
394
|
sent = true;
|
|
261
|
-
var lines = done.map(function (i, idx) { return (idx + 1) + '. ' + i
|
|
395
|
+
var lines = done.map(function (i, idx) { return (idx + 1) + '. ' + filenameFor(i) + ' (' + i.kind + '): ' + i.url; });
|
|
262
396
|
window.kolbo.sendMessage('I uploaded ' + done.length + ' file(s) to my Kolbo media library:\\n' + lines.join('\\n') + '\\nContinue with these files.');
|
|
263
397
|
el('actions').innerHTML = '<span style="font-size:12px;color:var(--text-muted)">' + ICONS.check + ' Sent to Claude — continuing…</span>';
|
|
264
398
|
window.kolbo.notifySize();
|
|
265
399
|
};
|
|
266
|
-
el('btn-more').onclick = function () {
|
|
400
|
+
el('btn-more').onclick = function () {
|
|
401
|
+
if (isMobileHost()) openExternalUploader();
|
|
402
|
+
else armInlinePicker();
|
|
403
|
+
};
|
|
404
|
+
el('btn-external').onclick = function (e) { e.preventDefault(); openExternalUploader(); };
|
|
405
|
+
} else if (!items.length) {
|
|
406
|
+
// keep boot() actions (external / inline)
|
|
267
407
|
} else if (!done.length || busy) {
|
|
268
|
-
el('actions').style.display = '
|
|
408
|
+
el('actions').style.display = '';
|
|
409
|
+
el('actions').innerHTML = (busy ? '<span style="font-size:12px;color:var(--text-muted)">Uploading…</span>' : '') +
|
|
410
|
+
'<button class="k-btn ghost" id="btn-external">' + ICONS.open + ' Full-screen uploader</button>';
|
|
411
|
+
el('btn-external').onclick = function (e) { e.preventDefault(); openExternalUploader(); };
|
|
269
412
|
}
|
|
270
413
|
window.kolbo.notifySize();
|
|
271
414
|
}
|
package/src/index.js
CHANGED
|
@@ -72,6 +72,7 @@ const { registerArtifactTools } = require('./tools/artifacts');
|
|
|
72
72
|
const { registerProjectTools } = require('./tools/projects');
|
|
73
73
|
const { registerAgentTools } = require('./tools/agents');
|
|
74
74
|
const { registerDocTools } = require('./tools/docs');
|
|
75
|
+
const { registerReviewTools } = require('./tools/review');
|
|
75
76
|
const { registerVoiceTools } = require('./tools/voices');
|
|
76
77
|
const { registerMusicLibraryTools } = require('./tools/music_library');
|
|
77
78
|
const { registerStockLibraryTools } = require('./tools/stock_library');
|
|
@@ -156,6 +157,7 @@ function createServer(opts = {}) {
|
|
|
156
157
|
registerProjectTools(server, client, toolOptions);
|
|
157
158
|
registerAgentTools(server, client, toolOptions);
|
|
158
159
|
registerDocTools(server, client, toolOptions);
|
|
160
|
+
registerReviewTools(server, client, toolOptions);
|
|
159
161
|
registerMusicLibraryTools(server, client, toolOptions);
|
|
160
162
|
registerStockLibraryTools(server, client, toolOptions);
|
|
161
163
|
|
package/src/tools/media.js
CHANGED
|
@@ -43,7 +43,14 @@ function uploadTicketPayload(ticket) {
|
|
|
43
43
|
accepted: ticket.accepted,
|
|
44
44
|
rate_limit: rate,
|
|
45
45
|
how_to_upload: {
|
|
46
|
-
example: 'curl -X POST "<upload_url>" -H "Authorization: Bearer <token>" -F "file=@/absolute/path/to/file.mp3"',
|
|
46
|
+
example: 'curl -X POST "<upload_url>" -H "Authorization: Bearer <token>" -F "file=@/absolute/path/to/file.mp3;type=audio/mpeg"',
|
|
47
|
+
// curl types the part from ITS mime table and falls back to
|
|
48
|
+
// application/octet-stream for anything missing from it (.mp3 included).
|
|
49
|
+
// Newer servers resolve that from the extension, older ones answer
|
|
50
|
+
// "File type not supported: application/octet-stream" — so the example
|
|
51
|
+
// above declares the type and this says why, rather than leaving the
|
|
52
|
+
// caller to rediscover it from a 415.
|
|
53
|
+
mime_note: 'Append `;type=<mime>` to the file part (audio/mpeg, audio/wav, video/mp4, image/png, application/pdf …). Without it curl declares application/octet-stream and the upload can be rejected as an unsupported type.',
|
|
47
54
|
optional_fields: ['project_id', 'description'],
|
|
48
55
|
response: 'JSON — the stable CDN URL is at media.url. One POST per file; reuse the ticket for a batch.',
|
|
49
56
|
pacing: `RATE LIMIT: ${rate.max_uploads} uploads per ${rate.per_seconds}s. For a batch larger than that, pace it (e.g. sleep ${Math.max(1, Math.ceil(rate.per_seconds / rate.max_uploads))}s between files) instead of firing them back to back. Over the limit you get HTTP 429 with a Retry-After header and retry_after_seconds in the body — wait that long, then continue; do not guess a backoff and do not treat it as a failed upload.`,
|
|
@@ -95,10 +102,15 @@ function registerMediaTools(server, client, options = {}) {
|
|
|
95
102
|
};
|
|
96
103
|
|
|
97
104
|
if (ui()) {
|
|
105
|
+
// upload_ui_url: top-level page for Claude iOS/Android — in-iframe
|
|
106
|
+
// <input type=file> selections are dropped by WebKit (see upload widget).
|
|
107
|
+
const uploadUiUrl = ticket.upload_ui_url
|
|
108
|
+
|| String(ticket.upload_url || '').replace(/\/upload\/?$/, '/upload-ui');
|
|
98
109
|
return uiResult(UI.upload, JSON.stringify(info, null, 2), {
|
|
99
110
|
widget: 'upload',
|
|
100
111
|
title: purpose || 'Upload media',
|
|
101
112
|
upload_url: ticket.upload_url,
|
|
113
|
+
upload_ui_url: uploadUiUrl,
|
|
102
114
|
token: ticket.token,
|
|
103
115
|
expires_at: Date.now() + (ticket.expires_in || 900) * 1000,
|
|
104
116
|
kinds: media_types && media_types.length ? media_types : undefined,
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/* ⛔ BACKWARD COMPATIBILITY: Tool names and arg names below are a PUBLIC
|
|
2
|
+
* CONTRACT. Never rename, remove, or break an existing tool/arg — old cached
|
|
3
|
+
* `npx @kolbo/mcp` installs in the wild will break silently. Add new tools or
|
|
4
|
+
* new OPTIONAL args only. Full rules: ../index.js top-of-file and CLAUDE.md. */
|
|
5
|
+
|
|
6
|
+
const { z } = require('zod');
|
|
7
|
+
const { projectIdField } = require('./_shared');
|
|
8
|
+
|
|
9
|
+
const REVIEW_STATUS = z.enum(['in_progress', 'needs_review', 'approved', 'changes_requested']);
|
|
10
|
+
|
|
11
|
+
function registerReviewTools(server, client) {
|
|
12
|
+
// ─── get_review_storage_usage ────────────────────────────────
|
|
13
|
+
server.tool(
|
|
14
|
+
'get_review_storage_usage',
|
|
15
|
+
'Get Kolbo Review storage usage for the API-key owner (5GB cap across all review versions).',
|
|
16
|
+
{},
|
|
17
|
+
async () => {
|
|
18
|
+
const result = await client.get('/v1/review/storage-usage');
|
|
19
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
20
|
+
}
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
// ─── list_review_assets ──────────────────────────────────────
|
|
24
|
+
server.tool(
|
|
25
|
+
'list_review_assets',
|
|
26
|
+
'List review assets in a project (Frame.io-style client review). Pass `project_id` from `list_projects`. Optional filters: `collection_id`, `status`, pagination.',
|
|
27
|
+
{
|
|
28
|
+
project_id: projectIdField,
|
|
29
|
+
collection_id: z.string().optional().describe('Filter to one review collection.'),
|
|
30
|
+
status: REVIEW_STATUS.optional(),
|
|
31
|
+
page: z.number().optional(),
|
|
32
|
+
limit: z.number().optional(),
|
|
33
|
+
},
|
|
34
|
+
async ({ project_id, collection_id, status, page, limit }) => {
|
|
35
|
+
const params = new URLSearchParams();
|
|
36
|
+
if (project_id) params.set('project_id', project_id);
|
|
37
|
+
if (collection_id) params.set('collection_id', collection_id);
|
|
38
|
+
if (status) params.set('status', status);
|
|
39
|
+
if (page) params.set('page', String(page));
|
|
40
|
+
if (limit) params.set('limit', String(limit));
|
|
41
|
+
const qs = params.toString();
|
|
42
|
+
const result = await client.get(`/v1/review/assets${qs ? '?' + qs : ''}`);
|
|
43
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
44
|
+
}
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
// ─── get_review_asset ────────────────────────────────────────
|
|
48
|
+
server.tool(
|
|
49
|
+
'get_review_asset',
|
|
50
|
+
'Fetch one review asset with all versions, status, and media URLs.',
|
|
51
|
+
{
|
|
52
|
+
asset_id: z.string().describe('Review asset ObjectId from list_review_assets or create_review_asset.'),
|
|
53
|
+
},
|
|
54
|
+
async ({ asset_id }) => {
|
|
55
|
+
const result = await client.get(`/v1/review/assets/${encodeURIComponent(asset_id)}`);
|
|
56
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
57
|
+
}
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
// ─── create_review_asset ─────────────────────────────────────
|
|
61
|
+
server.tool(
|
|
62
|
+
'create_review_asset',
|
|
63
|
+
'Create a Kolbo Review asset with v1 media attached. Upload first via `upload_media`, `create_upload_ticket`, or `media_upload_widget`, then pass the returned `media_id`. Requires `project_id` when the user named a project.',
|
|
64
|
+
{
|
|
65
|
+
name: z.string().describe('Display name for the review asset.'),
|
|
66
|
+
media_id: z.string().describe('MediaLibraryItem id from upload_media / list_media.'),
|
|
67
|
+
project_id: projectIdField,
|
|
68
|
+
collection_id: z.string().optional().describe('Optional review collection folder id.'),
|
|
69
|
+
version_note: z.string().optional().describe('Optional note on the first version (max 1000 chars).'),
|
|
70
|
+
},
|
|
71
|
+
async (args) => {
|
|
72
|
+
const result = await client.post('/v1/review/assets', args);
|
|
73
|
+
return {
|
|
74
|
+
content: [{
|
|
75
|
+
type: 'text',
|
|
76
|
+
text: JSON.stringify({
|
|
77
|
+
...result,
|
|
78
|
+
_hint: 'Asset created. Share with clients via create_review_share_link, or add feedback with create_review_comment.',
|
|
79
|
+
}, null, 2),
|
|
80
|
+
}],
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
// ─── update_review_asset ─────────────────────────────────────
|
|
86
|
+
server.tool(
|
|
87
|
+
'update_review_asset',
|
|
88
|
+
'Rename a review asset, move it to a collection, or switch the active version index.',
|
|
89
|
+
{
|
|
90
|
+
asset_id: z.string(),
|
|
91
|
+
name: z.string().optional(),
|
|
92
|
+
collection_id: z.string().nullable().optional().describe('Collection id, or null to uncollected.'),
|
|
93
|
+
current_version_index: z.number().optional(),
|
|
94
|
+
},
|
|
95
|
+
async ({ asset_id, ...body }) => {
|
|
96
|
+
const result = await client.patch(`/v1/review/assets/${encodeURIComponent(asset_id)}`, body);
|
|
97
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
98
|
+
}
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
// ─── add_review_version ──────────────────────────────────────
|
|
102
|
+
server.tool(
|
|
103
|
+
'add_review_version',
|
|
104
|
+
'Append a new version to an existing review asset from an uploaded `media_id`.',
|
|
105
|
+
{
|
|
106
|
+
asset_id: z.string(),
|
|
107
|
+
media_id: z.string(),
|
|
108
|
+
version_note: z.string().optional(),
|
|
109
|
+
},
|
|
110
|
+
async ({ asset_id, media_id, version_note }) => {
|
|
111
|
+
const result = await client.post(`/v1/review/assets/${encodeURIComponent(asset_id)}/versions`, {
|
|
112
|
+
media_id,
|
|
113
|
+
version_note,
|
|
114
|
+
});
|
|
115
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
116
|
+
}
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
// ─── set_review_status ───────────────────────────────────────
|
|
120
|
+
server.tool(
|
|
121
|
+
'set_review_status',
|
|
122
|
+
'Update review workflow status on an asset (in_progress, needs_review, approved, changes_requested).',
|
|
123
|
+
{
|
|
124
|
+
asset_id: z.string(),
|
|
125
|
+
review_status: REVIEW_STATUS,
|
|
126
|
+
},
|
|
127
|
+
async ({ asset_id, review_status }) => {
|
|
128
|
+
const result = await client.post(`/v1/review/assets/${encodeURIComponent(asset_id)}/status`, {
|
|
129
|
+
review_status,
|
|
130
|
+
});
|
|
131
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
132
|
+
}
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
// ─── delete_review_asset ─────────────────────────────────────
|
|
136
|
+
server.tool(
|
|
137
|
+
'delete_review_asset',
|
|
138
|
+
'Soft-delete a review asset and its underlying review media.',
|
|
139
|
+
{ asset_id: z.string() },
|
|
140
|
+
async ({ asset_id }) => {
|
|
141
|
+
const result = await client.delete(`/v1/review/assets/${encodeURIComponent(asset_id)}`);
|
|
142
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
143
|
+
}
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
// ─── Collections ─────────────────────────────────────────────
|
|
147
|
+
server.tool(
|
|
148
|
+
'list_review_collections',
|
|
149
|
+
'List review collection folders in a project.',
|
|
150
|
+
{ project_id: projectIdField },
|
|
151
|
+
async ({ project_id }) => {
|
|
152
|
+
const qs = project_id ? `project_id=${encodeURIComponent(project_id)}` : '';
|
|
153
|
+
const result = await client.get(`/v1/review/collections${qs ? '?' + qs : ''}`);
|
|
154
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
155
|
+
}
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
server.tool(
|
|
159
|
+
'create_review_collection',
|
|
160
|
+
'Create a review collection folder inside a project.',
|
|
161
|
+
{
|
|
162
|
+
name: z.string(),
|
|
163
|
+
project_id: projectIdField,
|
|
164
|
+
},
|
|
165
|
+
async (args) => {
|
|
166
|
+
const result = await client.post('/v1/review/collections', args);
|
|
167
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
168
|
+
}
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
server.tool(
|
|
172
|
+
'update_review_collection',
|
|
173
|
+
'Rename a review collection.',
|
|
174
|
+
{
|
|
175
|
+
collection_id: z.string(),
|
|
176
|
+
name: z.string(),
|
|
177
|
+
},
|
|
178
|
+
async ({ collection_id, name }) => {
|
|
179
|
+
const result = await client.patch(`/v1/review/collections/${encodeURIComponent(collection_id)}`, { name });
|
|
180
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
181
|
+
}
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
server.tool(
|
|
185
|
+
'delete_review_collection',
|
|
186
|
+
'Soft-delete a review collection (assets become uncollected).',
|
|
187
|
+
{ collection_id: z.string() },
|
|
188
|
+
async ({ collection_id }) => {
|
|
189
|
+
const result = await client.delete(`/v1/review/collections/${encodeURIComponent(collection_id)}`);
|
|
190
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
191
|
+
}
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
// ─── Comments ────────────────────────────────────────────────
|
|
195
|
+
server.tool(
|
|
196
|
+
'list_review_comments',
|
|
197
|
+
'List text comments on a review asset (default: current version media). Optional `version_media_id` for a specific version.',
|
|
198
|
+
{
|
|
199
|
+
asset_id: z.string(),
|
|
200
|
+
version_media_id: z.string().optional(),
|
|
201
|
+
},
|
|
202
|
+
async ({ asset_id, version_media_id }) => {
|
|
203
|
+
const qs = version_media_id ? `version_media_id=${encodeURIComponent(version_media_id)}` : '';
|
|
204
|
+
const result = await client.get(`/v1/review/assets/${encodeURIComponent(asset_id)}/comments${qs ? '?' + qs : ''}`);
|
|
205
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
206
|
+
}
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
server.tool(
|
|
210
|
+
'create_review_comment',
|
|
211
|
+
'Add a text comment on a review asset. Optional video timecodes: time_start / time_end (seconds).',
|
|
212
|
+
{
|
|
213
|
+
asset_id: z.string(),
|
|
214
|
+
body: z.string(),
|
|
215
|
+
time_start: z.number().optional(),
|
|
216
|
+
time_end: z.number().optional(),
|
|
217
|
+
version_media_id: z.string().optional(),
|
|
218
|
+
},
|
|
219
|
+
async ({ asset_id, ...body }) => {
|
|
220
|
+
const result = await client.post(`/v1/review/assets/${encodeURIComponent(asset_id)}/comments`, body);
|
|
221
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
222
|
+
}
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
server.tool(
|
|
226
|
+
'reply_review_comment',
|
|
227
|
+
'Reply to an existing review comment (one level of threading).',
|
|
228
|
+
{
|
|
229
|
+
note_id: z.string().describe('Comment id from list_review_comments.'),
|
|
230
|
+
body: z.string(),
|
|
231
|
+
},
|
|
232
|
+
async ({ note_id, body }) => {
|
|
233
|
+
const result = await client.post(`/v1/review/comments/${encodeURIComponent(note_id)}/reply`, { body });
|
|
234
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
235
|
+
}
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
server.tool(
|
|
239
|
+
'edit_review_comment',
|
|
240
|
+
'Edit your own review comment text and/or timecodes.',
|
|
241
|
+
{
|
|
242
|
+
note_id: z.string(),
|
|
243
|
+
body: z.string().optional(),
|
|
244
|
+
time_start: z.number().optional(),
|
|
245
|
+
time_end: z.number().optional(),
|
|
246
|
+
},
|
|
247
|
+
async ({ note_id, ...body }) => {
|
|
248
|
+
const result = await client.patch(`/v1/review/comments/${encodeURIComponent(note_id)}`, body);
|
|
249
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
250
|
+
}
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
server.tool(
|
|
254
|
+
'delete_review_comment',
|
|
255
|
+
'Delete a review comment (own comment, or any comment if you have full project access).',
|
|
256
|
+
{ note_id: z.string() },
|
|
257
|
+
async ({ note_id }) => {
|
|
258
|
+
const result = await client.delete(`/v1/review/comments/${encodeURIComponent(note_id)}`);
|
|
259
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
260
|
+
}
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
server.tool(
|
|
264
|
+
'resolve_review_comment',
|
|
265
|
+
'Mark a review comment thread as resolved.',
|
|
266
|
+
{ note_id: z.string() },
|
|
267
|
+
async ({ note_id }) => {
|
|
268
|
+
const result = await client.post(`/v1/review/comments/${encodeURIComponent(note_id)}/resolve`, {});
|
|
269
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
270
|
+
}
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
server.tool(
|
|
274
|
+
'unresolve_review_comment',
|
|
275
|
+
'Re-open a resolved review comment thread.',
|
|
276
|
+
{ note_id: z.string() },
|
|
277
|
+
async ({ note_id }) => {
|
|
278
|
+
const result = await client.post(`/v1/review/comments/${encodeURIComponent(note_id)}/unresolve`, {});
|
|
279
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
280
|
+
}
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
// ─── Share links ─────────────────────────────────────────────
|
|
284
|
+
server.tool(
|
|
285
|
+
'create_review_share_link',
|
|
286
|
+
'Create a guest review link for an asset or collection. Returns share_url for clients (no Kolbo account needed).',
|
|
287
|
+
{
|
|
288
|
+
target_type: z.enum(['asset', 'collection']),
|
|
289
|
+
target_id: z.string(),
|
|
290
|
+
role_label: z.string().optional().describe('Guest role label shown in the UI (e.g. Client).'),
|
|
291
|
+
require_email: z.boolean().optional(),
|
|
292
|
+
permissions: z.object({
|
|
293
|
+
canComment: z.boolean().optional(),
|
|
294
|
+
canDownload: z.boolean().optional(),
|
|
295
|
+
canViewOtherComments: z.boolean().optional(),
|
|
296
|
+
canResolveOwn: z.boolean().optional(),
|
|
297
|
+
canSwitchVersions: z.boolean().optional(),
|
|
298
|
+
canSetStatus: z.boolean().optional(),
|
|
299
|
+
}).optional(),
|
|
300
|
+
password: z.string().optional(),
|
|
301
|
+
allowed_emails: z.array(z.string()).optional(),
|
|
302
|
+
expires_at: z.string().optional().describe('ISO8601 expiry datetime.'),
|
|
303
|
+
},
|
|
304
|
+
async ({ target_type, target_id, ...body }) => {
|
|
305
|
+
const path = target_type === 'collection'
|
|
306
|
+
? `/v1/review/collections/${encodeURIComponent(target_id)}/share-links`
|
|
307
|
+
: `/v1/review/assets/${encodeURIComponent(target_id)}/share-links`;
|
|
308
|
+
const result = await client.post(path, body);
|
|
309
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
310
|
+
}
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
server.tool(
|
|
314
|
+
'list_review_share_links',
|
|
315
|
+
'List active share links for a review asset or collection.',
|
|
316
|
+
{
|
|
317
|
+
target_type: z.enum(['asset', 'collection']),
|
|
318
|
+
target_id: z.string(),
|
|
319
|
+
},
|
|
320
|
+
async ({ target_type, target_id }) => {
|
|
321
|
+
const params = new URLSearchParams({ target_type, target_id });
|
|
322
|
+
const result = await client.get(`/v1/review/share-links?${params}`);
|
|
323
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
324
|
+
}
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
server.tool(
|
|
328
|
+
'revoke_review_share_link',
|
|
329
|
+
'Revoke a guest review share link by link id.',
|
|
330
|
+
{ link_id: z.string() },
|
|
331
|
+
async ({ link_id }) => {
|
|
332
|
+
const result = await client.post(`/v1/review/share-links/${encodeURIComponent(link_id)}/revoke`, {});
|
|
333
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
334
|
+
}
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
module.exports = { registerReviewTools };
|
package/src/tools/voices.js
CHANGED
|
@@ -11,13 +11,15 @@ function registerVoiceTools(server, client, options = {}) {
|
|
|
11
11
|
// ─── list_voices ──────────────────────────────────────────────
|
|
12
12
|
server.tool(
|
|
13
13
|
'list_voices',
|
|
14
|
-
'List available TTS voices for speech generation. Filter by language, gender, or provider to find the right voice. Returns voice_id, name, provider, language, gender, accent, description, styles, and preview_url for each voice.',
|
|
14
|
+
'List available TTS voices for speech generation. Filter by language, gender, or provider to find the right voice. Returns voice_id, name, provider, language, gender, accent, description, styles, and preview_url for each voice. The catalogue runs to hundreds of voices, so results are paginated: filter first, then use `page` to walk the rest.',
|
|
15
15
|
{
|
|
16
|
-
language: z.string().optional().describe('Filter by language name (e.g. "english", "hebrew", "spanish", "french"). Case-insensitive partial match.'),
|
|
16
|
+
language: z.string().optional().describe('Filter by language name (e.g. "english", "hebrew", "spanish", "french") or locale code ("he", "he-IL"). Case-insensitive partial match.'),
|
|
17
17
|
gender: z.enum(['male', 'female']).optional().describe('Filter by gender.'),
|
|
18
|
-
provider: z.string().optional().describe('Filter by provider (e.g. "elevenlabs", "google"). Omit for all providers.')
|
|
18
|
+
provider: z.string().optional().describe('Filter by provider (e.g. "elevenlabs", "google"). Omit for all providers.'),
|
|
19
|
+
page: z.number().optional().describe('Page number, 1-indexed. Default: 1'),
|
|
20
|
+
limit: z.number().optional().describe('Results per page, max 200. Default: 60')
|
|
19
21
|
},
|
|
20
|
-
async ({ language, gender, provider }) => {
|
|
22
|
+
async ({ language, gender, provider, page, limit }) => {
|
|
21
23
|
const params = new URLSearchParams();
|
|
22
24
|
if (gender) params.set('gender', gender);
|
|
23
25
|
if (provider) params.set('provider', provider);
|
|
@@ -28,10 +30,9 @@ function registerVoiceTools(server, client, options = {}) {
|
|
|
28
30
|
const result = await client.get(`/v1/voices${qs.toString() ? '?' + qs.toString() : ''}`);
|
|
29
31
|
|
|
30
32
|
let voices = result.voices || [];
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
// match, and an empty list reads to the model as "Kolbo has no Google
|
|
33
|
+
// Safety net for a server whose `language` matching is narrower than this
|
|
34
|
+
// arg promises (it was once an exact match, so "he" / "heb" returned
|
|
35
|
+
// nothing). An empty list reads to the model as "Kolbo has no Google
|
|
35
36
|
// Hebrew voices at all", which is how a whole provider goes missing.
|
|
36
37
|
// Retry ONCE, unfiltered, and match locally — only on the empty path, so
|
|
37
38
|
// the normal call still costs one request.
|
|
@@ -48,29 +49,42 @@ function registerVoiceTools(server, client, options = {}) {
|
|
|
48
49
|
};
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
|
|
52
|
+
// The unfiltered catalog measured 190,286 chars — past what hosts accept — so
|
|
53
|
+
// only a slice can ever come back. Page it instead of truncating it: the API
|
|
54
|
+
// returns the whole filtered set, the window is picked here. Without `page`
|
|
55
|
+
// every voice past the first 60 was simply unreachable.
|
|
56
|
+
const VOICE_CAP = 60;
|
|
57
|
+
const perPage = Math.min(Math.max(Math.trunc(limit) || VOICE_CAP, 1), 200);
|
|
58
|
+
const pageNum = Math.max(Math.trunc(page) || 1, 1);
|
|
59
|
+
const pageCount = Math.ceil(voices.length / perPage);
|
|
60
|
+
const start = (pageNum - 1) * perPage;
|
|
61
|
+
const shownVoices = voices.slice(start, start + perPage);
|
|
62
|
+
|
|
63
|
+
if (shownVoices.length === 0) {
|
|
64
|
+
return {
|
|
65
|
+
content: [{ type: 'text', text: `Page ${pageNum} is past the end — ${voices.length} voice(s) match those filters (${pageCount} page(s) at ${perPage} per page).` }]
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const lines = shownVoices.map(v => {
|
|
52
70
|
const tags = [v.language, v.gender, v.accent].filter(Boolean).join(' · ');
|
|
53
71
|
const styles = Array.isArray(v.styles) && v.styles.length ? ` | styles: ${v.styles.join(', ')}` : '';
|
|
54
72
|
const v3 = v.v3_optimized ? ' [v3]' : '';
|
|
55
73
|
return `${v.voice_id} — ${v.name} (${v.provider})${v3}\n ${tags}${styles}${v.description ? `\n ${v.description}` : ''}`;
|
|
56
74
|
});
|
|
57
75
|
|
|
58
|
-
|
|
59
|
-
// Cap the listing and tell the model how to narrow, rather than handing
|
|
60
|
-
// back a blob the host truncates at an arbitrary byte.
|
|
61
|
-
const VOICE_CAP = 60;
|
|
62
|
-
const shown = lines.slice(0, VOICE_CAP);
|
|
63
|
-
const more = voices.length - shown.length;
|
|
76
|
+
const more = voices.length - (start + shownVoices.length);
|
|
64
77
|
const narrowHint = more > 0
|
|
65
|
-
? `\n\n…and ${more} more.
|
|
78
|
+
? `\n\n…and ${more} more. Pass \`page: ${pageNum + 1}\` for the next ${perPage}, or narrow with \`language\` / \`gender\` / \`provider\`.`
|
|
66
79
|
: '';
|
|
67
|
-
const
|
|
80
|
+
const range = `${start + 1}–${start + shownVoices.length}`;
|
|
81
|
+
const text = `Available voices (showing ${range} of ${voices.length}, page ${pageNum}/${pageCount}):\n\n${lines.join('\n\n')}${narrowHint}\n\nUse the "voice_id" value in generate_speech calls.`;
|
|
68
82
|
|
|
69
83
|
if (ui()) {
|
|
70
84
|
return uiResult(UI.mediaGrid, text, {
|
|
71
85
|
widget: 'media-grid',
|
|
72
86
|
title: 'Voices',
|
|
73
|
-
items:
|
|
87
|
+
items: shownVoices.map(v => ({
|
|
74
88
|
id: v.voice_id,
|
|
75
89
|
title: v.name,
|
|
76
90
|
subtitle: [v.provider, v.language, v.gender, v.accent].filter(Boolean).join(' · '),
|
|
@@ -80,7 +94,7 @@ function registerVoiceTools(server, client, options = {}) {
|
|
|
80
94
|
use_hint: 'Use voice "{TITLE}" (voice_id: {ID}) for text-to-speech — ask me what text to speak.'
|
|
81
95
|
})),
|
|
82
96
|
total: voices.length,
|
|
83
|
-
has_more:
|
|
97
|
+
has_more: more > 0
|
|
84
98
|
});
|
|
85
99
|
}
|
|
86
100
|
|