From d96e10b0fce6719cb62c90f3684fac94fd925422 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Sat, 9 May 2026 10:41:13 +0000 Subject: [PATCH] feat(dashboard): structured record rendering with markdown support (#169) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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
 blocks with proper structured rendering

Refs #169
小橘 
---
 .../src/commands/serve/routes-live.ts         |   2 +-
 .../src/commands/serve/routes-thread.ts       |  18 +--
 packages/workflow-dashboard/package.json      |   4 +-
 packages/workflow-dashboard/src/api.ts        |  30 +++-
 .../src/components/markdown.tsx               | 107 +++++++++++++++
 .../src/components/record-card.tsx            | 128 ++++++++++++++++++
 .../src/components/thread-detail.tsx          |  36 +----
 7 files changed, 271 insertions(+), 54 deletions(-)
 create mode 100644 packages/workflow-dashboard/src/components/markdown.tsx
 create mode 100644 packages/workflow-dashboard/src/components/record-card.tsx

diff --git a/packages/cli-workflow/src/commands/serve/routes-live.ts b/packages/cli-workflow/src/commands/serve/routes-live.ts
index 3b1ab04..47bb134 100644
--- a/packages/cli-workflow/src/commands/serve/routes-live.ts
+++ b/packages/cli-workflow/src/commands/serve/routes-live.ts
@@ -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;
diff --git a/packages/cli-workflow/src/commands/serve/routes-thread.ts b/packages/cli-workflow/src/commands/serve/routes-thread.ts
index fdd9826..e2955f1 100644
--- a/packages/cli-workflow/src/commands/serve/routes-thread.ts
+++ b/packages/cli-workflow/src/commands/serve/routes-thread.ts
@@ -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;
     }
diff --git a/packages/workflow-dashboard/package.json b/packages/workflow-dashboard/package.json
index d0bdfc7..4a1d32a 100644
--- a/packages/workflow-dashboard/package.json
+++ b/packages/workflow-dashboard/package.json
@@ -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",
diff --git a/packages/workflow-dashboard/src/api.ts b/packages/workflow-dashboard/src/api.ts
index 4310989..1d1703a 100644
--- a/packages/workflow-dashboard/src/api.ts
+++ b/packages/workflow-dashboard/src/api.ts
@@ -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;
+};
+
+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 {
diff --git a/packages/workflow-dashboard/src/components/markdown.tsx b/packages/workflow-dashboard/src/components/markdown.tsx
new file mode 100644
index 0000000..453da12
--- /dev/null
+++ b/packages/workflow-dashboard/src/components/markdown.tsx
@@ -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> | null = null;
+
+const LANGS: BundledLanguage[] = ["typescript", "javascript", "json", "yaml", "bash", "python", "markdown"];
+
+function getHighlighter(): Promise> {
+  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(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 (
+      
+ ); + } + + return ( +
+      {code}
+    
+ ); +} + +export function Markdown({ content }: { content: string }) { + return ( +
+ + {children} + + ); + } + return {children}; + }, + p({ children }) { + return

{children}

; + }, + ul({ children }) { + return
    {children}
; + }, + ol({ children }) { + return
    {children}
; + }, + h1({ children }) { + return

{children}

; + }, + h2({ children }) { + return

{children}

; + }, + h3({ children }) { + return

{children}

; + }, + blockquote({ children }) { + return ( +
+ {children} +
+ ); + }, + }} + /> +
+ ); +} diff --git a/packages/workflow-dashboard/src/components/record-card.tsx b/packages/workflow-dashboard/src/components/record-card.tsx new file mode 100644 index 0000000..cb966fc --- /dev/null +++ b/packages/workflow-dashboard/src/components/record-card.tsx @@ -0,0 +1,128 @@ +import type { ThreadStartRecord, RoleRecord, WorkflowResultRecord, ThreadRecord } from "../api.ts"; +import { Markdown } from "./markdown.tsx"; + +const ROLE_COLORS: Record = { + 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 ( +
+
+ 🚀 + + {record.workflow} + + + {record.status} + +
+ {record.prompt !== null && ( +
+
+ Prompt +
+ +
+ )} +
+ ); +} + +function RoleMessage({ record }: { record: RoleRecord }) { + const color = roleColor(record.role); + return ( +
+
+ + {record.role} + + {formatTime(record.timestamp) !== null && ( + + {formatTime(record.timestamp)} + + )} +
+ +
+ ); +} + +function ResultCard({ record }: { record: WorkflowResultRecord }) { + const success = record.returnCode === 0; + return ( +
+
+ {success ? "✅" : "❌"} + + {success ? "Completed" : "Failed"} + + + exit {record.returnCode} + + {formatTime(record.timestamp) !== null && ( + + {formatTime(record.timestamp)} + + )} +
+ +
+ ); +} + +export function RecordCard({ record }: { record: ThreadRecord }) { + switch (record.type) { + case "thread-start": + return ; + case "role": + return ; + case "workflow-result": + return ; + } +} diff --git a/packages/workflow-dashboard/src/components/thread-detail.tsx b/packages/workflow-dashboard/src/components/thread-detail.tsx index 9722832..cb3f516 100644 --- a/packages/workflow-dashboard/src/components/thread-detail.tsx +++ b/packages/workflow-dashboard/src/components/thread-detail.tsx @@ -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) && (
- {records.map((r) => ( -
-
- - {r.type} - - {r.role && ( - - {r.role} - - )} - {r.timestamp !== null && ( - - {new Date(r.timestamp).toLocaleTimeString()} - - )} -
- {r.content && ( -
-                  {typeof r.content === "string" ? r.content : JSON.stringify(r.content, null, 2)}
-                
- )} -
+ {records.map((r, i) => ( + ))}