UNPKG

@kolbo/mcp

Version:

Kolbo AI MCP Server - Generate images, videos, music, speech, and sound effects from Claude Code

342 lines (324 loc) • 15.7 kB
'use strict'; const { widgetPage } = require('../html'); /** * Media grid widget — media library, stock search, presets, voices, moodboards, * visual DNAs, music library. * * Layout: a contact sheet, not a list. One PAGE of square tiles at a time with * a pager underneath, so the card keeps a constant height no matter how many * results there are. Forward past the last loaded page still goes through the * server's own paging contract (page_tool + next_args) — the pages already * fetched just stay in state so Back is instant. * * structuredContent contract: * { * widget: 'media-grid', * title: 'Stock Search — "rain on window"', * items: [{ * id, title, subtitle, thumbnail, media_type: 'image'|'video'|'audio'|'3d', * url, // full asset / playback URL * preview_audio, // audio preview URL (voices, music) * use_hint // unused — Use pastes `id` into the composer * }], * total, has_more, * page_tool, next_args, // paging contract — the server names its own args * import_tool_hint // e.g. 'import via import_stock_asset' — shown on Use * } */ const BODY = ` <div class="k-card"> <div class="k-head"> <span class="k-logo" id="logo"></span> <span class="k-title" id="title"></span> <span class="k-spacer"></span> <span class="k-chip" id="count-chip" style="display:none"></span> </div> <div class="k-body"><div id="stage" class="k-empty">Loading…</div></div> <div class="k-footer"><span>Powered by <a href="#" id="kolbo-link">Kolbo.AI</a></span></div> </div> `; const SCRIPT = ` el('logo').innerHTML = KOLBO_LOGO + '<span>Kolbo</span>'; el('kolbo-link').onclick = function (e) { e.preventDefault(); window.kolbo.openLink('https://app.kolbo.ai'); }; var state = null; var page = 0; function isAudio(item) { return item && item.media_type === 'audio'; } // Audio comes down as full-width rows (a name + a player), so half a dozen of // those already fill the card where a dozen square tiles do. function pageSize() { var items = (state && state.items) || []; var audio = items.filter(isAudio).length; return audio > items.length / 2 ? 6 : 12; } // Items fetched per Next when the widget pages the server itself. var FETCH_BATCH = 24; function pageCount() { return Math.max(1, Math.ceil(((state && state.items) || []).length / pageSize())); } // Only a tool that told us HOW to page can go past what is already loaded. // Every other grid used to render a Load more that did nothing. function hasMore() { if (!state) return false; var shown = (state.items || []).length; var flagged = typeof state.has_next === 'boolean' ? state.has_next : (state.total != null && state.total > shown); return !!(flagged && state.page_tool && (state.next_args || state.query || state.page)); } function boot(sc) { if (!sc || !sc.items) return; state = sc; page = 0; el('title').textContent = sc.title || 'Library'; if (sc.total != null && sc.total >= 0) { el('count-chip').style.display = ''; el('count-chip').textContent = sc.total + ' results'; } else { el('count-chip').style.display = 'none'; } render(); } function render() { var items = (state && state.items) || []; if (!items.length) { el('stage').classList.add('k-empty'); el('stage').innerHTML = 'No results'; window.kolbo.notifySize(); return; } var size = pageSize(); if (page > pageCount() - 1) page = pageCount() - 1; var slice = items.slice(page * size, page * size + size); var visual = slice.filter(function (i) { return !isAudio(i); }); var audio = slice.filter(isAudio); var h = ''; if (visual.length) h += '<div class="k-grid">' + visual.map(tileHTML).join('') + '</div>'; if (audio.length) h += '<div' + (visual.length ? ' style="margin-top:8px"' : '') + '>' + audio.map(audioRowHTML).join('') + '</div>'; h += pagerHTML(); el('stage').innerHTML = h; el('stage').classList.remove('k-empty'); wire(); window.kolbo.notifySize(); } function pagerHTML() { var n = pageCount(); var more = hasMore(); if (n <= 1 && !more) return ''; var mid; if (n <= 8) { var dots = ''; for (var i = 0; i < n; i++) { dots += '<button class="k-dot' + (i === page ? ' on' : '') + '" data-page="' + i + '" aria-label="Page ' + (i + 1) + '"></button>'; } // A dimmed trailing dot says "there is more behind Next" without claiming // to know how much — the server only ever promises the NEXT page. if (more) dots += '<span class="k-dot ghost"></span>'; mid = '<div class="k-dots">' + dots + '</div>'; } else { mid = '<span class="k-pager-label">' + (page + 1) + ' / ' + n + (more ? '+' : '') + '</span>'; } return '<div class="k-pager">' + '<button class="k-pager-btn" id="page-prev" aria-label="Previous"' + (page === 0 ? ' disabled' : '') + '>' + ICONS.chevronLeft + '</button>' + mid + '<button class="k-pager-btn" id="page-next" aria-label="Next"' + (page >= n - 1 && !more ? ' disabled' : '') + '>' + ICONS.chevronRight + '</button>' + '</div>'; } function tileHTML(item) { var idx = state.items.indexOf(item); var isVideo = item.media_type === 'video' && item.url; // onerror matters: a thumbnail that 404s / expires / can't be fetched by the // host webview left a BLACK tile with no glyph and no label hint — visually // identical to "corrupted media". Fall back to the same kind icon a // thumbnail-less item gets, so a dead URL degrades instead of looking broken. // outerHTML, not parentNode.innerHTML: the parent is now the whole tile, and // wiping it would take the caption and the Use/Download buttons with it. var fallback = '<div class="k-tile-fb">' + kindIcon(item.media_type) + '</div>'; var peekUrl = item.url || item.thumbnail; var media = item.thumbnail ? '<img class="k-peek-hit" src="' + esc(item.thumbnail) + '" loading="lazy" alt=""' + peekAttrs(peekUrl, item.media_type === 'video' ? 'video' : 'image', item.title) + ' onerror="this.outerHTML=this.getAttribute(\\'data-fb\\')" data-fb="' + esc(fallback) + '">' : fallback; var cap = (item.title || item.subtitle) ? '<div class="k-tile-cap">' + (item.title ? '<div class="k-tile-t">' + esc(item.title) + '</div>' : '') + (item.subtitle ? '<div class="k-tile-s">' + esc(item.subtitle) + '</div>' : '') + '</div>' : ''; // Clicking the tile sends the id, but nothing on a bare thumbnail signals // that — the explicit buttons keep the affordance visible (and on touch // hosts CSS keeps them permanently on, since there is no hover there). var acts = '<div class="k-tile-acts">' + '<button class="k-tile-act" data-use="' + idx + '" title="Use" aria-label="Use">' + ICONS.arrowRight + '</button>' + (item.url ? '<button class="k-tile-act" data-dl="' + esc(item.url) + '" title="Download" aria-label="Download">' + ICONS.download + '</button>' : '') + '</div>'; return '<div class="k-tile" data-i="' + idx + '" title="' + esc(item.title || '') + '">' + media + (isVideo ? '<button class="k-play k-tile-play" data-video-play="' + esc(item.url) + '">' + ICONS.play + '</button>' : '') + cap + acts + '</div>'; } function audioRowHTML(item) { var idx = state.items.indexOf(item); var src = item.preview_audio || item.url; // Same onerror contract as tileHTML, and for the same reason: a thumbnail the // host cannot fetch (CSP, 404, expired) otherwise renders the browser's // broken-image glyph. this.outerHTML, NOT this.parentNode.innerHTML: here the // <img> IS the art and its parent is the whole row, so wiping the parent would // erase the title, subtitle, Use button and player along with it. var artFallback = '<div class="k-audio-art k-audio-art-fallback">' + kindIcon('audio') + '</div>'; return '<div class="k-audio-row k-generated-audio" data-i="' + idx + '">' + (item.thumbnail ? '<img class="k-audio-art k-peek-hit" src="' + esc(item.thumbnail) + '" alt=""' + peekAttrs(item.thumbnail, 'image', item.title) + ' onerror="this.outerHTML=this.getAttribute(\\'data-fb\\')" data-fb="' + esc(artFallback) + '">' : artFallback) + '<div class="k-audio-meta"><div class="k-audio-title">' + esc(item.title || '') + '</div>' + '<div class="k-audio-sub">' + esc(item.subtitle || '') + '</div></div>' + '<button class="k-btn" data-use="' + idx + '">Use</button>' + (src ? '<audio class="k-audio-player" src="' + esc(src) + '" controls preload="none" aria-label="Play ' + esc(item.title || '') + '"></audio>' : '') + '</div>'; } function wire() { var stage = el('stage'); Array.prototype.forEach.call(stage.querySelectorAll('.k-tile, .k-audio-row'), function (c) { c.onclick = function (e) { if (e.target && e.target.closest && e.target.closest('[data-peek],[data-use],[data-dl],[data-video-play]')) return; useItem(+c.getAttribute('data-i')); }; }); Array.prototype.forEach.call(stage.querySelectorAll('[data-use]'), function (b) { b.onclick = function (e) { e.stopPropagation(); useItem(+b.getAttribute('data-use')); }; }); Array.prototype.forEach.call(stage.querySelectorAll('.k-tile-act[data-dl]'), function (b) { b.onclick = function (e) { e.stopPropagation(); window.kolbo.openLink(downloadUrl(b.getAttribute('data-dl'))); }; }); Array.prototype.forEach.call(stage.querySelectorAll('[data-page]'), function (d) { d.onclick = function () { var p = +d.getAttribute('data-page'); if (p !== page) { page = p; render(); } }; }); // Native <audio controls> already gives play/pause + a seek bar + duration; // just stop clicks on it from bubbling up to the row's "Use" handler, and // pause any other preview when a new one starts. var players = stage.querySelectorAll('.k-audio-player'); Array.prototype.forEach.call(players, function (player) { player.onclick = function (e) { e.stopPropagation(); }; player.addEventListener('play', function () { Array.prototype.forEach.call(players, function (other) { if (other !== player) other.pause(); }); }); }); // Video tiles stay a static thumbnail until the play overlay is clicked, then // swap the <img> for a native <video controls>. Replace only the image — the // caption goes because the control bar lands in exactly that strip. Array.prototype.forEach.call(stage.querySelectorAll('[data-video-play]'), function (b) { b.onclick = function (e) { e.stopPropagation(); var tile = b.parentNode; var v = document.createElement('video'); v.src = b.getAttribute('data-video-play'); v.controls = true; v.autoplay = true; v.playsInline = true; v.setAttribute('style', 'width:100%;height:100%;object-fit:contain;background:#000'); v.onclick = function (ev) { ev.stopPropagation(); }; var img = tile.querySelector('img'); if (img) tile.replaceChild(v, img); else tile.insertBefore(v, tile.firstChild); var cap = tile.querySelector('.k-tile-cap'); if (cap && cap.parentNode) cap.parentNode.removeChild(cap); if (b.parentNode) b.parentNode.removeChild(b); }; }); var prev = el('page-prev'); if (prev) prev.onclick = function () { if (page > 0) { page--; render(); } }; var next = el('page-next'); if (next) next.onclick = function () { if (page < pageCount() - 1) { page++; render(); return; } fetchNextPage(next); }; } // Fetch the next page IN the widget and append it. // // This used to sendMessage() a request for "the next page of this same media // search", on the stated belief that a widget has no way to invoke a tool. It // does — window.kolbo.callTool, the same bridge call every generation card // polls status with. And the message could not have worked anyway: the payload // carried no page number and none of the filters, so the model had nothing to // reconstruct the query from and would re-run page 1 or something else. function fetchNextPage(btn) { if (!state || !state.page_tool || btn.disabled) return; var nextPage = (state.page || 1) + 1; btn.disabled = true; btn.innerHTML = '<span class="k-spin"></span>'; // The server knows its own paging arg names (page/limit, offset, cursor…): // when it shipped next_args, send exactly that. The query+page shape is the // legacy fallback list_media has always used. var args = {}; // Set when the first page was too small to page with (the model asked // list_media for 3 items): re-read from the start in a full batch instead of // adding 3 more tiles per Next. var replaceFrom = -1; if (state.next_args) { for (var nk in state.next_args) { if (state.next_args[nk] !== undefined && state.next_args[nk] !== null) args[nk] = state.next_args[nk]; } } else { var q = state.query || {}; for (var k in q) { if (q[k] !== undefined && q[k] !== null && q[k] !== '') args[k] = q[k]; } args.page = nextPage; if (state.page_size) args.page_size = state.page_size; var loaded = (state.items || []).length; if ((state.page_size || 0) < FETCH_BATCH && loaded < 200 - FETCH_BATCH) { // One aligned batch: page 1 at a size that is a whole number of batches // covering everything loaded plus one more batch. After this the batch // size divides the loaded count, so later Nexts append normally. args.page = 1; args.page_size = Math.ceil((loaded + 1) / FETCH_BATCH) * FETCH_BATCH; replaceFrom = loaded; } } window.kolbo.callTool(state.page_tool, args).then(function (res) { var sc = structured(res); if (res && res.isError) { btn.disabled = false; btn.innerHTML = ICONS.chevronRight; btn.title = 'Could not load more — try again'; return; } var more = (sc && sc.items) || []; if (!more.length) { // Nothing came back: retire the control rather than leaving a Next that // still looks like it has pages behind it. state.has_next = false; state.next_args = undefined; render(); return; } if (replaceFrom >= 0) { // Keep the view on the first tile the user has not seen yet. state.items = more; state.page = (args.page_size / FETCH_BATCH); state.page_size = FETCH_BATCH; state.next_args = undefined; if (sc && sc.total != null && sc.total >= 0) state.total = sc.total; state.has_next = sc && typeof sc.has_next === 'boolean' ? sc.has_next : undefined; page = Math.floor(replaceFrom / pageSize()); render(); return; } state.items = (state.items || []).concat(more); state.page = (sc && sc.page) || nextPage; // The next page's args come from THIS response; none means this was the last page. state.next_args = sc && sc.next_args; if (sc && sc.total != null && sc.total >= 0) state.total = sc.total; state.has_next = sc && typeof sc.has_next === 'boolean' ? sc.has_next : undefined; page = page + 1; render(); }).catch(function () { btn.disabled = false; btn.innerHTML = ICONS.chevronRight; }); } function useItem(i) { var item = state.items[i]; if (!item || !item.id) return; window.kolbo.insertText(String(item.id)); } window.kolbo.onToolResult(function (result) { var sc = result.structuredContent || structured(result); if (sc && sc.items) return boot(sc); // No grid data (empty result set, error, or timeout path returned plain // text) — collapse instead of showing a dead "Loading…" card forever. var card = document.querySelector('.k-card'); if (card) card.style.display = 'none'; window.kolbo.notifySize(); }); `; function mediaGridWidgetHtml() { return widgetPage({ title: 'Kolbo Library', body: BODY, script: SCRIPT }); } module.exports = { mediaGridWidgetHtml };