@kolbo/mcp 1.86.3 → 1.87.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.
@@ -1,457 +1,456 @@
1
- 'use strict';
2
-
3
- const { widgetPage } = require('../html');
4
-
5
- /**
6
- * Upload widget — media_upload_widget tool.
7
- *
8
- * Lets claude.ai users upload LOCAL files (images / video / audio / documents)
9
- * into their Kolbo media library from inside the chat. Chat attachments are
10
- * unreachable from remote MCP servers — this widget is the bridge: the file is
11
- * POSTed straight from the iframe to api.kolbo.ai/mcp/upload with a
12
- * short-lived, upload-only ticket (never the user's API key).
13
- *
14
- * 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
- *
19
- * structuredContent: {
20
- * widget: 'upload', title, upload_url, upload_ui_url, token, expires_at (epoch ms),
21
- * accept (input accept attr), max_files, max_mb: {image,video,audio,document},
22
- * project_id?
23
- * }
24
- *
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.
29
- */
30
-
31
- const BODY = `
32
- <div class="k-card">
33
- <div class="k-head">
34
- <span class="k-logo" id="logo"></span>
35
- <span class="k-title" id="title">Upload media</span>
36
- <span class="k-spacer"></span>
37
- <span class="k-chip" id="count-chip" style="display:none"></span>
38
- </div>
39
- <div class="k-body">
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">
41
- <div id="drop-icon" style="font-size:26px;line-height:1;margin-bottom:8px;color:var(--text-muted)"></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>
44
- </div>
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)">
46
- <div id="rows" style="margin-top:10px"></div>
47
- <div class="k-actions" id="actions" style="display:none"></div>
48
- <div id="notice" style="display:none;margin-top:8px;font-size:12px;color:var(--text-muted);line-height:1.4"></div>
49
- </div>
50
- <div class="k-footer">
51
- <span><a href="#" id="kolbo-link">Kolbo.AI</a> Media Library</span>
52
- <span class="k-credits">free</span>
53
- </div>
54
- </div>
55
- `;
56
-
57
- const SCRIPT = `
58
- el('logo').innerHTML = KOLBO_LOGO + '<span>Kolbo</span>';
59
- el('drop-icon').innerHTML = ICONS.upload;
60
- el('kolbo-link').onclick = function (e) { e.preventDefault(); window.kolbo.openLink('https://app.kolbo.ai/media-library'); };
61
-
62
- var state = null;
63
- var items = []; // {file, kind, status, pct, url, err, id, thumb}
64
- var nextItemId = 1;
65
- var CONCURRENCY = 2;
66
- var active = 0;
67
- var sent = false;
68
- var pickerArmed = false;
69
-
70
- var KINDS = {
71
- image: { exts: ['jpg','jpeg','png','webp','gif','heic','heif','avif','bmp','tif','tiff'], icon: ICONS.image },
72
- video: { exts: ['mp4','mov','webm','m4v','mkv','avi'], icon: ICONS.video },
73
- audio: { exts: ['mp3','wav','m4a','aac','ogg','flac'], icon: ICONS.audio },
74
- document: { exts: ['pdf','txt','md','csv','json','docx','xlsx','pptx','doc','xls'], icon: ICONS.document }
75
- };
76
-
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';
97
- return null;
98
- }
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
-
107
- function fmtSize(b) {
108
- if (b == null) return '';
109
- if (b > 1024 * 1024) return (Math.round(b / 1024 / 102.4) / 10) + 'MB';
110
- return Math.max(1, Math.round(b / 1024)) + 'KB';
111
- }
112
-
113
- function expired() { return state && state.expires_at && Date.now() > state.expires_at; }
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
-
147
- function boot(sc) {
148
- if (!sc || sc.widget !== 'upload') return;
149
- state = sc;
150
- if (sc.title) el('title').textContent = sc.title;
151
- var kinds = sc.kinds && sc.kinds.length ? sc.kinds : ['image','video','audio','document'];
152
- var exts = [];
153
- kinds.forEach(function (k) { if (KINDS[k]) exts = exts.concat(KINDS[k].exts); });
154
- el('picker').setAttribute('accept', exts.map(function (e) { return '.' + e; }).concat(kinds.map(function (k) { return k + '/*'; })).join(','));
155
- var maxN = sc.max_files || 10;
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
- }
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(); };
175
- window.kolbo.notifySize();
176
- }
177
-
178
- function showExpired() {
179
- el('drop').style.pointerEvents = 'none';
180
- el('drop').style.opacity = '0.5';
181
- el('notice').style.display = '';
182
- el('notice').innerHTML = ICONS.clock + ' This upload window expired. Ask Claude to open a new upload widget.';
183
- window.kolbo.notifySize();
184
- }
185
-
186
- function armInlinePicker() {
187
- pickerArmed = true;
188
- el('picker').click();
189
- }
190
-
191
- // ---- picking ----
192
- el('drop').onclick = function () {
193
- if (!state) return;
194
- if (expired()) return showExpired();
195
- if (isMobileHost()) return openExternalUploader();
196
- armInlinePicker();
197
- };
198
- el('drop').ondragover = function (e) { e.preventDefault(); el('drop').style.borderColor = 'var(--accent, #7c6cff)'; };
199
- el('drop').ondragleave = function () { el('drop').style.borderColor = 'var(--border)'; };
200
- el('drop').ondrop = function (e) {
201
- e.preventDefault();
202
- el('drop').style.borderColor = 'var(--border)';
203
- addFiles(e.dataTransfer && e.dataTransfer.files);
204
- };
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
- }
253
-
254
- function addFiles(list) {
255
- if (!list || !state) return;
256
- if (expired()) return showExpired();
257
- var maxFiles = state.max_files || 10;
258
- for (var i = 0; i < list.length; i++) {
259
- if (items.length >= maxFiles) break;
260
- var f = list[i];
261
- var kind = classify(f);
262
- var it = { file: f, kind: kind, status: 'queued', pct: 0, url: null, err: null, id: nextItemId++, thumb: null };
263
- var allowedKinds = state.kinds && state.kinds.length ? state.kinds : ['image','video','audio','document'];
264
- if (!kind || allowedKinds.indexOf(kind) === -1) {
265
- it.status = 'error'; it.err = 'Unsupported file type';
266
- } else {
267
- var capMb = (state.max_mb && state.max_mb[kind]) || 50;
268
- if (f.size > capMb * 1024 * 1024) { it.status = 'error'; it.err = kind + ' files are limited to ' + capMb + 'MB'; }
269
- else makeThumb(it);
270
- }
271
- items.push(it);
272
- }
273
- render();
274
- pump();
275
- }
276
-
277
- // ---- upload queue ----
278
- function pump() {
279
- if (expired()) return showExpired();
280
- while (active < CONCURRENCY) {
281
- var next = null;
282
- for (var i = 0; i < items.length; i++) { if (items[i].status === 'queued') { next = items[i]; break; } }
283
- if (!next) break;
284
- upload(next);
285
- }
286
- }
287
-
288
- function upload(it) {
289
- it.status = 'uploading';
290
- active++;
291
- render();
292
- var fd = new FormData();
293
- fd.append('file', it.file, filenameFor(it));
294
- if (state.project_id) fd.append('project_id', state.project_id);
295
- var xhr = new XMLHttpRequest();
296
- xhr.open('POST', state.upload_url, true);
297
- xhr.setRequestHeader('Authorization', 'Bearer ' + state.token);
298
- // A stalled upload used to sit at N% forever: there was no timeout and no
299
- // ontimeout/onabort handler, only onload/onerror. A file above the CDN body
300
- // cap in front of the API dies exactly this way the edge sees Content-Length,
301
- // kills the connection a few percent in, and the browser never reports it, so
302
- // the row froze at 1% with no error and no retry. Watch PROGRESS rather than
303
- // total elapsed time, so a genuinely slow large upload is never punished.
304
- var STALL_MS = 90000;
305
- var lastTick = Date.now();
306
- var stalled = false;
307
- var watchdog = setInterval(function () {
308
- if (Date.now() - lastTick < STALL_MS) return;
309
- stalled = true;
310
- clearInterval(watchdog);
311
- try { xhr.abort(); } catch (e) {}
312
- }, 5000);
313
- function settle() { clearInterval(watchdog); }
314
- xhr.upload.onprogress = function (e) {
315
- lastTick = Date.now();
316
- if (e.lengthComputable) { it.pct = Math.round((e.loaded / e.total) * 100); renderRow(it); }
317
- };
318
- xhr.onabort = function () {
319
- settle();
320
- if (!stalled) return;
321
- active--;
322
- it.status = 'error';
323
- it.err = 'Upload stalled — the file may be too large for this connection';
324
- render();
325
- pump();
326
- };
327
- xhr.onload = function () {
328
- settle();
329
- active--;
330
- var res = null;
331
- try { res = JSON.parse(xhr.responseText); } catch (e) {}
332
- if (xhr.status >= 200 && xhr.status < 300 && res && res.success && res.media && res.media.url) {
333
- it.status = 'done';
334
- it.url = res.media.url;
335
- if (!it.thumb && res.media.thumbnail_url) it.thumb = res.media.thumbnail_url;
336
- try {
337
- window.kolbo.updateModelContext('Upload widget: "' + filenameFor(it) + '" (' + it.kind + ') uploaded to the Kolbo media library. URL: ' + it.url);
338
- } catch (e) {}
339
- } else {
340
- it.status = 'error';
341
- it.err = (res && res.error) || ('Upload failed (' + xhr.status + ')');
342
- }
343
- render();
344
- pump();
345
- };
346
- xhr.onerror = function () {
347
- settle();
348
- active--;
349
- it.status = 'error';
350
- it.err = 'Network error — try the full-screen uploader';
351
- render();
352
- pump();
353
- };
354
- xhr.send(fd);
355
- }
356
-
357
- // ---- rendering ----
358
- function rowHtml(it) {
359
- var icon = it.kind && KINDS[it.kind] ? KINDS[it.kind].icon : ICONS.file;
360
- var right = '';
361
- if (it.status === 'queued') right = '<span style="color:var(--text-muted)">queued</span>';
362
- else if (it.status === 'uploading') right = '<span style="color:var(--text-muted)">' + it.pct + '%</span>';
363
- else if (it.status === 'done') right = '<span style="color:#4ade80">' + ICONS.check + ' uploaded</span>';
364
- else right = '<span class="k-error" style="padding:0;border:0;background:none">' + ICONS.x + ' ' + esc(it.err || 'failed') + '</span> <a href="#" data-retry="' + it.id + '" style="font-size:11px">retry</a>';
365
- var bar = it.status === 'uploading'
366
- ? '<div style="height:3px;border-radius:2px;background:var(--surface);margin-top:5px;overflow:hidden"><div id="bar-' + it.id + '" style="height:100%;width:' + it.pct + '%;background:var(--accent,#7c6cff);transition:width .2s"></div></div>'
367
- : '';
368
- var left = it.thumb
369
- ? '<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)">'
370
- : '<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>';
371
- return '<div id="row-' + it.id + '" style="padding:8px 10px;border:1px solid var(--border);border-radius:10px;margin-bottom:6px;background:var(--surface)">' +
372
- '<div style="display:flex;align-items:center;gap:8px;font-size:12.5px">' +
373
- left +
374
- '<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + esc(filenameFor(it)) + '">' + esc(filenameFor(it)) + '</span>' +
375
- '<span style="color:var(--text-muted);font-size:11px">' + fmtSize(it.file.size) + '</span>' +
376
- '<span id="status-' + it.id + '" style="font-size:11.5px">' + right + '</span>' +
377
- '</div>' + bar + '</div>';
378
- }
379
-
380
- function renderRow(it) {
381
- var s = el('status-' + it.id);
382
- if (s && it.status === 'uploading') s.innerHTML = '<span style="color:var(--text-muted)">' + it.pct + '%</span>';
383
- var b = el('bar-' + it.id);
384
- if (b) b.style.width = it.pct + '%';
385
- }
386
-
387
- function render() {
388
- el('rows').innerHTML = items.map(rowHtml).join('');
389
- Array.prototype.forEach.call(el('rows').querySelectorAll('[data-thumb]'), function (img) {
390
- img.onerror = function () {
391
- var id = Number(img.getAttribute('data-thumb'));
392
- for (var i = 0; i < items.length; i++) {
393
- if (items[i].id !== id) continue;
394
- if (items[i].url && img.src !== items[i].url) { img.src = items[i].url; return; }
395
- items[i].thumb = null;
396
- }
397
- render();
398
- };
399
- });
400
- Array.prototype.forEach.call(el('rows').querySelectorAll('[data-retry]'), function (a) {
401
- a.onclick = function (e) {
402
- e.preventDefault();
403
- var id = Number(a.getAttribute('data-retry'));
404
- for (var i = 0; i < items.length; i++) {
405
- if (items[i].id === id) { items[i].status = 'queued'; items[i].err = null; items[i].pct = 0; }
406
- }
407
- render();
408
- pump();
409
- };
410
- });
411
- var done = items.filter(function (i) { return i.status === 'done'; });
412
- var busy = items.some(function (i) { return i.status === 'uploading' || i.status === 'queued'; });
413
- el('count-chip').style.display = items.length ? '' : 'none';
414
- el('count-chip').textContent = done.length + '/' + items.length + ' uploaded';
415
- if (done.length && !busy && !sent) {
416
- el('actions').style.display = '';
417
- 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>' +
418
- '<button class="k-btn ghost" id="btn-more">Add more</button>' +
419
- '<button class="k-btn ghost" id="btn-external">' + ICONS.open + ' Full-screen</button>';
420
- el('btn-use').onclick = function () {
421
- if (sent) return;
422
- sent = true;
423
- var lines = done.map(function (i, idx) { return (idx + 1) + '. ' + filenameFor(i) + ' (' + i.kind + '): ' + i.url; });
424
- window.kolbo.sendMessage('I uploaded ' + done.length + ' file(s) to my Kolbo media library:\\n' + lines.join('\\n') + '\\nContinue with these files.');
425
- el('actions').innerHTML = '<span style="font-size:12px;color:var(--text-muted)">' + ICONS.check + ' Sent to Claude — continuing…</span>';
426
- window.kolbo.notifySize();
427
- };
428
- el('btn-more').onclick = function () {
429
- if (isMobileHost()) openExternalUploader();
430
- else armInlinePicker();
431
- };
432
- el('btn-external').onclick = function (e) { e.preventDefault(); openExternalUploader(); };
433
- } else if (!items.length) {
434
- // keep boot() actions (external / inline)
435
- } else if (!done.length || busy) {
436
- el('actions').style.display = '';
437
- el('actions').innerHTML = (busy ? '<span style="font-size:12px;color:var(--text-muted)">Uploading…</span>' : '') +
438
- '<button class="k-btn ghost" id="btn-external">' + ICONS.open + ' Full-screen uploader</button>';
439
- el('btn-external').onclick = function (e) { e.preventDefault(); openExternalUploader(); };
440
- }
441
- window.kolbo.notifySize();
442
- }
443
-
444
- window.kolbo.onToolResult(function (result) {
445
- var sc = result.structuredContent || structured(result);
446
- if (sc && sc.widget === 'upload') return boot(sc);
447
- var card = document.querySelector('.k-card');
448
- if (card && !state) card.style.display = 'none';
449
- window.kolbo.notifySize();
450
- });
451
- `;
452
-
453
- function uploadWidgetHtml() {
454
- return widgetPage({ title: 'Kolbo Upload', body: BODY, script: SCRIPT });
455
- }
456
-
457
- module.exports = { uploadWidgetHtml };
1
+ 'use strict';
2
+
3
+ const { widgetPage } = require('../html');
4
+
5
+ /**
6
+ * Upload widget — media_upload_widget tool.
7
+ *
8
+ * Lets claude.ai users upload LOCAL files (images / video / audio / documents)
9
+ * into their Kolbo media library from inside the chat. Chat attachments are
10
+ * unreachable from remote MCP servers — this widget is the bridge: the file is
11
+ * POSTed straight from the iframe to api.kolbo.ai/mcp/upload with a
12
+ * short-lived, upload-only ticket (never the user's API key).
13
+ *
14
+ * 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
+ *
19
+ * structuredContent: {
20
+ * widget: 'upload', title, upload_url, upload_ui_url, token, expires_at (epoch ms),
21
+ * accept (input accept attr), max_files, max_mb: {image,video,audio,document},
22
+ * project_id?
23
+ * }
24
+ *
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.
29
+ */
30
+
31
+ const BODY = `
32
+ <div class="k-card">
33
+ <div class="k-head">
34
+ <span class="k-logo" id="logo"></span>
35
+ <span class="k-title" id="title">Upload media</span>
36
+ <span class="k-spacer"></span>
37
+ <span class="k-chip" id="count-chip" style="display:none"></span>
38
+ </div>
39
+ <div class="k-body">
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">
41
+ <div id="drop-icon" style="font-size:26px;line-height:1;margin-bottom:8px;color:var(--text-muted)"></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>
44
+ </div>
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)">
46
+ <div id="rows" style="margin-top:10px"></div>
47
+ <div class="k-actions" id="actions" style="display:none"></div>
48
+ <div id="notice" style="display:none;margin-top:8px;font-size:12px;color:var(--text-muted);line-height:1.4"></div>
49
+ </div>
50
+ <div class="k-footer">
51
+ <span><a href="#" id="kolbo-link">Kolbo.AI</a> Media Library</span>
52
+ </div>
53
+ </div>
54
+ `;
55
+
56
+ const SCRIPT = `
57
+ el('logo').innerHTML = KOLBO_LOGO + '<span>Kolbo</span>';
58
+ el('drop-icon').innerHTML = ICONS.upload;
59
+ el('kolbo-link').onclick = function (e) { e.preventDefault(); window.kolbo.openLink('https://app.kolbo.ai/media-library'); };
60
+
61
+ var state = null;
62
+ var items = []; // {file, kind, status, pct, url, err, id, thumb}
63
+ var nextItemId = 1;
64
+ var CONCURRENCY = 2;
65
+ var active = 0;
66
+ var sent = false;
67
+ var pickerArmed = false;
68
+
69
+ var KINDS = {
70
+ image: { exts: ['jpg','jpeg','png','webp','gif','heic','heif','avif','bmp','tif','tiff'], icon: ICONS.image },
71
+ video: { exts: ['mp4','mov','webm','m4v','mkv','avi'], icon: ICONS.video },
72
+ audio: { exts: ['mp3','wav','m4a','aac','ogg','flac'], icon: ICONS.audio },
73
+ document: { exts: ['pdf','txt','md','csv','json','docx','xlsx','pptx','doc','xls'], icon: ICONS.document }
74
+ };
75
+
76
+ function isMobileHost() {
77
+ var ua = navigator.userAgent || '';
78
+ if (/iPhone|iPad|iPod|Android/i.test(ua)) return true;
79
+ // iPadOS desktop-UA spoof
80
+ if (navigator.maxTouchPoints > 1 && /MacIntel/.test(navigator.platform || '')) return true;
81
+ return false;
82
+ }
83
+
84
+ function classify(file) {
85
+ var name = (file && file.name) || '';
86
+ var ext = name.indexOf('.') >= 0 ? String(name).split('.').pop().toLowerCase() : '';
87
+ if (ext) {
88
+ for (var k in KINDS) { if (KINDS[k].exts.indexOf(ext) !== -1) return k; }
89
+ }
90
+ // Mobile Photos often yields a MIME with a weak/missing filename.
91
+ var mime = String((file && file.type) || '').toLowerCase();
92
+ if (mime.indexOf('image/') === 0) return 'image';
93
+ if (mime.indexOf('video/') === 0) return 'video';
94
+ if (mime.indexOf('audio/') === 0) return 'audio';
95
+ if (mime === 'application/pdf' || mime.indexOf('text/') === 0 || mime.indexOf('application/vnd.') === 0 || mime === 'application/msword' || mime === 'application/json') return 'document';
96
+ return null;
97
+ }
98
+
99
+ function filenameFor(it) {
100
+ var n = (it.file && it.file.name) || '';
101
+ if (n && n.indexOf('.') !== -1) return n;
102
+ var ext = { image: 'jpg', video: 'mp4', audio: 'mp3', document: 'pdf' }[it.kind] || 'bin';
103
+ return 'upload-' + it.id + '.' + ext;
104
+ }
105
+
106
+ function fmtSize(b) {
107
+ if (b == null) return '';
108
+ if (b > 1024 * 1024) return (Math.round(b / 1024 / 102.4) / 10) + 'MB';
109
+ return Math.max(1, Math.round(b / 1024)) + 'KB';
110
+ }
111
+
112
+ function expired() { return state && state.expires_at && Date.now() > state.expires_at; }
113
+
114
+ function uploadUiUrl() {
115
+ if (!state) return '';
116
+ var base = state.upload_ui_url || String(state.upload_url || '').replace(/\\/upload\\/?$/, '/upload-ui');
117
+ if (!base) return '';
118
+ var cfg = {
119
+ token: state.token,
120
+ upload_url: state.upload_url,
121
+ title: state.title || 'Upload media',
122
+ kinds: state.kinds,
123
+ max_files: state.max_files,
124
+ max_mb: state.max_mb,
125
+ project_id: state.project_id,
126
+ expires_at: state.expires_at
127
+ };
128
+ try {
129
+ var json = JSON.stringify(cfg);
130
+ var b64 = btoa(unescape(encodeURIComponent(json))).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');
131
+ return base + '#' + b64;
132
+ } catch (e) {
133
+ return base + '#' + encodeURIComponent(JSON.stringify(cfg));
134
+ }
135
+ }
136
+
137
+ function openExternalUploader() {
138
+ var url = uploadUiUrl();
139
+ if (!url) return;
140
+ window.kolbo.openLink(url);
141
+ el('notice').style.display = '';
142
+ el('notice').innerHTML = ICONS.open + ' Uploader opened. After files finish, return here and paste the copied message into Claude.';
143
+ window.kolbo.notifySize();
144
+ }
145
+
146
+ function boot(sc) {
147
+ if (!sc || sc.widget !== 'upload') return;
148
+ state = sc;
149
+ if (sc.title) el('title').textContent = sc.title;
150
+ var kinds = sc.kinds && sc.kinds.length ? sc.kinds : ['image','video','audio','document'];
151
+ var exts = [];
152
+ kinds.forEach(function (k) { if (KINDS[k]) exts = exts.concat(KINDS[k].exts); });
153
+ el('picker').setAttribute('accept', exts.map(function (e) { return '.' + e; }).concat(kinds.map(function (k) { return k + '/*'; })).join(','));
154
+ var maxN = sc.max_files || 20;
155
+ var mobile = isMobileHost();
156
+ if (mobile) {
157
+ el('drop-title').textContent = 'Tap to open uploader';
158
+ el('accept-hint').textContent = kinds.join(' · ') + (maxN === 1 ? ' — one file' : ' — up to ' + maxN + ' files') + '. Opens a full-screen picker (required on iPhone & Android).';
159
+ } else {
160
+ el('drop-title').textContent = 'Click or drop files here';
161
+ el('accept-hint').textContent = kinds.join(' · ') + (maxN === 1 ? ' — one file' : ' — up to ' + maxN + ' files');
162
+ }
163
+ if (expired()) return showExpired();
164
+ // Always offer the external path — desktop users may be inside a host that
165
+ // also sandboxes file inputs; mobile uses it as the primary CTA.
166
+ el('actions').style.display = '';
167
+ el('actions').innerHTML = mobile
168
+ ? '<button class="k-btn primary" id="btn-external" style="width:100%;min-height:44px">' + ICONS.upload + ' Open uploader</button>' +
169
+ '<button class="k-btn ghost" id="btn-inline">Try in-chat picker</button>'
170
+ : '<button class="k-btn ghost" id="btn-external">' + ICONS.open + ' Open full-screen uploader</button>';
171
+ el('btn-external').onclick = function (e) { e.preventDefault(); e.stopPropagation(); openExternalUploader(); };
172
+ var inline = el('btn-inline');
173
+ if (inline) inline.onclick = function (e) { e.preventDefault(); e.stopPropagation(); armInlinePicker(); };
174
+ window.kolbo.notifySize();
175
+ }
176
+
177
+ function showExpired() {
178
+ el('drop').style.pointerEvents = 'none';
179
+ el('drop').style.opacity = '0.5';
180
+ el('notice').style.display = '';
181
+ el('notice').innerHTML = ICONS.clock + ' This upload window expired. Ask Claude to open a new upload widget.';
182
+ window.kolbo.notifySize();
183
+ }
184
+
185
+ function armInlinePicker() {
186
+ pickerArmed = true;
187
+ el('picker').click();
188
+ }
189
+
190
+ // ---- picking ----
191
+ el('drop').onclick = function () {
192
+ if (!state) return;
193
+ if (expired()) return showExpired();
194
+ if (isMobileHost()) return openExternalUploader();
195
+ armInlinePicker();
196
+ };
197
+ el('drop').ondragover = function (e) { e.preventDefault(); el('drop').style.borderColor = 'var(--accent, #7c6cff)'; };
198
+ el('drop').ondragleave = function () { el('drop').style.borderColor = 'var(--border)'; };
199
+ el('drop').ondrop = function (e) {
200
+ e.preventDefault();
201
+ el('drop').style.borderColor = 'var(--border)';
202
+ addFiles(e.dataTransfer && e.dataTransfer.files);
203
+ };
204
+ el('picker').onchange = function () {
205
+ var files = el('picker').files;
206
+ // iOS/WebKit cross-origin iframe: picker UI runs, selection is dropped → empty FileList.
207
+ if (pickerArmed && (!files || !files.length)) {
208
+ pickerArmed = false;
209
+ el('notice').style.display = '';
210
+ el('notice').innerHTML = ICONS.warn + ' In-chat picker could not receive the file on this device. Use the full-screen uploader instead.';
211
+ el('actions').style.display = '';
212
+ el('actions').innerHTML = '<button class="k-btn primary" id="btn-external" style="width:100%;min-height:44px">' + ICONS.upload + ' Open uploader</button>';
213
+ el('btn-external').onclick = function (e) { e.preventDefault(); openExternalUploader(); };
214
+ window.kolbo.notifySize();
215
+ return;
216
+ }
217
+ pickerArmed = false;
218
+ addFiles(files);
219
+ el('picker').value = '';
220
+ };
221
+
222
+ function makeThumb(it) {
223
+ if (it.kind === 'image') {
224
+ try { it.thumb = URL.createObjectURL(it.file); } catch (e) {}
225
+ return;
226
+ }
227
+ if (it.kind !== 'video') return;
228
+ try {
229
+ var url = URL.createObjectURL(it.file);
230
+ var v = document.createElement('video');
231
+ v.muted = true; v.playsInline = true; v.preload = 'metadata'; v.src = url;
232
+ var done = false;
233
+ function snap() {
234
+ if (done) return;
235
+ try {
236
+ var c = document.createElement('canvas');
237
+ c.width = 68; c.height = 68;
238
+ c.getContext('2d').drawImage(v, 0, 0, 68, 68);
239
+ it.thumb = c.toDataURL('image/jpeg', 0.7);
240
+ done = true;
241
+ render();
242
+ } catch (e) {}
243
+ try { URL.revokeObjectURL(url); } catch (e2) {}
244
+ }
245
+ v.addEventListener('loadeddata', function () {
246
+ try { v.currentTime = Math.min(0.25, (v.duration || 1) * 0.1); } catch (e) { snap(); }
247
+ });
248
+ v.addEventListener('seeked', snap);
249
+ setTimeout(snap, 1500);
250
+ } catch (e) {}
251
+ }
252
+
253
+ function addFiles(list) {
254
+ if (!list || !state) return;
255
+ if (expired()) return showExpired();
256
+ var maxFiles = state.max_files || 20;
257
+ for (var i = 0; i < list.length; i++) {
258
+ if (items.length >= maxFiles) break;
259
+ var f = list[i];
260
+ var kind = classify(f);
261
+ var it = { file: f, kind: kind, status: 'queued', pct: 0, url: null, err: null, id: nextItemId++, thumb: null };
262
+ var allowedKinds = state.kinds && state.kinds.length ? state.kinds : ['image','video','audio','document'];
263
+ if (!kind || allowedKinds.indexOf(kind) === -1) {
264
+ it.status = 'error'; it.err = 'Unsupported file type';
265
+ } else {
266
+ var capMb = (state.max_mb && state.max_mb[kind]) || 50;
267
+ if (f.size > capMb * 1024 * 1024) { it.status = 'error'; it.err = kind + ' files are limited to ' + capMb + 'MB'; }
268
+ else makeThumb(it);
269
+ }
270
+ items.push(it);
271
+ }
272
+ render();
273
+ pump();
274
+ }
275
+
276
+ // ---- upload queue ----
277
+ function pump() {
278
+ if (expired()) return showExpired();
279
+ while (active < CONCURRENCY) {
280
+ var next = null;
281
+ for (var i = 0; i < items.length; i++) { if (items[i].status === 'queued') { next = items[i]; break; } }
282
+ if (!next) break;
283
+ upload(next);
284
+ }
285
+ }
286
+
287
+ function upload(it) {
288
+ it.status = 'uploading';
289
+ active++;
290
+ render();
291
+ var fd = new FormData();
292
+ fd.append('file', it.file, filenameFor(it));
293
+ if (state.project_id) fd.append('project_id', state.project_id);
294
+ var xhr = new XMLHttpRequest();
295
+ xhr.open('POST', state.upload_url, true);
296
+ xhr.setRequestHeader('Authorization', 'Bearer ' + state.token);
297
+ // A stalled upload used to sit at N% forever: there was no timeout and no
298
+ // ontimeout/onabort handler, only onload/onerror. A file above the CDN body
299
+ // cap in front of the API dies exactly this way — the edge sees Content-Length,
300
+ // kills the connection a few percent in, and the browser never reports it, so
301
+ // the row froze at 1% with no error and no retry. Watch PROGRESS rather than
302
+ // total elapsed time, so a genuinely slow large upload is never punished.
303
+ var STALL_MS = 90000;
304
+ var lastTick = Date.now();
305
+ var stalled = false;
306
+ var watchdog = setInterval(function () {
307
+ if (Date.now() - lastTick < STALL_MS) return;
308
+ stalled = true;
309
+ clearInterval(watchdog);
310
+ try { xhr.abort(); } catch (e) {}
311
+ }, 5000);
312
+ function settle() { clearInterval(watchdog); }
313
+ xhr.upload.onprogress = function (e) {
314
+ lastTick = Date.now();
315
+ if (e.lengthComputable) { it.pct = Math.round((e.loaded / e.total) * 100); renderRow(it); }
316
+ };
317
+ xhr.onabort = function () {
318
+ settle();
319
+ if (!stalled) return;
320
+ active--;
321
+ it.status = 'error';
322
+ it.err = 'Upload stalled — the file may be too large for this connection';
323
+ render();
324
+ pump();
325
+ };
326
+ xhr.onload = function () {
327
+ settle();
328
+ active--;
329
+ var res = null;
330
+ try { res = JSON.parse(xhr.responseText); } catch (e) {}
331
+ if (xhr.status >= 200 && xhr.status < 300 && res && res.success && res.media && res.media.url) {
332
+ it.status = 'done';
333
+ it.url = res.media.url;
334
+ if (!it.thumb && res.media.thumbnail_url) it.thumb = res.media.thumbnail_url;
335
+ try {
336
+ window.kolbo.updateModelContext('Upload widget: "' + filenameFor(it) + '" (' + it.kind + ') uploaded to the Kolbo media library. URL: ' + it.url);
337
+ } catch (e) {}
338
+ } else {
339
+ it.status = 'error';
340
+ it.err = (res && res.error) || ('Upload failed (' + xhr.status + ')');
341
+ }
342
+ render();
343
+ pump();
344
+ };
345
+ xhr.onerror = function () {
346
+ settle();
347
+ active--;
348
+ it.status = 'error';
349
+ it.err = 'Network error — try the full-screen uploader';
350
+ render();
351
+ pump();
352
+ };
353
+ xhr.send(fd);
354
+ }
355
+
356
+ // ---- rendering ----
357
+ function rowHtml(it) {
358
+ var icon = it.kind && KINDS[it.kind] ? KINDS[it.kind].icon : ICONS.file;
359
+ var right = '';
360
+ if (it.status === 'queued') right = '<span style="color:var(--text-muted)">queued</span>';
361
+ else if (it.status === 'uploading') right = '<span style="color:var(--text-muted)">' + it.pct + '%</span>';
362
+ else if (it.status === 'done') right = '<span style="color:#4ade80">' + ICONS.check + ' uploaded</span>';
363
+ else right = '<span class="k-error" style="padding:0;border:0;background:none">' + ICONS.x + ' ' + esc(it.err || 'failed') + '</span> <a href="#" data-retry="' + it.id + '" style="font-size:11px">retry</a>';
364
+ var bar = it.status === 'uploading'
365
+ ? '<div style="height:3px;border-radius:2px;background:var(--surface);margin-top:5px;overflow:hidden"><div id="bar-' + it.id + '" style="height:100%;width:' + it.pct + '%;background:var(--accent,#7c6cff);transition:width .2s"></div></div>'
366
+ : '';
367
+ var left = it.thumb
368
+ ? '<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)">'
369
+ : '<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>';
370
+ return '<div id="row-' + it.id + '" style="padding:8px 10px;border:1px solid var(--border);border-radius:10px;margin-bottom:6px;background:var(--surface)">' +
371
+ '<div style="display:flex;align-items:center;gap:8px;font-size:12.5px">' +
372
+ left +
373
+ '<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + esc(filenameFor(it)) + '">' + esc(filenameFor(it)) + '</span>' +
374
+ '<span style="color:var(--text-muted);font-size:11px">' + fmtSize(it.file.size) + '</span>' +
375
+ '<span id="status-' + it.id + '" style="font-size:11.5px">' + right + '</span>' +
376
+ '</div>' + bar + '</div>';
377
+ }
378
+
379
+ function renderRow(it) {
380
+ var s = el('status-' + it.id);
381
+ if (s && it.status === 'uploading') s.innerHTML = '<span style="color:var(--text-muted)">' + it.pct + '%</span>';
382
+ var b = el('bar-' + it.id);
383
+ if (b) b.style.width = it.pct + '%';
384
+ }
385
+
386
+ function render() {
387
+ el('rows').innerHTML = items.map(rowHtml).join('');
388
+ Array.prototype.forEach.call(el('rows').querySelectorAll('[data-thumb]'), function (img) {
389
+ img.onerror = function () {
390
+ var id = Number(img.getAttribute('data-thumb'));
391
+ for (var i = 0; i < items.length; i++) {
392
+ if (items[i].id !== id) continue;
393
+ if (items[i].url && img.src !== items[i].url) { img.src = items[i].url; return; }
394
+ items[i].thumb = null;
395
+ }
396
+ render();
397
+ };
398
+ });
399
+ Array.prototype.forEach.call(el('rows').querySelectorAll('[data-retry]'), function (a) {
400
+ a.onclick = function (e) {
401
+ e.preventDefault();
402
+ var id = Number(a.getAttribute('data-retry'));
403
+ for (var i = 0; i < items.length; i++) {
404
+ if (items[i].id === id) { items[i].status = 'queued'; items[i].err = null; items[i].pct = 0; }
405
+ }
406
+ render();
407
+ pump();
408
+ };
409
+ });
410
+ var done = items.filter(function (i) { return i.status === 'done'; });
411
+ var busy = items.some(function (i) { return i.status === 'uploading' || i.status === 'queued'; });
412
+ el('count-chip').style.display = items.length ? '' : 'none';
413
+ el('count-chip').textContent = done.length + '/' + items.length + ' uploaded';
414
+ if (done.length && !busy && !sent) {
415
+ el('actions').style.display = '';
416
+ 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>' +
417
+ '<button class="k-btn ghost" id="btn-more">Add more</button>' +
418
+ '<button class="k-btn ghost" id="btn-external">' + ICONS.open + ' Full-screen</button>';
419
+ el('btn-use').onclick = function () {
420
+ if (sent) return;
421
+ sent = true;
422
+ var lines = done.map(function (i, idx) { return (idx + 1) + '. ' + filenameFor(i) + ' (' + i.kind + '): ' + i.url; });
423
+ window.kolbo.sendMessage('I uploaded ' + done.length + ' file(s) to my Kolbo media library:\\n' + lines.join('\\n') + '\\nContinue with these files.');
424
+ el('actions').innerHTML = '<span style="font-size:12px;color:var(--text-muted)">' + ICONS.check + ' Sent to Claude — continuing…</span>';
425
+ window.kolbo.notifySize();
426
+ };
427
+ el('btn-more').onclick = function () {
428
+ if (isMobileHost()) openExternalUploader();
429
+ else armInlinePicker();
430
+ };
431
+ el('btn-external').onclick = function (e) { e.preventDefault(); openExternalUploader(); };
432
+ } else if (!items.length) {
433
+ // keep boot() actions (external / inline)
434
+ } else if (!done.length || busy) {
435
+ el('actions').style.display = '';
436
+ el('actions').innerHTML = (busy ? '<span style="font-size:12px;color:var(--text-muted)">Uploading…</span>' : '') +
437
+ '<button class="k-btn ghost" id="btn-external">' + ICONS.open + ' Full-screen uploader</button>';
438
+ el('btn-external').onclick = function (e) { e.preventDefault(); openExternalUploader(); };
439
+ }
440
+ window.kolbo.notifySize();
441
+ }
442
+
443
+ window.kolbo.onToolResult(function (result) {
444
+ var sc = result.structuredContent || structured(result);
445
+ if (sc && sc.widget === 'upload') return boot(sc);
446
+ var card = document.querySelector('.k-card');
447
+ if (card && !state) card.style.display = 'none';
448
+ window.kolbo.notifySize();
449
+ });
450
+ `;
451
+
452
+ function uploadWidgetHtml() {
453
+ return widgetPage({ title: 'Kolbo Upload', body: BODY, script: SCRIPT });
454
+ }
455
+
456
+ module.exports = { uploadWidgetHtml };