fix: stabilize background operations and large embedding results
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed, watch, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAgentStore } from '@/stores/agent'
|
||||
import { localeTag, t } from '@/i18n'
|
||||
@@ -8,6 +8,10 @@ import { runStatusLabel } from './labels'
|
||||
const agentStore = useAgentStore()
|
||||
const router = useRouter()
|
||||
const error = ref('')
|
||||
const page = ref(1)
|
||||
const pages = computed(() => Math.max(1, Math.ceil(agentStore.sortedRuns.length / 50)))
|
||||
const visibleRuns = computed(() => agentStore.sortedRuns.slice((page.value - 1) * 50, page.value * 50))
|
||||
watch(pages, count => { page.value = Math.min(page.value, count) })
|
||||
|
||||
onMounted(async () => {
|
||||
try { await agentStore.loadRuns() } catch (reason) { error.value = reason instanceof Error ? reason.message : t('运行记录加载失败', 'Failed to load runs') }
|
||||
@@ -20,8 +24,9 @@ function selectRun(runId: string) { void router.push({ name: 'agent', params: {
|
||||
<div class="sidebar-panel">
|
||||
<button class="button-primary new-button" @click="router.push({ name: 'agent' })">+ {{ t('新建运行', 'New run') }}</button>
|
||||
<p v-if="error" class="subtle error-text">{{ error }}</p>
|
||||
<div v-if="pages > 1" class="inline-actions"><button class="button-secondary" :disabled="page === 1" @click="page--">{{ t('上一页', 'Previous') }}</button><span>{{ page }} / {{ pages }}</span><button class="button-secondary" :disabled="page === pages" @click="page++">{{ t('下一页', 'Next') }}</button></div>
|
||||
<div class="sidebar-list">
|
||||
<button v-for="run in agentStore.sortedRuns" :key="run.run_id" class="sidebar-list-item run-item"
|
||||
<button v-for="run in visibleRuns" :key="run.run_id" class="sidebar-list-item run-item"
|
||||
:class="{ active: agentStore.activeRunId === run.run_id }" @click="selectRun(run.run_id)">
|
||||
<span class="badge" :class="{ success: run.status === 'completed', error: run.status === 'failed', warning: run.status === 'waiting_permission' }">{{ runStatusLabel(run.status) }}</span>
|
||||
<strong>{{ run.run_id.slice(0, 12) }}</strong><small>{{ run.started_at ? new Date(run.started_at).toLocaleString(localeTag()) : t('等待开始', 'Waiting to start') }}</small>
|
||||
|
||||
@@ -41,6 +41,17 @@ async function switchToTree(wrapper: ReturnType<typeof mountTree>) {
|
||||
}
|
||||
|
||||
describe('TraceTimeline 树形视图', () => {
|
||||
it('bounds rendered events while searching the complete history', async () => {
|
||||
const events = Array.from({ length: 1000 }, (_, i) => event('TextDelta', { text: `message-${i}` }))
|
||||
const wrapper = mountTree(events)
|
||||
expect(wrapper.findAll('.event-card')).toHaveLength(200)
|
||||
await wrapper.findAll('button').find(button => button.text() === '下一页')!.trigger('click')
|
||||
expect(wrapper.findAll('.event-card')).toHaveLength(200)
|
||||
await wrapper.get('input').setValue('message-999')
|
||||
expect(wrapper.findAll('.event-card')).toHaveLength(1)
|
||||
expect(wrapper.text()).toContain('message-999')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('filters errors while retaining tree ancestors and final tool data', async () => {
|
||||
const events = sampleEvents()
|
||||
const result = events.find(item => item.event === 'ToolResult')!
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { localeTag, t } from '@/i18n'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { TraceNode, AgentEvent } from '@/contracts'
|
||||
import { buildTraceNodes, getToolCallsFromEvents, getTotalDuration } from '@/services/traceService'
|
||||
import { eventLabel, localizeDetails } from './labels'
|
||||
@@ -37,9 +37,12 @@ const filteredEvents = computed(() => {
|
||||
})
|
||||
const filteredTree = computed(() => {
|
||||
if (!filtering.value) return traceNodes.value
|
||||
const matches = (node: TraceNode) => filteredEvents.value.some(event => event.sequence === node.sequence
|
||||
|| (node.type === 'tool_call' && node.data.tool_call_id != null && node.data.tool_call_id === event.data.tool_call_id)
|
||||
|| (node.type === 'model_call' && node.data.model_call_id != null && node.data.model_call_id === event.data.model_call_id))
|
||||
const sequences = new Set(filteredEvents.value.map(event => event.sequence))
|
||||
const tools = new Set<unknown>(filteredEvents.value.map(event => event.data.tool_call_id).filter(id => id != null))
|
||||
const models = new Set<unknown>(filteredEvents.value.map(event => event.data.model_call_id).filter(id => id != null))
|
||||
const matches = (node: TraceNode) => sequences.has(node.sequence)
|
||||
|| (node.type === 'tool_call' && tools.has(node.data.tool_call_id))
|
||||
|| (node.type === 'model_call' && models.has(node.data.model_call_id))
|
||||
const prune = (nodes: TraceNode[]): TraceNode[] => nodes.flatMap(node => {
|
||||
const children = prune(node.children)
|
||||
return matches(node) || children.length ? [{ ...node, children }] : []
|
||||
@@ -50,6 +53,17 @@ function resetFilters() { query.value = ''; eventType.value = ''; toolName.value
|
||||
|
||||
const traceNodes = computed(() => buildTraceNodes(props.events))
|
||||
const toolCalls = computed(() => getToolCallsFromEvents(props.events))
|
||||
const toolSummary = computed(() => {
|
||||
const groups = new Map<string, { key: string; name: string; status: string; count: number; duration_ms: number }>()
|
||||
for (const call of toolCalls.value) {
|
||||
const key = `${call.name}:${call.status}`
|
||||
const group = groups.get(key) ?? { key, name: call.name, status: call.status, count: 0, duration_ms: 0 }
|
||||
group.count++
|
||||
group.duration_ms += call.duration_ms ?? 0
|
||||
groups.set(key, group)
|
||||
}
|
||||
return [...groups.values()]
|
||||
})
|
||||
const totalDuration = computed(() => getTotalDuration(props.events))
|
||||
|
||||
const summaryStats = computed(() => {
|
||||
@@ -153,6 +167,12 @@ function flatNodes(nodes: TraceNode[], depth = 0): Array<{ node: TraceNode; dept
|
||||
}
|
||||
|
||||
const flatTrace = computed(() => flatNodes(filteredTree.value))
|
||||
const page = ref(1)
|
||||
const pageCount = computed(() => Math.max(1, Math.ceil((viewMode.value === 'tree' ? flatTrace.value.length : filteredEvents.value.length) / 200)))
|
||||
const visibleEvents = computed(() => filteredEvents.value.slice((page.value - 1) * 200, page.value * 200))
|
||||
const visibleTrace = computed(() => flatTrace.value.slice((page.value - 1) * 200, page.value * 200))
|
||||
watch([query, eventType, toolName, errorsOnly, viewMode], () => { page.value = 1 })
|
||||
watch(pageCount, count => { page.value = Math.min(page.value, count) })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -199,11 +219,12 @@ const flatTrace = computed(() => flatNodes(filteredTree.value))
|
||||
<button v-if="filtering" class="button-secondary" @click="resetFilters">清除筛选</button>
|
||||
<span aria-live="polite">{{ filteredEvents.length }} / {{ events.length }} 事件</span>
|
||||
</div>
|
||||
<nav v-if="pageCount > 1" class="inline-actions"><button class="button-secondary" :disabled="page === 1" @click="page--">{{ t('上一页', 'Previous') }}</button><span>{{ page }} / {{ pageCount }}</span><button class="button-secondary" :disabled="page === pageCount" @click="page++">{{ t('下一页', 'Next') }}</button></nav>
|
||||
<p v-if="filtering && !filteredEvents.length" class="subtle" role="status">没有匹配的事件</p>
|
||||
<div v-if="viewMode === 'timeline'" class="timeline-view">
|
||||
<div class="timeline">
|
||||
<article
|
||||
v-for="event in filteredEvents"
|
||||
v-for="event in visibleEvents"
|
||||
:key="event.sequence"
|
||||
class="event-card"
|
||||
:class="{ expanded: isDetailOpen(`event-${event.sequence}`) }"
|
||||
@@ -255,7 +276,7 @@ const flatTrace = computed(() => flatNodes(filteredTree.value))
|
||||
</div>
|
||||
|
||||
<div v-else class="tree-view">
|
||||
<div v-for="item in flatTrace" :key="item.node.id" class="tree-node" :style="{ paddingLeft: `${item.depth * 24 + 8}px` }">
|
||||
<div v-for="item in visibleTrace" :key="item.node.id" class="tree-node" :style="{ paddingLeft: `${item.depth * 24 + 8}px` }">
|
||||
<div
|
||||
class="node-row"
|
||||
:class="[getNodeStatusClass(item.node), { 'detail-open': isDetailOpen(item.node.id) }]"
|
||||
@@ -303,9 +324,10 @@ const flatTrace = computed(() => flatNodes(filteredTree.value))
|
||||
<div v-if="!filtering && toolCalls.length > 0 && viewMode === 'timeline'" class="tool-calls-summary panel">
|
||||
<h3 class="panel-title">工具调用统计</h3>
|
||||
<div class="tool-call-list">
|
||||
<div v-for="call in toolCalls" :key="call.tool_call_id" class="tool-call-item" :class="call.status">
|
||||
<div v-for="call in toolSummary" :key="call.key" class="tool-call-item" :class="call.status">
|
||||
<span class="tool-status-dot"></span>
|
||||
<code class="tool-name">{{ call.name }}</code>
|
||||
<span>{{ call.count }} {{ t('次', 'calls') }}</span>
|
||||
<span v-if="call.duration_ms != null" class="tool-duration">
|
||||
{{ formatDuration(call.duration_ms) }}
|
||||
</span>
|
||||
|
||||
Reference in New Issue
Block a user