feat: improve AgentRun trace continuity
Pipelines as Code CI / hwlab-nc01-v03-ci-poll-9f876ae42a3cd218986017e288372578a758ebbc Success

This commit is contained in:
root
2026-07-21 09:32:49 +02:00
parent 692e4fcc80
commit 9f876ae42a
5 changed files with 280 additions and 156 deletions
@@ -1,6 +1,6 @@
<!-- SPEC: PJ2026-0106050514 Workbench实时运行面 draft-2026-06-30-p0-1297-spec-first; PJ2026-010401080313 Workbench实时权威 draft-2026-07-14-p0-pure-kafka-authority. Render a bounded, stable Trace tail from the Kafka SSE projection. -->
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { computed, nextTick, ref, watch } from "vue";
import type { RunnerTrace } from "@/types";
import LoadingState from "@/components/common/LoadingState.vue";
import MessageMarkdown from "@/components/workbench/MessageMarkdown.vue";
@@ -20,15 +20,19 @@ const props = defineProps<{
const TRACE_RENDER_ROW_WINDOW = 120;
const TRACE_RENDER_EVENT_WINDOW = TRACE_RENDER_ROW_WINDOW * 2;
const TRACE_LOAD_OLDER_THRESHOLD_PX = 24;
const listRef = ref<HTMLOListElement | null>(null);
const expanded = ref(false);
const visibleRowLimit = ref(TRACE_RENDER_ROW_WINDOW);
const visibleEventLimit = ref(TRACE_RENDER_EVENT_WINDOW);
const loadingOlder = ref(false);
const expandedToolRowIds = ref(new Set<string>());
const { following, keepBottomAfterUpdate, onScroll, scrollToBottom } = useWorkbenchBottomFollowScroll(listRef, { threshold: 24 });
const events = computed(() => Array.isArray(props.trace?.events) ? props.trace.events : []);
const eventCount = computed(() => props.trace?.eventCount ?? events.value.length);
const renderEvents = computed(() => events.value.length > TRACE_RENDER_EVENT_WINDOW
? events.value.slice(-TRACE_RENDER_EVENT_WINDOW)
const renderEvents = computed(() => events.value.length > visibleEventLimit.value
? events.value.slice(-visibleEventLimit.value)
: events.value);
const rawReadableRows = computed(() => expanded.value ? traceDisplayRows(traceRecord(props.trace), renderEvents.value as Record<string, unknown>[], {
formatClock: formatDisplayClock,
@@ -36,9 +40,10 @@ const rawReadableRows = computed(() => expanded.value ? traceDisplayRows(traceRe
outerFinalResponseText: props.outerFinalResponseText
}) : []);
const readableRows = computed(() => rawReadableRows.value);
const hiddenRowCount = computed(() => Math.max(0, readableRows.value.length - TRACE_RENDER_ROW_WINDOW));
const renderedRows = computed(() => hiddenRowCount.value > 0 ? readableRows.value.slice(-TRACE_RENDER_ROW_WINDOW) : readableRows.value);
const hiddenRowCount = computed(() => Math.max(0, readableRows.value.length - visibleRowLimit.value));
const renderedRows = computed(() => hiddenRowCount.value > 0 ? readableRows.value.slice(-visibleRowLimit.value) : readableRows.value);
const inputEventWindowed = computed(() => renderEvents.value.length < events.value.length);
const hasOlderRows = computed(() => hiddenRowCount.value > 0 || inputEventWindowed.value);
const traceSummaryLabel = computed(() => {
const count = eventCount.value;
if (count <= 0) return String(props.trace?.status ?? "").trim() || "pending";
@@ -58,6 +63,12 @@ const emptyTraceLabel = computed(() => {
});
let programmaticToggleTarget: boolean | null = null;
watch(() => traceExpansionScope(), () => {
visibleRowLimit.value = TRACE_RENDER_ROW_WINDOW;
visibleEventLimit.value = TRACE_RENDER_EVENT_WINDOW;
loadingOlder.value = false;
}, { immediate: true });
watch(() => [eventCount.value, props.trace?.status, readableRows.value.length, expanded.value], () => {
if (!expanded.value) return;
void keepBottomAfterUpdate();
@@ -105,6 +116,26 @@ function onDisclosureToggle(event: Event): void {
writeStoredExpanded(target.open);
}
function onTimelineScroll(event: Event): void {
onScroll();
const target = event.currentTarget;
if (!(target instanceof HTMLOListElement)) return;
if (target.scrollTop > TRACE_LOAD_OLDER_THRESHOLD_PX || !hasOlderRows.value || loadingOlder.value) return;
void loadOlderRows(target);
}
async function loadOlderRows(target: HTMLOListElement): Promise<void> {
loadingOlder.value = true;
const previousScrollTop = target.scrollTop;
const previousScrollHeight = target.scrollHeight;
visibleRowLimit.value = Math.min(events.value.length, visibleRowLimit.value + TRACE_RENDER_ROW_WINDOW);
visibleEventLimit.value = Math.min(events.value.length, visibleEventLimit.value + TRACE_RENDER_EVENT_WINDOW);
await nextTick();
target.scrollTop = previousScrollTop + Math.max(0, target.scrollHeight - previousScrollHeight);
onScroll();
loadingOlder.value = false;
}
function onToolDisclosureToggle(row: TraceEventRow, event: Event): void {
const target = event.currentTarget;
if (!(target instanceof HTMLDetailsElement)) return;
@@ -180,8 +211,8 @@ function firstNonEmptyString(...values: unknown[]): string | null {
<span class="trace-disclosure-count">{{ traceSummaryLabel }}</span>
</summary>
<div v-if="expanded" class="trace-disclosure-body">
<p v-if="hiddenRowCount > 0 || inputEventWindowed" class="trace-window-notice">显示最近 {{ renderedRows.length }} 行,共 {{ eventCount }} events</p>
<ol v-if="renderedRows.length > 0" ref="listRef" @scroll="onScroll">
<p v-if="hasOlderRows" class="trace-window-notice" :data-loading="loadingOlder ? 'true' : 'false'">已显示 {{ renderedRows.length }} 行,共 {{ eventCount }} events</p>
<ol v-if="renderedRows.length > 0" ref="listRef" @scroll="onTimelineScroll">
<li v-for="(row, index) in renderedRows" :key="rowKey(row, index)" v-memo="[trace.traceId, row.rowId, row.seq, row.header, row.body, row.bodyFormat, row.tone, row.toolState, row.terminal, hiddenRowCount + index, expandedToolRowIds.has(row.rowId)]" class="trace-render-row" data-testid="trace-render-row" :data-trace-id="trace.traceId || undefined" :data-event-seq="row.seq ?? undefined" :data-event-seq-authority="row.sequenceAuthority" :data-projected-seq="row.sequenceAuthority === 'projected' ? row.seq ?? undefined : undefined" :data-event-kind="row.rowId" :data-event-status="row.tone" :data-tool-state="row.toolState ?? undefined" :data-terminal="row.terminal ? 'true' : 'false'" :data-row-kind="rowIsTool(row) ? 'tool' : 'message'" :data-row-id="row.rowId" :aria-posinset="hiddenRowCount + index + 1">
<details v-if="rowIsTool(row)" class="trace-tool-details" :aria-busy="row.toolState === 'running' ? 'true' : undefined" @toggle="onToolDisclosureToggle(row, $event)">
<summary :aria-label="traceToolAccessibilityLabel(row)" :title="traceToolAccessibilityLabel(row)" aria-live="polite" aria-atomic="true">
@@ -13,7 +13,6 @@ import { workbenchTraceTimelinePolicy } from "@/config/runtime";
import { messageDiagnosticView } from "@/utils/workbench-error-runtime";
import MessageMarkdown from "./MessageMarkdown.vue";
import { traceLifecycleExpanded } from "./trace-lifecycle";
import { semanticRuntimeStatusView } from "./workbench-semantic-runtime-status";
const props = withDefaults(defineProps<{
message: ChatMessage;
@@ -37,7 +36,6 @@ const diagnostic = computed(() => messageDiagnosticView(props.message));
const visibleText = computed(() => visibleMessageText(props.message));
const durationMeta = computed(() => messageDurationMeta(props.message));
const activityMeta = computed(() => messageActivityMeta(props.message));
const semanticRuntimeStatus = computed(() => semanticRuntimeStatusView(props.message, nowMs.value));
const autoExpanded = computed(() => props.traceAutoExpanded ?? traceLifecycleExpanded(props.message, traceTimelinePolicy.value));
function isRunningMessage(message: ChatMessage): boolean {
@@ -136,103 +134,20 @@ function traceStorageKey(message: ChatMessage): string {
:data-trace-id="message.traceId || message.runnerTrace?.traceId || undefined"
:data-turn-id="message.turnId || message.traceId || message.runnerTrace?.traceId || undefined"
>
<section v-if="semanticRuntimeStatus" class="semantic-failure-banner" :data-tone="semanticRuntimeStatus.tone" :data-emphasis="semanticRuntimeStatus.emphasis" role="status" aria-live="polite" :title="semanticRuntimeStatus.code ? `${semanticRuntimeStatus.title} · ${semanticRuntimeStatus.detail} · ${semanticRuntimeStatus.code}` : undefined">
<span class="semantic-status-marker" aria-hidden="true" />
<strong>{{ semanticRuntimeStatus.title }}</strong>
<span>{{ semanticRuntimeStatus.detail }}</span>
<code v-if="semanticRuntimeStatus.code && semanticRuntimeStatus.emphasis === 'alert'">{{ semanticRuntimeStatus.code }}</code>
</section>
<TraceTimeline v-if="message.role === 'agent' && message.runnerTrace" :trace="message.runnerTrace" :auto-expanded="autoExpanded" :storage-key="traceStorageKey(message)" final-response-placement="outer" :outer-final-response-text="visibleText" />
<LoadingState v-if="isAwaitingAgentBody(message)" class="message-loading" label="启动中..." compact />
<MessageMarkdown v-if="visibleText" class="message-text" :source="visibleText" />
<ApiErrorDiagnostic v-if="diagnostic.visible" class="message-diagnostic projection-diagnostic" :error="diagnostic.text" :api-error="diagnostic.apiError" :diagnostic="diagnostic.diagnostic" :show-message="diagnostic.showMessage" compact />
<footer v-if="message.role !== 'user'" class="message-meta-footer">
<div class="message-header-main">
<strong>{{ message.title }}</strong>
<div v-if="running || activityMeta" class="message-activity-group">
<span v-if="running" class="agent-running-indicator" aria-label="运行中" role="status"><span aria-hidden="true" /></span>
<span v-if="activityMeta" class="message-activity-meta" :aria-label="activityMeta.label" :title="activityMeta.label">{{ activityMeta.text }}</span>
</div>
<div class="message-header-actions">
<span v-if="running" class="agent-running-indicator" aria-label="运行中" role="status"><span aria-hidden="true" /></span>
<StatusBadge v-if="showStatusBadge(message)" :status="message.status" />
<button v-if="detailEnabled && message.role === 'agent'" class="message-detail-button" type="button" aria-label="运行详情" title="运行详情" @click="emit('details', message)">!</button>
</div>
<div class="message-footer-timing">
<span v-if="activityMeta" class="message-activity-meta" :aria-label="activityMeta.label" :title="activityMeta.label">{{ activityMeta.text }}</span>
<span v-if="durationMeta" class="message-timing-meta message-duration-meta" :aria-label="durationMeta.label" :title="durationMeta.label">{{ durationMeta.text }}</span>
<button v-if="detailEnabled && message.role === 'agent'" class="message-detail-button" type="button" aria-label="运行详情" title="运行详情" @click="emit('details', message)">!</button>
</div>
</footer>
</article>
</template>
<style scoped>
.semantic-failure-banner {
display: grid;
gap: 0.2rem;
margin: 0.55rem 0;
border-left: 3px solid #d97706;
border-radius: 0.35rem;
background: #fffbeb;
color: #92400e;
padding: 0.55rem 0.7rem;
}
.semantic-status-marker {
display: none;
}
.semantic-failure-banner[data-emphasis="quiet"] {
display: flex;
align-items: center;
gap: 0.45rem;
min-width: 0;
margin: 0.25rem 0;
border-left-width: 2px;
border-radius: 0.2rem;
background: transparent;
padding: 0.2rem 0.45rem;
color: #475569;
font-size: 0.82rem;
line-height: 1.35;
}
.semantic-failure-banner[data-emphasis="quiet"] .semantic-status-marker {
display: block;
flex: 0 0 0.42rem;
width: 0.42rem;
height: 0.42rem;
border-radius: 50%;
background: currentColor;
}
.semantic-failure-banner[data-emphasis="quiet"] strong {
overflow: hidden;
min-width: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.semantic-failure-banner[data-emphasis="quiet"] span:not(.semantic-status-marker) {
flex: 0 0 auto;
color: #64748b;
}
.semantic-failure-banner[data-tone="recovered"] {
border-left-color: #15803d;
color: #166534;
}
.semantic-failure-banner[data-tone="info"] {
border-left-color: #2563eb;
color: #1e40af;
}
.semantic-failure-banner[data-tone="failed"] {
border-left-color: #b91c1c;
background: #fef2f2;
color: #991b1b;
}
.semantic-failure-banner span,
.semantic-failure-banner code {
font-size: 0.82rem;
}
</style>
+16 -37
View File
@@ -510,22 +510,6 @@
padding-top: 6px;
}
.message-header-main {
display: flex;
min-width: 0;
flex: 1 1 auto;
flex-wrap: wrap;
align-items: center;
gap: 6px 8px;
}
.message-header-main strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.message-timing-meta {
display: inline-flex;
min-width: 64px;
@@ -545,39 +529,34 @@
.message-activity-meta {
display: inline-flex;
min-width: 86px;
flex: 0 0 auto;
justify-content: flex-end;
min-width: 0;
overflow: hidden;
flex: 0 1 auto;
justify-content: flex-start;
color: #475569;
font-size: 12px;
font-variant-numeric: tabular-nums;
font-weight: 650;
line-height: 1.25;
text-overflow: ellipsis;
}
.message-activity-group {
display: inline-flex;
min-width: 0;
overflow: hidden;
align-items: center;
justify-self: start;
gap: 4px;
}
.message-header-actions {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-self: end;
gap: 8px;
}
.message-footer-timing {
display: grid;
grid-column: 1 / -1;
grid-template-columns: minmax(0, 1fr) auto;
min-width: 0;
align-items: center;
gap: 8px;
}
.message-footer-timing .message-activity-meta {
justify-content: flex-start;
}
.message-footer-timing .message-duration-meta {
justify-content: flex-end;
text-align: right;
white-space: nowrap;
}
.message-detail-button {