Files
boocode/apps/server/src/services/inference/parts.ts
indifferentketchup ad45b28250 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>
2026-05-23 12:43:13 +00:00

109 lines
3.7 KiB
TypeScript

import type { Sql } from '../../db.js';
import type { ToolCall, ToolResult } from '../../types/api.js';
// v1.13.0: dual-write helper. Every site that writes the legacy
// messages.tool_calls / messages.tool_results JSON columns calls into here
// to mirror the same data into message_parts rows. Reads still go to the
// JSON columns; the swap to parts-as-source-of-truth happens in a later
// v1.13 dispatch alongside the AI SDK streamText migration.
// v1.13.13: 'synthesis' added. Schema CHECK constraint is updated in lockstep
// (schema.sql adds 'synthesis' to message_parts_kind_chk on startup). The
// dispatch's claim that no schema migration was needed assumed kind was a
// bare text column — it isn't; the constraint enumerates allowed values.
// v1.14.x-html-artifact-panes: 'html_artifact' added. Schema CHECK constraint
// in schema.sql updated in lockstep.
export type PartKind =
| 'text'
| 'tool_call'
| 'tool_result'
| 'reasoning'
| 'step_start'
| 'synthesis'
| 'html_artifact';
export interface PartInsert {
message_id: string;
sequence: number;
kind: PartKind;
payload: unknown;
}
export async function insertParts(sql: Sql, parts: PartInsert[]): Promise<void> {
if (parts.length === 0) return;
// postgres-js fans out an array of objects to a multi-row INSERT. Each
// payload field needs sql.json() so jsonb storage receives a JSON value
// rather than a quoted string.
await sql`
INSERT INTO message_parts ${sql(
parts.map((p) => ({
message_id: p.message_id,
sequence: p.sequence,
kind: p.kind,
payload: sql.json(p.payload as never),
})),
'message_id',
'sequence',
'kind',
'payload',
)}
`;
}
// Derive parts from the canonical messages row for an assistant message.
// reasoning (when non-empty) becomes a 'reasoning' part at sequence 0 —
// it precedes user-visible content logically. content (when non-empty)
// becomes a 'text' part next; each tool_call becomes a 'tool_call' part
// with payload { id, name, args } where args is the parsed object (we
// use the in-memory ToolCall shape, not the OpenAI stringified one).
export function partsFromAssistantMessage(args: {
content: string;
tool_calls: ToolCall[] | null;
// v1.13.1-C: optional reasoning text streamed alongside the answer.
// Most rows have none — only models with separate reasoning channels
// (qwen3.6 etc.) populate this.
reasoning?: string;
}): Omit<PartInsert, 'message_id'>[] {
const out: Omit<PartInsert, 'message_id'>[] = [];
let seq = 0;
if (args.reasoning && args.reasoning.length > 0) {
out.push({ sequence: seq, kind: 'reasoning', payload: { text: args.reasoning } });
seq += 1;
}
if (args.content && args.content.length > 0) {
out.push({ sequence: seq, kind: 'text', payload: { text: args.content } });
seq += 1;
}
for (const tc of args.tool_calls ?? []) {
out.push({
sequence: seq,
kind: 'tool_call',
payload: { id: tc.id, name: tc.name, args: tc.args },
});
seq += 1;
}
return out;
}
// Derive a single tool_result part from a tool message's tool_results JSON.
// The payload includes the same shape that buildMessagesPayload reads from
// later: tool_call_id, output, optional error/truncated metadata.
export function partsFromToolMessage(args: {
tool_results: ToolResult | null;
}): Omit<PartInsert, 'message_id'>[] {
if (!args.tool_results) return [];
const tr = args.tool_results;
return [
{
sequence: 0,
kind: 'tool_result',
payload: {
tool_call_id: tr.tool_call_id,
output: tr.output,
truncated: tr.truncated,
...(tr.error ? { error: tr.error } : {}),
},
},
];
}