feat(dashboard): structured record rendering with markdown support (#169)

- API returns structured fields for thread-start (workflow, prompt, status)
  and workflow-result (returnCode, content, timestamp)
- New RecordCard component renders by type:
  - StartCard: workflow name badge + prompt blockquote
  - RoleMessage: role-colored badges (preparer/agent/extractor) + markdown
  - ResultCard: success/fail status badge + summary
- Added react-markdown + shiki for markdown rendering with syntax highlighting
- Replaces generic <pre> blocks with proper structured rendering

Refs #169
小橘 <xiaoju@shazhou.work>
This commit is contained in:
2026-05-09 10:41:13 +00:00
parent 8e36d3e1f5
commit d96e10b0fc
7 changed files with 271 additions and 54 deletions
@@ -118,7 +118,7 @@ async function emitRecordsForHead(params: {
params.eventId.n++;
await params.stream.writeSSE({
event: "record",
data: JSON.stringify({ type: "workflow-result", role: null, content: wf.summary, timestamp: null, returnCode: wf.returnCode }),
data: JSON.stringify({ type: "workflow-result", returnCode: wf.returnCode, content: wf.summary, timestamp: null }),
id: String(params.eventId.n),
});
return true;
@@ -45,19 +45,11 @@ async function buildThreadDetailRecords(
const records: unknown[] = [
{
type: "thread-start",
role: null,
content: [
`workflow: ${workflowName ?? "unknown"}`,
`thread: ${resolved.threadId}`,
`status: ${resolved.source}`,
prompt !== null ? `\nprompt: ${prompt}` : null,
].filter(Boolean).join("\n"),
timestamp: null,
workflow: workflowName ?? "unknown",
prompt: prompt ?? null,
threadId: resolved.threadId,
bundleHash: resolved.bundleHash,
head: resolved.head,
start: resolved.start,
source: resolved.source,
status: resolved.source,
timestamp: null,
},
];
@@ -69,7 +61,7 @@ async function buildThreadDetailRecords(
const returnCode = fr.payload.meta.returnCode;
const summary = fr.payload.meta.summary;
if (typeof returnCode === "number" && typeof summary === "string") {
records.push({ type: "workflow-result", role: null, content: summary, timestamp: null, returnCode });
records.push({ type: "workflow-result", returnCode, content: summary, timestamp: fr.payload.timestamp });
}
continue;
}
+3 -1
View File
@@ -10,7 +10,9 @@
},
"dependencies": {
"react": "^19.2.6",
"react-dom": "^19.2.6"
"react-dom": "^19.2.6",
"react-markdown": "^10.1.0",
"shiki": "^4.0.2"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.4",
+24 -6
View File
@@ -52,14 +52,32 @@ export type ThreadSummary = {
status: string | null;
};
export type ThreadRecord = {
type: string;
role: string | null;
content: string | null;
timestamp: number | null;
[key: string]: unknown;
export type ThreadStartRecord = {
type: "thread-start";
workflow: string;
prompt: string | null;
threadId: string;
status: string;
timestamp: null;
};
export type RoleRecord = {
type: "role";
role: string;
content: string;
timestamp: number | null;
meta: Record<string, unknown>;
};
export type WorkflowResultRecord = {
type: "workflow-result";
returnCode: number;
content: string;
timestamp: number | null;
};
export type ThreadRecord = ThreadStartRecord | RoleRecord | WorkflowResultRecord;
// ── Gateway endpoints ───────────────────────────────────────────────
export function listAgents(): Promise<AgentEndpoint[]> {
@@ -0,0 +1,107 @@
import ReactMarkdown from "react-markdown";
import { useEffect, useState } from "react";
import { createHighlighter, type HighlighterGeneric, type BundledLanguage, type BundledTheme } from "shiki";
let highlighterPromise: Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> | null = null;
const LANGS: BundledLanguage[] = ["typescript", "javascript", "json", "yaml", "bash", "python", "markdown"];
function getHighlighter(): Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> {
if (highlighterPromise === null) {
highlighterPromise = createHighlighter({
themes: ["github-dark"],
langs: LANGS,
});
}
return highlighterPromise;
}
function CodeBlock({ className, children }: { className?: string; children?: React.ReactNode }) {
const [html, setHtml] = useState<string | null>(null);
const code = String(children).replace(/\n$/, "");
const lang = className?.replace("language-", "") ?? "text";
useEffect(() => {
let cancelled = false;
getHighlighter().then((hl) => {
if (cancelled) return;
try {
const result = hl.codeToHtml(code, { lang, theme: "github-dark" });
setHtml(result);
} catch {
setHtml(null);
}
});
return () => { cancelled = true; };
}, [code, lang]);
if (html !== null) {
return (
<div
className="rounded overflow-x-auto text-xs my-2"
// biome-ignore lint/security/noDangerouslySetInnerHtml: shiki output is safe
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
return (
<pre className="rounded overflow-x-auto text-xs my-2 p-3" style={{ background: "var(--color-bg)" }}>
<code>{code}</code>
</pre>
);
}
export function Markdown({ content }: { content: string }) {
return (
<div className="prose prose-invert prose-sm max-w-none">
<ReactMarkdown
components={{
code({ className, children, ...props }) {
const isInline = !className;
if (isInline) {
return (
<code
className="text-xs px-1 py-0.5 rounded"
style={{ background: "var(--color-border)", color: "var(--color-accent)" }}
{...props}
>
{children}
</code>
);
}
return <CodeBlock className={className}>{children}</CodeBlock>;
},
p({ children }) {
return <p className="my-1.5 leading-relaxed">{children}</p>;
},
ul({ children }) {
return <ul className="list-disc pl-4 my-1.5">{children}</ul>;
},
ol({ children }) {
return <ol className="list-decimal pl-4 my-1.5">{children}</ol>;
},
h1({ children }) {
return <h1 className="text-lg font-bold mt-3 mb-1">{children}</h1>;
},
h2({ children }) {
return <h2 className="text-base font-bold mt-2 mb-1">{children}</h2>;
},
h3({ children }) {
return <h3 className="text-sm font-bold mt-2 mb-1">{children}</h3>;
},
blockquote({ children }) {
return (
<blockquote
className="border-l-2 pl-3 my-2 text-sm"
style={{ borderColor: "var(--color-accent)", color: "var(--color-text-muted)" }}
>
{children}
</blockquote>
);
},
}}
/>
</div>
);
}
@@ -0,0 +1,128 @@
import type { ThreadStartRecord, RoleRecord, WorkflowResultRecord, ThreadRecord } from "../api.ts";
import { Markdown } from "./markdown.tsx";
const ROLE_COLORS: Record<string, string> = {
preparer: "#8b5cf6",
agent: "#3b82f6",
extractor: "#f59e0b",
};
function roleColor(role: string): string {
return ROLE_COLORS[role] ?? "var(--color-accent)";
}
function formatTime(ts: number | null): string | null {
if (ts === null) return null;
return new Date(ts).toLocaleTimeString();
}
function StartCard({ record }: { record: ThreadStartRecord }) {
return (
<div
className="p-4 rounded-lg border"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
<div className="flex items-center gap-2 mb-2">
<span className="text-lg">🚀</span>
<span className="font-semibold" style={{ color: "var(--color-accent)" }}>
{record.workflow}
</span>
<span
className="text-xs px-2 py-0.5 rounded"
style={{
background: record.status === "active" ? "var(--color-success)" : "var(--color-border)",
color: record.status === "active" ? "var(--color-bg)" : "var(--color-text-muted)",
}}
>
{record.status}
</span>
</div>
{record.prompt !== null && (
<div
className="mt-2 p-3 rounded text-sm border-l-2"
style={{
background: "var(--color-bg)",
borderColor: "var(--color-accent)",
color: "var(--color-text)",
}}
>
<div className="text-xs mb-1" style={{ color: "var(--color-text-muted)" }}>
Prompt
</div>
<Markdown content={record.prompt} />
</div>
)}
</div>
);
}
function RoleMessage({ record }: { record: RoleRecord }) {
const color = roleColor(record.role);
return (
<div
className="p-3 rounded-lg border text-sm"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
<div className="flex items-center gap-2 mb-2">
<span
className="text-xs px-2 py-0.5 rounded font-mono font-medium"
style={{ background: color, color: "#fff" }}
>
{record.role}
</span>
{formatTime(record.timestamp) !== null && (
<span className="text-xs ml-auto" style={{ color: "var(--color-text-muted)" }}>
{formatTime(record.timestamp)}
</span>
)}
</div>
<Markdown content={record.content} />
</div>
);
}
function ResultCard({ record }: { record: WorkflowResultRecord }) {
const success = record.returnCode === 0;
return (
<div
className="p-4 rounded-lg border"
style={{
background: "var(--color-surface)",
borderColor: success ? "var(--color-success)" : "var(--color-error)",
}}
>
<div className="flex items-center gap-2 mb-2">
<span className="text-lg">{success ? "✅" : "❌"}</span>
<span className="font-semibold text-sm">
{success ? "Completed" : "Failed"}
</span>
<span
className="text-xs px-2 py-0.5 rounded font-mono"
style={{
background: success ? "var(--color-success)" : "var(--color-error)",
color: "#fff",
}}
>
exit {record.returnCode}
</span>
{formatTime(record.timestamp) !== null && (
<span className="text-xs ml-auto" style={{ color: "var(--color-text-muted)" }}>
{formatTime(record.timestamp)}
</span>
)}
</div>
<Markdown content={record.content} />
</div>
);
}
export function RecordCard({ record }: { record: ThreadRecord }) {
switch (record.type) {
case "thread-start":
return <StartCard record={record} />;
case "role":
return <RoleMessage record={record} />;
case "workflow-result":
return <ResultCard record={record} />;
}
}
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { getThread, killThread, pauseThread, resumeThread } from "../api.ts";
import { useFetch } from "../hooks.ts";
import { useSSE } from "../use-sse.ts";
import { RecordCard } from "./record-card.tsx";
type Props = {
agent: string;
@@ -102,39 +103,8 @@ export function ThreadDetail({ agent, threadId, onBack }: Props) {
)}
{(status === "ok" || liveActive || records.length > 0) && (
<div className="space-y-3">
{records.map((r) => (
<div
key={`${threadId}-${r.type}-${String(r.timestamp)}-${r.role ?? ""}-${r.content ?? ""}`}
className="p-3 rounded border text-sm"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
<div className="flex items-center gap-2 mb-1">
<span
className="text-xs px-1.5 py-0.5 rounded font-mono"
style={{ background: "var(--color-border)", color: "var(--color-accent)" }}
>
{r.type}
</span>
{r.role && (
<span className="text-xs" style={{ color: "var(--color-text-muted)" }}>
{r.role}
</span>
)}
{r.timestamp !== null && (
<span className="text-xs ml-auto" style={{ color: "var(--color-text-muted)" }}>
{new Date(r.timestamp).toLocaleTimeString()}
</span>
)}
</div>
{r.content && (
<pre
className="whitespace-pre-wrap text-xs mt-1"
style={{ color: "var(--color-text)" }}
>
{typeof r.content === "string" ? r.content : JSON.stringify(r.content, null, 2)}
</pre>
)}
</div>
{records.map((r, i) => (
<RecordCard key={`${threadId}-${i}`} record={r} />
))}
<div ref={recordsEndRef} aria-hidden />
</div>