Merge pull request #1235 from pikasTech/fix/issue-1233-trace-rendering

fix: 收敛 v0.3 Trace 可读渲染
This commit is contained in:
Lyon
2026-06-15 10:27:20 +08:00
committed by GitHub
4 changed files with 141 additions and 12 deletions
+18 -1
View File
@@ -76,7 +76,10 @@ export function traceDisplayRows(trace: Record<string, unknown> = {}, events: Tr
if (lastAssistantRow) rows[lastAssistantRowIndex] = markAssistantRowTerminal(trace, lastAssistantRow, completionEvent);
else rows.push(traceCompletionSummaryRow(trace, completionEvent));
}
return rows.length > 0 ? rows : events.map((event) => traceDisplayRow(trace, event));
if (rows.length > 0) return rows;
if (events.length === 0) return [];
if (traceNoiseEventCount(events) === events.length) return [traceNoiseSummaryRow(trace, events)];
return events.filter((event) => !isNoisyTraceEvent(event)).map((event) => traceDisplayRow(trace, event));
}
export function renderTraceRowsMarkdown(rows: TraceEventRow[] = []): string {
@@ -350,6 +353,20 @@ function traceCompletionSummaryRow(trace: Record<string, unknown>, event: TraceE
};
}
function traceNoiseSummaryRow(trace: Record<string, unknown>, events: TraceEvent[]): TraceEventRow {
const lastEvent = events.at(-1);
const status = nonEmptyString(trace.status ?? trace.traceStatus ?? lastEvent?.status) ?? "running";
const lastLabel = lastEvent ? readableTraceLabel(lastEvent) : "未观测";
return {
rowId: "trace-noise-summary",
seq: numberOrNull(lastEvent?.seq),
tone: lastEvent ? traceEventTone(lastEvent) : "source",
header: `${traceClock(lastEvent?.createdAt)} Trace ${status},等待可读事件`,
body: `已隐藏 ${events.length} 条 AgentRun backend 状态事件。最新原始事件:${lastLabel}`,
bodyFormat: "text"
};
}
function traceDisplayRow(trace: Record<string, unknown>, event: TraceEvent): TraceEventRow {
const label = readableTraceLabel(event);
return {
@@ -3,6 +3,7 @@ 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";
import { traceDisplayRows, traceNoiseEventCount } from "../../../tools/src/hwlab-cli/trace-renderer.ts";
test("R1 markdown rendering keeps structure and strips unsafe HTML", () => {
const html = renderSafeMarkdown("# 标题\n\n- 项目\n\n<script>alert('x')</script>\n\n[link](https://example.com)");
@@ -48,6 +49,20 @@ test("R1 trace rendering hides chunk noise without dropping meaningful events",
assert.equal(visibleTraceEvents(events, false).length, 3);
});
test("R1 shared trace renderer collapses pure AgentRun backend noise", () => {
const events = Array.from({ length: 12 }, (_, index) => ({
label: index % 3 === 0 ? "agentrun:backend:run-created" : index % 3 === 1 ? "agentrun:backend:command-created" : "agentrun:backend:runner-job-created",
status: "running",
createdAt: "2026-06-15T02:00:00.000Z"
}));
const rows = traceDisplayRows({ traceId: "trc_noise", status: "running" }, events);
assert.equal(traceNoiseEventCount(events), 12);
assert.equal(rows.length, 1);
assert.equal(rows[0]?.rowId, "trace-noise-summary");
assert.match(rows[0]?.body ?? "", / 12 AgentRun backend /u);
assert.doesNotMatch(rows[0]?.body ?? "", /run-created\ncommand-created/u);
});
test("R1 status summary keeps the restored 23-row floor", () => {
const message = agentMessage({
status: "failed",
@@ -3,21 +3,35 @@ import { computed, nextTick, ref, watch } from "vue";
import type { RunnerTrace, TraceEvent } from "@/types";
import { isTerminalStatus } from "@/composables/useTraceSubscription";
import { firstNonEmptyString } from "@/utils";
import { traceEventBody, traceEventLabel, traceEventMeta, visibleTraceEvents } from "@/components/workbench/message-rendering";
import MessageMarkdown from "@/components/workbench/MessageMarkdown.vue";
import { traceEventBody, traceEventLabel, traceEventMeta } from "@/components/workbench/message-rendering";
import { traceDisplayRows, traceNoiseEventCount, type TraceEventRow } from "../../../../../tools/src/hwlab-cli/trace-renderer.ts";
const props = defineProps<{ trace?: RunnerTrace | null; defaultExpanded?: boolean; storageKey?: string }>();
const listRef = ref<HTMLOListElement | null>(null);
const following = ref(true);
const expanded = ref(false);
const compact = ref(true);
const rawMode = ref(false);
const events = computed(() => Array.isArray(props.trace?.events) ? props.trace.events : []);
const eventCount = computed(() => props.trace?.eventCount ?? events.value.length);
const visibleEvents = computed(() => visibleTraceEvents(events.value, compact.value));
const noiseCount = computed(() => Math.max(0, events.value.length - visibleEvents.value.length));
const readableRows = computed(() => traceDisplayRows(traceRecord(props.trace), events.value as Record<string, unknown>[]));
const noiseCount = computed(() => traceNoiseEventCount(events.value as Record<string, unknown>[]));
const lastEvent = computed(() => events.value.at(-1) ?? null);
const lastEventLabel = computed(() => firstNonEmptyString(props.trace?.lastEventLabel, lastEvent.value?.label, lastEvent.value?.type, lastEvent.value?.kind) ?? "未观测");
const lastReadableRow = computed(() => readableRows.value.at(-1) ?? null);
const lastEventLabel = computed(() => firstNonEmptyString(lastReadableRow.value?.header, props.trace?.lastEventLabel, lastEvent.value?.label, lastEvent.value?.type, lastEvent.value?.kind) ?? "未观测");
const summaryItems = computed(() => {
const agentRun = recordValue(props.trace?.agentRun);
return [
{ label: "status", value: firstNonEmptyString(props.trace?.status, props.trace?.traceStatus) },
{ label: "session", value: props.trace?.sessionId },
{ label: "thread", value: props.trace?.threadId },
{ label: "run", value: firstNonEmptyString(agentRun?.runId, props.trace?.runId) },
{ label: "command", value: firstNonEmptyString(agentRun?.commandId, props.trace?.commandId) },
{ label: "waiting", value: props.trace?.waitingFor }
].filter((item): item is { label: string; value: string } => Boolean(item.value));
});
watch(() => [eventCount.value, props.trace?.status, following.value, expanded.value], async () => {
if (!following.value || !expanded.value) return;
@@ -50,6 +64,18 @@ function eventKey(event: TraceEvent, index: number): string {
return `${event.ts ?? "no-ts"}-${event.label ?? event.type ?? event.kind ?? "event"}-${index}`;
}
function rowKey(row: TraceEventRow, index: number): string {
return `${row.rowId}-${row.seq ?? "no-seq"}-${index}`;
}
function traceRecord(trace: RunnerTrace | null | undefined): Record<string, unknown> {
return trace && typeof trace === "object" ? trace as Record<string, unknown> : {};
}
function recordValue(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" ? value as Record<string, unknown> : null;
}
</script>
<template>
@@ -58,21 +84,31 @@ function eventKey(event: TraceEvent, index: number): string {
<button class="trace-toggle" type="button" @click="expanded = !expanded">{{ expanded ? "收起" : "展开" }}</button>
<strong>Trace</strong>
<span class="trace-id">{{ trace.traceId || "pending" }}</span>
<small>{{ visibleEvents.length }}/{{ eventCount }} events</small>
<small>{{ readableRows.length }} readable / {{ eventCount }} events</small>
<small v-if="noiseCount > 0">hidden={{ noiseCount }}</small>
<small>last={{ lastEventLabel }}</small>
<button class="btn btn-secondary btn-sm" type="button" @click="following = !following">{{ following ? "跟随" : "暂停" }}</button>
<button class="btn btn-secondary btn-sm" type="button" @click="jumpToBottom">到底部</button>
<button class="btn btn-secondary btn-sm" type="button" @click="compact = !compact">{{ compact ? "显示全部" : "低噪声" }}</button>
<button class="btn btn-secondary btn-sm" type="button" @click="rawMode = !rawMode">{{ rawMode ? "可读视图" : "原始事件" }}</button>
</header>
<div v-if="summaryItems.length > 0" class="trace-summary-strip" aria-label="Trace identity summary">
<span v-for="item in summaryItems" :key="item.label" class="trace-summary-chip"><b>{{ item.label }}</b>{{ item.value }}</span>
</div>
<p v-if="trace.eventsCompacted" class="trace-warning">当前 result 返回被压缩已通过 trace replay 入口请求完整事件完成前不要把压缩窗口当完整 trace</p>
<p v-if="noiseCount > 0 && compact" class="trace-warning">已聚合隐藏 {{ noiseCount }} chunk/delta/token 噪声事件</p>
<p v-if="noiseCount > 0 && !rawMode" class="trace-warning">按共享 trace renderer 聚合隐藏 {{ noiseCount }} backend/chunk/token 噪声事件需要审计原始 payload 时可切换到原始事件</p>
<ol v-show="expanded" ref="listRef" @scroll="onScroll">
<li v-for="(event, index) in visibleEvents" :key="eventKey(event, index)" :data-event-status="event.status || 'source'">
<li v-for="(row, index) in readableRows" v-if="!rawMode" :key="rowKey(row, index)" class="trace-render-row" :data-event-status="row.tone" :data-terminal="row.terminal ? 'true' : 'false'">
<code>{{ row.header }}</code>
<MessageMarkdown v-if="row.body && row.bodyFormat === 'markdown'" class="trace-row-body trace-row-markdown" :source="row.body" />
<pre v-else-if="row.body" class="trace-row-body">{{ row.body }}</pre>
<span v-else class="trace-row-body muted">已记录</span>
</li>
<li v-for="(event, index) in events" v-else :key="eventKey(event, index)" class="trace-raw-row" :data-event-status="event.status || 'source'">
<code>{{ traceEventLabel(event) }}</code>
<span>{{ traceEventBody(event) }}</span>
<small v-if="traceEventMeta(event)">{{ traceEventMeta(event) }}</small>
</li>
</ol>
<p v-if="expanded && visibleEvents.length === 0" class="trace-empty">trace={{ trace.traceId || "pending" }}等待后端事件</p>
<p v-if="expanded && readableRows.length === 0 && !rawMode" class="trace-empty">trace={{ trace.traceId || "pending" }}等待后端事件</p>
</section>
</template>
+62 -1
View File
@@ -752,6 +752,33 @@
flex-wrap: wrap;
}
.trace-summary-strip {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.trace-summary-chip {
display: inline-flex;
min-width: 0;
max-width: 100%;
align-items: center;
gap: 6px;
border: 1px solid #d8e1eb;
border-radius: 8px;
background: white;
color: #334155;
padding: 5px 8px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 11px;
}
.trace-summary-chip b {
color: #64748b;
font-family: inherit;
text-transform: uppercase;
}
.trace-toolbar strong,
.trace-id {
min-width: 0;
@@ -811,12 +838,46 @@
.trace-timeline li {
display: grid;
grid-template-columns: minmax(96px, 160px) minmax(0, 1fr) auto;
grid-template-columns: minmax(180px, 280px) minmax(0, 1fr) auto;
gap: 8px;
color: #475569;
font-size: 12px;
}
.trace-timeline li[data-terminal="true"] {
border-left: 3px solid #22c55e;
padding-left: 8px;
}
.trace-render-row .trace-row-body {
min-width: 0;
margin: 0;
white-space: pre-wrap;
}
.trace-render-row .trace-row-markdown {
display: grid;
gap: 6px;
white-space: normal;
}
.trace-render-row .muted {
color: #94a3b8;
}
.trace-render-row pre.trace-row-body {
max-height: 120px;
overflow: auto;
border: 1px solid #d8e1eb;
border-radius: 8px;
background: white;
color: #334155;
padding: 8px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 11px;
line-height: 1.45;
}
.trace-timeline li span,
.trace-timeline li small {
min-width: 0;