mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
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:
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -34,6 +34,7 @@ export const ContentRenderer: React.FC<{
|
||||
if (
|
||||
renderer?.name === "ImageRenderer" ||
|
||||
renderer?.name === "VideoRenderer" ||
|
||||
renderer?.name === "WorkspaceFileRenderer" ||
|
||||
!shortContent
|
||||
) {
|
||||
return (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
54
autogpt_platform/frontend/src/lib/workspace-uri.ts
Normal file
54
autogpt_platform/frontend/src/lib/workspace-uri.ts
Normal 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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user