Merge pull request #1277 from pikasTech/fix/issue-1276-trace-debug-details

fix: Trace 调试信息收进消息详情
This commit is contained in:
Lyon
2026-06-15 20:21:15 +08:00
committed by GitHub
5 changed files with 157 additions and 79 deletions
+6 -1
View File
@@ -35,6 +35,7 @@ const requiredFiles = Object.freeze([
"src/components/layout/AppShell.vue",
"src/components/layout/TablePageLayout.vue",
"src/components/common/EmptyState.vue",
"src/components/workbench/MessageTraceDebugPanel.vue",
"src/views/workbench/CodeWorkbenchView.vue",
"src/views/admin/AccessView.vue",
"src/views/admin/ProviderProfilesView.vue",
@@ -57,6 +58,8 @@ for (const file of requiredFiles) {
const html = readWeb("index.html");
const pkg = JSON.parse(readWeb("package.json")) as { dependencies?: Record<string, string>; devDependencies?: Record<string, string> };
const appSource = readCloudWebAppSource(rootDir);
const traceTimelineSource = readWeb("src/components/agent/TraceTimeline.vue");
const messageTraceDebugSource = readWeb("src/components/workbench/MessageTraceDebugPanel.vue");
assert.match(html, /<div id="app"><\/div>/u, "index.html must expose Vue mount root");
assert.match(html, /src="\/src\/main\.ts"/u, "index.html must load Vue TS entry");
@@ -98,7 +101,9 @@ assertIncludes(appSource, "/v1/agent/chat/trace/", "trace replay must use Cloud
assertIncludes(appSource, "/v1/workbench/workspace", "workspace bootstrap must use Cloud Web same-origin workbench API");
assert.doesNotMatch(appSource, /\/auth\/workspace-bootstrap/u, "stale auth workspace bootstrap route must not remain in Cloud Web app source");
assert.doesNotMatch(appSource, /trace-meta-details/u, "Trace details must not render a second message-detail exclamation button");
assertIncludes(appSource, "trace-meta-panel", "Trace identity and raw-event controls must remain in the expanded trace body");
assert.doesNotMatch(traceTimelineSource, /trace-meta-panel|trace-summary-chip|trace-meta-actions||traceEventLabel/u, "Expanded TraceTimeline body must not render debug identity or raw-event controls");
assertIncludes(appSource, "MessageTraceDebugPanel", "Message details dialog must mount the trace debug panel behind the top-right details button");
assertIncludes(messageTraceDebugSource, "trace-meta-panel", "Trace identity and raw-event controls must live inside message details");
assertIncludes(appSource, "scrollbar-width: none;", "Platform sidebar must scroll without a visible scrollbar");
assertIncludes(appSource, "border-collapse: collapse;", "Markdown tables must use GitHub-like bordered table styling");
assertIncludes(appSource, "grid-template-columns: minmax(0, 1fr);", "Trace rows must give the readable body the full content width");
@@ -1,36 +1,18 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from "vue";
import type { RunnerTrace, TraceEvent } from "@/types";
import { firstNonEmptyString } from "@/utils";
import type { RunnerTrace } from "@/types";
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";
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 }>();
const listRef = ref<HTMLOListElement | null>(null);
const following = ref(true);
const expanded = ref(false);
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 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 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;
@@ -65,10 +47,6 @@ function onScroll(): void {
following.value = list.scrollTop + list.clientHeight >= list.scrollHeight - 24;
}
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}`;
}
@@ -96,10 +74,6 @@ function traceRecord(trace: RunnerTrace | null | undefined): Record<string, unkn
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>
@@ -107,54 +81,26 @@ function recordValue(value: unknown): Record<string, unknown> | null {
<details class="trace-disclosure" :open="expanded" @toggle="onDisclosureToggle">
<summary class="trace-disclosure-summary" aria-label="折叠运行记录">
<span class="trace-disclosure-caret" aria-hidden="true" />
<span class="trace-disclosure-count">{{ readableRows.length }}/{{ eventCount }}</span>
<span v-if="noiseCount > 0" class="trace-disclosure-hidden">+{{ noiseCount }}</span>
<span class="trace-disclosure-label">运行记录</span>
</summary>
<div class="trace-disclosure-body">
<div class="trace-meta-panel" aria-label="运行记录摘要">
<dl class="trace-meta-grid">
<div><dt>trace</dt><dd>{{ trace.traceId || "pending" }}</dd></div>
<div><dt>rows</dt><dd>{{ readableRows.length }} / {{ eventCount }}</dd></div>
<div v-if="noiseCount > 0"><dt>hidden</dt><dd>{{ noiseCount }}</dd></div>
<div><dt>last</dt><dd>{{ lastEventLabel }}</dd></div>
</dl>
<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 && !rawMode" class="trace-warning">已聚合隐藏 {{ noiseCount }} backend/chunk/token 噪声事件原始 payload 仍可在原始事件中审计</p>
<div class="trace-meta-actions">
<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="rawMode = !rawMode">{{ rawMode ? "可读视图" : "原始事件" }}</button>
</div>
</div>
<ol ref="listRef" @scroll="onScroll">
<template v-if="!rawMode">
<li v-for="(row, index) in readableRows" :key="rowKey(row, index)" class="trace-render-row" :data-event-status="row.tone" :data-terminal="row.terminal ? 'true' : 'false'" :data-row-kind="rowIsTool(row) ? 'tool' : 'message'">
<details v-if="rowIsTool(row)" class="trace-tool-details">
<summary><code>{{ row.header }}</code></summary>
<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>
</details>
<template v-else>
<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>
</template>
</li>
</template>
<template v-else>
<li v-for="(event, index) in events" :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>
</template>
<li v-for="(row, index) in readableRows" :key="rowKey(row, index)" class="trace-render-row" :data-event-status="row.tone" :data-terminal="row.terminal ? 'true' : 'false'" :data-row-kind="rowIsTool(row) ? 'tool' : 'message'">
<details v-if="rowIsTool(row)" class="trace-tool-details">
<summary><code>{{ row.header }}</code></summary>
<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>
</details>
<template v-else>
<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>
</template>
</li>
</ol>
<p v-if="readableRows.length === 0 && !rawMode" class="trace-empty">id={{ trace.traceId || "pending" }}等待后端事件</p>
<p v-if="readableRows.length === 0" class="trace-empty">等待后端事件</p>
</div>
</details>
</section>
@@ -6,6 +6,7 @@ import TraceTimeline from "@/components/agent/TraceTimeline.vue";
import CodeAgentStatusSummary from "@/components/workbench/CodeAgentStatusSummary.vue";
import MessageActions from "@/components/workbench/MessageActions.vue";
import MessageMarkdown from "@/components/workbench/MessageMarkdown.vue";
import MessageTraceDebugPanel from "@/components/workbench/MessageTraceDebugPanel.vue";
import { useClipboard } from "@/composables/useClipboard";
import { useWorkbenchStore } from "@/stores/workbench";
import { traceIdentityText } from "./message-rendering";
@@ -91,6 +92,7 @@ function traceStorageKey(message: ChatMessage): string {
<button type="button" class="dialog-close" aria-label="关闭" @click="detailMessageId = null">x</button>
</header>
<CodeAgentStatusSummary :message="detailMessage" />
<MessageTraceDebugPanel :message="detailMessage" />
<MessageActions
:message="detailMessage"
@cancel="workbench.cancelAgentMessage"
@@ -0,0 +1,94 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from "vue";
import type { ChatMessage, RunnerTrace, TraceEvent } from "@/types";
import { firstNonEmptyString } from "@/utils";
import { traceEventBody, traceEventLabel, traceEventMeta } from "@/components/workbench/message-rendering";
import { traceDisplayRows, traceNoiseEventCount } from "../../../../../tools/src/hwlab-cli/trace-renderer.ts";
const props = defineProps<{ message: ChatMessage }>();
const rawListRef = ref<HTMLOListElement | null>(null);
const following = ref(true);
const rawMode = ref(false);
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 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);
const lastEventLabel = computed(() => firstNonEmptyString(lastReadableRow.value?.header, trace.value?.lastEventLabel, lastEvent.value?.label, lastEvent.value?.type, lastEvent.value?.kind) ?? "未观测");
const traceId = computed(() => firstNonEmptyString(props.message.traceId, trace.value?.traceId) ?? "pending");
const summaryItems = computed(() => {
const agentRun = recordValue(trace.value?.agentRun);
return [
{ label: "status", value: firstNonEmptyString(trace.value?.status, trace.value?.traceStatus) },
{ label: "session", value: firstNonEmptyString(props.message.sessionId, trace.value?.sessionId) },
{ label: "thread", value: firstNonEmptyString(props.message.threadId, trace.value?.threadId) },
{ label: "run", value: firstNonEmptyString(agentRun?.runId, trace.value?.runId) },
{ label: "command", value: firstNonEmptyString(agentRun?.commandId, trace.value?.commandId) },
{ label: "waiting", value: trace.value?.waitingFor }
].filter((item): item is { label: string; value: string } => Boolean(item.value));
});
watch(() => [eventCount.value, rawMode.value, following.value] as const, async () => {
if (!rawMode.value || !following.value) return;
await nextTick();
jumpToBottom();
});
function jumpToBottom(): void {
const list = rawListRef.value;
if (!list) return;
list.scrollTop = list.scrollHeight;
}
function onScroll(): void {
const list = rawListRef.value;
if (!list) return;
following.value = list.scrollTop + list.clientHeight >= list.scrollHeight - 24;
}
function eventKey(event: TraceEvent, index: number): string {
return `${event.ts ?? "no-ts"}-${event.label ?? event.type ?? event.kind ?? "event"}-${index}`;
}
function traceRecord(value: RunnerTrace | null | undefined): Record<string, unknown> {
return value && typeof value === "object" ? value 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>
<section v-if="trace" class="message-trace-debug-panel" aria-label="Trace 调试信息">
<div class="trace-meta-panel" aria-label="运行记录摘要">
<dl class="trace-meta-grid">
<div><dt>trace</dt><dd>{{ traceId }}</dd></div>
<div><dt>rows</dt><dd>{{ readableRows.length }} / {{ eventCount }}</dd></div>
<div v-if="noiseCount > 0"><dt>hidden</dt><dd>{{ noiseCount }}</dd></div>
<div><dt>last</dt><dd>{{ lastEventLabel }}</dd></div>
</dl>
<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" class="trace-warning">已聚合隐藏 {{ noiseCount }} backend/chunk/token 噪声事件原始 payload 仍可在原始事件中审计</p>
<div class="trace-meta-actions">
<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="rawMode = !rawMode">{{ rawMode ? "隐藏原始事件" : "原始事件" }}</button>
</div>
<ol v-if="rawMode" ref="rawListRef" class="trace-raw-list" @scroll="onScroll">
<li v-for="(event, index) in events" :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>
</div>
</section>
</template>
+37 -6
View File
@@ -899,15 +899,10 @@
transform: rotate(90deg);
}
.trace-disclosure-count,
.trace-disclosure-hidden {
.trace-disclosure-label {
line-height: 1;
}
.trace-disclosure-hidden {
color: #92400e;
}
.trace-disclosure-body {
display: grid;
gap: 5px;
@@ -959,6 +954,42 @@
gap: 6px;
}
.message-trace-debug-panel {
display: grid;
gap: 8px;
}
.trace-raw-list {
display: grid;
max-height: min(340px, 42vh);
gap: 4px;
margin: 0;
overflow: auto;
padding: 0;
list-style: none;
}
.trace-raw-list li {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 3px;
border-top: 1px solid #e2e8f0;
padding: 5px 0 0;
color: #475569;
font-size: 12px;
}
.trace-raw-list li:first-child {
border-top: 0;
padding-top: 0;
}
.trace-raw-list li span,
.trace-raw-list li small {
min-width: 0;
overflow-wrap: anywhere;
}
.trace-summary-strip {
display: flex;
flex-wrap: wrap;