feat(platform): switch builder file inputs from base64 to workspace uploads (#12226)

## Summary

Builder node file inputs were stored as base64 data URIs directly in
graph JSON, bloating saves and causing lag. This PR uploads files to the
existing workspace system and stores lightweight `workspace://`
references instead.

## What changed

- **Upload**: When a user picks a file in a builder node input, it gets
uploaded to workspace storage and the graph stores a small
`workspace://file-id#mime/type` URI instead of a huge base64 string.

- **Delete**: When a user clears a file input, the workspace file is
soft-deleted from storage so it doesn't leave orphaned files behind.

- **Execution**: Wired up `workspace_id` on `ExecutionContext` so blocks
can resolve `workspace://` URIs during graph runs. `store_media_file()`
already knew how to handle them.

- **Output rendering**: Added a renderer that displays `workspace://`
URIs as images, videos, audio players, or download cards in node output.

- **Proxy fix**: Removed a `Content-Type: text/plain` override on
multipart form responses that was breaking the generated hooks' response
parsing.

Existing graphs with base64 `data:` URIs continue to work — no migration
needed.

## Test plan

- [x] Upload file in builder → spinner shows, completes, file label
appears
- [x] Save/reload graph → `workspace://` URI persists, not base64
- [x] Clear file input → workspace file is deleted
- [x] Run graph → blocks resolve `workspace://` files correctly
- [x] Output renders images/video/audio from `workspace://` URIs
- [x] Old graphs with base64 still work

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Abhimanyu Yadav
2026-03-05 14:08:18 +05:30
committed by GitHub
parent 3722d05b9b
commit f1b771b7ee
17 changed files with 689 additions and 107 deletions

View File

@@ -22,6 +22,7 @@ from backend.data.human_review import (
)
from backend.data.model import USER_TIMEZONE_NOT_SET
from backend.data.user import get_user_by_id
from backend.data.workspace import get_or_create_workspace
from backend.executor.utils import add_graph_execution
from .model import PendingHumanReviewModel, ReviewRequest, ReviewResponse
@@ -321,10 +322,13 @@ async def process_review_action(
user.timezone if user.timezone != USER_TIMEZONE_NOT_SET else "UTC"
)
workspace = await get_or_create_workspace(user_id)
execution_context = ExecutionContext(
human_in_the_loop_safe_mode=settings.human_in_the_loop_safe_mode,
sensitive_action_safe_mode=settings.sensitive_action_safe_mode,
user_timezone=user_timezone,
workspace_id=workspace.id,
)
await add_graph_execution(

View File

@@ -120,6 +120,10 @@ class UploadFileResponse(BaseModel):
size_bytes: int
class DeleteFileResponse(BaseModel):
deleted: bool
class StorageUsageResponse(BaseModel):
used_bytes: int
limit_bytes: int
@@ -151,6 +155,31 @@ async def download_file(
return await _create_file_download_response(file)
@router.delete(
"/files/{file_id}",
summary="Delete a workspace file",
)
async def delete_workspace_file(
user_id: Annotated[str, fastapi.Security(get_user_id)],
file_id: str,
) -> DeleteFileResponse:
"""
Soft-delete a workspace file and attempt to remove it from storage.
Used when a user clears a file input in the builder.
"""
workspace = await get_workspace(user_id)
if workspace is None:
raise fastapi.HTTPException(status_code=404, detail="Workspace not found")
manager = WorkspaceManager(user_id, workspace.id)
deleted = await manager.delete_file(file_id)
if not deleted:
raise fastapi.HTTPException(status_code=404, detail="File not found")
return DeleteFileResponse(deleted=True)
@router.post(
"/files/upload",
summary="Upload file to workspace",

View File

@@ -305,3 +305,55 @@ def test_download_file_not_found(mocker: pytest_mock.MockFixture):
response = client.get("/files/some-file-id/download")
assert response.status_code == 404
# ---- Delete ----
def test_delete_file_success(mocker: pytest_mock.MockFixture):
"""Deleting an existing file should return {"deleted": true}."""
mocker.patch(
"backend.api.features.workspace.routes.get_workspace",
return_value=MOCK_WORKSPACE,
)
mock_manager = mocker.MagicMock()
mock_manager.delete_file = mocker.AsyncMock(return_value=True)
mocker.patch(
"backend.api.features.workspace.routes.WorkspaceManager",
return_value=mock_manager,
)
response = client.delete("/files/file-aaa-bbb")
assert response.status_code == 200
assert response.json() == {"deleted": True}
mock_manager.delete_file.assert_called_once_with("file-aaa-bbb")
def test_delete_file_not_found(mocker: pytest_mock.MockFixture):
"""Deleting a non-existent file should return 404."""
mocker.patch(
"backend.api.features.workspace.routes.get_workspace",
return_value=MOCK_WORKSPACE,
)
mock_manager = mocker.MagicMock()
mock_manager.delete_file = mocker.AsyncMock(return_value=False)
mocker.patch(
"backend.api.features.workspace.routes.WorkspaceManager",
return_value=mock_manager,
)
response = client.delete("/files/nonexistent-id")
assert response.status_code == 404
assert "File not found" in response.text
def test_delete_file_no_workspace(mocker: pytest_mock.MockFixture):
"""Deleting when user has no workspace should return 404."""
mocker.patch(
"backend.api.features.workspace.routes.get_workspace",
return_value=None,
)
response = client.delete("/files/file-aaa-bbb")
assert response.status_code == 404
assert "Workspace not found" in response.text

View File

@@ -32,6 +32,7 @@ from backend.data.execution import (
from backend.data.graph import GraphModel, Node
from backend.data.model import USER_TIMEZONE_NOT_SET, CredentialsMetaInput, GraphInput
from backend.data.rabbitmq import Exchange, ExchangeType, Queue, RabbitMQConfig
from backend.data.workspace import get_or_create_workspace
from backend.util.clients import (
get_async_execution_event_bus,
get_async_execution_queue,
@@ -891,6 +892,7 @@ async def add_graph_execution(
if execution_context is None:
user = await udb.get_user_by_id(user_id)
settings = await gdb.get_graph_settings(user_id=user_id, graph_id=graph_id)
workspace = await get_or_create_workspace(user_id)
execution_context = ExecutionContext(
# Execution identity
@@ -907,6 +909,8 @@ async def add_graph_execution(
),
# Execution hierarchy
root_execution_id=graph_exec.id,
# Workspace (enables workspace:// file resolution in blocks)
workspace_id=workspace.id,
)
try:

View File

@@ -368,6 +368,12 @@ async def test_add_graph_execution_is_repeatable(mocker: MockerFixture):
mock_get_event_bus = mocker.patch(
"backend.executor.utils.get_async_execution_event_bus"
)
mock_workspace = mocker.MagicMock()
mock_workspace.id = "test-workspace-id"
mocker.patch(
"backend.executor.utils.get_or_create_workspace",
new=mocker.AsyncMock(return_value=mock_workspace),
)
# Setup mock returns
# The function returns (graph, starting_nodes_input, compiled_nodes_input_masks, nodes_to_skip)
@@ -643,6 +649,12 @@ async def test_add_graph_execution_with_nodes_to_skip(mocker: MockerFixture):
mock_get_event_bus = mocker.patch(
"backend.executor.utils.get_async_execution_event_bus"
)
mock_workspace = mocker.MagicMock()
mock_workspace.id = "test-workspace-id"
mocker.patch(
"backend.executor.utils.get_or_create_workspace",
new=mocker.AsyncMock(return_value=mock_workspace),
)
# Setup returns - include nodes_to_skip in the tuple
mock_validate.return_value = (
@@ -681,6 +693,10 @@ async def test_add_graph_execution_with_nodes_to_skip(mocker: MockerFixture):
assert "nodes_to_skip" in captured_kwargs
assert captured_kwargs["nodes_to_skip"] == nodes_to_skip
# Verify workspace_id is set in the execution context
assert "execution_context" in captured_kwargs
assert captured_kwargs["execution_context"].workspace_id == "test-workspace-id"
@pytest.mark.asyncio
async def test_stop_graph_execution_in_review_status_cancels_pending_reviews(

View File

@@ -34,6 +34,7 @@ export const ContentRenderer: React.FC<{
if (
renderer?.name === "ImageRenderer" ||
renderer?.name === "VideoRenderer" ||
renderer?.name === "WorkspaceFileRenderer" ||
!shortContent
) {
return (

View File

@@ -9,6 +9,7 @@ import {
OutputItem,
} from "@/components/contextual/OutputRenderers";
import type { OutputMetadata } from "@/components/contextual/OutputRenderers";
import { isWorkspaceURI, parseWorkspaceURI } from "@/lib/workspace-uri";
import {
ContentBadge,
ContentCard,
@@ -23,30 +24,23 @@ interface Props {
const COLLAPSED_LIMIT = 3;
function isWorkspaceRef(value: unknown): value is string {
return typeof value === "string" && value.startsWith("workspace://");
}
function resolveForRenderer(value: unknown): {
value: unknown;
metadata?: OutputMetadata;
} {
if (!isWorkspaceRef(value)) return { value };
if (!isWorkspaceURI(value)) return { value };
const withoutPrefix = value.replace("workspace://", "");
const fileId = withoutPrefix.split("#")[0];
const apiPath = getGetWorkspaceDownloadFileByIdUrl(fileId);
const parsed = parseWorkspaceURI(value);
if (!parsed) return { value };
const apiPath = getGetWorkspaceDownloadFileByIdUrl(parsed.fileID);
const url = `/api/proxy${apiPath}`;
const hashIndex = value.indexOf("#");
const mimeHint =
hashIndex !== -1 ? value.slice(hashIndex + 1) || undefined : undefined;
const metadata: OutputMetadata = {};
if (mimeHint) {
metadata.mimeType = mimeHint;
if (mimeHint.startsWith("image/")) metadata.type = "image";
else if (mimeHint.startsWith("video/")) metadata.type = "video";
if (parsed.mimeType) {
metadata.mimeType = parsed.mimeType;
if (parsed.mimeType.startsWith("image/")) metadata.type = "image";
else if (parsed.mimeType.startsWith("video/")) metadata.type = "video";
}
return { value: url, metadata };
@@ -71,7 +65,7 @@ function RenderOutputValue({ value }: { value: unknown }) {
// Fallback for audio workspace refs
if (
isWorkspaceRef(value) &&
isWorkspaceURI(value) &&
resolved.metadata?.mimeType?.startsWith("audio/")
) {
return (

View File

@@ -8,6 +8,7 @@ import {
OutputItem,
} from "@/components/contextual/OutputRenderers";
import type { OutputMetadata } from "@/components/contextual/OutputRenderers";
import { isWorkspaceURI, parseWorkspaceURI } from "@/lib/workspace-uri";
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
import {
@@ -46,30 +47,23 @@ interface Props {
part: ViewAgentOutputToolPart;
}
function isWorkspaceRef(value: unknown): value is string {
return typeof value === "string" && value.startsWith("workspace://");
}
function resolveForRenderer(value: unknown): {
value: unknown;
metadata?: OutputMetadata;
} {
if (!isWorkspaceRef(value)) return { value };
if (!isWorkspaceURI(value)) return { value };
const withoutPrefix = value.replace("workspace://", "");
const fileId = withoutPrefix.split("#")[0];
const apiPath = getGetWorkspaceDownloadFileByIdUrl(fileId);
const parsed = parseWorkspaceURI(value);
if (!parsed) return { value };
const apiPath = getGetWorkspaceDownloadFileByIdUrl(parsed.fileID);
const url = `/api/proxy${apiPath}`;
const hashIndex = value.indexOf("#");
const mimeHint =
hashIndex !== -1 ? value.slice(hashIndex + 1) || undefined : undefined;
const metadata: OutputMetadata = {};
if (mimeHint) {
metadata.mimeType = mimeHint;
if (mimeHint.startsWith("image/")) metadata.type = "image";
else if (mimeHint.startsWith("video/")) metadata.type = "video";
if (parsed.mimeType) {
metadata.mimeType = parsed.mimeType;
if (parsed.mimeType.startsWith("image/")) metadata.type = "image";
else if (parsed.mimeType.startsWith("video/")) metadata.type = "video";
}
return { value: url, metadata };
@@ -94,7 +88,7 @@ function RenderOutputValue({ value }: { value: unknown }) {
// Fallback for audio workspace refs
if (
isWorkspaceRef(value) &&
isWorkspaceURI(value) &&
resolved.metadata?.mimeType?.startsWith("audio/")
) {
return (

View File

@@ -6596,6 +6596,44 @@
}
}
},
"/api/workspace/files/{file_id}": {
"delete": {
"tags": ["workspace"],
"summary": "Delete a workspace file",
"description": "Soft-delete a workspace file and attempt to remove it from storage.\n\nUsed when a user clears a file input in the builder.",
"operationId": "deleteWorkspaceDelete a workspace file",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
"name": "file_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "File Id" }
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/DeleteFileResponse" }
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/api/workspace/files/{file_id}/download": {
"get": {
"tags": ["workspace"],
@@ -8248,6 +8286,12 @@
"enum": ["TOP_UP", "USAGE", "GRANT", "REFUND", "CARD_CHECK"],
"title": "CreditTransactionType"
},
"DeleteFileResponse": {
"properties": { "deleted": { "type": "boolean", "title": "Deleted" } },
"type": "object",
"required": ["deleted"],
"title": "DeleteFileResponse"
},
"DeleteGraphResponse": {
"properties": {
"version_counts": { "type": "integer", "title": "Version Counts" }

View File

@@ -71,9 +71,13 @@ async function handleWorkspaceDownload(
responseHeaders["Content-Disposition"] = contentDisposition;
}
// Return the binary content
const arrayBuffer = await response.arrayBuffer();
return new NextResponse(arrayBuffer, {
const contentLength = response.headers.get("Content-Length");
if (contentLength) {
responseHeaders["Content-Length"] = contentLength;
}
// Stream the response body directly instead of buffering in memory
return new NextResponse(response.body, {
status: 200,
headers: responseHeaders,
});
@@ -255,7 +259,6 @@ async function handler(
responseBody = await handleJsonRequest(req, method, backendUrl);
} else if (contentType?.includes("multipart/form-data")) {
responseBody = await handleFormDataRequest(req, backendUrl);
responseHeaders["Content-Type"] = "text/plain";
} else if (contentType?.includes("application/x-www-form-urlencoded")) {
responseBody = await handleUrlEncodedRequest(req, method, backendUrl);
} else {

View File

@@ -1,10 +1,9 @@
import { FileTextIcon, TrashIcon, UploadIcon } from "@phosphor-icons/react";
import { Cross2Icon } from "@radix-ui/react-icons";
import { FileTextIcon, TrashIcon, UploadIcon, X } from "@phosphor-icons/react";
import { useRef, useState } from "react";
import { Button } from "../Button/Button";
import { formatFileSize, getFileLabel } from "./helpers";
import { cn } from "@/lib/utils";
import { Progress } from "../Progress/Progress";
import { parseWorkspaceURI } from "@/lib/workspace-uri";
import { Text } from "../Text/Text";
type UploadFileResult = {
@@ -20,6 +19,7 @@ interface BaseProps {
value?: string;
placeholder?: string;
onChange: (value: string) => void;
onDeleteFile?: (fileURI: string) => void;
className?: string;
maxFileSize?: number;
accept?: string | string[];
@@ -30,7 +30,7 @@ interface BaseProps {
interface UploadModeProps extends BaseProps {
mode?: "upload";
onUploadFile: (file: File) => Promise<UploadFileResult>;
uploadProgress: number;
uploadProgress?: number;
}
interface Base64ModeProps extends BaseProps {
@@ -45,6 +45,7 @@ export function FileInput(props: Props) {
const {
value,
onChange,
onDeleteFile,
className,
maxFileSize,
accept,
@@ -56,8 +57,6 @@ export function FileInput(props: Props) {
const onUploadFile =
mode === "upload" ? (props as UploadModeProps).onUploadFile : undefined;
const uploadProgress =
mode === "upload" ? (props as UploadModeProps).uploadProgress : 0;
const [isUploading, setIsUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
@@ -69,8 +68,7 @@ export function FileInput(props: Props) {
const inputRef = useRef<HTMLInputElement>(null);
const storageNote =
"Files are stored securely and will be automatically deleted at most 24 hours after upload.";
const storageNote = "Files are stored securely in your workspace.";
function acceptToString(a?: string | string[]) {
if (!a) return "*/*";
@@ -104,7 +102,7 @@ export function FileInput(props: Props) {
return false;
}
const getFileLabelFromValue = (val: unknown): string => {
function getFileLabelFromValue(val: unknown): string {
// Handle object format from external API: { name, type, size, data }
if (val && typeof val === "object") {
const obj = val as Record<string, unknown>;
@@ -124,11 +122,23 @@ export function FileInput(props: Props) {
return "File";
}
// Handle string values (data URIs or file paths)
// Handle string values (workspace URIs, data URIs, or file paths)
if (typeof val !== "string") {
return "File";
}
const wsURI = parseWorkspaceURI(val);
if (wsURI) {
if (wsURI.mimeType) {
const parts = wsURI.mimeType.split("/");
if (parts.length > 1) {
return `${parts[1].toUpperCase()} file`;
}
return "File";
}
return "Uploaded file";
}
if (val.startsWith("data:")) {
const matches = val.match(/^data:([^;]+);/);
if (matches?.[1]) {
@@ -146,9 +156,9 @@ export function FileInput(props: Props) {
}
}
return "File";
};
}
const processFileBase64 = (file: File) => {
function processFileBase64(file: File) {
setIsUploading(true);
setUploadError(null);
@@ -168,9 +178,9 @@ export function FileInput(props: Props) {
setIsUploading(false);
};
reader.readAsDataURL(file);
};
}
const uploadFile = async (file: File) => {
async function uploadFile(file: File) {
if (mode === "base64") {
processFileBase64(file);
return;
@@ -184,6 +194,8 @@ export function FileInput(props: Props) {
setIsUploading(true);
setUploadError(null);
const oldURI = value;
try {
const result = await onUploadFile(file);
@@ -194,15 +206,20 @@ export function FileInput(props: Props) {
});
onChange(result.file_uri);
// Delete the old file only after the new upload succeeds
if (oldURI && onDeleteFile) {
onDeleteFile(oldURI);
}
} catch (error) {
console.error("Upload failed:", error);
setUploadError(error instanceof Error ? error.message : "Upload failed");
} finally {
setIsUploading(false);
}
};
}
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;
// Validate max size
@@ -218,21 +235,24 @@ export function FileInput(props: Props) {
return;
}
uploadFile(file);
};
}
const handleFileDrop = (event: React.DragEvent<HTMLDivElement>) => {
function handleFileDrop(event: React.DragEvent<HTMLDivElement>) {
event.preventDefault();
const file = event.dataTransfer.files[0];
if (file) uploadFile(file);
};
}
const handleClear = () => {
function handleClear() {
if (value && onDeleteFile) {
onDeleteFile(value);
}
if (inputRef.current) {
inputRef.current.value = "";
}
onChange("");
setFileInfo(null);
};
}
const displayName = placeholder || "File";
@@ -241,27 +261,14 @@ export function FileInput(props: Props) {
<div className={cn("flex flex-col gap-1.5", className)}>
<div className="nodrag flex flex-col gap-1.5">
{isUploading ? (
<div className="flex flex-col gap-1.5 rounded-md border border-blue-200 bg-blue-50 p-2 dark:border-blue-800 dark:bg-blue-950">
<div className="flex items-center gap-2">
<UploadIcon className="h-4 w-4 animate-pulse text-blue-600 dark:text-blue-400" />
<Text
variant="small"
className="text-blue-700 dark:text-blue-300"
>
{mode === "base64" ? "Processing..." : "Uploading..."}
</Text>
{mode === "upload" && (
<Text
variant="small-medium"
className="ml-auto text-blue-600 dark:text-blue-400"
>
{Math.round(uploadProgress)}%
</Text>
)}
</div>
{mode === "upload" && (
<Progress value={uploadProgress} className="h-1 w-full" />
)}
<div className="flex items-center gap-2 rounded-md border border-blue-200 bg-blue-50 p-2 dark:border-blue-800 dark:bg-blue-950">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-blue-300 border-t-blue-600 dark:border-blue-700 dark:border-t-blue-400" />
<Text
variant="small"
className="text-blue-700 dark:text-blue-300"
>
{mode === "base64" ? "Processing..." : "Uploading..."}
</Text>
</div>
) : value ? (
<div className="flex items-center gap-2">
@@ -292,7 +299,7 @@ export function FileInput(props: Props) {
onClick={handleClear}
type="button"
>
<Cross2Icon className="h-3.5 w-3.5" />
<X size={14} />
</Button>
</div>
) : (
@@ -333,26 +340,13 @@ export function FileInput(props: Props) {
{isUploading ? (
<div className="space-y-2">
<div className="flex min-h-14 items-center gap-4">
<div className="agpt-border-input flex min-h-14 w-full flex-col justify-center rounded-xl bg-zinc-50 p-4 text-sm">
<div className="mb-2 flex items-center gap-2">
<UploadIcon className="h-5 w-5 text-blue-600" />
<span className="text-gray-700">
{mode === "base64" ? "Processing..." : "Uploading..."}
</span>
{mode === "upload" && (
<span className="text-gray-500">
{Math.round(uploadProgress)}%
</span>
)}
</div>
{mode === "upload" && (
<Progress value={uploadProgress} className="w-full" />
)}
<div className="agpt-border-input flex min-h-14 w-full items-center gap-3 rounded-xl bg-zinc-50 p-4 text-sm">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-blue-300 border-t-blue-600" />
<span className="text-gray-700">
{mode === "base64" ? "Processing..." : "Uploading..."}
</span>
</div>
</div>
{showStorageNote && mode === "upload" && (
<p className="text-xs text-gray-500">{storageNote}</p>
)}
</div>
) : value ? (
<div className="space-y-2">

View File

@@ -5,8 +5,10 @@ import { imageRenderer } from "./renderers/ImageRenderer";
import { videoRenderer } from "./renderers/VideoRenderer";
import { jsonRenderer } from "./renderers/JSONRenderer";
import { markdownRenderer } from "./renderers/MarkdownRenderer";
import { workspaceFileRenderer } from "./renderers/WorkspaceFileRenderer";
// Register all renderers in priority order
globalRegistry.register(workspaceFileRenderer);
globalRegistry.register(videoRenderer);
globalRegistry.register(imageRenderer);
globalRegistry.register(codeRenderer);

View File

@@ -0,0 +1,115 @@
import { describe, expect, it } from "vitest";
import {
parseWorkspaceURI,
parseWorkspaceFileID,
isWorkspaceURI,
buildWorkspaceURI,
} from "@/lib/workspace-uri";
describe("parseWorkspaceURI", () => {
it("parses a full workspace URI with mime type", () => {
const result = parseWorkspaceURI("workspace://file-abc-123#image/png");
expect(result).toEqual({ fileID: "file-abc-123", mimeType: "image/png" });
});
it("parses a workspace URI without mime type", () => {
const result = parseWorkspaceURI("workspace://file-abc-123");
expect(result).toEqual({ fileID: "file-abc-123", mimeType: null });
});
it("returns null for non-workspace URIs", () => {
expect(parseWorkspaceURI("https://example.com")).toBeNull();
expect(parseWorkspaceURI("data:image/png;base64,abc")).toBeNull();
expect(parseWorkspaceURI("")).toBeNull();
expect(parseWorkspaceURI("file:///tmp/test.txt")).toBeNull();
});
it("handles empty fragment after hash as null mime type", () => {
const result = parseWorkspaceURI("workspace://file-abc-123#");
expect(result).toEqual({ fileID: "file-abc-123", mimeType: null });
});
it("handles mime types with subtype", () => {
const result = parseWorkspaceURI(
"workspace://file-id#application/octet-stream",
);
expect(result).toEqual({
fileID: "file-id",
mimeType: "application/octet-stream",
});
});
it("handles UUID-style file IDs", () => {
const uuid = "550e8400-e29b-41d4-a716-446655440000";
const result = parseWorkspaceURI(`workspace://${uuid}#text/plain`);
expect(result).toEqual({ fileID: uuid, mimeType: "text/plain" });
});
});
describe("parseWorkspaceFileID", () => {
it("extracts file ID from a full workspace URI", () => {
expect(parseWorkspaceFileID("workspace://file-abc-123#image/png")).toBe(
"file-abc-123",
);
});
it("extracts file ID when no mime type fragment", () => {
expect(parseWorkspaceFileID("workspace://file-abc-123")).toBe(
"file-abc-123",
);
});
it("returns null for non-workspace URIs", () => {
expect(parseWorkspaceFileID("https://example.com")).toBeNull();
expect(parseWorkspaceFileID("data:image/png;base64,abc")).toBeNull();
expect(parseWorkspaceFileID("")).toBeNull();
});
it("is consistent with parseWorkspaceURI for file ID extraction", () => {
const uris = [
"workspace://abc#image/png",
"workspace://abc",
"workspace://abc#",
"workspace://550e8400-e29b-41d4-a716-446655440000#text/plain",
];
for (const uri of uris) {
const fullParse = parseWorkspaceURI(uri);
const idOnly = parseWorkspaceFileID(uri);
expect(idOnly).toBe(fullParse?.fileID ?? null);
}
});
});
describe("isWorkspaceURI", () => {
it("returns true for workspace URIs", () => {
expect(isWorkspaceURI("workspace://abc")).toBe(true);
expect(isWorkspaceURI("workspace://abc#image/png")).toBe(true);
});
it("returns false for non-workspace values", () => {
expect(isWorkspaceURI("https://example.com")).toBe(false);
expect(isWorkspaceURI("")).toBe(false);
expect(isWorkspaceURI(null)).toBe(false);
expect(isWorkspaceURI(undefined)).toBe(false);
expect(isWorkspaceURI(123)).toBe(false);
});
});
describe("buildWorkspaceURI", () => {
it("builds URI with mime type", () => {
expect(buildWorkspaceURI("file-123", "image/png")).toBe(
"workspace://file-123#image/png",
);
});
it("builds URI without mime type", () => {
expect(buildWorkspaceURI("file-123")).toBe("workspace://file-123");
});
it("roundtrips with parseWorkspaceURI", () => {
const uri = buildWorkspaceURI("file-abc", "text/plain");
const parsed = parseWorkspaceURI(uri);
expect(parsed).toEqual({ fileID: "file-abc", mimeType: "text/plain" });
});
});

View File

@@ -0,0 +1,226 @@
import { DownloadSimple, FileText } from "@phosphor-icons/react";
import { type ReactNode, useState } from "react";
import {
OutputRenderer,
OutputMetadata,
DownloadContent,
CopyContent,
} from "../types";
import { parseWorkspaceURI, isWorkspaceURI } from "@/lib/workspace-uri";
import { Skeleton } from "@/components/atoms/Skeleton/Skeleton";
const imageMimeTypes = [
"image/jpeg",
"image/png",
"image/gif",
"image/bmp",
"image/svg+xml",
"image/webp",
"image/x-icon",
];
const videoMimeTypes = [
"video/mp4",
"video/webm",
"video/ogg",
"video/quicktime",
"video/x-msvideo",
"video/x-matroska",
];
const audioMimeTypes = [
"audio/mpeg",
"audio/ogg",
"audio/wav",
"audio/webm",
"audio/aac",
"audio/flac",
];
function buildDownloadURL(fileID: string): string {
return `/api/proxy/api/workspace/files/${fileID}/download`;
}
function canRenderWorkspaceFile(value: unknown): boolean {
return isWorkspaceURI(value);
}
function getFileTypeLabel(mimeType: string | null): string {
if (!mimeType) return "File";
const sub = mimeType.split("/")[1];
if (!sub) return "File";
return `${sub.toUpperCase()} file`;
}
function WorkspaceImage({ src, alt }: { src: string; alt: string }) {
const [loaded, setLoaded] = useState(false);
return (
<div className="group relative">
{!loaded && (
<Skeleton className="absolute inset-0 h-full min-h-40 w-full rounded-md" />
)}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={src}
alt={alt}
className={`h-auto max-w-full rounded-md border border-gray-200 ${loaded ? "opacity-100" : "min-h-40 opacity-0"}`}
loading="lazy"
onLoad={() => setLoaded(true)}
onError={() => setLoaded(true)}
/>
</div>
);
}
function WorkspaceVideo({ src, mimeType }: { src: string; mimeType: string }) {
const [loaded, setLoaded] = useState(false);
return (
<div className="group relative">
{!loaded && (
<Skeleton className="absolute inset-0 h-full min-h-40 w-full rounded-md" />
)}
<video
controls
className={`h-auto max-w-full rounded-md border border-gray-200 ${loaded ? "opacity-100" : "min-h-40 opacity-0"}`}
preload="metadata"
onLoadedMetadata={() => setLoaded(true)}
onError={() => setLoaded(true)}
>
<source src={src} type={mimeType} />
Your browser does not support the video tag.
</video>
</div>
);
}
function WorkspaceAudio({ src, mimeType }: { src: string; mimeType: string }) {
const [loaded, setLoaded] = useState(false);
return (
<div className="group relative">
{!loaded && (
<Skeleton className="absolute inset-0 h-full min-h-12 w-full rounded-md" />
)}
<audio
controls
preload="metadata"
className={`w-full ${loaded ? "opacity-100" : "min-h-12 opacity-0"}`}
onLoadedMetadata={() => setLoaded(true)}
onError={() => setLoaded(true)}
>
<source src={src} type={mimeType} />
Your browser does not support the audio tag.
</audio>
</div>
);
}
function renderWorkspaceFile(
value: unknown,
metadata?: OutputMetadata,
): ReactNode {
const uri = parseWorkspaceURI(String(value));
if (!uri) return null;
const downloadURL = buildDownloadURL(uri.fileID);
const mimeType = uri.mimeType || metadata?.mimeType || null;
if (mimeType && imageMimeTypes.includes(mimeType)) {
return (
<WorkspaceImage src={downloadURL} alt={metadata?.filename || "Image"} />
);
}
if (mimeType && videoMimeTypes.includes(mimeType)) {
return <WorkspaceVideo src={downloadURL} mimeType={mimeType} />;
}
if (mimeType && audioMimeTypes.includes(mimeType)) {
return <WorkspaceAudio src={downloadURL} mimeType={mimeType} />;
}
// Generic file card with icon and download link
const label = getFileTypeLabel(mimeType);
return (
<div className="flex items-center gap-3 rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800">
<FileText size={28} className="flex-shrink-0 text-gray-500" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-medium text-gray-900 dark:text-gray-100">
{metadata?.filename || label}
</span>
{mimeType && (
<span className="text-xs text-gray-500 dark:text-gray-400">
{mimeType}
</span>
)}
</div>
<a
href={downloadURL}
download
className="flex-shrink-0 rounded-md p-1.5 text-gray-500 transition-colors hover:bg-gray-200 hover:text-gray-700 dark:hover:bg-gray-700 dark:hover:text-gray-300"
>
<DownloadSimple size={18} />
</a>
</div>
);
}
function getCopyContentWorkspaceFile(
value: unknown,
metadata?: OutputMetadata,
): CopyContent | null {
const uri = parseWorkspaceURI(String(value));
if (!uri) return null;
const downloadURL = buildDownloadURL(uri.fileID);
const mimeType =
uri.mimeType || metadata?.mimeType || "application/octet-stream";
return {
mimeType,
data: async () => {
const response = await fetch(downloadURL);
if (!response.ok) {
throw new Error(`Failed to fetch file: ${response.status}`);
}
return await response.blob();
},
alternativeMimeTypes: ["text/plain"],
fallbackText: String(value),
};
}
function getDownloadContentWorkspaceFile(
value: unknown,
metadata?: OutputMetadata,
): DownloadContent | null {
const uri = parseWorkspaceURI(String(value));
if (!uri) return null;
const mimeType =
uri.mimeType || metadata?.mimeType || "application/octet-stream";
const ext = mimeType.split("/")[1] || "bin";
const filename = metadata?.filename || `file.${ext}`;
return {
data: buildDownloadURL(uri.fileID),
filename,
mimeType,
};
}
function isConcatenableWorkspaceFile(): boolean {
return false;
}
export const workspaceFileRenderer: OutputRenderer = {
name: "WorkspaceFileRenderer",
priority: 50, // Higher than video (45) and image (40) so it matches first
canRender: canRenderWorkspaceFile,
render: renderWorkspaceFile,
getCopyContent: getCopyContentWorkspaceFile,
getDownloadContent: getDownloadContentWorkspaceFile,
isConcatenable: isConcatenableWorkspaceFile,
};

View File

@@ -1,27 +1,30 @@
import { WidgetProps } from "@rjsf/utils";
import { FileInput } from "@/components/atoms/FileInput/FileInput";
import { useWorkspaceUpload } from "./useWorkspaceUpload";
export const FileWidget = (props: WidgetProps) => {
export function FileWidget(props: WidgetProps) {
const { onChange, disabled, readonly, value, schema, formContext } = props;
const { size } = formContext || {};
const displayName = schema?.title || "File";
const { handleUploadFile, handleDeleteFile } = useWorkspaceUpload();
const handleChange = (fileUri: string) => {
onChange(fileUri);
};
function handleChange(fileURI: string) {
onChange(fileURI);
}
return (
<FileInput
variant={size === "large" ? "default" : "compact"}
mode="base64"
mode="upload"
value={value}
placeholder={displayName}
onChange={handleChange}
onDeleteFile={handleDeleteFile}
onUploadFile={handleUploadFile}
showStorageNote={false}
className={
disabled || readonly ? "pointer-events-none opacity-50" : undefined
}
/>
);
};
}

View File

@@ -0,0 +1,47 @@
import {
usePostWorkspaceUploadFileToWorkspace,
useDeleteWorkspaceDeleteAWorkspaceFile,
} from "@/app/api/__generated__/endpoints/workspace/workspace";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { parseWorkspaceFileID, buildWorkspaceURI } from "@/lib/workspace-uri";
export function useWorkspaceUpload() {
const { toast } = useToast();
const { mutateAsync: uploadMutation } =
usePostWorkspaceUploadFileToWorkspace();
const { mutate: deleteMutation } = useDeleteWorkspaceDeleteAWorkspaceFile({
mutation: {
onError: () => {
toast({
title: "Failed to delete file",
description: "The file could not be removed from storage.",
variant: "destructive",
});
},
},
});
async function handleUploadFile(file: File) {
const response = await uploadMutation({ data: { file } });
if (response.status !== 200) {
throw new Error("Upload failed");
}
const d = response.data;
return {
file_name: d.name,
size: d.size_bytes,
content_type: d.mime_type,
file_uri: buildWorkspaceURI(d.file_id, d.mime_type),
};
}
function handleDeleteFile(fileURI: string) {
const fileID = parseWorkspaceFileID(fileURI);
if (!fileID) return;
deleteMutation({ fileId: fileID });
}
return { handleUploadFile, handleDeleteFile };
}

View File

@@ -0,0 +1,54 @@
/**
* Shared utilities for parsing and constructing workspace:// URIs.
*
* Format: workspace://{fileID}#{mimeType}
* - fileID: unique identifier for the file
* - mimeType: optional MIME type hint (e.g. "image/png")
*/
export interface WorkspaceURI {
fileID: string;
mimeType: string | null;
}
/**
* Parse a workspace:// URI into its components.
* Returns null if the string is not a workspace URI.
*/
export function parseWorkspaceURI(value: string): WorkspaceURI | null {
if (!value.startsWith("workspace://")) return null;
const rest = value.slice("workspace://".length);
const hashIndex = rest.indexOf("#");
if (hashIndex === -1) {
return { fileID: rest, mimeType: null };
}
return {
fileID: rest.slice(0, hashIndex),
mimeType: rest.slice(hashIndex + 1) || null,
};
}
/**
* Extract just the file ID from a workspace:// URI.
* Returns null if the string is not a workspace URI.
*/
export function parseWorkspaceFileID(uri: string): string | null {
const parsed = parseWorkspaceURI(uri);
return parsed?.fileID ?? null;
}
/**
* Check if a value is a workspace:// URI string.
*/
export function isWorkspaceURI(value: unknown): value is string {
return typeof value === "string" && value.startsWith("workspace://");
}
/**
* Build a workspace:// URI from a file ID and optional MIME type.
*/
export function buildWorkspaceURI(fileID: string, mimeType?: string): string {
return mimeType
? `workspace://${fileID}#${mimeType}`
: `workspace://${fileID}`;
}