Every assistant message gets an "Open in pane" affordance that opens the
message in the workspace splitter — Markdown pane (Copy + Download .md) by
default; HTML pane (Download .html only) when the model emits a self-contained
<!DOCTYPE html> or fenced ```html artifact. BOOCHAT.md rule keeps Markdown
default at every length; HTML opt-in on explicit user request.
Backend: services/artifacts.ts (slug derivation + write helpers with
symlink-escape guard via realpath-after-mkdir), routes/artifacts.ts (POST
download + GET stream with nosniff + CSP sandbox defense-in-depth), HTML
detection in finalizeCompletion writing a new message_parts.kind='html_artifact'
row (schema CHECK extended via v1.13.13 pattern), graceful 1MB cap via the
pure decideHtmlArtifactWrite helper. PartKind union extended.
Frontend: MarkdownRenderer.tsx extracted from MessageBubble's inline
MarkdownBody for reuse; MarkdownArtifactPane.tsx + HtmlArtifactPane.tsx with
loading/error states; pane state is reference-only ({chat_id, message_id,
title}) — content fetched on mount to keep workspace_panes jsonb small and
avoid 1MB blobs riding session_workspace_updated frames. iframe sandbox
locked to allow-scripts allow-clipboard-write allow-downloads with no
allow-same-origin, srcDoc not src. openInPane discriminates 404 (expected
fallback) from real errors (toast + bail). PanelRightOpen icon button with
mobile 44px tap-target.
31 new server unit tests including a real-symlink filesystem case; 332/332
server tests passing, tsc clean both sides, pnpm -C apps/web build green.
Smoke deferred to first deploy.
117 lines
4.3 KiB
TypeScript
117 lines
4.3 KiB
TypeScript
// v1.14.x-html-artifact-panes: full-height HTML artifact viewer. Renders the
|
|
// model's HTML inside a sandboxed iframe — no allow-same-origin, srcdoc only
|
|
// (no separate URL), CSP injected by the backend writer. JS runs inside the
|
|
// iframe (interactive controls work) but fetch / WS / tracking pixels are
|
|
// blocked by connect-src 'none' on the CSP. NO Copy button per the spec.
|
|
//
|
|
// Pane state is a reference only (chat_id + message_id + title); the iframe
|
|
// payload is fetched on mount from
|
|
// GET /api/chats/:chat_id/messages/:msg_id/html_artifact so that
|
|
// sessions.workspace_panes jsonb stays small and message_parts.payload is the
|
|
// single source of truth.
|
|
import { useEffect, useState } from 'react';
|
|
import { Download, X } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { api } from '@/api/client';
|
|
import type { HtmlArtifactState } from '@/api/types';
|
|
|
|
interface Props {
|
|
chatId: string;
|
|
state: HtmlArtifactState;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export function HtmlArtifactPane({ chatId, state, onClose }: Props) {
|
|
const [downloading, setDownloading] = useState(false);
|
|
const [htmlContent, setHtmlContent] = useState<string | null>(null);
|
|
const [loadError, setLoadError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setHtmlContent(null);
|
|
setLoadError(null);
|
|
void (async () => {
|
|
try {
|
|
const payload = await api.messages.getHtmlArtifact(chatId, state.message_id);
|
|
if (cancelled) return;
|
|
setHtmlContent(payload.html_content);
|
|
} catch (err) {
|
|
if (cancelled) return;
|
|
setLoadError(err instanceof Error ? err.message : 'failed to load HTML artifact');
|
|
}
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [chatId, state.message_id]);
|
|
|
|
async function download() {
|
|
if (downloading) return;
|
|
setDownloading(true);
|
|
try {
|
|
const { url, path } = await api.messages.downloadArtifact(
|
|
chatId,
|
|
state.message_id,
|
|
'html',
|
|
);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.rel = 'noopener';
|
|
a.click();
|
|
toast.success(`Saved to ${path}`);
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'download failed');
|
|
} finally {
|
|
setDownloading(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col h-full min-h-0">
|
|
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-2 py-1 shrink-0">
|
|
<span className="text-xs text-muted-foreground truncate flex-1" title={state.title}>
|
|
{state.title || 'HTML artifact'}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => void download()}
|
|
disabled={downloading || htmlContent === null}
|
|
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 max-md:min-h-[44px] max-md:min-w-[44px]"
|
|
aria-label="Download HTML"
|
|
title="Download"
|
|
>
|
|
<Download size={12} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
|
aria-label="Close artifact pane"
|
|
title="Close"
|
|
>
|
|
<X size={12} />
|
|
</button>
|
|
</div>
|
|
<div className="flex-1 min-h-0 overflow-hidden bg-background">
|
|
{loadError ? (
|
|
<div className="p-4 text-sm text-destructive">Failed to load: {loadError}</div>
|
|
) : htmlContent === null ? (
|
|
<div className="p-4 text-sm text-muted-foreground">Loading HTML artifact…</div>
|
|
) : (
|
|
<iframe
|
|
// Sandbox attributes are non-negotiable per the v1.14.x spec S5:
|
|
// no allow-same-origin → opaque origin → can't reach parent cookies
|
|
// or DOM. srcdoc (not src) means no URL exists to leak. JS runs
|
|
// (allow-scripts) but connect-src 'none' on the CSP inside the
|
|
// payload blocks fetch / WS / pixels.
|
|
srcDoc={htmlContent}
|
|
sandbox="allow-scripts allow-clipboard-write allow-downloads"
|
|
className="w-full h-full border-0"
|
|
title={state.title || 'HTML artifact'}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|