diff --git a/web/hwlab-cloud-web/scripts/workbench-r1-parity.test.ts b/web/hwlab-cloud-web/scripts/workbench-r1-parity.test.ts
new file mode 100644
index 00000000..0dd95ee6
--- /dev/null
+++ b/web/hwlab-cloud-web/scripts/workbench-r1-parity.test.ts
@@ -0,0 +1,125 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import type { AgentRunProvenance, ChatMessage, TraceEvent } from "../src/types/index.ts";
+import { canCancelMessage, canReplayTrace, canRetryMessage, messageTraceId, renderSafeMarkdown, traceEventBody, traceEventLabel, traceIdentityText, visibleTraceEvents } from "../src/components/workbench/message-rendering.ts";
+
+test("R1 markdown rendering keeps structure and strips unsafe HTML", () => {
+ const html = renderSafeMarkdown("# 标题\n\n- 项目\n\n\n\n[link](https://example.com)");
+ assert.match(html, /
标题<\/h1>/u);
+ assert.match(html, /
项目<\/li>/u);
+ assert.match(html, /href="https:\/\/example.com"/u);
+ assert.doesNotMatch(html, /
-
-
+
+
-
- -
-
{{ event.label || event.type || event.kind || "event" }}
- {{ event.message || event.text || event.outputSummary || event.status || "已记录" }}
+ 当前 result 返回被压缩,已通过 trace replay 入口请求完整事件;完成前不要把压缩窗口当完整 trace。
+ 已聚合隐藏 {{ noiseCount }} 条 chunk/delta/token 噪声事件。
+
+ -
+
{{ traceEventLabel(event) }}
+ {{ traceEventBody(event) }}
+ {{ traceEventMeta(event) }}
+ trace={{ trace.traceId || "pending" }};等待后端事件。
diff --git a/web/hwlab-cloud-web/src/components/workbench/CodeAgentStatusSummary.vue b/web/hwlab-cloud-web/src/components/workbench/CodeAgentStatusSummary.vue
new file mode 100644
index 00000000..ea6a95a2
--- /dev/null
+++ b/web/hwlab-cloud-web/src/components/workbench/CodeAgentStatusSummary.vue
@@ -0,0 +1,58 @@
+
+
+
+
+
+ Code Agent 状态摘要
+
+
+
+
+
- {{ row.label }}
+ - {{ row.value }}
+
+
+
+
diff --git a/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue b/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue
index 3a98f662..898e4f52 100644
--- a/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue
+++ b/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue
@@ -1,9 +1,16 @@
@@ -13,12 +20,20 @@ const workbench = useWorkbenchStore();
{{ message.title }}
- {{ message.text }}
+
+
+ clipboard.copy(traceIdentityText(target))"
+ />
- 请先显式新建或选择 session。Code Agent 不会自动滚动新 session 掩盖失败。
+
diff --git a/web/hwlab-cloud-web/src/components/workbench/MessageActions.vue b/web/hwlab-cloud-web/src/components/workbench/MessageActions.vue
new file mode 100644
index 00000000..37e0952b
--- /dev/null
+++ b/web/hwlab-cloud-web/src/components/workbench/MessageActions.vue
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
打开 Trace
+
{{ message.traceReplayStatus }}
+
+
diff --git a/web/hwlab-cloud-web/src/components/workbench/MessageMarkdown.vue b/web/hwlab-cloud-web/src/components/workbench/MessageMarkdown.vue
new file mode 100644
index 00000000..51e213de
--- /dev/null
+++ b/web/hwlab-cloud-web/src/components/workbench/MessageMarkdown.vue
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/web/hwlab-cloud-web/src/components/workbench/message-rendering.ts b/web/hwlab-cloud-web/src/components/workbench/message-rendering.ts
new file mode 100644
index 00000000..4e98547a
--- /dev/null
+++ b/web/hwlab-cloud-web/src/components/workbench/message-rendering.ts
@@ -0,0 +1,100 @@
+import createDOMPurify from "dompurify";
+import { marked } from "marked";
+import type { ChatMessage, TraceEvent } from "@/types";
+import { firstNonEmptyString } from "@/utils";
+
+marked.setOptions({ async: false, breaks: true, gfm: true });
+
+export function renderSafeMarkdown(source: string | null | undefined): string {
+ const text = source?.trim();
+ if (!text) return "";
+ const rendered = marked.parse(text) as string;
+ return sanitizeHtml(rendered, {
+ ADD_ATTR: ["target", "rel"],
+ USE_PROFILES: { html: true }
+ });
+}
+
+interface HtmlSanitizeConfig {
+ ADD_ATTR?: string[];
+ USE_PROFILES?: { html?: boolean };
+}
+
+interface HtmlSanitizer {
+ sanitize(dirty: string, config?: HtmlSanitizeConfig): string;
+}
+
+type DomPurifyFactory = (targetWindow: Window) => HtmlSanitizer;
+type DomPurifyExport = HtmlSanitizer | DomPurifyFactory;
+
+function sanitizeHtml(html: string, config: HtmlSanitizeConfig): string {
+ const purifierExport = createDOMPurify as unknown as DomPurifyExport;
+ if (hasSanitize(purifierExport)) return purifierExport.sanitize(html, config);
+ if (typeof window !== "undefined" && typeof purifierExport === "function") {
+ const purifier = purifierExport(window);
+ if (hasSanitize(purifier)) return purifier.sanitize(html, config);
+ }
+ return conservativeSanitize(html);
+}
+
+function hasSanitize(value: unknown): value is HtmlSanitizer {
+ if (typeof value !== "function" && (value === null || typeof value !== "object")) return false;
+ return typeof (value as { sanitize?: unknown }).sanitize === "function";
+}
+
+function conservativeSanitize(html: string): string {
+ return html
+ .replace(/