Add artifact preview, versions, and download to the sidebar

Extend the artifact sidebar with a document/file view: a version selector,
authenticated download honoring Content-Disposition (following the backend
redirect to a presigned URL when configured), and version restore. Preview is
chosen by kind - html, svg, and mermaid render inside a sandboxed iframe with a
restrictive CSP (mermaid via its own sandboxed render so untrusted diagram text
never reaches the app DOM), office documents and PDFs show a download card, and
code or data render as text. Wires the new artifact endpoints into the API
service and leaves the existing notes/todo path unchanged.
This commit is contained in:
Alex committed 2026-06-24 11:29:33 +01:00
1 parent 3b795dbb83
commit 182a14d257
6 files changed
+907 -59

No files matched your search

+8
View File
@@ -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',
+21
View File
@@ -322,6 +322,27 @@ const userService = {
apiClient.post(endpoints.USER.MOVE_AGENT_TO_FOLDER, data, token),
getArtifact: (artifactId: string, token: string | null): Promise<any> =>
apiClient.get(endpoints.USER.GET_ARTIFACT(artifactId), token),
getDocumentArtifact: (
artifactId: string,
token: string | null,
): Promise<Response> =>
apiClient.get(endpoints.USER.GET_DOCUMENT_ARTIFACT(artifactId), token),
downloadArtifact: (
artifactId: string,
token: string | null,
version?: number,
): Promise<Response> =>
apiClient.get(endpoints.USER.DOWNLOAD_ARTIFACT(artifactId, version), token),
restoreArtifactVersion: (
artifactId: string,
version: number,
token: string | null,
): Promise<Response> =>
apiClient.post(
endpoints.USER.RESTORE_ARTIFACT(artifactId),
{ version },
token,
),
getWorkflow: (id: string, token: string | null): Promise<any> =>
apiClient.get(endpoints.USER.WORKFLOW(id), token),
createWorkflow: (data: any, token: string | null): Promise<any> =>
+115 -59
View File
@@ -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<string | null>(null);
const token = useSelector(selectToken);
const [artifact, setArtifact] = useState<ArtifactData | null>(null);
const [documentArtifact, setDocumentArtifact] =
useState<DocumentArtifact | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [refreshNonce, setRefreshNonce] = useState(0);
const [effectiveArtifactId, setEffectiveArtifactId] = useState<string | null>(
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/<id>
// endpoint; notes/todos behind the legacy /api/artifact/<id>. 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({
</div>
);
}
if (documentArtifact) {
return (
<DocumentArtifactView
artifact={documentArtifact}
onRefresh={() => setRefreshNonce((n) => n + 1)}
/>
);
}
// Avoid rendering an empty panel if the artifact couldn't be loaded for any reason.
if (!artifact) {
return (
@@ -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<string, unknown>;
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<string | null>(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 `<iframe src="data:text/html;base64,…">` wrapper, so untrusted diagram
// text never becomes live nodes in the app origin. The wrapper is embedded
// into the outer scriptless `sandbox=""` preview iframe below.
useEffect(() => {
if (!isMermaid) {
setRendered(source);
return;
}
let cancelled = false;
setRendered(null);
setRenderError(false);
mermaid.initialize({
startOnLoad: false,
securityLevel: 'sandbox',
theme: isDarkTheme ? 'dark' : 'default',
});
mermaid
.render(`artifact-mermaid-${Date.now()}`, source)
.then(({ svg }) => {
if (!cancelled) setRendered(svg);
})
.catch(() => {
if (!cancelled) setRenderError(true);
});
return () => {
cancelled = true;
};
}, [isMermaid, source, isDarkTheme]);
if (renderError) {
return (
<pre className="overflow-auto p-4 text-xs text-gray-600 dark:text-gray-400">
{source}
</pre>
);
}
if (rendered === null) {
return (
<div className="flex h-full items-center justify-center">
<Spinner />
</div>
);
}
// For mermaid, `rendered` is mermaid's sandboxed-iframe wrapper; the 'mermaid'
// preview document carries a CSP that permits its inner `data:` frame.
const previewKind = isMermaid ? 'mermaid' : artifactKind;
return (
<iframe
// No allow-same-origin: content is fully isolated from the app origin.
sandbox=""
title="Artifact preview"
className="h-full w-full rounded-md border border-gray-200 bg-white dark:border-gray-700"
srcDoc={buildPreviewDocument(previewKind, rendered)}
/>
);
}
function DownloadCard({
filename,
size,
onDownload,
downloading,
}: {
filename: string;
size: number | null;
onDownload: () => void;
downloading: boolean;
}) {
return (
<div className="flex h-full flex-col items-center justify-center gap-4">
<div className="flex flex-col items-center gap-3 rounded-xl border border-gray-200 p-8 text-center dark:border-gray-700">
<FileText className="h-12 w-12 text-gray-400" />
<div>
<p className="text-sm font-medium break-all text-gray-800 dark:text-gray-200">
{filename}
</p>
{size != null && (
<p className="text-xs text-gray-500 dark:text-gray-400">
{formatBytes(size)}
</p>
)}
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={onDownload}
disabled={downloading}
>
{downloading ? (
<Spinner size="small" />
) : (
<Download className="h-4 w-4" />
)}
Download
</Button>
</div>
</div>
);
}
export default function DocumentArtifactView({
artifact,
onRefresh,
}: {
artifact: DocumentArtifact;
onRefresh: () => void;
}) {
const token = useSelector(selectToken);
const versionsDesc = useMemo(
() => sortVersionsDesc(artifact.versions),
[artifact.versions],
);
const [selectedVersion, setSelectedVersion] = useState<number>(
artifact.current_version,
);
const [downloading, setDownloading] = useState(false);
const [restoring, setRestoring] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
// Guards async setState after the sidebar closes mid-request (the download /
// restore promises can resolve after unmount), mirroring the sidebar's
// currentFetchIdRef discipline.
const isMountedRef = useRef(true);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
useEffect(() => {
setSelectedVersion(artifact.current_version);
}, [artifact.id, artifact.current_version]);
const selectedRow: ArtifactVersion | null = useMemo(() => {
return (
artifact.versions.find((v) => v.version === selectedVersion) ??
findCurrentVersion(artifact)
);
}, [artifact, selectedVersion]);
const isCurrent = selectedVersion === artifact.current_version;
const mode = previewModeForKind(artifact.kind, selectedRow?.mime_type);
const filename = displayFilename(artifact, selectedRow);
const handleDownload = async () => {
setActionError(null);
setDownloading(true);
try {
const response = await userService.downloadArtifact(
artifact.id,
token,
selectedVersion,
);
const ok = await triggerResponseDownload(response, filename);
if (!ok && isMountedRef.current) setActionError('Download failed');
} catch {
if (isMountedRef.current) setActionError('Download failed');
} finally {
if (isMountedRef.current) setDownloading(false);
}
};
const handleRestore = async () => {
setActionError(null);
setRestoring(true);
try {
const response = await userService.restoreArtifactVersion(
artifact.id,
selectedVersion,
token,
);
if (!response.ok) {
if (isMountedRef.current) setActionError('Restore failed');
return;
}
if (isMountedRef.current) onRefresh();
} catch {
if (isMountedRef.current) setActionError('Restore failed');
} finally {
if (isMountedRef.current) setRestoring(false);
}
};
const renderPreview = () => {
if (mode === 'frame') {
const source = asPreviewString(
isCurrent ? artifact.spec : selectedRow?.preview_text,
);
if (!source) {
return (
<p className="p-4 text-sm text-gray-500 dark:text-gray-400">
No preview for this version.
</p>
);
}
return <FramePreview artifactKind={artifact.kind} source={source} />;
}
if (mode === 'image') {
return (
<DownloadCard
filename={filename}
size={selectedRow?.size ?? null}
onDownload={handleDownload}
downloading={downloading}
/>
);
}
if (mode === 'card') {
return (
<DownloadCard
filename={filename}
size={selectedRow?.size ?? null}
onDownload={handleDownload}
downloading={downloading}
/>
);
}
const text = isCurrent
? asPreviewString(artifact.spec) || (selectedRow?.preview_text ?? '')
: (selectedRow?.preview_text ?? '');
return (
<pre className="h-full overflow-auto p-4 text-xs whitespace-pre-wrap text-gray-700 dark:text-gray-300">
{text || 'No preview for this version.'}
</pre>
);
};
return (
<div className="flex h-full w-full flex-col gap-3 overflow-hidden">
<div className="flex flex-wrap items-center gap-2">
<Select
value={String(selectedVersion)}
onValueChange={(v) => setSelectedVersion(Number(v))}
>
<SelectTrigger className="h-8 w-auto gap-2 rounded-md px-3 text-xs">
<History className="h-3.5 w-3.5" />
<SelectValue />
</SelectTrigger>
<SelectContent>
{versionsDesc.map((v) => (
<SelectItem key={v.version} value={String(v.version)}>
Version {v.version}
{v.version === artifact.current_version ? ' (current)' : ''}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleDownload}
disabled={downloading}
>
{downloading ? (
<Spinner size="small" />
) : (
<Download className="h-4 w-4" />
)}
Download
</Button>
{!isCurrent && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleRestore}
disabled={restoring}
>
{restoring ? (
<Spinner size="small" />
) : (
<RotateCcw className="h-4 w-4" />
)}
Restore
</Button>
)}
</div>
{actionError && <p className="text-xs text-red-500">{actionError}</p>}
<div className="min-h-0 flex-1 overflow-hidden">{renderPreview()}</div>
</div>
);
}
@@ -0,0 +1,192 @@
import { describe, expect, it } from 'vitest';
import {
buildPreviewDocument,
displayFilename,
filenameFromContentDisposition,
findCurrentVersion,
formatBytes,
isDocumentArtifact,
PREVIEW_CSP,
PREVIEW_CSP_MERMAID,
previewModeForKind,
sortVersionsDesc,
type ArtifactVersion,
} from './artifactViewUtils';
const version = (
n: number,
extra: Partial<ArtifactVersion> = {},
): ArtifactVersion => ({
version: n,
mime_type: null,
filename: null,
size: null,
sha256: null,
preview_text: null,
produced_by: null,
created_at: null,
...extra,
});
describe('isDocumentArtifact', () => {
it('accepts the generalized document shape', () => {
expect(isDocumentArtifact({ id: 'a', kind: 'html', versions: [] })).toBe(
true,
);
});
it('rejects the legacy notes/todo shape', () => {
expect(isDocumentArtifact({ artifact_type: 'note', data: {} })).toBe(false);
});
it('rejects nullish / non-objects', () => {
expect(isDocumentArtifact(null)).toBe(false);
expect(isDocumentArtifact('x')).toBe(false);
});
});
describe('previewModeForKind', () => {
it('maps html/svg/mermaid to the sandboxed frame', () => {
expect(previewModeForKind('html')).toBe('frame');
expect(previewModeForKind('svg')).toBe('frame');
expect(previewModeForKind('mermaid')).toBe('frame');
});
it('maps office kinds and pdf to a download card', () => {
expect(previewModeForKind('document')).toBe('card');
expect(previewModeForKind('spreadsheet')).toBe('card');
expect(previewModeForKind('presentation')).toBe('card');
expect(previewModeForKind('pdf')).toBe('card');
expect(previewModeForKind('file')).toBe('card');
});
it('maps code/data and unknown kinds to text', () => {
expect(previewModeForKind('code')).toBe('text');
expect(previewModeForKind('data')).toBe('text');
expect(previewModeForKind('something-new')).toBe('text');
expect(previewModeForKind(null)).toBe('text');
});
it('infers from mime when the kind is coarse', () => {
expect(previewModeForKind('file', 'image/svg+xml')).toBe('frame');
expect(previewModeForKind('file', 'application/pdf')).toBe('card');
expect(previewModeForKind('file', 'image/png')).toBe('image');
expect(previewModeForKind('image', 'image/jpeg')).toBe('image');
});
});
describe('sortVersionsDesc', () => {
it('orders newest-first without mutating the input', () => {
const input = [version(1), version(3), version(2)];
const sorted = sortVersionsDesc(input);
expect(sorted.map((v) => v.version)).toEqual([3, 2, 1]);
expect(input.map((v) => v.version)).toEqual([1, 3, 2]);
});
});
describe('findCurrentVersion', () => {
it('returns the row matching current_version', () => {
const v = findCurrentVersion({
current_version: 2,
versions: [version(1), version(2), version(3)],
});
expect(v?.version).toBe(2);
});
it('falls back to the highest version when the pointer is missing', () => {
const v = findCurrentVersion({
current_version: 9,
versions: [version(1), version(4), version(2)],
});
expect(v?.version).toBe(4);
});
it('returns null for no versions', () => {
expect(findCurrentVersion({ current_version: 1, versions: [] })).toBeNull();
});
});
describe('formatBytes', () => {
it('formats across unit boundaries', () => {
expect(formatBytes(0)).toBe('0 B');
expect(formatBytes(512)).toBe('512 B');
expect(formatBytes(1536)).toBe('1.5 KB');
expect(formatBytes(10 * 1024)).toBe('10 KB');
expect(formatBytes(5 * 1024 * 1024)).toBe('5 MB');
});
it('returns empty for missing/invalid sizes', () => {
expect(formatBytes(null)).toBe('');
expect(formatBytes(undefined)).toBe('');
expect(formatBytes(-1)).toBe('');
});
});
describe('displayFilename', () => {
it('prefers the version filename, then title, then id', () => {
expect(
displayFilename({ id: 'x', title: 'Deck' }, { filename: 'deck.pptx' }),
).toBe('deck.pptx');
expect(displayFilename({ id: 'x', title: 'Deck' }, null)).toBe('Deck');
expect(displayFilename({ id: 'x', title: null }, { filename: null })).toBe(
'artifact-x',
);
});
});
describe('buildPreviewDocument', () => {
it('embeds the restrictive CSP and content', () => {
const doc = buildPreviewDocument('html', '<p>hi</p>');
expect(doc).toContain(PREVIEW_CSP);
expect(doc).toContain('<p>hi</p>');
expect(doc).toContain('Content-Security-Policy');
});
it('keeps script-src none in the CSP', () => {
expect(PREVIEW_CSP).toContain("script-src 'none'");
expect(PREVIEW_CSP).toContain("default-src 'none'");
expect(PREVIEW_CSP).not.toContain('allow-same-origin');
});
it('does not allow frame-src for non-mermaid kinds', () => {
expect(PREVIEW_CSP).not.toContain('frame-src');
const doc = buildPreviewDocument('html', '<p>hi</p>');
expect(doc).not.toContain('frame-src');
});
it('uses the mermaid CSP that permits a data: frame for mermaid', () => {
const doc = buildPreviewDocument(
'mermaid',
'<iframe src="data:..."></iframe>',
);
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();
});
});
@@ -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<string, unknown>;
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<DocumentArtifact, 'current_version' | 'versions'>,
): 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<DocumentArtifact, 'id' | 'title'>,
version: Pick<ArtifactVersion, 'filename'> | 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
* `<iframe src="data:text/html;base64,…" sandbox>` carrying the diagram, so the
* preview document must additionally permit a `data:` nested frame. The diagram
* itself runs inside that inner sandboxed frame; this outer document still
* forbids scripts and origins.
*/
export const PREVIEW_CSP_MERMAID =
"default-src 'none'; img-src data: blob:; style-src 'unsafe-inline'; " +
"font-src data:; script-src 'none'; frame-src data:; form-action 'none'; " +
"base-uri 'none'; frame-ancestors 'none'";
/**
* Wrap artifact markup in a minimal HTML document carrying the restrictive CSP.
* The result is fed to a `sandbox="allow-scripts"`-less iframe via `srcdoc`;
* the iframe must NOT be granted `allow-same-origin`, so the content cannot
* reach the app's origin, cookies, or storage even though the CSP also blocks
* script execution as defense in depth. The `mermaid` kind selects the variant
* CSP that allows mermaid's inner `data:` sandboxed-iframe output to load.
*/
export function buildPreviewDocument(kind: string, content: string): string {
const csp = kind === 'mermaid' ? PREVIEW_CSP_MERMAID : PREVIEW_CSP;
return [
'<!doctype html>',
'<html>',
'<head>',
'<meta charset="utf-8">',
`<meta http-equiv="Content-Security-Policy" content="${csp}">`,
'<style>html,body{margin:0;padding:12px;background:#fff;color:#111;' +
'font-family:system-ui,sans-serif}img,svg{max-width:100%;height:auto}' +
'iframe{border:0;width:100%}</style>',
'</head>',
'<body>',
content,
'</body>',
'</html>',
].join('');
}
/**
* Extract a filename from a `Content-Disposition` header, honoring both the
* RFC 5987 `filename*` form and the plain quoted `filename` form.
*/
export function filenameFromContentDisposition(
header: string | null,
): string | null {
if (!header) return null;
const star = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(header);
if (star?.[1]) {
try {
return decodeURIComponent(star[1].replace(/^"|"$/g, '').trim());
} catch {
return star[1].replace(/^"|"$/g, '').trim();
}
}
const plain = /filename="?([^";]+)"?/i.exec(header);
return plain?.[1]?.trim() ?? null;
}
/**
* Turn a fetched download Response into a browser file-save, honoring
* `Content-Disposition` when present. Returns false if the response is not OK.
*/
export async function triggerResponseDownload(
response: Response,
fallbackName: string,
): Promise<boolean> {
if (!response.ok) return false;
const name =
filenameFromContentDisposition(
response.headers.get('Content-Disposition'),
) || fallbackName;
const blob = await response.blob();
const url = URL.createObjectURL(blob);
try {
const link = document.createElement('a');
link.href = url;
link.download = name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} finally {
URL.revokeObjectURL(url);
}
return true;
}