feat: improve AgentRun trace continuity
Pipelines as Code CI / hwlab-nc01-v03-ci-poll-9f876ae42a3cd218986017e288372578a758ebbc Success
Pipelines as Code CI / hwlab-nc01-v03-ci-poll-9f876ae42a3cd218986017e288372578a758ebbc Success
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user