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, / { + const running = agentMessage({ status: "running", traceId: "trc_running", retryInput: "retry me" }); + assert.equal(messageTraceId(running), "trc_running"); + assert.equal(canCancelMessage(running), true); + assert.equal(canRetryMessage(running), true); + assert.equal(canReplayTrace(running), true); + + const completed = agentMessage({ status: "completed", traceId: "trc_done", retryInput: "retry me" }); + assert.equal(canCancelMessage(completed), false); + assert.equal(canRetryMessage(completed), false); + assert.equal(canReplayTrace(completed), true); + + const failed = agentMessage({ status: "failed", traceId: "trc_failed", retryInput: "retry me" }); + assert.equal(canCancelMessage(failed), false); + assert.equal(canRetryMessage(failed), true); + assert.equal(canReplayTrace(failed), true); + + const user: ChatMessage = { id: "msg_user", role: "user", title: "用户", text: "hi", status: "sent", createdAt: "2026-01-01T00:00:00.000Z", traceId: "trc_user" }; + assert.equal(canReplayTrace(user), false); +}); + +test("R1 trace rendering hides chunk noise without dropping meaningful events", () => { + const events: TraceEvent[] = [ + { label: "assistant:delta", message: "token" }, + { label: "command:completed", command: "npm test", exitCode: 0, elapsedMs: 42 }, + { type: "assistant_message", text: "final", final: true } + ]; + const compact = visibleTraceEvents(events, true); + assert.equal(compact.length, 2); + assert.equal(traceEventLabel(compact[0]!), "command:completed"); + assert.equal(traceEventBody(compact[0]!), "npm test"); + assert.equal(traceEventLabel(compact[1]!), "assistant_message"); + assert.equal(traceEventBody(compact[1]!), "final"); + assert.equal(visibleTraceEvents(events, false).length, 3); +}); + +test("R1 status summary keeps the restored 23-row floor", () => { + const message = agentMessage({ + status: "failed", + traceId: "trc_summary", + conversationId: "cnv_summary", + sessionId: "ses_summary", + threadId: "thr_summary", + runnerTrace: { + traceId: "trc_summary", + status: "failed", + eventCount: 2, + eventsCompacted: false, + fullTraceLoaded: true, + lastEventLabel: "assistant:completed", + waitingFor: "result", + runnerKind: "agentrun", + sessionMode: "long-lived", + agentRun: { + runId: "run_1", + commandId: "cmd_1", + attemptId: "att_1", + runnerId: "runner_1", + jobName: "job_1", + namespace: "hwlab-v03", + terminalStatus: "failed" + } + }, + error: { code: "provider_timeout", category: "provider", providerStatus: 504 } + }); + const values = [ + message.status, + message.runnerTrace?.status, + message.traceId, + message.conversationId, + message.sessionId, + message.threadId, + message.runnerTrace?.runnerKind, + message.runnerTrace?.sessionMode, + message.runnerTrace?.waitingFor, + message.runnerTrace?.lastEventLabel, + String(message.runnerTrace?.eventCount), + String(message.runnerTrace?.eventsCompacted), + message.runnerTrace?.fullTraceLoaded === true ? "loaded" : "unverified", + asAgentRun(message.runnerTrace?.agentRun)?.runId, + asAgentRun(message.runnerTrace?.agentRun)?.commandId, + asAgentRun(message.runnerTrace?.agentRun)?.attemptId, + asAgentRun(message.runnerTrace?.agentRun)?.runnerId, + asAgentRun(message.runnerTrace?.agentRun)?.jobName, + asAgentRun(message.runnerTrace?.agentRun)?.namespace, + asAgentRun(message.runnerTrace?.agentRun)?.terminalStatus, + message.error?.code, + message.error?.category, + String(message.error?.providerStatus) + ]; + assert.equal(values.length, 23); + assert.equal(values.every((value) => value != null && value !== ""), true); + assert.match(traceIdentityText(message), /traceId=trc_summary/u); + assert.match(traceIdentityText(message), /sessionId=ses_summary/u); +}); + +function agentMessage(patch: Partial): ChatMessage { + return { + id: "msg_agent", + role: "agent", + title: "Code Agent", + text: "done", + status: "completed", + createdAt: "2026-01-01T00:00:00.000Z", + ...patch + } as ChatMessage; +} + +function asAgentRun(value: unknown): AgentRunProvenance | null { + return value && typeof value === "object" ? value as AgentRunProvenance : null; +} diff --git a/web/hwlab-cloud-web/src/components/agent/TraceTimeline.vue b/web/hwlab-cloud-web/src/components/agent/TraceTimeline.vue index 6b416f44..f120becb 100644 --- a/web/hwlab-cloud-web/src/components/agent/TraceTimeline.vue +++ b/web/hwlab-cloud-web/src/components/agent/TraceTimeline.vue @@ -1,21 +1,68 @@ 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 @@ + + + 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 @@ 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 @@ + + + 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(/]*>[\s\S]*?<\/script>/giu, "") + .replace(/\son[a-z]+=("[^"]*"|'[^']*'|[^\s>]+)/giu, "") + .replace(/\s(href|src)=("javascript:[^"]*"|'javascript:[^']*'|javascript:[^\s>]+)/giu, ""); +} + +export function messageTraceId(message: ChatMessage): string | null { + return firstNonEmptyString(message.traceId, message.runnerTrace?.traceId); +} + +export function canCancelMessage(message: ChatMessage): boolean { + return message.role === "agent" && String(message.status ?? "").toLowerCase() === "running" && Boolean(messageTraceId(message)); +} + +export function canRetryMessage(message: ChatMessage): boolean { + if (message.role !== "agent" || !message.retryInput) return false; + return ["running", "failed", "timeout", "canceled", "cancelled", "blocked"].includes(String(message.status ?? "").toLowerCase()); +} + +export function canReplayTrace(message: ChatMessage): boolean { + return message.role === "agent" && Boolean(messageTraceId(message)); +} + +export function traceIdentityText(message: ChatMessage): string { + return [ + messageTraceId(message) ? `traceId=${messageTraceId(message)}` : null, + message.conversationId ? `conversationId=${message.conversationId}` : null, + message.sessionId ? `sessionId=${message.sessionId}` : null, + message.threadId ? `threadId=${message.threadId}` : null + ].filter((item): item is string => Boolean(item)).join("\n"); +} + +export function isNoiseTraceEvent(event: TraceEvent): boolean { + const label = String(event.label ?? event.type ?? event.kind ?? "").toLowerCase(); + return label.includes("delta") || label.includes("chunk") || label.includes("token"); +} + +export function visibleTraceEvents(events: TraceEvent[], compact: boolean): TraceEvent[] { + return compact ? events.filter((event) => !isNoiseTraceEvent(event)) : events; +} + +export function traceEventLabel(event: TraceEvent): string { + return firstNonEmptyString(event.label, event.type, event.kind) ?? "event"; +} + +export function traceEventBody(event: TraceEvent): string { + const body = firstNonEmptyString(event.message, event.text, event.outputSummary, event.stdoutSummary, event.stderrSummary, event.command, event.status); + if (body) return body; + if (event.exitCode != null) return `exitCode=${event.exitCode}`; + return "已记录"; +} + +export function traceEventMeta(event: TraceEvent): string { + return [event.ts, event.elapsedMs == null ? null : `${event.elapsedMs}ms`, event.exitCode == null ? null : `exit=${event.exitCode}`].filter((item): item is string => Boolean(item)).join(" / "); +} diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index c32111ed..fff28996 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -119,11 +119,60 @@ export const useWorkbenchStore = defineStore("workbench", () => { subscribe(traceId, response.data); } + async function cancelAgentMessage(message: ChatMessage): Promise { + const traceId = message.traceId ?? message.runnerTrace?.traceId; + if (!traceId) return; + await api.agent.cancelAgentMessage({ traceId, projectId: activeProjectId.value, sessionId: message.sessionId ?? selectedSessionId.value, threadId: message.threadId ?? selectedThreadId.value, conversationId: message.conversationId ?? activeConversationId.value }); + markMessage(traceId, { status: "canceled", text: "用户已取消该 turn。" }); + if (message.status === "running") chatPending.value = false; + } + async function cancelRunningTrace(): Promise { const traceId = messages.value.find((message) => message.status === "running" && message.traceId)?.traceId; if (!traceId) return; - await api.agent.cancelAgentMessage({ traceId, projectId: activeProjectId.value, sessionId: selectedSessionId.value }); - markMessage(traceId, { status: "canceled", text: "用户已取消该 turn。" }); + const message = messages.value.find((item) => item.traceId === traceId && item.role === "agent"); + if (message) await cancelAgentMessage(message); + } + + async function retryAgentMessage(message: ChatMessage): Promise { + const retryInput = firstNonEmptyString(message.retryInput, messages.value.find((item) => item.role === "user" && item.traceId === message.traceId)?.text); + if (!retryInput) { + if (message.traceId) markMessage(message.traceId, { traceReplayStatus: "没有可重试的上一条输入。" }); + return; + } + await submitMessage(retryInput); + } + + async function replayAgentTrace(message: ChatMessage): Promise { + const traceId = message.traceId ?? message.runnerTrace?.traceId; + if (!traceId) return; + markMessage(traceId, { traceReplayStatus: "Trace 回放中" }); + const result = await api.agent.getAgentChatResult(`/v1/agent/chat/trace/${encodeURIComponent(traceId)}?projectId=${encodeURIComponent(activeProjectId.value)}`, codeAgentTimeoutMs.value, () => activityRef.value); + if (!result.ok || !result.data) { + markMessage(traceId, { traceReplayStatus: result.error ?? "Trace 回放失败" }); + return; + } + const events = Array.isArray(result.data.events) ? result.data.events : Array.isArray(result.data.traceEvents) ? result.data.traceEvents : []; + const runnerTrace = mergeRunnerTrace(message.runnerTrace, { + ...(result.data.runnerTrace ?? {}), + traceId: result.data.traceId ?? traceId, + status: result.data.status ?? result.data.traceStatus ?? message.runnerTrace?.status, + sessionId: result.data.sessionId ?? message.runnerTrace?.sessionId, + threadId: result.data.threadId ?? message.runnerTrace?.threadId, + events, + eventCount: result.data.eventCount ?? events.length, + eventsCompacted: result.data.runnerTrace?.eventsCompacted ?? false, + fullTraceLoaded: true, + traceStatus: result.data.traceStatus, + retention: result.data.retention, + terminalEvidence: result.data.terminalEvidence, + finalResponse: result.data.finalResponse, + traceSummary: result.data.traceSummary, + agentRun: result.data.agentRun, + lastEventLabel: result.data.lastEventLabel ?? events.at(-1)?.label ?? events.at(-1)?.type, + updatedAt: new Date().toISOString() + }); + markMessage(traceId, { runnerTrace, traceReplayStatus: "Trace 已回放" }); } function setProviderProfile(value: ProviderProfile): void { @@ -170,7 +219,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { return workspace.value; } - return { projectId, workspace, conversations, messages, providerProfile, codeAgentTimeoutMs, gatewayShellTimeoutMs, live, loading, chatPending, error, activeConversationId, selectedSessionId, selectedThreadId, sessionTabs, composer, hydrate, refreshLive, createSession, selectConversation, refreshConversations, submitMessage, cancelRunningTrace, setProviderProfile, recordActivity }; + return { projectId, workspace, conversations, messages, providerProfile, codeAgentTimeoutMs, gatewayShellTimeoutMs, live, loading, chatPending, error, activeConversationId, selectedSessionId, selectedThreadId, sessionTabs, composer, hydrate, refreshLive, createSession, selectConversation, refreshConversations, submitMessage, cancelAgentMessage, cancelRunningTrace, retryAgentMessage, replayAgentTrace, setProviderProfile, recordActivity }; }); function messagesFromWorkspace(workspace: WorkspaceRecord | null): ChatMessage[] { diff --git a/web/hwlab-cloud-web/src/styles/workbench.css b/web/hwlab-cloud-web/src/styles/workbench.css index 673aea4f..bbacc227 100644 --- a/web/hwlab-cloud-web/src/styles/workbench.css +++ b/web/hwlab-cloud-web/src/styles/workbench.css @@ -304,13 +304,61 @@ gap: 10px; } -.message-text { +.message-text, +.message-markdown { margin: 0; - white-space: pre-wrap; color: #1f2937; line-height: 1.55; } +.message-markdown { + display: grid; + gap: 8px; + min-width: 0; +} + +.message-markdown > * { + margin: 0; +} + +.message-markdown ul, +.message-markdown ol { + display: grid; + gap: 4px; + padding-left: 20px; +} + +.message-markdown pre, +.message-markdown code { + border-radius: 6px; + background: #0f172a; + color: #dbeafe; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; +} + +.message-markdown pre { + max-width: 100%; + overflow: auto; + padding: 10px; +} + +.message-markdown code { + padding: 2px 4px; +} + +.message-markdown pre code { + padding: 0; + background: transparent; +} + +.message-markdown a { + color: #0e7490; + font-weight: 700; + text-decoration: underline; + text-underline-offset: 2px; +} + .trace-timeline { display: grid; gap: 8px; @@ -319,6 +367,57 @@ padding: 10px; } +.trace-toolbar { + flex-wrap: wrap; +} + +.trace-toolbar strong, +.trace-id { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.trace-id { + max-width: 220px; + color: #475569; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; +} + +.trace-toggle { + min-height: 28px; + border: 1px solid #cbd5e1; + border-radius: 8px; + background: white; + color: #334155; + padding: 0 9px; + font-size: 12px; + font-weight: 700; +} + +.trace-warning, +.trace-empty { + margin: 0; + border-radius: 8px; + padding: 8px 10px; + font-size: 12px; + line-height: 1.45; +} + +.trace-warning { + border: 1px solid #fde68a; + background: #fffbeb; + color: #92400e; +} + +.trace-empty { + border: 1px solid #d8e1eb; + background: white; + color: #64748b; +} + .trace-timeline ol { display: grid; max-height: 170px; @@ -331,12 +430,89 @@ .trace-timeline li { display: grid; - grid-template-columns: minmax(96px, 160px) minmax(0, 1fr); + grid-template-columns: minmax(96px, 160px) minmax(0, 1fr) auto; gap: 8px; color: #475569; font-size: 12px; } +.trace-timeline li span, +.trace-timeline li small { + min-width: 0; + overflow-wrap: anywhere; +} + +.message-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + border-top: 1px solid #e2e8f0; + padding-top: 10px; +} + +.message-actions a { + text-decoration: none; +} + +.message-action-status { + color: #64748b; + font-size: 12px; + font-weight: 700; +} + +.code-agent-status-summary { + border: 1px solid #d8e1eb; + border-radius: 8px; + background: #f8fafc; +} + +.code-agent-status-summary summary { + display: flex; + cursor: pointer; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 9px 10px; + color: #334155; + font-size: 12px; + font-weight: 800; +} + +.code-agent-status-summary dl { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0; + margin: 0; + border-top: 1px solid #d8e1eb; +} + +.code-agent-status-row { + display: grid; + grid-template-columns: minmax(110px, 0.8fr) minmax(0, 1.2fr); + gap: 8px; + border-bottom: 1px solid #e2e8f0; + padding: 7px 10px; + font-size: 12px; +} + +.code-agent-status-row dt, +.code-agent-status-row dd { + min-width: 0; + margin: 0; +} + +.code-agent-status-row dt { + color: #64748b; + font-weight: 700; +} + +.code-agent-status-row dd { + overflow-wrap: anywhere; + color: #1f2937; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + .trace-timeline code, .mini-log, .json-panel, diff --git a/web/hwlab-cloud-web/src/types/index.ts b/web/hwlab-cloud-web/src/types/index.ts index f6b22631..a2f6487d 100644 --- a/web/hwlab-cloud-web/src/types/index.ts +++ b/web/hwlab-cloud-web/src/types/index.ts @@ -162,6 +162,7 @@ export interface ChatMessage { sessionId?: string | null; threadId?: string | null; retryInput?: string | null; + traceReplayStatus?: string | null; runnerTrace?: RunnerTrace | null; traceEvents?: TraceEvent[]; error?: { code?: string; message?: string; category?: string; providerStatus?: number; [key: string]: unknown } | null;