fix: render workbench trace clocks with display timezone

This commit is contained in:
lyon
2026-06-20 14:07:16 +08:00
parent 5124713374
commit 766f41d415
6 changed files with 59 additions and 27 deletions
+32 -25
View File
@@ -10,8 +10,13 @@ export interface TraceEventRow {
}
type TraceEvent = Record<string, unknown>;
type TraceClockFormatter = (value: string) => string;
export function traceDisplayRows(trace: Record<string, unknown> = {}, events: TraceEvent[] = []): TraceEventRow[] {
export interface TraceDisplayRowsOptions {
formatClock?: TraceClockFormatter;
}
export function traceDisplayRows(trace: Record<string, unknown> = {}, events: TraceEvent[] = [], options: TraceDisplayRowsOptions = {}): TraceEventRow[] {
const effectiveTrace = traceWithInferredStart(trace, events);
const rows: TraceEventRow[] = [];
const renderedSourceEvents = new Set<string>();
@@ -31,7 +36,7 @@ export function traceDisplayRows(trace: Record<string, unknown> = {}, 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<string, unknown> = {}, 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<string, unknown>, event: TraceEvent): TraceEventRow {
function traceToolCallRow(trace: Record<string, unknown>, 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<string, unknown>, 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<string, unknown>, event: TraceEvent, { terminal }: { terminal: boolean }): TraceEventRow {
function traceAssistantMessageRow(trace: Record<string, unknown>, 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<string, unknown>, 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, unknown>): string | null {
return nonEmptyString(response?.text ?? response?.content ?? response?.message);
}
function traceCompletionSummaryRow(trace: Record<string, unknown>, event: TraceEvent): TraceEventRow {
function traceCompletionSummaryRow(trace: Record<string, unknown>, 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<string, unknown>, events: TraceEvent[]): TraceEventRow {
function traceNoiseSummaryRow(trace: Record<string, unknown>, 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<string, unknown>, 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<string, unknown>, rows: TraceEventRow[], events: TraceEvent[]): TraceEventRow | null {
function traceBackendProgressSummaryRow(trace: Record<string, unknown>, 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<string, unknown>, 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<string, unknown>, event: TraceEvent): TraceEventRow {
function traceDisplayRow(trace: Record<string, unknown>, 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<string, unknown>, events: TraceEvent[]): Record<string, unknown> {
@@ -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",
@@ -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);
@@ -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<string, unknown>[]));
const readableRows = computed(() => traceDisplayRows(traceRecord(props.trace), events.value as Record<string, unknown>[], { formatClock: formatDisplayClock }));
const projectionDiagnosticLabel = computed(() => {
const projection = props.trace?.projection ?? null;
const health = projection?.projectionHealth ?? props.trace?.projectionHealth;
@@ -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<string, unknown>[]));
const readableRows = computed(() => traceDisplayRows(traceRecord(trace.value), events.value as Record<string, unknown>[], { formatClock: formatDisplayClock }));
const noiseCount = computed(() => traceNoiseEventCount(events.value as Record<string, unknown>[]));
const lastEvent = computed(() => events.value.at(-1) ?? null);
const lastReadableRow = computed(() => readableRows.value.at(-1) ?? null);
@@ -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`);