mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-14 22:17:00 +00:00
Preview artifact bytes inline in the sidebar
Render produced-artifact bytes inline when a version has no embedded spec: HTML and SVG in the existing sandboxed iframe (with the restrictive CSP), images inline via a blob URL, and Markdown/CSV/JSON/plain text as escaped text; office documents and PDFs keep the download card. Bytes are fetched through the existing authenticated download path, version-aware, with object URLs revoked on change. Untrusted HTML/SVG renders only inside the scriptless sandboxed iframe and Markdown is rendered without raw HTML, so artifact content cannot script the app.
This commit is contained in:
1 parent
afc423a818
commit
a06b348c34
5 files changed
+404
-31
No files matched your search
@@ -1,14 +1,16 @@
|
||||
import mermaid from 'mermaid';
|
||||
import { Download, FileText, History, RotateCcw } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import userService from '../api/services/userService';
|
||||
import { useDarkTheme } from '../hooks';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import MarkdownPreview from './MarkdownPreview';
|
||||
import Spinner from './Spinner';
|
||||
import {
|
||||
buildPreviewDocument,
|
||||
bytesPreviewModeForMime,
|
||||
displayFilename,
|
||||
findCurrentVersion,
|
||||
formatBytes,
|
||||
@@ -16,9 +18,11 @@ import {
|
||||
sortVersionsDesc,
|
||||
triggerResponseDownload,
|
||||
type ArtifactVersion,
|
||||
type BytesPreviewMode,
|
||||
type DocumentArtifact,
|
||||
} from './artifactViewUtils';
|
||||
import { Button } from './ui/button';
|
||||
import { useArtifactBytes } from './useArtifactBytes';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -154,6 +158,74 @@ function DownloadCard({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a version's downloadable BYTES inline by fetching them through the
|
||||
* authed download path. html/svg are embedded ONLY inside the scriptless
|
||||
* `sandbox=""` + CSP iframe (never the app DOM); images are shown via an inert
|
||||
* blob-URL `<img>`; text/markdown render as escaped React content. On fetch
|
||||
* error (or oversized text) it falls back to the supplied download card.
|
||||
*/
|
||||
function BytesPreview({
|
||||
artifactId,
|
||||
version,
|
||||
mode,
|
||||
token,
|
||||
fallback,
|
||||
}: {
|
||||
artifactId: string;
|
||||
version: number;
|
||||
mode: Exclude<BytesPreviewMode, 'card'>;
|
||||
token: string | null;
|
||||
fallback: ReactNode;
|
||||
}) {
|
||||
const state = useArtifactBytes(artifactId, version, mode, token);
|
||||
|
||||
if (state.status === 'loading') {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (state.status === 'error') {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
if (state.status === 'image') {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center overflow-auto p-4">
|
||||
<img
|
||||
src={state.url}
|
||||
alt="Artifact preview"
|
||||
className="max-h-full max-w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// state.status === 'text'
|
||||
if (mode === 'iframe-html' || mode === 'iframe-svg') {
|
||||
return (
|
||||
<iframe
|
||||
// No allow-same-origin / no scripts: bytes are 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(
|
||||
mode === 'iframe-svg' ? 'svg' : 'html',
|
||||
state.text,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (mode === 'text-markdown') {
|
||||
return <MarkdownPreview content={state.text} />;
|
||||
}
|
||||
return (
|
||||
<pre className="h-full overflow-auto p-4 text-xs whitespace-pre-wrap text-gray-700 dark:text-gray-300">
|
||||
{state.text}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DocumentArtifactView({
|
||||
artifact,
|
||||
onRefresh,
|
||||
@@ -238,47 +310,65 @@ export default function DocumentArtifactView({
|
||||
}
|
||||
};
|
||||
|
||||
const downloadCard = (
|
||||
<DownloadCard
|
||||
filename={filename}
|
||||
size={selectedRow?.size ?? null}
|
||||
onDownload={handleDownload}
|
||||
downloading={downloading}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderPreview = () => {
|
||||
// Preferred path: the version's `spec` already carries renderable
|
||||
// html/svg/mermaid markup — render it in the sandboxed frame, no fetch.
|
||||
if (mode === 'frame') {
|
||||
const source = asPreviewString(
|
||||
isCurrent ? artifact.spec : selectedRow?.preview_text,
|
||||
);
|
||||
if (!source) {
|
||||
if (source) {
|
||||
return <FramePreview artifactKind={artifact.kind} source={source} />;
|
||||
}
|
||||
// No usable spec content: fall through to the bytes path below.
|
||||
}
|
||||
|
||||
// Inline text already on hand (code/data kinds expose it as preview_text /
|
||||
// spec) — render it without a fetch.
|
||||
if (mode === 'text') {
|
||||
const text = isCurrent
|
||||
? asPreviewString(artifact.spec) || (selectedRow?.preview_text ?? '')
|
||||
: (selectedRow?.preview_text ?? '');
|
||||
if (text) {
|
||||
return (
|
||||
<p className="p-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
No preview for this version.
|
||||
</p>
|
||||
<pre className="h-full overflow-auto p-4 text-xs whitespace-pre-wrap text-gray-700 dark:text-gray-300">
|
||||
{text}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
return <FramePreview artifactKind={artifact.kind} source={source} />;
|
||||
// No inline text: try fetching the bytes below.
|
||||
}
|
||||
if (mode === 'image') {
|
||||
return (
|
||||
<DownloadCard
|
||||
filename={filename}
|
||||
size={selectedRow?.size ?? null}
|
||||
onDownload={handleDownload}
|
||||
downloading={downloading}
|
||||
/>
|
||||
);
|
||||
|
||||
// Bytes path: no usable spec/inline content, so decide the preview by the
|
||||
// version's mime (kind fallback) and fetch the stored bytes for an inline
|
||||
// render. Anything not previewable (office docs, pdf, octet-stream) and any
|
||||
// fetch failure fall back to the download card.
|
||||
const bytesMode: BytesPreviewMode = bytesPreviewModeForMime(
|
||||
selectedRow?.mime_type,
|
||||
artifact.kind,
|
||||
);
|
||||
if (bytesMode === 'card' || !selectedRow) {
|
||||
return downloadCard;
|
||||
}
|
||||
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>
|
||||
<BytesPreview
|
||||
// Refetch on artifact/version/mode change via the key + hook deps.
|
||||
key={`${artifact.id}:${selectedVersion}:${bytesMode}`}
|
||||
artifactId={artifact.id}
|
||||
version={selectedVersion}
|
||||
mode={bytesMode}
|
||||
token={token}
|
||||
fallback={downloadCard}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
/**
|
||||
* Minimal GFM markdown renderer for artifact text previews. ReactMarkdown
|
||||
* parses to React elements (no `dangerouslySetInnerHTML`), so untrusted
|
||||
* markdown bytes never become raw DOM in the app origin.
|
||||
*/
|
||||
export default function MarkdownPreview({ content }: { content: string }) {
|
||||
return (
|
||||
<div className="h-full overflow-auto p-4">
|
||||
<ReactMarkdown
|
||||
className="flex flex-col gap-3 text-sm leading-normal break-words whitespace-pre-wrap text-gray-800 dark:text-gray-200"
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
a({ children, href }) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline dark:text-blue-400"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
ul({ children }) {
|
||||
return (
|
||||
<ul className="list-inside list-disc pl-4 whitespace-normal">
|
||||
{children}
|
||||
</ul>
|
||||
);
|
||||
},
|
||||
ol({ children }) {
|
||||
return (
|
||||
<ol className="list-inside list-decimal pl-4 whitespace-normal">
|
||||
{children}
|
||||
</ol>
|
||||
);
|
||||
},
|
||||
h1({ children }) {
|
||||
return <h1 className="text-xl font-bold">{children}</h1>;
|
||||
},
|
||||
h2({ children }) {
|
||||
return <h2 className="text-lg font-bold">{children}</h2>;
|
||||
},
|
||||
h3({ children }) {
|
||||
return <h3 className="text-base font-bold">{children}</h3>;
|
||||
},
|
||||
code({ children, ...rest }) {
|
||||
return (
|
||||
<code
|
||||
className="dark:bg-accent dark:text-foreground rounded-md bg-gray-200 px-2 py-1 text-xs font-normal"
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
blockquote({ children }) {
|
||||
return (
|
||||
<blockquote className="border-l-4 border-gray-300 pl-4 italic dark:border-gray-600">
|
||||
{children}
|
||||
</blockquote>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,11 +2,13 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildPreviewDocument,
|
||||
bytesPreviewModeForMime,
|
||||
displayFilename,
|
||||
filenameFromContentDisposition,
|
||||
findCurrentVersion,
|
||||
formatBytes,
|
||||
isDocumentArtifact,
|
||||
MAX_INLINE_TEXT_BYTES,
|
||||
PREVIEW_CSP,
|
||||
PREVIEW_CSP_MERMAID,
|
||||
previewModeForKind,
|
||||
@@ -76,6 +78,68 @@ describe('previewModeForKind', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('bytesPreviewModeForMime', () => {
|
||||
it('routes html/svg markup to the sandboxed iframe', () => {
|
||||
expect(bytesPreviewModeForMime('text/html')).toBe('iframe-html');
|
||||
expect(bytesPreviewModeForMime('image/svg+xml')).toBe('iframe-svg');
|
||||
});
|
||||
|
||||
it('routes raster images to the inline image renderer', () => {
|
||||
expect(bytesPreviewModeForMime('image/png')).toBe('image');
|
||||
expect(bytesPreviewModeForMime('image/jpeg')).toBe('image');
|
||||
expect(bytesPreviewModeForMime('image/gif')).toBe('image');
|
||||
expect(bytesPreviewModeForMime('image/webp')).toBe('image');
|
||||
});
|
||||
|
||||
it('routes markdown to the markdown renderer and other text to plain', () => {
|
||||
expect(bytesPreviewModeForMime('text/markdown')).toBe('text-markdown');
|
||||
expect(bytesPreviewModeForMime('text/x-markdown')).toBe('text-markdown');
|
||||
expect(bytesPreviewModeForMime('text/plain')).toBe('text-plain');
|
||||
expect(bytesPreviewModeForMime('text/csv')).toBe('text-plain');
|
||||
expect(bytesPreviewModeForMime('application/json')).toBe('text-plain');
|
||||
expect(bytesPreviewModeForMime('text/whatever')).toBe('text-plain');
|
||||
});
|
||||
|
||||
it('ignores charset parameters and casing on the mime', () => {
|
||||
expect(bytesPreviewModeForMime('TEXT/HTML; charset=utf-8')).toBe(
|
||||
'iframe-html',
|
||||
);
|
||||
expect(bytesPreviewModeForMime('text/plain;charset=UTF-8')).toBe(
|
||||
'text-plain',
|
||||
);
|
||||
});
|
||||
|
||||
it('routes office docs / pdf / unknown to the download card', () => {
|
||||
expect(
|
||||
bytesPreviewModeForMime(
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
),
|
||||
).toBe('card');
|
||||
expect(bytesPreviewModeForMime('application/pdf')).toBe('card');
|
||||
expect(bytesPreviewModeForMime('application/zip')).toBe('card');
|
||||
});
|
||||
|
||||
it('falls back to kind when the mime is missing or octet-stream', () => {
|
||||
expect(bytesPreviewModeForMime(null, 'html')).toBe('iframe-html');
|
||||
expect(bytesPreviewModeForMime(undefined, 'svg')).toBe('iframe-svg');
|
||||
expect(bytesPreviewModeForMime('application/octet-stream', 'image')).toBe(
|
||||
'image',
|
||||
);
|
||||
expect(bytesPreviewModeForMime('', 'code')).toBe('text-plain');
|
||||
expect(bytesPreviewModeForMime('', 'data')).toBe('text-plain');
|
||||
});
|
||||
|
||||
it('cards an unknown kind with no usable mime', () => {
|
||||
expect(bytesPreviewModeForMime(null, 'presentation')).toBe('card');
|
||||
expect(bytesPreviewModeForMime(null, null)).toBe('card');
|
||||
});
|
||||
|
||||
it('exposes a sane inline text bound', () => {
|
||||
expect(MAX_INLINE_TEXT_BYTES).toBeGreaterThan(0);
|
||||
expect(MAX_INLINE_TEXT_BYTES).toBeLessThanOrEqual(1024 * 1024);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortVersionsDesc', () => {
|
||||
it('orders newest-first without mutating the input', () => {
|
||||
const input = [version(1), version(3), version(2)];
|
||||
|
||||
@@ -40,6 +40,65 @@ export type DocumentArtifact = {
|
||||
/** How the sidebar should render an artifact's current version. */
|
||||
export type PreviewMode = 'frame' | 'image' | 'card' | 'text';
|
||||
|
||||
/**
|
||||
* How fetched artifact BYTES should be rendered inline when the version has no
|
||||
* usable `spec`. Finer-grained than {@link PreviewMode}: it distinguishes the
|
||||
* markup family (html vs svg) and the text family (markdown vs plain) so the
|
||||
* renderer can pick the sandboxed iframe, a blob-URL `<img>`, the markdown
|
||||
* renderer, or an escaped `<pre>`. `card` means "not previewable inline".
|
||||
*/
|
||||
export type BytesPreviewMode =
|
||||
| 'iframe-html'
|
||||
| 'iframe-svg'
|
||||
| 'image'
|
||||
| 'text-markdown'
|
||||
| 'text-plain'
|
||||
| 'card';
|
||||
|
||||
/** Upper bound on fetched text rendered inline; larger bytes fall back to the card. */
|
||||
export const MAX_INLINE_TEXT_BYTES = 512 * 1024;
|
||||
|
||||
const TEXT_PLAIN_MIME = new Set([
|
||||
'text/plain',
|
||||
'text/csv',
|
||||
'application/json',
|
||||
'text/xml',
|
||||
'application/xml',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Decide how to render downloadable artifact bytes from the version's
|
||||
* `mime_type` (falling back to `kind` when the mime is missing/coarse). Office
|
||||
* documents, pdf, and anything unrecognized map to `card` (download only).
|
||||
* Mermaid is intentionally NOT handled here — mermaid lives in `spec`, not as
|
||||
* stored bytes, so it stays on the spec-based frame path.
|
||||
*/
|
||||
export function bytesPreviewModeForMime(
|
||||
mimeType?: string | null,
|
||||
kind?: string | null,
|
||||
): BytesPreviewMode {
|
||||
const mime = (mimeType ?? '').toLowerCase().split(';')[0].trim();
|
||||
const k = (kind ?? '').toLowerCase();
|
||||
|
||||
if (mime === 'image/svg+xml') return 'iframe-svg';
|
||||
if (mime === 'text/html') return 'iframe-html';
|
||||
if (mime === 'text/markdown' || mime === 'text/x-markdown')
|
||||
return 'text-markdown';
|
||||
if (mime.startsWith('image/')) return 'image';
|
||||
if (TEXT_PLAIN_MIME.has(mime)) return 'text-plain';
|
||||
if (mime.startsWith('text/')) return 'text-plain';
|
||||
|
||||
// Mime missing or coarse (e.g. application/octet-stream): fall back to kind.
|
||||
if (!mime || mime === 'application/octet-stream') {
|
||||
if (k === 'svg') return 'iframe-svg';
|
||||
if (k === 'html') return 'iframe-html';
|
||||
if (k === 'image') return 'image';
|
||||
if (k === 'code' || k === 'data') return 'text-plain';
|
||||
}
|
||||
|
||||
return 'card';
|
||||
}
|
||||
|
||||
/**
|
||||
* Discriminates the legacy notes/todo response (`artifact_type`) from the
|
||||
* generalized document/file response (`kind`).
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import userService from '../api/services/userService';
|
||||
import {
|
||||
type BytesPreviewMode,
|
||||
MAX_INLINE_TEXT_BYTES,
|
||||
} from './artifactViewUtils';
|
||||
|
||||
type BytesState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'error' }
|
||||
| { status: 'text'; text: string }
|
||||
| { status: 'image'; url: string };
|
||||
|
||||
/**
|
||||
* Fetch a version's downloadable bytes via the authed download endpoint and
|
||||
* expose them for inline preview. Version-aware: refetches whenever the
|
||||
* artifact id, version, or mode changes, and ignores stale responses. Images
|
||||
* are exposed as a blob object URL that is revoked on cleanup/version change;
|
||||
* text/markup is read as a string and bounded by {@link MAX_INLINE_TEXT_BYTES}.
|
||||
*
|
||||
* On any fetch/size failure the state becomes `error` so the caller can fall
|
||||
* back to the download card. The returned object URL must NOT be revoked by the
|
||||
* caller; this hook owns its lifecycle.
|
||||
*/
|
||||
export function useArtifactBytes(
|
||||
artifactId: string,
|
||||
version: number,
|
||||
mode: BytesPreviewMode,
|
||||
token: string | null,
|
||||
): BytesState {
|
||||
const [state, setState] = useState<BytesState>({ status: 'loading' });
|
||||
const objectUrlRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === 'card') {
|
||||
setState({ status: 'error' });
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setState({ status: 'loading' });
|
||||
|
||||
const revoke = () => {
|
||||
if (objectUrlRef.current) {
|
||||
URL.revokeObjectURL(objectUrlRef.current);
|
||||
objectUrlRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
userService
|
||||
.downloadArtifact(artifactId, token, version)
|
||||
.then(async (response: Response) => {
|
||||
if (!response.ok) throw new Error('download failed');
|
||||
|
||||
if (mode === 'image') {
|
||||
const blob = await response.blob();
|
||||
if (cancelled) return;
|
||||
revoke();
|
||||
const url = URL.createObjectURL(blob);
|
||||
objectUrlRef.current = url;
|
||||
setState({ status: 'image', url });
|
||||
return;
|
||||
}
|
||||
|
||||
// html / svg / text families render as a (bounded) string.
|
||||
const blob = await response.blob();
|
||||
if (cancelled) return;
|
||||
if (blob.size > MAX_INLINE_TEXT_BYTES) throw new Error('too large');
|
||||
const text = await blob.text();
|
||||
if (cancelled) return;
|
||||
setState({ status: 'text', text });
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setState({ status: 'error' });
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
revoke();
|
||||
};
|
||||
}, [artifactId, version, mode, token]);
|
||||
|
||||
return state;
|
||||
}
|
||||
Reference in new issue
Block a user