From fc4bfca3f191b455404815cd20c9e08341de70b8 Mon Sep 17 00:00:00 2001 From: guitavano Date: Wed, 19 Aug 2026 14:59:28 -0300 Subject: [PATCH] feat(assets): remove the assets UI (fetch_assets tool, resource, and view) The media asset gallery served here is superseded by Studio's native per-site Assets tab, which reads/uploads/deletes objects directly against the site's storage bucket. Remove the whole assets feature: - api/tools/assets.ts (fetch_assets / upload_asset / delete_asset) - api/resources/assets.ts (the "Assets UI" app resource) - web/tools/assets/ (the React gallery view) - their registrations in api/tools/index.ts, api/app.ts, web/router.tsx No other code references these symbols. Co-Authored-By: Claude Opus 4.8 --- api/app.ts | 2 - api/resources/assets.ts | 27 -- api/tools/assets.ts | 297 ---------------- api/tools/index.ts | 4 - web/router.tsx | 2 - web/tools/assets/index.tsx | 701 ------------------------------------- 6 files changed, 1033 deletions(-) delete mode 100644 api/resources/assets.ts delete mode 100644 api/tools/assets.ts delete mode 100644 web/tools/assets/index.tsx diff --git a/api/app.ts b/api/app.ts index 6ebe37d..c3e438b 100644 --- a/api/app.ts +++ b/api/app.ts @@ -1,5 +1,4 @@ import { withRuntime } from "@decocms/runtime"; -import { createAssetsAppResource } from "./resources/assets.ts"; import { createExperimentsAppResource } from "./resources/experiments.ts"; import { createMonitorAppResource } from "./resources/monitor.ts"; import { tools } from "./tools/index.ts"; @@ -98,7 +97,6 @@ export function createApp(opts: CreateAppOptions): Fetcher { }, tools, resources: [ - createAssetsAppResource(getClientHTML), createExperimentsAppResource(getClientHTML), createMonitorAppResource(getClientHTML), ], diff --git a/api/resources/assets.ts b/api/resources/assets.ts deleted file mode 100644 index 2e1eacd..0000000 --- a/api/resources/assets.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { createPublicResource } from "@decocms/runtime/tools"; -import { ASSETS_RESOURCE_URI } from "../tools/assets.ts"; - -const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"; - -export const createAssetsAppResource = (getClientHTML: () => Promise) => - createPublicResource({ - uri: ASSETS_RESOURCE_URI, - name: "Assets UI", - description: "Interactive media asset gallery for deco.cx sites", - mimeType: RESOURCE_MIME_TYPE, - read: async () => { - const html = await getClientHTML(); - return { - uri: ASSETS_RESOURCE_URI, - mimeType: RESOURCE_MIME_TYPE, - text: html, - _meta: { - ui: { - csp: { - connectDomains: ["https://admin.deco.cx"], - }, - }, - }, - }; - }, - }); diff --git a/api/tools/assets.ts b/api/tools/assets.ts deleted file mode 100644 index df471cf..0000000 --- a/api/tools/assets.ts +++ /dev/null @@ -1,297 +0,0 @@ -import { createTool } from "@decocms/runtime/tools"; -import { z } from "zod"; -import { ADMIN_BASE_URL, getConfig } from "../lib/admin.ts"; - -export const ASSETS_RESOURCE_URI = "ui://mcp-app/assets"; - -// ─── shared schema ──────────────────────────────────────────────────────────── - -export const assetSchema = z.object({ - id: z.number(), - asset_id: z.string().nullable(), - site_id: z.number(), - publicUrl: z.string(), - label: z.string().nullable(), - mime: z.string().nullable(), - path: z.string(), - brightness: z.number().nullable(), - preview: z.string().nullable(), - created_at: z.string(), - updated_at: z.string(), -}); - -export type Asset = z.infer; - -// ─── fetch_assets ───────────────────────────────────────────────────────────── - -export const assetsInputSchema = z.object({ - term: z - .string() - .optional() - .describe("Optional search term to filter assets by label"), - limit: z - .number() - .int() - .min(1) - .max(200) - .default(47) - .describe("Number of assets to return (default: 50)"), - offset: z - .number() - .int() - .min(0) - .default(0) - .describe("Offset for pagination (default: 0)"), -}); - -export type AssetsInput = z.infer; - -export const assetsOutputSchema = z.object({ - assets: z.array(assetSchema), - sitename: z.string(), - total: z.number(), -}); - -export type AssetsOutput = z.infer; - -export const assetsTool = createTool({ - id: "fetch_assets", - title: "Assets", - description: - "Fetch media assets (images, videos, documents, fonts) for the configured deco.cx site. Returns a paginated gallery of all uploaded assets with URLs, labels, and MIME types. Supports optional search by filename.", - inputSchema: assetsInputSchema, - outputSchema: assetsOutputSchema, - _meta: { ui: { resourceUri: ASSETS_RESOURCE_URI } }, - annotations: { - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - openWorldHint: false, - }, - execute: async ({ context }, ctx) => { - const { term, limit = 42, offset = 0 } = context; - const { site: sitename, apiKey } = getConfig(ctx); - - const response = await fetch( - `${ADMIN_BASE_URL}/live/invoke/deco-sites/admin/loaders/sites/assets.ts`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-api-key": apiKey, - }, - body: JSON.stringify({ - sitename, - filters: { offset, limit }, - ...(term ? { term } : {}), - }), - }, - ); - - if (!response.ok) { - throw new Error( - `Failed to fetch assets: ${response.status} ${response.statusText}`, - ); - } - - const data = await response.json(); - const assets: Asset[] = data.assets ?? []; - - return { - assets, - sitename, - total: assets.length, - }; - }, -}); - -// ─── upload_asset ───────────────────────────────────────────────────────────── - -export const uploadAssetInputSchema = z.object({ - url: z - .string() - .url() - .optional() - .describe( - "Public URL of the file to fetch and upload as a site asset. Provide either url or data, not both.", - ), - data: z - .string() - .optional() - .describe( - "Base64-encoded file content to upload directly (alternative to url, for local files).", - ), - mimeType: z - .string() - .optional() - .describe("MIME type of the file. Required when using data."), - filename: z - .string() - .optional() - .describe( - "Custom filename for the asset. Defaults to the filename extracted from the URL.", - ), -}); - -export type UploadAssetInput = z.infer; - -export const uploadAssetOutputSchema = z.object({ - asset: assetSchema, - message: z.string(), -}); - -export type UploadAssetOutput = z.infer; - -function filenameFromUrl(url: string): string { - try { - const pathname = new URL(url).pathname; - const name = pathname.split("/").pop(); - return name?.includes(".") ? name : "asset"; - } catch { - return "asset"; - } -} - -export const uploadAssetTool = createTool({ - id: "upload_asset", - description: - "Upload a media asset for the configured deco.cx site. Accepts either a public URL (the server downloads it) or base64-encoded file content. Returns the uploaded asset with its CDN URL.", - inputSchema: uploadAssetInputSchema, - outputSchema: uploadAssetOutputSchema, - annotations: { - readOnlyHint: false, - destructiveHint: false, - idempotentHint: false, - openWorldHint: false, - }, - execute: async ({ context }, ctx) => { - const { url, data, mimeType, filename } = context; - const { site: sitename, apiKey } = getConfig(ctx); - - if (!url && !data) { - throw new Error("Either url or data must be provided."); - } - - let fileBlob: Blob; - let contentType: string; - let name: string; - - if (data) { - const binary = atob(data); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i); - } - contentType = mimeType ?? "application/octet-stream"; - name = filename ?? "asset"; - fileBlob = new Blob([bytes], { type: contentType }); - } else { - const resolvedUrl = url as string; - const fetchResponse = await fetch(resolvedUrl); - if (!fetchResponse.ok) { - throw new Error( - `Failed to fetch file from URL: ${fetchResponse.status} ${fetchResponse.statusText}`, - ); - } - fileBlob = await fetchResponse.blob(); - contentType = - fetchResponse.headers.get("content-type") ?? - fileBlob.type ?? - "application/octet-stream"; - name = filename ?? filenameFromUrl(resolvedUrl); - } - - const form = new FormData(); - form.append("sitename", sitename); - form.append( - "file", - new File([fileBlob], name, { type: contentType }), - name, - ); - - const uploadResponse = await fetch( - `${ADMIN_BASE_URL}/live/invoke/deco-sites/admin/actions/assets/upload.ts`, - { - method: "POST", - headers: { "x-api-key": apiKey }, - body: form, - }, - ); - - if (!uploadResponse.ok) { - const text = await uploadResponse - .text() - .catch(() => uploadResponse.statusText); - throw new Error(`Upload failed: ${uploadResponse.status} — ${text}`); - } - - const asset = await uploadResponse.json(); - - return { - asset, - message: `Successfully uploaded "${name}" to ${sitename}. CDN URL: ${asset.publicUrl}`, - }; - }, -}); - -// ─── delete_asset ───────────────────────────────────────────────────────────── - -export const deleteAssetInputSchema = z.object({ - id: z - .string() - .describe("The numeric ID of the asset to delete (as a string, e.g. '42')"), -}); - -export const deleteAssetOutputSchema = z.object({ - deleted: z.boolean(), - id: z.string(), - sitename: z.string(), - message: z.string(), -}); - -export type DeleteAssetOutput = z.infer; - -export const deleteAssetTool = createTool({ - id: "delete_asset", - description: - "Permanently delete a media asset by its ID from the configured deco.cx site. This is irreversible — the file is removed from storage and the database index.", - inputSchema: deleteAssetInputSchema, - outputSchema: deleteAssetOutputSchema, - _meta: { ui: { visibility: ["app"] } }, - annotations: { - readOnlyHint: false, - destructiveHint: true, - idempotentHint: false, - openWorldHint: false, - }, - execute: async ({ context }, ctx) => { - const { id } = context; - const { site: sitename, apiKey } = getConfig(ctx); - - const response = await fetch( - `${ADMIN_BASE_URL}/live/invoke/deco-sites/admin/actions/assets/remove_asset.ts`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-api-key": apiKey, - }, - body: JSON.stringify({ sitename, id }), - }, - ); - - if (!response.ok) { - const text = await response.text().catch(() => response.statusText); - throw new Error( - `Failed to delete asset ${id}: ${response.status} ${text}`, - ); - } - - return { - deleted: true, - id, - sitename, - message: `Asset ${id} deleted successfully from ${sitename}.`, - }; - }, -}); diff --git a/api/tools/index.ts b/api/tools/index.ts index 4fc8e0d..dae3ed3 100644 --- a/api/tools/index.ts +++ b/api/tools/index.ts @@ -1,5 +1,4 @@ import { analyticsQueryTool } from "./analytics-query.ts"; -import { assetsTool, deleteAssetTool, uploadAssetTool } from "./assets.ts"; import { createExperimentTool, experimentResultsTool, @@ -17,9 +16,6 @@ import { } from "./monitor.ts"; export const tools = [ analyticsQueryTool, - assetsTool, - uploadAssetTool, - deleteAssetTool, getMonitorDataTool, getMonitorSummaryTool, getMonitorTimelineTool, diff --git a/web/router.tsx b/web/router.tsx index adae607..af4cf57 100644 --- a/web/router.tsx +++ b/web/router.tsx @@ -7,12 +7,10 @@ import { RouterProvider, } from "@tanstack/react-router"; import { useMcpHostContext, useMcpState } from "./context.tsx"; -import AssetsPage from "./tools/assets/index.tsx"; import ExperimentsPage from "./tools/experiments/index.tsx"; import MonitorPage from "./tools/monitor/index.tsx"; const TOOL_PAGES: Record = { - fetch_assets: AssetsPage, get_monitor_data: MonitorPage, list_experiments: ExperimentsPage, }; diff --git a/web/tools/assets/index.tsx b/web/tools/assets/index.tsx deleted file mode 100644 index 8df2446..0000000 --- a/web/tools/assets/index.tsx +++ /dev/null @@ -1,701 +0,0 @@ -import { - AlertTriangle, - Check, - Copy, - Download, - File, - FileText, - Film, - Image, - Music, - Package, - Search, - Trash2, - Upload, - X, -} from "lucide-react"; -import { useCallback, useRef, useState } from "react"; -import { Badge } from "@/components/ui/badge.tsx"; -import { Button } from "@/components/ui/button.tsx"; -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "@/components/ui/card.tsx"; -import { Input } from "@/components/ui/input.tsx"; -import { Skeleton } from "@/components/ui/skeleton.tsx"; -import { useMcpApp, useMcpState } from "@/context.tsx"; -import { cn } from "@/lib/utils.ts"; - -const PAGE_SIZE = 21; - -import type { - Asset, - AssetsInput, - AssetsOutput, - UploadAssetOutput, -} from "../../../api/tools/assets.ts"; - -// ─── helpers ──────────────────────────────────────────────────────────────── - -function getMimeIcon(mime: string | null) { - const base = "w-7 h-7"; - if (!mime) return ; - if (mime.startsWith("image/")) - return ; - if (mime.startsWith("video/")) - return ; - if (mime.startsWith("audio/")) - return ; - if (mime === "application/pdf") - return ; - if (mime.startsWith("font/") || mime.includes("font")) - return ; - return ; -} - -function filenameFromPath(path: string) { - return path.split("/").pop() ?? path; -} - -// ─── upload helpers ────────────────────────────────────────────────────────── - -interface UploadItem { - id: string; - file: File; - status: "pending" | "uploading" | "done" | "error"; - error?: string; - result?: Asset; -} - -function fileToBase64(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => { - const result = reader.result as string; - resolve(result.split(",")[1]); - }; - reader.onerror = reject; - reader.readAsDataURL(file); - }); -} - -// ─── AssetCard ─────────────────────────────────────────────────────────────── - -function AssetCard({ - asset, - onDelete, - onDeleted, -}: { - asset: Asset; - onDelete: (id: number) => Promise; - onDeleted: (id: number) => void; -}) { - const [copied, setCopied] = useState(false); - const [deleteState, setDeleteState] = useState< - "idle" | "confirm" | "deleting" | "error" - >("idle"); - const [deleteError, setDeleteError] = useState(); - - const isImage = asset.mime?.startsWith("image/") ?? false; - const name = asset.label ?? filenameFromPath(asset.path); - - const handleCopy = async () => { - await navigator.clipboard.writeText(asset.publicUrl); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - - const handleDeleteClick = (e: React.MouseEvent) => { - e.stopPropagation(); - setDeleteState("confirm"); - }; - - const handleDeleteCancel = (e: React.MouseEvent) => { - e.stopPropagation(); - setDeleteState("idle"); - setDeleteError(undefined); - }; - - const handleDeleteConfirm = async (e: React.MouseEvent) => { - e.stopPropagation(); - setDeleteState("deleting"); - setDeleteError(undefined); - try { - await onDelete(asset.id); - onDeleted(asset.id); - } catch (err) { - setDeleteError(err instanceof Error ? err.message : "Delete failed"); - setDeleteState("error"); - } - }; - - return ( -
- {/* Preview area */} -
- {isImage ? ( - {name} - ) : ( -
- {getMimeIcon(asset.mime)} - {asset.mime && ( - - {asset.mime.split("/")[1]?.toUpperCase() ?? asset.mime} - - )} -
- )} - - {/* Hover overlay — normal actions */} - {deleteState === "idle" && ( -
- - - - - -
- )} - - {/* Confirm delete overlay */} - {(deleteState === "confirm" || - deleteState === "deleting" || - deleteState === "error") && ( -
- {deleteState === "error" ? ( - <> - -

- {deleteError} -

- - - ) : deleteState === "deleting" ? ( - - ) : ( - <> -

- Delete? -

-
- - -
- - )} -
- )} -
- - {/* Name */} -
-

- {name} -

- {asset.mime && ( -

- {asset.mime.split("/")[1]?.toUpperCase() ?? asset.mime} -

- )} -
-
- ); -} - -// ─── UploadQueue ───────────────────────────────────────────────────────────── - -function UploadQueue({ - items, - onDismiss, -}: { - items: UploadItem[]; - onDismiss: (id: string) => void; -}) { - if (items.length === 0) return null; - - return ( -
- {items.map((item) => ( -
- {item.status === "uploading" && ( - - )} - {item.status === "done" && ( - - )} - {item.status === "error" && ( - - )} - {item.status === "pending" && ( - - )} - {item.file.name} - {item.status === "error" && item.error && ( - - {item.error} - - )} - {(item.status === "done" || item.status === "error") && ( - - )} -
- ))} -
- ); -} - -// ─── AssetsGallery ──────────────────────────────────────────────────────────── - -function AssetsGallery({ initialAssets }: { initialAssets: Asset[] }) { - const app = useMcpApp(); - const [assets, setAssets] = useState(initialAssets); - const [search, setSearch] = useState(""); - const [hasMore, setHasMore] = useState(initialAssets.length >= PAGE_SIZE); - const [isFetchingMore, setIsFetchingMore] = useState(false); - const [fetchMoreError, setFetchMoreError] = useState(); - const [queue, setQueue] = useState([]); - const [isDragging, setIsDragging] = useState(false); - const fileInputRef = useRef(null); - - const filtered = search.trim() - ? assets.filter((a) => { - const label = (a.label ?? filenameFromPath(a.path)).toLowerCase(); - return label.includes(search.toLowerCase()); - }) - : assets; - - const handleShowMore = useCallback(async () => { - setIsFetchingMore(true); - setFetchMoreError(undefined); - try { - const result = await app?.callServerTool({ - name: "fetch_assets", - arguments: { offset: assets.length, limit: PAGE_SIZE }, - }); - if (result?.isError) { - const text = result.content?.find((c) => c.type === "text"); - throw new Error( - text?.type === "text" ? text.text : "Failed to load more assets", - ); - } - const data = result?.structuredContent as AssetsOutput | undefined; - const newAssets = data?.assets ?? []; - setAssets((prev) => [...prev, ...newAssets]); - if (newAssets.length < PAGE_SIZE) setHasMore(false); - } catch (err) { - setFetchMoreError( - err instanceof Error ? err.message : "Failed to load more", - ); - } finally { - setIsFetchingMore(false); - } - }, [app, assets.length]); - - const handleDelete = useCallback( - async (id: number) => { - const result = await app?.callServerTool({ - name: "delete_asset", - arguments: { id: String(id) }, - }); - if (result?.isError) { - const text = result.content?.find((c) => c.type === "text"); - throw new Error(text?.type === "text" ? text.text : "Delete failed"); - } - }, - [app], - ); - - const processFiles = useCallback( - async (files: File[]) => { - if (files.length === 0) return; - - const newItems: UploadItem[] = files.map((f) => ({ - id: crypto.randomUUID(), - file: f, - status: "pending", - })); - - setQueue((prev) => [...prev, ...newItems]); - - for (const item of newItems) { - setQueue((prev) => - prev.map((q) => - q.id === item.id ? { ...q, status: "uploading" } : q, - ), - ); - try { - const base64 = await fileToBase64(item.file); - const result = await app?.callServerTool({ - name: "upload_asset", - arguments: { - data: base64, - mimeType: item.file.type || "application/octet-stream", - filename: item.file.name, - }, - }); - if (result?.isError) { - const text = result.content?.find((c) => c.type === "text"); - throw new Error( - text?.type === "text" ? text.text : "Upload failed", - ); - } - const uploaded = result?.structuredContent as - | UploadAssetOutput - | undefined; - const uploadedAsset = uploaded?.asset; - setQueue((prev) => - prev.map((q) => - q.id === item.id - ? { ...q, status: "done", result: uploadedAsset } - : q, - ), - ); - if (uploadedAsset) { - setAssets((prev) => [uploadedAsset, ...prev]); - } - } catch (err) { - const msg = err instanceof Error ? err.message : "Unknown error"; - setQueue((prev) => - prev.map((q) => - q.id === item.id ? { ...q, status: "error", error: msg } : q, - ), - ); - } - } - }, - [app], - ); - - const handleSearchChange = (e: React.ChangeEvent) => { - setSearch(e.target.value); - }; - - const handleFileInput = (e: React.ChangeEvent) => { - const files = Array.from(e.target.files ?? []); - processFiles(files); - e.target.value = ""; - }; - - const handleDrop = (e: React.DragEvent) => { - e.preventDefault(); - setIsDragging(false); - const files = Array.from(e.dataTransfer.files); - processFiles(files); - }; - - const dismissQueueItem = (id: string) => { - setQueue((prev) => prev.filter((q) => q.id !== id)); - }; - - const handleAssetDeleted = (id: number) => { - setAssets((prev) => prev.filter((a) => a.id !== id)); - }; - - return ( -
{ - e.preventDefault(); - setIsDragging(true); - }} - onDragLeave={() => setIsDragging(false)} - onDrop={handleDrop} - > -
- - -
- - {/* Upload queue */} - - - {/* Search */} -
- - -
- - {/* Drop overlay hint */} - {isDragging && ( -
-
- -

Drop files to upload

-
-
- )} - - {/* Grid */} - {filtered.length === 0 ? ( -
- -

- {search - ? `No assets found for "${search}"` - : "No assets yet — upload some files to get started"} -

- {!search && ( - - )} -
- ) : ( - <> -
- {filtered.map((asset) => ( - - ))} -
- {hasMore && ( -
- - {fetchMoreError && ( -

{fetchMoreError}

- )} -
- )} - - )} -
- ); -} - -// ─── Loading skeleton ───────────────────────────────────────────────────────── - -function LoadingGrid() { - return ( -
-
- - Fetching assets… -
-
- {Array.from({ length: 12 }).map((_, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: static loading skeleton -
- - - -
- ))} -
-
- ); -} - -// ─── Page ───────────────────────────────────────────────────────────────────── - -export default function AssetsPage() { - const state = useMcpState(); - - if (state.status === "initializing") { - return ( -
-
- - Connecting to host… -
-
- ); - } - - if (state.status === "connected") { - return ( -
- - - Assets - - -

- Call the{" "} - - fetch_assets - {" "} - tool with a site name to browse assets. -

-
-
-
- ); - } - - if (state.status === "error") { - return ( -
- - - Error - - -

- {state.error ?? "Unknown error"} -

-
-
-
- ); - } - - if (state.status === "tool-cancelled") { - return ( -
- - -

- Tool call was cancelled. -

-
-
-
- ); - } - - if (state.status === "tool-input") { - return ( -
- -
- ); - } - - // tool-result - const { assets } = state.toolResult ?? { assets: [] }; - - return ( -
- -
- ); -}