diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index 65a9dc15..771596af 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -110,6 +110,14 @@ const endpoints = { AGENT_FOLDER: (id: string) => `/api/agents/folders/${id}`, MOVE_AGENT_TO_FOLDER: '/api/agents/folders/move_agent', GET_ARTIFACT: (artifactId: string) => `/api/artifact/${artifactId}`, + GET_DOCUMENT_ARTIFACT: (artifactId: string) => + `/api/artifacts/${artifactId}`, + DOWNLOAD_ARTIFACT: (artifactId: string, version?: number) => + version != null + ? `/api/artifacts/${artifactId}/download?version=${version}` + : `/api/artifacts/${artifactId}/download`, + RESTORE_ARTIFACT: (artifactId: string) => + `/api/artifacts/${artifactId}/restore`, WORKFLOWS: '/api/workflows', WORKFLOW: (id: string) => `/api/workflows/${id}`, CUSTOM_MODELS: '/api/user/models', diff --git a/frontend/src/api/services/userService.ts b/frontend/src/api/services/userService.ts index e3f49659..af3118fe 100644 --- a/frontend/src/api/services/userService.ts +++ b/frontend/src/api/services/userService.ts @@ -322,6 +322,27 @@ const userService = { apiClient.post(endpoints.USER.MOVE_AGENT_TO_FOLDER, data, token), getArtifact: (artifactId: string, token: string | null): Promise => apiClient.get(endpoints.USER.GET_ARTIFACT(artifactId), token), + getDocumentArtifact: ( + artifactId: string, + token: string | null, + ): Promise => + apiClient.get(endpoints.USER.GET_DOCUMENT_ARTIFACT(artifactId), token), + downloadArtifact: ( + artifactId: string, + token: string | null, + version?: number, + ): Promise => + apiClient.get(endpoints.USER.DOWNLOAD_ARTIFACT(artifactId, version), token), + restoreArtifactVersion: ( + artifactId: string, + version: number, + token: string | null, + ): Promise => + apiClient.post( + endpoints.USER.RESTORE_ARTIFACT(artifactId), + { version }, + token, + ), getWorkflow: (id: string, token: string | null): Promise => apiClient.get(endpoints.USER.WORKFLOW(id), token), createWorkflow: (data: any, token: string | null): Promise => diff --git a/frontend/src/components/ArtifactSidebar.tsx b/frontend/src/components/ArtifactSidebar.tsx index 718831ed..4c003703 100644 --- a/frontend/src/components/ArtifactSidebar.tsx +++ b/frontend/src/components/ArtifactSidebar.tsx @@ -12,7 +12,9 @@ import remarkGfm from 'remark-gfm'; import userService from '../api/services/userService'; import { useDarkTheme } from '../hooks'; import { selectToken } from '../preferences/preferenceSlice'; +import { isDocumentArtifact, type DocumentArtifact } from './artifactViewUtils'; import CopyButton from './CopyButton'; +import DocumentArtifactView from './DocumentArtifactView'; import Spinner from './Spinner'; import { Button } from './ui/button'; import { Sheet, SheetContent } from './ui/sheet'; @@ -282,13 +284,16 @@ export default function ArtifactSidebar({ const currentFetchIdRef = React.useRef(null); const token = useSelector(selectToken); const [artifact, setArtifact] = useState(null); + const [documentArtifact, setDocumentArtifact] = + useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [refreshNonce, setRefreshNonce] = useState(0); const [effectiveArtifactId, setEffectiveArtifactId] = useState( artifactId, ); - const title = getArtifactTitle(artifact, toolName); + const title = documentArtifact?.title || getArtifactTitle(artifact, toolName); // Reset last successful todo artifact ID when conversation changes useEffect(() => { @@ -308,6 +313,7 @@ export default function ArtifactSidebar({ useEffect(() => { if (!isOpen || !effectiveArtifactId) { setArtifact(null); + setDocumentArtifact(null); setError(null); setLoading(false); currentFetchIdRef.current = null; @@ -315,76 +321,118 @@ export default function ArtifactSidebar({ } // Generate a unique ID for this fetch - const fetchId = `${effectiveArtifactId}-${Date.now()}`; + const artifactIdToFetch = effectiveArtifactId; + const fetchId = `${artifactIdToFetch}-${Date.now()}`; currentFetchIdRef.current = fetchId; setLoading(true); setError(null); - // Note: For todo artifacts, the endpoint always returns all todos for the tool; will be coversation scoped later + // Document/file artifacts live behind the generalized /api/artifacts/ + // endpoint; notes/todos behind the legacy /api/artifact/. Try the + // document path first and fall back to the legacy flow on a non-document + // shape so a single id resolves cleanly either way. userService - .getArtifact(effectiveArtifactId, token) - .then(async (res: any) => { - // Ignore if this is not the current fetch - if (currentFetchIdRef.current !== fetchId) return; - - const isResponseLike = res && typeof res.json === 'function'; - const status = isResponseLike ? res.status : undefined; - const ok = isResponseLike ? Boolean(res.ok) : true; - - let data: any = res; - if (isResponseLike) { - try { - data = await res.json(); - } catch { - data = null; - } + .getDocumentArtifact(artifactIdToFetch, token) + .then(async (res: Response) => { + if (currentFetchIdRef.current !== fetchId) return true; + if (!res.ok) return false; + let data: any = null; + try { + data = await res.json(); + } catch { + data = null; } - - // Check again after async operation - if (currentFetchIdRef.current !== fetchId) return; - - if (ok && data?.success && data?.artifact) { - setArtifact(data.artifact); - // Remember the last successful todo artifact id so we can fallback if a newer id 404s. - if (data.artifact?.artifact_type === 'todo_list') { - lastSuccessfulTodoArtifactIdRef.current = effectiveArtifactId; - } + if (currentFetchIdRef.current !== fetchId) return true; + if (data?.success && isDocumentArtifact(data.artifact)) { + setDocumentArtifact(data.artifact); + setArtifact(null); setLoading(false); - return; + return true; } - - const isTodoTool = (toolName ?? '').toLowerCase().includes('todo'); - - // If the latest todo artifact id is missing (404), fall back to the last known good one - // so the backend can still resolve `tool_id` for the todo list. - if ( - status === 404 && - isTodoTool && - lastSuccessfulTodoArtifactIdRef.current && - lastSuccessfulTodoArtifactIdRef.current !== effectiveArtifactId - ) { - // Update effectiveArtifactId to trigger a new fetch with the fallback id - setEffectiveArtifactId(lastSuccessfulTodoArtifactIdRef.current); - setLoading(false); - return; - } - - // Ensure we show a visible error state instead of rendering nothing. - const message = - data?.message || - (status === 404 ? 'Artifact not found' : null) || - 'Failed to load artifact'; - setError(message); - setLoading(false); + return false; }) - .catch((err) => { - // Ignore if this is not the current fetch + .catch(() => false) + .then((handled) => { + if (handled) return; if (currentFetchIdRef.current !== fetchId) return; - setError('Failed to fetch artifact'); - setLoading(false); + setDocumentArtifact(null); + fetchLegacyArtifact(fetchId); }); - }, [isOpen, effectiveArtifactId, token, toolName, conversationId]); + + // Legacy notes/todo fetch (unchanged behavior). + function fetchLegacyArtifact(fetchId: string) { + userService + .getArtifact(artifactIdToFetch, token) + .then(async (res: any) => { + // Ignore if this is not the current fetch + if (currentFetchIdRef.current !== fetchId) return; + + const isResponseLike = res && typeof res.json === 'function'; + const status = isResponseLike ? res.status : undefined; + const ok = isResponseLike ? Boolean(res.ok) : true; + + let data: any = res; + if (isResponseLike) { + try { + data = await res.json(); + } catch { + data = null; + } + } + + // Check again after async operation + if (currentFetchIdRef.current !== fetchId) return; + + if (ok && data?.success && data?.artifact) { + setArtifact(data.artifact); + // Remember the last successful todo artifact id so we can fallback if a newer id 404s. + if (data.artifact?.artifact_type === 'todo_list') { + lastSuccessfulTodoArtifactIdRef.current = effectiveArtifactId; + } + setLoading(false); + return; + } + + const isTodoTool = (toolName ?? '').toLowerCase().includes('todo'); + + // If the latest todo artifact id is missing (404), fall back to the last known good one + // so the backend can still resolve `tool_id` for the todo list. + if ( + status === 404 && + isTodoTool && + lastSuccessfulTodoArtifactIdRef.current && + lastSuccessfulTodoArtifactIdRef.current !== effectiveArtifactId + ) { + // Update effectiveArtifactId to trigger a new fetch with the fallback id + setEffectiveArtifactId(lastSuccessfulTodoArtifactIdRef.current); + setLoading(false); + return; + } + + // Ensure we show a visible error state instead of rendering nothing. + const message = + data?.message || + (status === 404 ? 'Artifact not found' : null) || + 'Failed to load artifact'; + setError(message); + setLoading(false); + }) + .catch(() => { + // Ignore if this is not the current fetch + if (currentFetchIdRef.current !== fetchId) return; + setError('Failed to fetch artifact'); + setLoading(false); + }); + } + }, [ + isOpen, + effectiveArtifactId, + token, + toolName, + conversationId, + refreshNonce, + ]); const renderContent = () => { if (loading) { @@ -401,6 +449,14 @@ export default function ArtifactSidebar({ ); } + if (documentArtifact) { + return ( + setRefreshNonce((n) => n + 1)} + /> + ); + } // Avoid rendering an empty panel if the artifact couldn't be loaded for any reason. if (!artifact) { return ( diff --git a/frontend/src/components/DocumentArtifactView.tsx b/frontend/src/components/DocumentArtifactView.tsx new file mode 100644 index 00000000..987d2f52 --- /dev/null +++ b/frontend/src/components/DocumentArtifactView.tsx @@ -0,0 +1,344 @@ +import mermaid from 'mermaid'; +import { Download, FileText, History, RotateCcw } from 'lucide-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useSelector } from 'react-redux'; + +import userService from '../api/services/userService'; +import { useDarkTheme } from '../hooks'; +import { selectToken } from '../preferences/preferenceSlice'; +import Spinner from './Spinner'; +import { + buildPreviewDocument, + displayFilename, + findCurrentVersion, + formatBytes, + previewModeForKind, + sortVersionsDesc, + triggerResponseDownload, + type ArtifactVersion, + type DocumentArtifact, +} from './artifactViewUtils'; +import { Button } from './ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from './ui/select'; + +function asPreviewString(spec: unknown): string { + if (typeof spec === 'string') return spec; + if (spec && typeof spec === 'object') { + const rec = spec as Record; + for (const key of ['html', 'svg', 'content', 'source', 'code']) { + if (typeof rec[key] === 'string') return rec[key] as string; + } + } + return ''; +} + +function FramePreview({ + artifactKind, + source, +}: { + artifactKind: string; + source: string; +}) { + const [isDarkTheme] = useDarkTheme(); + const [rendered, setRendered] = useState(null); + const [renderError, setRenderError] = useState(false); + const isMermaid = artifactKind.toLowerCase() === 'mermaid'; + + // `securityLevel: 'sandbox'` makes mermaid build the diagram's live DOM inside + // its OWN opaque-origin `sandbox=""` iframe (not the app document) and return + // an `', + ); + expect(doc).toContain(PREVIEW_CSP_MERMAID); + expect(PREVIEW_CSP_MERMAID).toContain('frame-src data:'); + // Scripts and origins stay forbidden on the outer document. + expect(PREVIEW_CSP_MERMAID).toContain("script-src 'none'"); + expect(PREVIEW_CSP_MERMAID).toContain("default-src 'none'"); + expect(PREVIEW_CSP_MERMAID).not.toContain('allow-same-origin'); + expect(PREVIEW_CSP_MERMAID).not.toContain('allow-scripts'); + }); +}); + +describe('filenameFromContentDisposition', () => { + it('reads the plain quoted filename', () => { + expect( + filenameFromContentDisposition('attachment; filename="report.pdf"'), + ).toBe('report.pdf'); + }); + + it('reads and decodes the RFC 5987 filename* form', () => { + expect( + filenameFromContentDisposition( + "attachment; filename*=UTF-8''my%20deck.pptx", + ), + ).toBe('my deck.pptx'); + }); + + it('returns null when absent', () => { + expect(filenameFromContentDisposition(null)).toBeNull(); + expect(filenameFromContentDisposition('attachment')).toBeNull(); + }); +}); diff --git a/frontend/src/components/artifactViewUtils.ts b/frontend/src/components/artifactViewUtils.ts new file mode 100644 index 00000000..d4c96069 --- /dev/null +++ b/frontend/src/components/artifactViewUtils.ts @@ -0,0 +1,227 @@ +/** + * Pure helpers for the document-artifact view: preview-mode mapping, version + * ordering, sandbox srcdoc/CSP construction, and download-trigger plumbing. + * Kept free of React so the logic is unit-testable in isolation. + */ + +export type ArtifactKind = + | 'document' + | 'spreadsheet' + | 'presentation' + | 'code' + | 'html' + | 'svg' + | 'mermaid' + | 'image' + | 'pdf' + | 'data' + | 'file'; + +export type ArtifactVersion = { + version: number; + mime_type: string | null; + filename: string | null; + size: number | null; + sha256: string | null; + preview_text: string | null; + produced_by: unknown; + created_at: string | null; +}; + +export type DocumentArtifact = { + id: string; + kind: string; + title: string | null; + current_version: number; + versions: ArtifactVersion[]; + spec: unknown; +}; + +/** How the sidebar should render an artifact's current version. */ +export type PreviewMode = 'frame' | 'image' | 'card' | 'text'; + +/** + * Discriminates the legacy notes/todo response (`artifact_type`) from the + * generalized document/file response (`kind`). + */ +export function isDocumentArtifact(value: unknown): value is DocumentArtifact { + if (!value || typeof value !== 'object') return false; + const rec = value as Record; + return ( + typeof rec.kind === 'string' && + typeof rec.id === 'string' && + Array.isArray(rec.versions) + ); +} + +const FRAME_KINDS = new Set(['html', 'svg', 'mermaid']); +const CARD_KINDS = new Set([ + 'document', + 'spreadsheet', + 'presentation', + 'pdf', + 'file', +]); +const TEXT_KINDS = new Set(['code', 'data']); + +/** + * Map a `kind` (with optional mime fallback) to a preview mode. SVG/PDF/image + * are also inferred from the mime type when the coarse `kind` is `html`/`file`, + * so a server that only emits the canonical kind set still previews correctly. + */ +export function previewModeForKind( + kind: string | null | undefined, + mimeType?: string | null, +): PreviewMode { + const mime = (mimeType ?? '').toLowerCase(); + + if (mime.startsWith('image/svg')) return 'frame'; + if (mime === 'application/pdf') return 'card'; + if (mime.startsWith('image/')) return 'image'; + + const k = (kind ?? '').toLowerCase(); + if (FRAME_KINDS.has(k)) return 'frame'; + if (k === 'image') return 'image'; + if (CARD_KINDS.has(k)) return 'card'; + if (TEXT_KINDS.has(k)) return 'text'; + return 'text'; +} + +/** Versions sorted newest-first by version number (does not mutate input). */ +export function sortVersionsDesc( + versions: ArtifactVersion[], +): ArtifactVersion[] { + return [...versions].sort((a, b) => b.version - a.version); +} + +/** The version row matching `current_version`, or the highest if absent. */ +export function findCurrentVersion( + artifact: Pick, +): ArtifactVersion | null { + if (!artifact.versions.length) return null; + const match = artifact.versions.find( + (v) => v.version === artifact.current_version, + ); + if (match) return match; + return sortVersionsDesc(artifact.versions)[0] ?? null; +} + +/** Human-readable byte size (e.g. 1536 -> "1.5 KB"). */ +export function formatBytes(size: number | null | undefined): string { + if (size == null || Number.isNaN(size) || size < 0) return ''; + if (size < 1024) return `${size} B`; + const units = ['KB', 'MB', 'GB', 'TB']; + let value = size / 1024; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; + } + const rounded = value >= 10 ? Math.round(value) : Math.round(value * 10) / 10; + return `${rounded} ${units[unit]}`; +} + +/** Best display filename for a version, falling back to the artifact title/id. */ +export function displayFilename( + artifact: Pick, + version: Pick | null, +): string { + return version?.filename || artifact.title || `artifact-${artifact.id}`; +} + +/** + * Restrictive CSP for the preview iframe. No network, no scripts from origins, + * no plugins; inline styles/SVG allowed so generated markup renders. Used for + * html/svg markup that is embedded inline as static nodes. + */ +export const PREVIEW_CSP = + "default-src 'none'; img-src data: blob:; style-src 'unsafe-inline'; " + + "font-src data:; script-src 'none'; form-action 'none'; base-uri 'none'; " + + "frame-ancestors 'none'"; + +/** + * CSP for the mermaid preview. Mermaid's `securityLevel: 'sandbox'` output is an + * `