diff --git a/tools/src/hwlab-cli/trace-renderer.ts b/tools/src/hwlab-cli/trace-renderer.ts index a9e77d66..e1e03769 100644 --- a/tools/src/hwlab-cli/trace-renderer.ts +++ b/tools/src/hwlab-cli/trace-renderer.ts @@ -10,8 +10,13 @@ export interface TraceEventRow { } type TraceEvent = Record; +type TraceClockFormatter = (value: string) => string; -export function traceDisplayRows(trace: Record = {}, events: TraceEvent[] = []): TraceEventRow[] { +export interface TraceDisplayRowsOptions { + formatClock?: TraceClockFormatter; +} + +export function traceDisplayRows(trace: Record = {}, events: TraceEvent[] = [], options: TraceDisplayRowsOptions = {}): TraceEventRow[] { const effectiveTrace = traceWithInferredStart(trace, events); const rows: TraceEventRow[] = []; const renderedSourceEvents = new Set(); @@ -31,7 +36,7 @@ export function traceDisplayRows(trace: Record = {}, events: Tr if (renderedToolIdentities.has(identity)) continue; renderedToolIdentities.add(identity); } - rows.push(traceToolCallRow(effectiveTrace, event)); + rows.push(traceToolCallRow(effectiveTrace, event, options)); continue; } if (isRequestTraceEvent(event)) { @@ -41,25 +46,25 @@ export function traceDisplayRows(trace: Record = {}, events: Tr continue; } if (isTerminalAssistantTraceEvent(event)) { - rows.push(traceAssistantMessageRow(effectiveTrace, event, { terminal: true })); + rows.push(traceAssistantMessageRow(effectiveTrace, event, { terminal: true }, options)); continue; } if (isAssistantTraceEvent(event)) { - rows.push(traceAssistantMessageRow(effectiveTrace, event, { terminal: false })); + rows.push(traceAssistantMessageRow(effectiveTrace, event, { terminal: false }, options)); continue; } if (isCompletionTraceEvent(event)) { const finalResponseText = traceFinalResponseText(effectiveTrace); - rows.push(finalResponseText ? traceFinalResponseRow(event, finalResponseText) : traceCompletionSummaryRow(effectiveTrace, event)); + rows.push(finalResponseText ? traceFinalResponseRow(event, finalResponseText, options) : traceCompletionSummaryRow(effectiveTrace, event, options)); continue; } - rows.push(traceDisplayRow(effectiveTrace, event)); + rows.push(traceDisplayRow(effectiveTrace, event, options)); } - const progressRow = traceBackendProgressSummaryRow(effectiveTrace, rows, events); + const progressRow = traceBackendProgressSummaryRow(effectiveTrace, rows, events, options); if (progressRow) rows.push(progressRow); if (rows.length > 0) return rows; if (events.length === 0) return []; - return events.filter((event) => !isSuppressedTraceEvent(event)).map((event) => traceDisplayRow(effectiveTrace, event)); + return events.filter((event) => !isSuppressedTraceEvent(event)).map((event) => traceDisplayRow(effectiveTrace, event, options)); } export function renderTraceRowsMarkdown(rows: TraceEventRow[] = []): string { @@ -100,7 +105,7 @@ function stripTraceToolOutputFromSummary(value: string): string { return value.replace(/\s+(stdout:|stderr:|exitCode=).*$/isu, "").trim(); } -function traceToolCallRow(trace: Record, event: TraceEvent): TraceEventRow { +function traceToolCallRow(trace: Record, event: TraceEvent, options: TraceDisplayRowsOptions): TraceEventRow { const command = cleanShellCommand(event.command); const status = traceStatusToken(event); const toolName = traceToolName(event, command); @@ -109,7 +114,7 @@ function traceToolCallRow(trace: Record, event: TraceEvent): Tr rowId: `tool:${event.itemId ?? event.seq ?? `${event.label ?? event.type ?? "tool"}:${event.createdAt ?? "unknown"}`}`, seq: numberOrNull(event.seq), tone: traceEventTone(event), - header: `${traceEventClock(event)} total=${formatTraceDuration(traceRelativeMs(trace, event))} ${status} ${toolName}`.trim(), + header: `${traceEventClock(event, options)} total=${formatTraceDuration(traceRelativeMs(trace, event))} ${status} ${toolName}`.trim(), body, preview: command ? compactTraceOneLine(command, 2000) : null, bodyFormat: "text" @@ -171,7 +176,7 @@ export function traceNoiseEventCount(events: TraceEvent[] = []): number { return Array.isArray(events) ? events.filter((event) => isNoisyTraceEvent(event)).length : 0; } -function traceAssistantMessageRow(trace: Record, event: TraceEvent, { terminal }: { terminal: boolean }): TraceEventRow { +function traceAssistantMessageRow(trace: Record, event: TraceEvent, { terminal }: { terminal: boolean }, options: TraceDisplayRowsOptions): TraceEventRow { const text = cleanTraceDetailText(event.message ?? event.outputSummary ?? assistantStreamText(trace, event) ?? ""); const index = Number.isInteger(event.messageIndex) ? Number(event.messageIndex) : null; const count = Number.isInteger(event.messageCount) ? Number(event.messageCount) : null; @@ -179,19 +184,19 @@ function traceAssistantMessageRow(trace: Record, event: TraceEv rowId: `event:${event.seq ?? `${event.label ?? event.type ?? "assistant"}:${event.createdAt ?? "unknown"}`}`, seq: numberOrNull(event.seq), tone: "ok", - header: `${traceEventClock(event)} ${terminal ? "助手最终消息" : "助手消息"}${index ? ` ${index}${count ? `/${count}` : ""}` : ""}`, + header: `${traceEventClock(event, options)} ${terminal ? "助手最终消息" : "助手消息"}${index ? ` ${index}${count ? `/${count}` : ""}` : ""}`, terminal: terminal ? true : undefined, body: text || null, bodyFormat: "markdown" }; } -function traceFinalResponseRow(event: TraceEvent, finalText: string): TraceEventRow { +function traceFinalResponseRow(event: TraceEvent, finalText: string, options: TraceDisplayRowsOptions): TraceEventRow { return { rowId: `trace-final-response:${event.seq ?? "completed"}`, seq: numberOrNull(event.seq), tone: "ok", - header: `${traceEventClock(event)} 助手最终消息`, + header: `${traceEventClock(event, options)} 助手最终消息`, terminal: true, body: finalText, bodyFormat: "markdown" @@ -203,17 +208,17 @@ function traceFinalResponseText(trace: Record): string | null { return nonEmptyString(response?.text ?? response?.content ?? response?.message); } -function traceCompletionSummaryRow(trace: Record, event: TraceEvent): TraceEventRow { +function traceCompletionSummaryRow(trace: Record, event: TraceEvent, options: TraceDisplayRowsOptions): TraceEventRow { return { rowId: `trace-completion:${event.seq ?? "turn"}`, seq: numberOrNull(event.seq), tone: traceEventTone(event), - header: `${traceEventClock(event)} 轮次完成(总耗时 ${formatTraceDuration(traceRelativeMs(trace, event))})`, + header: `${traceEventClock(event, options)} 轮次完成(总耗时 ${formatTraceDuration(traceRelativeMs(trace, event))})`, body: null }; } -function traceNoiseSummaryRow(trace: Record, events: TraceEvent[]): TraceEventRow { +function traceNoiseSummaryRow(trace: Record, events: TraceEvent[], options: TraceDisplayRowsOptions): TraceEventRow { const lastEvent = events.at(-1); const status = nonEmptyString(trace.status ?? trace.traceStatus ?? lastEvent?.status) ?? "running"; const lastLabel = lastEvent ? readableTraceLabel(lastEvent) : "未观测"; @@ -221,13 +226,13 @@ function traceNoiseSummaryRow(trace: Record, events: TraceEvent rowId: "trace-noise-summary", seq: numberOrNull(lastEvent?.seq), tone: lastEvent ? traceEventTone(lastEvent) : "source", - header: `${traceEventClock(lastEvent)} Trace ${status},等待可读事件`, + header: `${traceEventClock(lastEvent, options)} Trace ${status},等待可读事件`, body: `已隐藏 ${events.length} 条 AgentRun backend 状态事件。最新原始事件:${lastLabel}。`, bodyFormat: "text" }; } -function traceBackendProgressSummaryRow(trace: Record, rows: TraceEventRow[], events: TraceEvent[]): TraceEventRow | null { +function traceBackendProgressSummaryRow(trace: Record, rows: TraceEventRow[], events: TraceEvent[], options: TraceDisplayRowsOptions): TraceEventRow | null { if (!Array.isArray(events) || events.length === 0) return null; if (rows.some((row) => row.rowId.startsWith("tool:") || /助手|轮次完成/u.test(row.header))) return null; const lastRenderedSeq = Math.max(0, ...rows.map((row) => row.seq ?? 0).filter(Number.isFinite)); @@ -235,16 +240,16 @@ function traceBackendProgressSummaryRow(trace: Record, rows: Tr const seq = numberOrNull(event.seq) ?? 0; return seq > lastRenderedSeq && (isRequestTraceEvent(event) || isSetupTraceEvent(event) || isNoisyTraceEvent(event)); }); - return hiddenProgress.length > 0 ? traceNoiseSummaryRow(trace, hiddenProgress) : null; + return hiddenProgress.length > 0 ? traceNoiseSummaryRow(trace, hiddenProgress, options) : null; } -function traceDisplayRow(trace: Record, event: TraceEvent): TraceEventRow { +function traceDisplayRow(trace: Record, event: TraceEvent, options: TraceDisplayRowsOptions): TraceEventRow { const label = readableTraceLabel(event); return { rowId: `event:${event.seq ?? `${event.label ?? event.type ?? "event"}:${event.createdAt ?? "unknown"}`}`, seq: numberOrNull(event.seq), tone: traceEventTone(event), - header: `${traceEventClock(event)} total=${formatTraceDuration(traceRelativeMs(trace, event))} ${traceStatusToken(event)} ${label}`.trim(), + header: `${traceEventClock(event, options)} total=${formatTraceDuration(traceRelativeMs(trace, event))} ${traceStatusToken(event)} ${label}`.trim(), body: traceDisplayBody(event) }; } @@ -437,13 +442,15 @@ function traceEventTone(event: TraceEvent): TraceEventRow["tone"] { return "source"; } -function traceClock(value: unknown): string { +function traceClock(value: unknown, options: TraceDisplayRowsOptions): string { + const timestamp = nonEmptyString(value); + if (timestamp && options.formatClock) return options.formatClock(timestamp); const date = new Date(String(value ?? "")); return Number.isNaN(date.getTime()) ? "--:--:--" : date.toISOString().slice(11, 19); } -function traceEventClock(event: TraceEvent | null | undefined): string { - return traceClock(traceEventTimestamp(event)); +function traceEventClock(event: TraceEvent | null | undefined, options: TraceDisplayRowsOptions): string { + return traceClock(traceEventTimestamp(event), options); } function traceWithInferredStart(trace: Record, events: TraceEvent[]): Record { diff --git a/web/hwlab-cloud-web/scripts/workbench-r1-parity.test.ts b/web/hwlab-cloud-web/scripts/workbench-r1-parity.test.ts index 844e2c41..5ebc26e5 100644 --- a/web/hwlab-cloud-web/scripts/workbench-r1-parity.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-r1-parity.test.ts @@ -104,6 +104,16 @@ test("R1 shared trace renderer keeps completion final response at completion eve assert.equal(rows.some((row) => row.seq === 1 && row.terminal === true), false); }); +test("R1 shared trace renderer keeps UTC by default and lets Web supply display clock", () => { + const events = [{ seq: 1, label: "item/commandExecution:completed", type: "tool_call", toolName: "commandExecution", status: "completed", command: "date", createdAt: "2026-06-17T06:34:14.586Z" }]; + const defaultRows = traceDisplayRows({ traceId: "trc_clock" }, events); + const displayRows = traceDisplayRows({ traceId: "trc_clock" }, events, { + formatClock: (value) => new Intl.DateTimeFormat("zh-CN", { timeZone: "Asia/Shanghai", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }).format(new Date(value)) + }); + assert.match(defaultRows[0]?.header ?? "", /^06:34:14 /u); + assert.match(displayRows[0]?.header ?? "", /^14:34:14 /u); +}); + test("R1 trace API snapshots stay authoritative over compact result traces", () => { const previous = { traceId: "trc_merge", diff --git a/web/hwlab-cloud-web/scripts/workbench-r3-live-hwpod-parity.test.ts b/web/hwlab-cloud-web/scripts/workbench-r3-live-hwpod-parity.test.ts index 1ede8493..9312901f 100644 --- a/web/hwlab-cloud-web/scripts/workbench-r3-live-hwpod-parity.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-r3-live-hwpod-parity.test.ts @@ -5,6 +5,15 @@ import type { ApiResult, HwpodNodeOpsResponse, HwpodSpecsResponse, LiveProbePayl import { summarizeBuilds, summarizeHwpodNodeOps, summarizeProbeRows } from "../src/stores/workbench-live.ts"; import { liveCall } from "../src/stores/workbench.ts"; +Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + HWLAB_CLOUD_WEB_CONFIG: { + displayTime: { timeZone: "Asia/Shanghai", locale: "zh-CN", label: "北京时间" } + } + } +}); + test("R3 probe summary exposes React WorkbenchProbe rows", () => { const summary = summarizeProbeRows(liveSurface()); assert.equal(summary.rows.length, 3); diff --git a/web/hwlab-cloud-web/src/components/agent/TraceTimeline.vue b/web/hwlab-cloud-web/src/components/agent/TraceTimeline.vue index a4fc2faa..54f46793 100644 --- a/web/hwlab-cloud-web/src/components/agent/TraceTimeline.vue +++ b/web/hwlab-cloud-web/src/components/agent/TraceTimeline.vue @@ -4,6 +4,7 @@ import type { RunnerTrace } from "@/types"; import LoadingState from "@/components/common/LoadingState.vue"; import MessageMarkdown from "@/components/workbench/MessageMarkdown.vue"; import { useBottomFollowScroll } from "@/composables/useBottomFollowScroll"; +import { formatDisplayClock } from "@/config/runtime"; import { traceDisplayRows, type TraceEventRow } from "../../../../../tools/src/hwlab-cli/trace-renderer.ts"; const props = defineProps<{ trace?: RunnerTrace | null; defaultExpanded?: boolean; storageKey?: string; autoExpanded?: boolean | null }>(); @@ -14,7 +15,7 @@ const { following, keepBottomAfterUpdate, onScroll, scrollToBottom } = useBottom const events = computed(() => Array.isArray(props.trace?.events) ? props.trace.events : []); const eventCount = computed(() => props.trace?.eventCount ?? events.value.length); -const readableRows = computed(() => traceDisplayRows(traceRecord(props.trace), events.value as Record[])); +const readableRows = computed(() => traceDisplayRows(traceRecord(props.trace), events.value as Record[], { formatClock: formatDisplayClock })); const projectionDiagnosticLabel = computed(() => { const projection = props.trace?.projection ?? null; const health = projection?.projectionHealth ?? props.trace?.projectionHealth; diff --git a/web/hwlab-cloud-web/src/components/workbench/MessageTraceDebugPanel.vue b/web/hwlab-cloud-web/src/components/workbench/MessageTraceDebugPanel.vue index 4b953ed8..3d71da13 100644 --- a/web/hwlab-cloud-web/src/components/workbench/MessageTraceDebugPanel.vue +++ b/web/hwlab-cloud-web/src/components/workbench/MessageTraceDebugPanel.vue @@ -3,6 +3,7 @@ import { computed, ref, watch } from "vue"; import type { ChatMessage, RunnerTrace, TraceEvent } from "@/types"; import { firstNonEmptyString } from "@/utils"; import { useBottomFollowScroll } from "@/composables/useBottomFollowScroll"; +import { formatDisplayClock } from "@/config/runtime"; import { traceEventBody, traceEventLabel, traceEventMeta } from "@/components/workbench/message-rendering"; import { traceDisplayRows, traceNoiseEventCount } from "../../../../../tools/src/hwlab-cli/trace-renderer.ts"; @@ -15,7 +16,7 @@ const { following, keepBottomAfterUpdate, onScroll, scrollToBottom, toggleFollow const trace = computed(() => props.message.runnerTrace ?? null); const events = computed(() => Array.isArray(trace.value?.events) ? trace.value.events : []); const eventCount = computed(() => trace.value?.eventCount ?? events.value.length); -const readableRows = computed(() => traceDisplayRows(traceRecord(trace.value), events.value as Record[])); +const readableRows = computed(() => traceDisplayRows(traceRecord(trace.value), events.value as Record[], { formatClock: formatDisplayClock })); const noiseCount = computed(() => traceNoiseEventCount(events.value as Record[])); const lastEvent = computed(() => events.value.at(-1) ?? null); const lastReadableRow = computed(() => readableRows.value.at(-1) ?? null); diff --git a/web/hwlab-cloud-web/src/config/runtime.ts b/web/hwlab-cloud-web/src/config/runtime.ts index 23f5f110..3062a789 100644 --- a/web/hwlab-cloud-web/src/config/runtime.ts +++ b/web/hwlab-cloud-web/src/config/runtime.ts @@ -38,6 +38,10 @@ export function formatDisplayDateTime(value: string | Date, options: Intl.DateTi return new Intl.DateTimeFormat(config.locale, { hour12: false, ...dateTimeOptions, timeZone: config.timeZone }).format(date); } +export function formatDisplayClock(value: string | Date): string { + return formatDisplayDateTime(value, { hour: "2-digit", minute: "2-digit", second: "2-digit" }); +} + function requiredDisplayTimeField(value: unknown, field: string): string { if (typeof value !== "string" || value.trim().length === 0) { throw new Error(`HWLAB Cloud Web displayTime.${field} is required`);