Merge pull request #1277 from pikasTech/fix/issue-1276-trace-debug-details
fix: Trace 调试信息收进消息详情
This commit is contained in:
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user