diff --git a/src/vidxp/assets/mcp_app/index.html b/src/vidxp/assets/mcp_app/index.html index 3a46add5..d4b970e7 100644 --- a/src/vidxp/assets/mcp_app/index.html +++ b/src/vidxp/assets/mcp_app/index.html @@ -54,6 +54,8 @@ .notice { min-height: 20px; color: var(--muted); font-size: 13px; } .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; } @media (max-width: 560px) { .app { padding: 12px; } .header { align-items: stretch; flex-direction: column; } .header .button { align-self: flex-start; } } + .track { height: 6px; border-radius: 999px; background: var(--surface-2, rgba(127, 127, 127, 0.2)); overflow: hidden; } + .track > span { display: block; height: 100%; border-radius: inherit; background: var(--accent, #4f7cff); transition: width 160ms ease; } @@ -82,10 +84,12 @@

Preparing video workspace…

const selected = new Set(); let requestId = 1; let latestResult = null; + let progressTimer = null; let uploadPageUrl = null; let connected = false; let hostCapabilities = {}; let hostContext = {}; + let progressGeneration = 0; const element = (tag, className, text) => { const node = document.createElement(tag); @@ -167,6 +171,14 @@

Preparing video workspace…

return normalizeResult(await request("tools/call", { name, arguments: args })); }; + const stopProgressPolling = () => { + if (progressTimer !== null) { + window.clearTimeout(progressTimer); + progressTimer = null; + } + progressGeneration += 1; + }; + const imageBlocks = (result) => asArray(result?.content).filter((block) => { const mime = String(block?.mimeType ?? block?.mime_type ?? "").toLowerCase(); return block?.type === "image" && (mime === "image/jpeg" || mime === "image/png") && typeof block?.data === "string"; @@ -403,12 +415,197 @@

Preparing video workspace…

content.replaceChildren(wrapper); }; - const render = (result) => { + const persistMediaSelection = (mediaId) => { + if (connected) { + void request("ui/update-model-context", { + structuredContent: { selectedMediaId: mediaId }, + }).catch(() => undefined); + } + if (window.openai?.setWidgetState) { + void Promise.resolve(window.openai.setWidgetState({ selectedMediaId: mediaId })).catch(() => undefined); + } + }; + + const humanBytes = (value) => { + const bytes = Number(value); + if (!Number.isFinite(bytes) || bytes <= 0) return ""; + const units = ["B", "KB", "MB", "GB", "TB"]; + let size = bytes; + let unit = 0; + while (size >= 1024 && unit < units.length - 1) { + size /= 1024; + unit += 1; + } + return `${size < 10 && unit > 0 ? size.toFixed(1) : Math.round(size)} ${units[unit]}`; + }; + + const humanDuration = (value) => { + const total = Number(value); + if (!Number.isFinite(total) || total <= 0) return ""; + const seconds = Math.round(total); + const minutes = Math.floor(seconds / 60); + return `${minutes}:${String(seconds % 60).padStart(2, "0")}`; + }; + + const renderLibrary = (data, query) => { + const items = asArray(data.items).map(asObject); + title.textContent = "Select a video"; + lede.textContent = items.length + ? "Choose a registered video to work with, or load more from the library." + : "No registered videos yet. Upload one first."; + const wrapper = document.createDocumentFragment(); + + const overview = panel("Library"); + const metrics = element("div", "metrics"); + metrics.append( + metric(data.total ?? items.length, "registered"), + metric(items.length, "shown"), + metric(items.filter((item) => item.state === "ready").length, "ready") + ); + overview.append(metrics); + wrapper.append(overview); + + if (items.length) { + const listing = panel("Videos"); + const list = element("div", "tile-list"); + for (const item of items) { + if (typeof item.media_id !== "string") continue; + const tile = element("div", "tile"); + const copy = element("div", "tile-copy"); + copy.append(element("p", "tile-title", item.original_filename || item.media_id)); + const facts = [item.state, humanDuration(item.duration_seconds), humanBytes(item.byte_size), item.container] + .filter((fact) => typeof fact === "string" && fact.length > 0); + copy.append(element("p", "tile-meta", facts.join(" · "))); + tile.append(copy); + + const choose = element("button", "button primary", "Use this video"); + choose.type = "button"; + choose.addEventListener("click", async () => { + choose.disabled = true; + notice.textContent = ""; + try { + persistMediaSelection(item.media_id); + const next = await callTool("get_media", { media_id: item.media_id }); + const detail = next.structuredContent; + lede.textContent = `Selected ${detail.original_filename || item.original_filename || item.media_id}.`; + } catch (_error) { + notice.textContent = "Could not select that video."; + } finally { + choose.disabled = false; + } + }); + tile.append(choose); + list.append(tile); + } + listing.append(list); + wrapper.append(listing); + } + + if (typeof data.next_cursor === "string" && data.next_cursor && query) { + const actions = element("div", "toolbar"); + const more = element("button", "button", "Load more"); + more.type = "button"; + more.addEventListener("click", async () => { + more.disabled = true; + notice.textContent = ""; + try { + const nextQuery = { ...query, cursor: data.next_cursor }; + render(await callTool("list_media", nextQuery), { mediaQuery: nextQuery }); + } catch (_error) { + notice.textContent = "Could not load more videos."; + more.disabled = false; + } + }); + actions.append(more); + wrapper.append(actions); + } + + content.replaceChildren(wrapper); + }; + + const renderProgress = (data) => { + const generation = progressGeneration; + const progress = asObject(data.progress); + const state = typeof data.state === "string" ? data.state : "unknown"; + const kind = typeof data.kind === "string" ? data.kind : "job"; + title.textContent = kind === "index" ? "Indexing progress" : `VidXP ${kind}`; + lede.textContent = progress.message || `Job is ${state}.`; + const wrapper = document.createDocumentFragment(); + + const overview = panel("Status"); + const metrics = element("div", "metrics"); + metrics.append(metric(state, "state"), metric(progress.stage || "—", "stage")); + const current = Number(progress.current); + const total = Number(progress.total); + const measured = Number.isFinite(current) && Number.isFinite(total) && total > 0; + if (measured) metrics.append(metric(`${current}/${total}`, "steps")); + overview.append(metrics); + if (measured) { + const track = element("div", "track"); + const fill = element("span"); + fill.style.width = `${Math.min(100, Math.round((current / total) * 100))}%`; + track.append(fill); + overview.append(track); + } + wrapper.append(overview); + + const error = asObject(data.error); + if (error.message) { + const failure = panel("Error"); + failure.append(element("p", "error", error.message)); + wrapper.append(failure); + } + + const jobId = typeof data.job_id === "string" ? data.job_id : ""; + if (jobId) { + const poll = async () => { + if (generation !== progressGeneration) return; + try { + const next = await callTool("get_job_status", { job_id: jobId }); + if (generation !== progressGeneration) return; + render(next); + } catch (_error) { + if (generation === progressGeneration) notice.textContent = "Could not refresh job status."; + } + }; + const actions = element("div", "toolbar"); + const refresh = element("button", "button", "Refresh status"); + refresh.type = "button"; + refresh.addEventListener("click", async () => { + refresh.disabled = true; + notice.textContent = ""; + try { + await poll(); + } finally { + refresh.disabled = false; + } + }); + actions.append(refresh); + wrapper.append(actions); + if (data.terminal === false) { + const delay = Number(data.poll_after_seconds); + progressTimer = window.setTimeout(() => { + progressTimer = null; + void poll(); + }, (Number.isFinite(delay) && delay > 0 ? delay : 1) * 1000); + } + } + + content.replaceChildren(wrapper); + }; + + const render = (result, context = {}) => { latestResult = normalizeResult(result); const data = latestResult.structuredContent; + stopProgressPolling(); notice.textContent = latestResult.isError ? "VidXP returned an error." : ""; if (data.view === "upload" || data.upload_session_url || data.aggregate_state) renderUpload(data); else if (data.view === "evidence" || data.board) renderEvidence(data, latestResult); + else if ( + data.view === "library" || + (Array.isArray(data.items) && Number.isInteger(data.total) && (data.next_cursor === null || typeof data.next_cursor === "string")) + ) renderLibrary(data, context.mediaQuery); + else if (data.view === "job" || typeof data.job_id === "string") renderProgress(data); else { title.textContent = "VidXP"; lede.textContent = "This tool result does not include an interactive view."; @@ -432,6 +629,7 @@

Preparing video workspace…

if (message.method === "ui/notifications/host-context-changed") updateHostContext(message.params); if (message.method === "ui/notifications/tool-cancelled") notice.textContent = message.params?.reason || "The tool call was cancelled."; if (message.method === "ui/resource-teardown" && message.id !== undefined) { + stopProgressPolling(); window.parent.postMessage({ jsonrpc: "2.0", id: message.id, result: {} }, "*"); } }, { passive: true }); diff --git a/src/vidxp/mcp.py b/src/vidxp/mcp.py index c359c335..11b974fa 100644 --- a/src/vidxp/mcp.py +++ b/src/vidxp/mcp.py @@ -1352,6 +1352,10 @@ async def get_runtime_readiness() -> RuntimeReadiness: "that a video is present in the active index snapshot." ), annotations=_READ_ONLY, + meta=_mcp_app_tool_meta( + "Listing VidXP media…", + "VidXP media library ready.", + ), structured_output=True, ) async def list_media( @@ -2060,6 +2064,10 @@ def completed_evidence_job(_actor: Principal) -> Job: "initial observation." ), annotations=_READ_ONLY, + meta=_mcp_app_tool_meta( + "Checking VidXP job status…", + "VidXP job status ready.", + ), structured_output=True, ) async def get_job_status(job_id: JobId) -> JobSummary: diff --git a/web/mcp-app/package.json b/web/mcp-app/package.json new file mode 100644 index 00000000..bd5357a8 --- /dev/null +++ b/web/mcp-app/package.json @@ -0,0 +1,13 @@ +{ + "name": "@vidxp/mcp-app", + "private": true, + "version": "0.0.0", + "description": "Behavioural tests for the MCP App widget. The widget itself is hand-authored at src/vidxp/assets/mcp_app/index.html and has no build step, so this package carries tests only.", + "type": "module", + "engines": { + "node": ">=22" + }, + "scripts": { + "test": "node --test test/*.test.mjs" + } +} diff --git a/web/mcp-app/test/widget-behavior.test.mjs b/web/mcp-app/test/widget-behavior.test.mjs new file mode 100644 index 00000000..8152c7f2 --- /dev/null +++ b/web/mcp-app/test/widget-behavior.test.mjs @@ -0,0 +1,206 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import test from "node:test"; +import vm from "node:vm"; +import { fileURLToPath } from "node:url"; + +const widgetPath = + process.env.WIDGET_HTML ?? + fileURLToPath(new URL("../../../src/vidxp/assets/mcp_app/index.html", import.meta.url)); +const html = fs.readFileSync(widgetPath, "utf8"); +const script = html.match(/