v1.13.19-html-artifact-panes: pane-based artifact viewer with on-request HTML
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.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,14 @@
|
||||
import type { MessageMetadata, Session } from '../../types/api.js';
|
||||
import {
|
||||
decideHtmlArtifactWrite,
|
||||
detectHtmlArtifact,
|
||||
deriveHtmlTitle,
|
||||
HTML_ARTIFACT_MAX_BYTES,
|
||||
} from '../artifacts.js';
|
||||
import * as modelContext from '../model-context.js';
|
||||
import { maybeFlagForCompaction } from './payload.js';
|
||||
import { insertParts, partsFromAssistantMessage } from './parts.js';
|
||||
import type { PartInsert } from './parts.js';
|
||||
import type { InferenceContext, StreamResult, TurnArgs } from './turn.js';
|
||||
|
||||
export async function handleAbortOrError(
|
||||
@@ -120,17 +127,42 @@ export async function finalizeCompletion(
|
||||
// a kind='reasoning' part alongside the text.
|
||||
// TODO(v1.13.1): wrap the UPDATE above and this insertParts in a single
|
||||
// sql.begin before flipping read authority to message_parts.
|
||||
await insertParts(
|
||||
ctx.sql,
|
||||
partsFromAssistantMessage({
|
||||
content,
|
||||
tool_calls: null,
|
||||
reasoning: result.reasoning,
|
||||
}).map((p) => ({
|
||||
...p,
|
||||
message_id: assistantMessageId,
|
||||
})),
|
||||
);
|
||||
const baseParts: PartInsert[] = partsFromAssistantMessage({
|
||||
content,
|
||||
tool_calls: null,
|
||||
reasoning: result.reasoning,
|
||||
}).map((p) => ({
|
||||
...p,
|
||||
message_id: assistantMessageId,
|
||||
}));
|
||||
// v1.14.x-html-artifact-panes: opportunistic HTML detection. Adds a
|
||||
// SIBLING html_artifact part — never replaces the text part. 1MB cap is
|
||||
// graceful: oversized payloads are skipped and the assistant message
|
||||
// lands as plain content (warn logged).
|
||||
const htmlContent = detectHtmlArtifact(content);
|
||||
if (htmlContent !== null) {
|
||||
const decision = decideHtmlArtifactWrite(htmlContent);
|
||||
if (!decision.write) {
|
||||
ctx.log.warn(
|
||||
{ assistantMessageId, byteLen: decision.byteLen, cap: HTML_ARTIFACT_MAX_BYTES },
|
||||
'html_artifact exceeded 1MB cap; skipping artifact part',
|
||||
);
|
||||
} else {
|
||||
const title = deriveHtmlTitle(htmlContent);
|
||||
const nextSeq = baseParts.reduce((m, p) => Math.max(m, p.sequence), -1) + 1;
|
||||
baseParts.push({
|
||||
message_id: assistantMessageId,
|
||||
sequence: nextSeq,
|
||||
kind: 'html_artifact',
|
||||
payload: {
|
||||
html_content: htmlContent,
|
||||
char_count: htmlContent.length,
|
||||
title,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
await insertParts(ctx.sql, baseParts);
|
||||
// v1.11: flag for compaction on the terminal turn too. Catches the common
|
||||
// case of a turn that hit the limit without invoking tools.
|
||||
await maybeFlagForCompaction(ctx, chatId, updated);
|
||||
|
||||
Reference in New Issue
Block a user