fix usage trace active key selection

This commit is contained in:
elky
2026-05-30 19:48:18 +08:00
parent 0d76a8e478
commit ae96d5d61b
2 changed files with 256 additions and 50 deletions
@@ -224,13 +224,13 @@
</span>
</div>
<div
v-if="currentAttempt.key_name || currentAttempt.key_id"
v-if="currentAttemptKeyDisplay"
class="info-item"
>
<span class="info-label">{{ isOAuthType(currentAttempt.key_auth_type) ? '账号' : '密钥' }}</span>
<span class="info-value info-value-stacked">
<span class="key-name">
{{ currentAttempt.key_name || '未知' }}
{{ currentAttemptKeyDisplay }}
<span
v-if="currentAttempt.key_auth_type && currentAttempt.key_auth_type !== 'api_key'"
class="auth-type-tag"
@@ -666,6 +666,7 @@ const { isDark } = useDarkMode()
const trace = computed(() => props.traceData ?? internalTrace.value)
const selectedGroupIndex = ref(0)
const selectedAttemptIndex = ref(0)
const selectionPinnedByUser = ref(false)
const hoveredGroupIndex = ref<number | null>(null)
const traceLoadStarted = ref(false)
let tracePollTimer: ReturnType<typeof setTimeout> | null = null
@@ -1345,6 +1346,12 @@ const currentAttemptRequestPathDisplay = computed(() => {
return ''
})
const currentAttemptKeyDisplay = computed(() => {
const attempt = currentAttempt.value
if (!attempt) return ''
return attempt.key_account_label || attempt.key_name || attempt.key_id || ''
})
const currentAttemptKeyFormatsDisplay = computed(() => {
const attempt = currentAttempt.value
if (!attempt) return ''
@@ -1532,10 +1539,111 @@ const isGroupSelected = (group: NodeGroup) => {
return selectedGroupIndex.value === groupedTimeline.value.findIndex(g => g.id === group.id && g.startIndex === group.startIndex)
}
const findGroupIndex = (groups: NodeGroup[], group: NodeGroup): number => {
return groups.findIndex(g => g.id === group.id && g.startIndex === group.startIndex)
}
const selectedAttemptFromGroups = (groups: NodeGroup[]): CandidateRecord | null => {
const group = groups[selectedGroupIndex.value]
if (!group) return null
return group.allAttempts[selectedAttemptIndex.value] || null
}
const groupHasSuccess = (group: NodeGroup): boolean => {
return group.allAttempts.some(attempt => getDisplayStatus(attempt) === 'success')
}
const groupsHaveSuccess = (groups: NodeGroup[]): boolean => {
return groups.some(groupHasSuccess)
}
const groupsHaveLiveCandidate = (groups: NodeGroup[]): boolean => {
return groups.some(group => group.allAttempts.some(isLiveCandidate))
}
const TERMINAL_ATTEMPT_STATUSES = ['failed', 'cancelled', 'stream_interrupted', 'skipped']
const isTerminalResultAttempt = (attempt: CandidateRecord): boolean => {
return TERMINAL_ATTEMPT_STATUSES.includes(getDisplayStatus(attempt))
}
const groupsHaveTerminalResult = (groups: NodeGroup[]): boolean => {
return groups.some(group => group.allAttempts.some(isTerminalResultAttempt))
}
const selectedAttemptMatchesBestSilentState = (groups: NodeGroup[]): boolean => {
const attempt = selectedAttemptFromGroups(groups)
if (!attempt) return false
if (groupsHaveSuccess(groups)) {
return getDisplayStatus(attempt) === 'success'
}
if (groupsHaveLiveCandidate(groups)) {
return isLiveCandidate(attempt)
}
if (groupsHaveTerminalResult(groups)) {
return isTerminalResultAttempt(attempt)
}
return true
}
const selectMostRelevantGroup = (newGroups: NodeGroup[]) => {
if (!newGroups || newGroups.length === 0) return
// 查找成功的组
const successIdx = newGroups.findIndex(groupHasSuccess)
if (successIdx >= 0) {
selectedGroupIndex.value = successIdx
// 选中成功的尝试
const group = newGroups[successIdx]
const attemptIdx = group.allAttempts.findIndex(a => getDisplayStatus(a) === 'success')
selectedAttemptIndex.value = attemptIdx >= 0 ? attemptIdx : 0
return
}
// 查找正在进行的组
const activeIdx = newGroups.findIndex(g => g.allAttempts.some(isLiveCandidate))
if (activeIdx >= 0) {
selectedGroupIndex.value = activeIdx
// 选中正在进行的尝试,而非最后一个
const group = newGroups[activeIdx]
const attemptIdx = group.allAttempts.findIndex(isLiveCandidate)
selectedAttemptIndex.value = attemptIdx >= 0 ? attemptIdx : group.allAttempts.length - 1
return
}
// 查找最后一个有效结果的组(有实际执行过的状态:failed/cancelled/stream_interrupted/skipped
// 从后往前找第一个有效状态的组
for (let i = newGroups.length - 1; i >= 0; i--) {
const group = newGroups[i]
if (TERMINAL_ATTEMPT_STATUSES.includes(group.primaryStatus)) {
selectedGroupIndex.value = i
// 选中最后一个有效状态的尝试(从后往前遍历)
let targetIdx = -1
for (let j = group.allAttempts.length - 1; j >= 0; j--) {
if (isTerminalResultAttempt(group.allAttempts[j])) {
targetIdx = j
break
}
}
selectedAttemptIndex.value = targetIdx >= 0 ? targetIdx : group.allAttempts.length - 1
return
}
}
// 都没有有效状态,选择第一个组(避免选到末尾的未执行节点)
selectedGroupIndex.value = 0
selectedAttemptIndex.value = 0
}
// 选中一个组
const selectGroup = (group: NodeGroup) => {
const index = groupedTimeline.value.findIndex(g => g.id === group.id && g.startIndex === group.startIndex)
const index = findGroupIndex(groupedTimeline.value, group)
if (index >= 0) {
selectionPinnedByUser.value = true
selectedGroupIndex.value = index
// 默认选中成功的尝试,或最后一个尝试
const successIdx = group.allAttempts.findIndex(a => a.status === 'success')
@@ -1545,16 +1653,18 @@ const selectGroup = (group: NodeGroup) => {
// 选中一个组的首次请求
const selectFirstAttempt = (group: NodeGroup) => {
const index = groupedTimeline.value.findIndex(g => g.id === group.id && g.startIndex === group.startIndex)
const index = findGroupIndex(groupedTimeline.value, group)
if (index >= 0) {
selectionPinnedByUser.value = true
selectedGroupIndex.value = index
selectedAttemptIndex.value = 0
}
}
const selectAttemptInGroup = (group: NodeGroup, attemptIndex: number) => {
const groupIndex = groupedTimeline.value.findIndex(g => g.id === group.id && g.startIndex === group.startIndex)
const groupIndex = findGroupIndex(groupedTimeline.value, group)
if (groupIndex < 0) return
selectionPinnedByUser.value = true
selectedGroupIndex.value = groupIndex
selectedAttemptIndex.value = attemptIndex
}
@@ -1582,6 +1692,7 @@ const formatAttemptDotTitle = (attempt: CandidateRecord): string => {
const navigateGroup = (direction: number) => {
const newIndex = selectedGroupIndex.value + direction
if (newIndex >= 0 && newIndex < groupedTimeline.value.length) {
selectionPinnedByUser.value = true
selectedGroupIndex.value = newIndex
const group = groupedTimeline.value[newIndex]
// 默认选中成功的尝试,或最后一个尝试
@@ -1595,6 +1706,7 @@ const navigateAttempt = (direction: number) => {
if (!group) return
const newIndex = selectedAttemptIndex.value + direction
if (newIndex >= 0 && newIndex < group.allAttempts.length) {
selectionPinnedByUser.value = true
selectedAttemptIndex.value = newIndex
}
}
@@ -1678,57 +1790,20 @@ const scheduleTracePolling = () => {
watch(groupedTimeline, (newGroups) => {
if (!newGroups || newGroups.length === 0) return
// 静默刷新时不重置选中状态
// 静默刷新时保留用户手动选择;未手动选择时跟随成功/进行中的 Key。
if (isSilentRefresh.value) {
isSilentRefresh.value = false
return
}
// 查找成功的组
const successIdx = newGroups.findIndex(g => g.primaryStatus === 'success')
if (successIdx >= 0) {
selectedGroupIndex.value = successIdx
// 选中成功的尝试
const group = newGroups[successIdx]
const attemptIdx = group.allAttempts.findIndex(a => a.status === 'success')
selectedAttemptIndex.value = attemptIdx >= 0 ? attemptIdx : 0
return
}
// 查找正在进行的组
const activeIdx = newGroups.findIndex(g => g.allAttempts.some(isLiveCandidate))
if (activeIdx >= 0) {
selectedGroupIndex.value = activeIdx
// 选中正在进行的尝试,而非最后一个
const group = newGroups[activeIdx]
const attemptIdx = group.allAttempts.findIndex(isLiveCandidate)
selectedAttemptIndex.value = attemptIdx >= 0 ? attemptIdx : group.allAttempts.length - 1
return
}
// 查找最后一个有效结果的组(有实际执行过的状态:failed/cancelled/stream_interrupted/skipped
// 从后往前找第一个有效状态的组
const activeStatuses = ['failed', 'cancelled', 'stream_interrupted', 'skipped']
for (let i = newGroups.length - 1; i >= 0; i--) {
const group = newGroups[i]
if (activeStatuses.includes(group.primaryStatus)) {
selectedGroupIndex.value = i
// 选中最后一个有效状态的尝试(从后往前遍历)
let targetIdx = -1
for (let j = group.allAttempts.length - 1; j >= 0; j--) {
if (activeStatuses.includes(getDisplayStatus(group.allAttempts[j]))) {
targetIdx = j
break
}
}
selectedAttemptIndex.value = targetIdx >= 0 ? targetIdx : group.allAttempts.length - 1
if (selectionPinnedByUser.value && selectedAttemptFromGroups(newGroups)) {
return
}
if (selectedAttemptMatchesBestSilentState(newGroups)) {
return
}
selectMostRelevantGroup(newGroups)
return
}
// 都没有有效状态,选择第一个组(避免选到末尾的未执行节点)
selectedGroupIndex.value = 0
selectedAttemptIndex.value = 0
selectMostRelevantGroup(newGroups)
}, { immediate: true })
// 监听 requestId / 外部 trace 变化
@@ -1737,6 +1812,7 @@ watch(
() => {
selectedGroupIndex.value = 0
selectedAttemptIndex.value = 0
selectionPinnedByUser.value = false
traceLoadStarted.value = false
if (props.traceData) {
@@ -4,6 +4,14 @@ import { createApp, defineComponent, h, nextTick, type App } from 'vue'
import type { CandidateRecord, RequestTrace } from '@/api/requestTrace'
import HorizontalRequestTimeline from '../HorizontalRequestTimeline.vue'
const requestTraceApiMock = vi.hoisted(() => ({
getRequestTrace: vi.fn(),
}))
vi.mock('@/api/requestTrace', () => ({
requestTraceApi: requestTraceApiMock,
}))
vi.mock('@/components/ui/card.vue', async () => {
const { defineComponent, h } = await import('vue')
return {
@@ -75,6 +83,7 @@ vi.mock('lucide-vue-next', async () => {
})
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
type TimelineExpose = { refresh: () => Promise<void> | undefined }
function buildCandidate(overrides: Partial<CandidateRecord> = {}): CandidateRecord {
return {
@@ -105,6 +114,13 @@ function buildTrace(candidates: CandidateRecord[]): RequestTrace {
}
}
async function flushPendingUpdates() {
await Promise.resolve()
await nextTick()
await Promise.resolve()
await nextTick()
}
function mountTimeline(
traceData: RequestTrace,
extraProps: Record<string, unknown> = {},
@@ -121,7 +137,39 @@ function mountTimeline(
return root
}
function mountTimelineFromApi(
requestId: string,
extraProps: Record<string, unknown> = {},
) {
const root = document.createElement('div')
document.body.appendChild(root)
let timeline: TimelineExpose | null = null
const Host = defineComponent({
setup() {
return () => h(HorizontalRequestTimeline, {
ref: (value: unknown) => {
timeline = value as TimelineExpose | null
},
requestId,
...extraProps,
})
},
})
const app = createApp(Host)
app.mount(root)
mountedApps.push({ app, root })
return {
root,
refresh: async () => {
await timeline?.refresh()
await flushPendingUpdates()
},
}
}
afterEach(() => {
requestTraceApiMock.getRequestTrace.mockReset()
vi.useRealTimers()
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
@@ -550,4 +598,86 @@ describe('HorizontalRequestTimeline', () => {
expect(root.querySelector('.error-block .error-json')).toBeNull()
expect(root.textContent).not.toContain('"body_state":"none"')
})
it('falls back to key id while an active candidate is missing key name', async () => {
const trace = buildTrace([
buildCandidate({
id: 'cand-active-key-id',
provider_id: 'provider-active',
provider_name: 'Provider Active',
key_id: 'key-active-id',
key_name: undefined,
candidate_index: 0,
status: 'pending',
finished_at: undefined,
}),
])
trace.final_status = 'pending'
const root = mountTimeline(trace)
await nextTick()
const detailText = root.querySelector('.detail-panel')?.textContent ?? ''
expect(detailText).toContain('key-active-id')
expect(detailText).not.toContain('未知')
})
it('follows the active key when silent polling updates the trace', async () => {
const initialTrace = buildTrace([
buildCandidate({
id: 'cand-available-a',
provider_id: 'provider-a',
provider_name: 'Provider A',
key_id: 'key-a',
key_name: 'Available Key',
candidate_index: 0,
status: 'available',
started_at: undefined,
finished_at: undefined,
}),
buildCandidate({
id: 'cand-available-b',
provider_id: 'provider-b',
provider_name: 'Provider B',
key_id: 'key-b',
key_name: 'Streaming Key',
candidate_index: 1,
status: 'available',
started_at: undefined,
finished_at: undefined,
}),
])
initialTrace.final_status = 'pending'
const activeTrace = buildTrace([
initialTrace.candidates[0],
buildCandidate({
id: 'cand-streaming-b',
provider_id: 'provider-b',
provider_name: 'Provider B',
key_id: 'key-b',
key_name: 'Streaming Key',
candidate_index: 1,
status: 'pending',
started_at: '2026-05-06T12:00:02.000Z',
finished_at: undefined,
}),
])
activeTrace.final_status = 'streaming'
requestTraceApiMock.getRequestTrace.mockResolvedValueOnce(initialTrace)
const { root, refresh } = mountTimelineFromApi('req-1', {
requestStatus: 'streaming',
})
await flushPendingUpdates()
expect(root.querySelector('.detail-panel')?.textContent).toContain('Available Key')
requestTraceApiMock.getRequestTrace.mockResolvedValueOnce(activeTrace)
await refresh()
const detailText = root.querySelector('.detail-panel')?.textContent ?? ''
expect(detailText).toContain('Streaming Key')
expect(detailText).toContain('进行中')
})
})