mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-12 16:15:14 +00:00
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:
1 parent
3b795dbb83
commit
182a14d257
6 files changed
+907
-59
No files matched your search
@@ -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',
|
||||
|
||||
@@ -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> =>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user