@kolbo/mcp 1.66.0 → 1.68.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 +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +2 -2
- 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/tools/generate.js +22 -8
- package/src/tools/media.js +13 -1
- package/src/tools/review.js +4 -4
- package/src/tools/voices.js +33 -19
package/README.md
CHANGED
|
@@ -128,7 +128,7 @@ Without the optional skill, the config block alone already exposes every tool
|
|
|
128
128
|
| `generate_lipsync` | Source image/video + audio → lipsynced video (Sync-3 adds active-speaker selection, emotion, model mode, temperature) |
|
|
129
129
|
| `generate_creative_director` | One brief → N coordinated scenes (image or video) |
|
|
130
130
|
| `generate_music` | Text (+ optional lyrics) → song. Style, title, negative tags, length, and Suno fine-controls (style weight, weirdness, audio weight, persona / singing voice) |
|
|
131
|
-
| `generate_speech` | Text + voice → spoken audio. Full expressive/style control:
|
|
131
|
+
| `generate_speech` | Text + voice → spoken audio. Full expressive/style control: Google/Gemini named voice-direction presets (`style_instructions_preset_id`: warm/dramatic/whisper/…) or free-form `style_instructions`, preset styles + emotions (DeepDub / MiniMax / Cartesia), speed, accent/`language`, and per-provider voice settings (ElevenLabs similarity/style, DeepDub accent/variance/tempo, MiniMax pitch/volume/intensity/timbre). Status returns the same fields for reuse. |
|
|
132
132
|
| `generate_sound` | Text → sound effect. Duration, prompt influence, and per-provider controls (Stable Audio guidance, Kie loop/tempo/key, Seed-Audio voice/speed/volume/pitch + reference audio/image) |
|
|
133
133
|
| `generate_3d` | Text or reference images → 3D model (GLB/FBX/OBJ/USDZ) |
|
|
134
134
|
| `transcribe_audio` | Audio/video URL or file → text + SRT subtitles. Language, speaker diarization, audio-event tagging, and SRT formatting (words/line, lines/subtitle, caption stretch) |
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -103,7 +103,7 @@ Each `references/models/*.md` mirrors the matching skill prompt in `kolbo-api/sr
|
|
|
103
103
|
| `generate_first_last_frame` | Keyframe interpolation between two frames. |
|
|
104
104
|
| `generate_lipsync` | Lipsync audio to an image or video face. |
|
|
105
105
|
| `generate_music` | Music generation (Suno + variants). |
|
|
106
|
-
| `generate_speech` | TTS. Use `list_voices` to pick a voice. |
|
|
106
|
+
| `generate_speech` | TTS. Use `list_voices` to pick a voice. Pass provider controls: Google/Gemini `style_instructions_preset_id` (warm/dramatic/whisper/excited/calm/cheerful/serious/storyteller/sad/intimate/british/commercial) or free-form `style_instructions` + `language` as Accent; DeepDub/MiniMax/Cartesia `selected_style`/`emotion`; `speaking_speed`; ElevenLabs `similarity_boost`/`style`; MiniMax `minimax_pitch`/`minimax_vol`/…. |
|
|
107
107
|
| `generate_sound` | Sound effects. |
|
|
108
108
|
| `generate_3d` | 3D models from text / single image / multi-view. Returns GLB/FBX/OBJ/USDZ. |
|
|
109
109
|
|
|
@@ -226,7 +226,7 @@ You are NOT allowed to:
|
|
|
226
226
|
|
|
227
227
|
Existing video → modify → **single `generate_video_from_video` call** with source video URL + edit prompt.
|
|
228
228
|
|
|
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`, 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" })`).
|
|
230
230
|
|
|
231
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.
|
|
232
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/tools/generate.js
CHANGED
|
@@ -660,12 +660,20 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
660
660
|
text: z.string().describe('The text to convert to speech'),
|
|
661
661
|
voice: z.string().optional().describe('Voice ID or display name — MUST come from a `list_voices` result, never constructed. Google/Gemini ids in particular are not validated provider-side: an id that is not in the catalog is silently mapped to another voice (or a default one) and the audio comes back in a voice nobody asked for. Do not pattern-match a locale onto an id you saw for another language. Default: "Rachel"'),
|
|
662
662
|
model: z.string().optional().describe('Model identifier. Use list_models type="text_to_speech" to see options. Default: eleven_v3'),
|
|
663
|
-
language: z.string().optional().describe('Language code (e.g., "en-US", "he-IL", "es-ES"). Default: "en-US"'),
|
|
663
|
+
language: z.string().optional().describe('Language / accent code (e.g., "en-US", "he-IL", "es-ES"). For Google/Gemini voices this is the Accent control (does not translate the text). Default: "en-US"'),
|
|
664
664
|
// ── Expressive style / emotion (provider-specific) ──
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
665
|
+
// Google/Gemini: prefer a named preset id from the Kolbo UI picker; free-form
|
|
666
|
+
// style_instructions is the escape hatch for custom direction.
|
|
667
|
+
style_instructions_preset_id: z.enum([
|
|
668
|
+
'warm', 'dramatic', 'whisper', 'excited', 'calm', 'cheerful',
|
|
669
|
+
'serious', 'storyteller', 'sad', 'intimate', 'british', 'commercial',
|
|
670
|
+
'custom', 'none',
|
|
671
|
+
]).optional().describe('Google/Gemini voices ONLY. Named voice-direction preset from the Kolbo TTS picker (warm, dramatic, whisper, excited, calm, cheerful, serious, storyteller, sad, intimate, british, commercial). Use "custom" with style_instructions for free-form direction, or "none" for default delivery. Preferred over raw style_instructions when a preset fits — the API expands it to the English directive fal receives and stores the label for history/reuse.'),
|
|
672
|
+
style_instructions: z.string().optional().describe('Google/Gemini voices ONLY. Free-form natural-language voice direction, e.g. "whisper conspiratorially, slightly amused" or "excited sports announcer". Max 500 chars. When set without a preset_id, treated as custom. Ignored by other providers.'),
|
|
673
|
+
style_instructions_label: z.string().optional().describe('Google/Gemini voices ONLY. Friendly label stored on the generation for history cards (defaults to the preset name or the custom text). Usually omit — the API fills it.'),
|
|
674
|
+
selected_style: z.string().optional().describe('DeepDub, MiniMax & Cartesia voices. Preset expressive style/emotion. DeepDub: reading, conversational, angry, breathy, panic, amused, sad, whisper, singing, shout, scream, mumbling, excited. MiniMax/Cartesia: happy, sad, angry, fearful, disgusted, surprised, calm, fluent, whisper (or provider emotion names). Ignored by other providers.'),
|
|
675
|
+
emotion: z.string().optional().describe('MiniMax / Cartesia voices. Emotion: happy, sad, angry, fearful, disgusted, surprised, calm, fluent, whisper. Prefer this OR selected_style (both map to the same delivery control).'),
|
|
676
|
+
speaking_speed: z.number().optional().describe('Speech speed 0.5 (slow) – 2.0 (fast); Cartesia clamped 0.6–1.5. Default 1.0. Applies to ElevenLabs / OpenAI / Google / MiniMax / Cartesia / DeepDub (as tempo when tempo omitted).'),
|
|
669
677
|
// ── ElevenLabs voice settings ──
|
|
670
678
|
similarity_boost: z.number().optional().describe('ElevenLabs voice similarity, 0–1. Default 0.75. Higher hews closer to the original voice.'),
|
|
671
679
|
style: z.number().optional().describe('ElevenLabs style exaggeration, 0–1. Default 0.5. Higher = more expressive/dramatic.'),
|
|
@@ -689,7 +697,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
689
697
|
project_id: projectIdField,
|
|
690
698
|
session_id: sessionIdField
|
|
691
699
|
},
|
|
692
|
-
async ({ text, voice, model, language, style_instructions, selected_style, emotion, speaking_speed, similarity_boost, style, use_speaker_boost, variance, tempo, promptBoost, seed, accentControl, voiceTitle, minimax_pitch, minimax_vol, minimax_intensity, minimax_timbre, project_id, session_id }) => {
|
|
700
|
+
async ({ text, voice, model, language, style_instructions_preset_id, style_instructions, style_instructions_label, selected_style, emotion, speaking_speed, similarity_boost, style, use_speaker_boost, variance, tempo, promptBoost, seed, accentControl, voiceTitle, minimax_pitch, minimax_vol, minimax_intensity, minimax_timbre, project_id, session_id }) => {
|
|
693
701
|
model = await canonicalModelId(client, model, 'text_to_speech'); // lenient id resolution ("z-image" → "z-image/turbo")
|
|
694
702
|
// Resolve the requested voice against the REAL catalog (cached) so the card
|
|
695
703
|
// can show its display name + portrait instead of a raw id, and so an id
|
|
@@ -703,7 +711,8 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
703
711
|
: null;
|
|
704
712
|
const gen = await client.post('/v1/generate/speech', {
|
|
705
713
|
text, voice, model, language,
|
|
706
|
-
|
|
714
|
+
style_instructions_preset_id, style_instructions, style_instructions_label,
|
|
715
|
+
selected_style, emotion, speaking_speed,
|
|
707
716
|
similarity_boost, style, use_speaker_boost,
|
|
708
717
|
variance, tempo, promptBoost, seed, accentControl, voiceTitle,
|
|
709
718
|
minimax_pitch, minimax_vol, minimax_intensity, minimax_timbre,
|
|
@@ -713,7 +722,12 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
713
722
|
if (ui()) return uiGenerating({
|
|
714
723
|
tool: 'generate_speech', kind: 'audio', gen, client, model, prompt: text,
|
|
715
724
|
voice: voiceRecord,
|
|
716
|
-
settings: {
|
|
725
|
+
settings: {
|
|
726
|
+
voice: voice || 'Rachel',
|
|
727
|
+
style: selected_style || emotion || style_instructions_preset_id || style_instructions,
|
|
728
|
+
speaking_speed,
|
|
729
|
+
language,
|
|
730
|
+
},
|
|
717
731
|
warning: unknownVoice
|
|
718
732
|
});
|
|
719
733
|
|
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,
|
package/src/tools/review.js
CHANGED
|
@@ -149,8 +149,8 @@ function registerReviewTools(server, client) {
|
|
|
149
149
|
'List review collection folders in a project.',
|
|
150
150
|
{ project_id: projectIdField },
|
|
151
151
|
async ({ project_id }) => {
|
|
152
|
-
const
|
|
153
|
-
const result = await client.get(`/v1/review/collections${
|
|
152
|
+
const qs = project_id ? `project_id=${encodeURIComponent(project_id)}` : '';
|
|
153
|
+
const result = await client.get(`/v1/review/collections${qs ? '?' + qs : ''}`);
|
|
154
154
|
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
155
155
|
}
|
|
156
156
|
);
|
|
@@ -200,8 +200,8 @@ function registerReviewTools(server, client) {
|
|
|
200
200
|
version_media_id: z.string().optional(),
|
|
201
201
|
},
|
|
202
202
|
async ({ asset_id, version_media_id }) => {
|
|
203
|
-
const
|
|
204
|
-
const result = await client.get(`/v1/review/assets/${encodeURIComponent(asset_id)}/comments${
|
|
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
205
|
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
206
206
|
}
|
|
207
207
|
);
|
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
|
|