refactor(gateway): 重构 ai pipeline 规划链路

This commit is contained in:
fawney19
2026-04-14 01:27:04 +08:00
parent 37bb120d20
commit 53acfbabf6
74 changed files with 7357 additions and 4380 deletions

View File

@@ -70,7 +70,7 @@
<div
class="node-dot"
:class="[
getStatusColorClass(getDisplayStatus(group.primary)),
getStatusColorClass(group.primaryStatus),
{ 'is-first-selected': isGroupSelected(group) && selectedAttemptIndex === 0 }
]"
@click.stop="selectFirstAttempt(group)"
@@ -118,7 +118,7 @@
<div class="panel-title">
<span
class="title-dot"
:class="getStatusColorClass(getDisplayStatus(currentAttempt))"
:class="getStatusColorClass(currentAttemptDisplayStatus)"
/>
<span class="title-text">{{ currentGroupTitle }}</span>
<a
@@ -133,15 +133,31 @@
</a>
<span
class="status-tag"
:class="getStatusColorClass(getDisplayStatus(currentAttempt))"
:class="getStatusColorClass(currentAttemptDisplayStatus)"
>
{{ currentAttempt.status_code || getStatusLabel(currentAttempt.status) }}
{{ currentAttempt.status_code || getStatusLabel(currentAttemptDisplayStatus) }}
</span>
<!-- Key 标识 -->
<template v-if="selectedGroup.retryCount > 0">
<span class="cache-hint">
{{ selectedAttemptIndex + 1 }}/{{ selectedGroup.allAttempts.length }}
</span>
<div class="attempt-switcher">
<button
class="attempt-nav-btn"
:disabled="selectedAttemptIndex === 0"
@click.stop="navigateAttempt(-1)"
>
<ChevronLeft class="w-3 h-3" />
</button>
<span class="cache-hint">
{{ selectedAttemptIndex + 1 }}/{{ selectedGroup.allAttempts.length }}
</span>
<button
class="attempt-nav-btn"
:disabled="selectedAttemptIndex === selectedGroup.allAttempts.length - 1"
@click.stop="navigateAttempt(1)"
>
<ChevronRight class="w-3 h-3" />
</button>
</div>
</template>
</div>
<div class="panel-nav">
@@ -457,6 +473,7 @@ import { requestTraceApi, type RequestTrace, type CandidateRecord } from '@/api/
import { log } from '@/utils/logger'
import { parseApiError } from '@/utils/errorParser'
import { formatApiFormat } from '@/api/endpoints/types/api-format'
import { resolveTimelineFinalStatus } from '../utils/status'
// 节点组类型
interface NodeGroup {
@@ -527,20 +544,14 @@ const formatNumber = (num: number): string => {
// 计算最终状态:优先检查进行中状态,再使用外部状态码
const computedFinalStatus = computed(() => {
// 优先检查是否有进行中或流式传输的候选(请求尚未完成)
const hasPending = trace.value?.candidates?.some(
c => c.status === 'pending' || c.status === 'streaming'
)
if (hasPending) {
return 'pending'
}
// 使用外部状态码判断最终状态
if (props.overrideStatusCode !== undefined) {
return props.overrideStatusCode === 200 ? 'success' : 'failed'
}
return trace.value?.final_status || 'pending'
return resolveTimelineFinalStatus({
hasPendingCandidates: hasPending,
statusCode: props.overrideStatusCode,
traceFinalStatus: trace.value?.final_status,
})
})
// 获取最终状态标签
@@ -548,6 +559,7 @@ const getFinalStatusLabel = (status: string) => {
const labels: Record<string, string> = {
success: '最终成功',
failed: '最终失败',
cancelled: '已取消',
streaming: '流式传输中',
pending: '进行中'
}
@@ -561,6 +573,7 @@ const getFinalStatusBadgeVariant = (status: string): BadgeVariant => {
const variants: Record<string, BadgeVariant> = {
success: 'success',
failed: 'destructive',
cancelled: 'warning',
streaming: 'secondary',
pending: 'secondary'
}
@@ -926,9 +939,9 @@ const buildProviderGroups = (items: CandidateRecord[]): NodeGroup[] => {
currentGroup.hasConversion = true
}
const currentPriority = STATUS_PRIORITY[currentGroup.primaryStatus] ?? 0
const newPriority = STATUS_PRIORITY[candidate.status] ?? 0
const newPriority = STATUS_PRIORITY[getDisplayStatus(candidate)] ?? 0
if (newPriority > currentPriority) {
currentGroup.primaryStatus = candidate.status
currentGroup.primaryStatus = getDisplayStatus(candidate)
}
return
}
@@ -937,7 +950,7 @@ const buildProviderGroups = (items: CandidateRecord[]): NodeGroup[] => {
id: providerKey,
providerName: getProviderDisplayName(candidate),
primary: candidate,
primaryStatus: candidate.status,
primaryStatus: getDisplayStatus(candidate),
allAttempts: [candidate],
retryCount: 0,
totalLatency: candidate.latency_ms || 0,
@@ -975,9 +988,10 @@ const groupedTimeline = computed<NodeGroup[]>(() => {
const poolPrimaryStatus = attempts.reduce((best, current) => {
const bestPriority = STATUS_PRIORITY[best] ?? 0
const currentPriority = STATUS_PRIORITY[current.status] ?? 0
return currentPriority > bestPriority ? current.status : best
}, attempts[0].status)
const currentStatus = getDisplayStatus(current)
const currentPriority = STATUS_PRIORITY[currentStatus] ?? 0
return currentPriority > bestPriority ? currentStatus : best
}, getDisplayStatus(attempts[0]))
const successAttempt = attempts.find((item) => item.status === 'success')
const poolPrimary = successAttempt || attempts[attempts.length - 1] || attempts[0]
@@ -1090,6 +1104,8 @@ const currentAttempt = computed(() => {
return selectedGroup.value.allAttempts[selectedAttemptIndex.value] || selectedGroup.value.primary
})
const currentAttemptDisplayStatus = computed(() => getDisplayStatus(currentAttempt.value))
watch(currentAttempt, (attempt) => {
emit('selectAttempt', attempt ?? null)
}, { immediate: true })
@@ -1345,6 +1361,15 @@ const navigateGroup = (direction: number) => {
}
}
const navigateAttempt = (direction: number) => {
const group = selectedGroup.value
if (!group) return
const newIndex = selectedAttemptIndex.value + direction
if (newIndex >= 0 && newIndex < group.allAttempts.length) {
selectedAttemptIndex.value = newIndex
}
}
// 加载请求追踪数据
const isSilentRefresh = ref(false)
const loadTrace = async (silent = false) => {
@@ -1403,7 +1428,10 @@ watch(groupedTimeline, (newGroups) => {
selectedGroupIndex.value = activeIdx
// 选中正在进行的尝试,而非最后一个
const group = newGroups[activeIdx]
const attemptIdx = group.allAttempts.findIndex(a => a.status === 'pending' || a.status === 'streaming')
const attemptIdx = group.allAttempts.findIndex(a => {
const status = getDisplayStatus(a)
return status === 'pending' || status === 'streaming'
})
selectedAttemptIndex.value = attemptIdx >= 0 ? attemptIdx : group.allAttempts.length - 1
return
}
@@ -1418,7 +1446,7 @@ watch(groupedTimeline, (newGroups) => {
// 选中最后一个有效状态的尝试(从后往前遍历)
let targetIdx = -1
for (let j = group.allAttempts.length - 1; j >= 0; j--) {
if (activeStatuses.includes(group.allAttempts[j].status)) {
if (activeStatuses.includes(getDisplayStatus(group.allAttempts[j]))) {
targetIdx = j
break
}
@@ -1490,7 +1518,7 @@ const getStatusLabel = (status: string) => {
const labels: Record<string, string> = {
available: '未执行',
unused: '未执行',
pending: '等待中',
pending: '进行中',
streaming: '传输中',
stream_interrupted: '流中断',
success: '成功',
@@ -1551,7 +1579,7 @@ const getDisplayStatus = (attempt: CandidateRecord | null | undefined): string =
align-items: center;
justify-content: safe center;
gap: 64px;
padding: 2rem;
padding: 2rem 2rem 2.75rem;
overflow-x: auto;
overflow-y: hidden;
@@ -1650,6 +1678,7 @@ const getDisplayStatus = (attempt: CandidateRecord | null | undefined): string =
gap: 6px;
padding: 0;
background: transparent;
z-index: 3;
}
/* 子节点 - 增大点击区域 */
@@ -1942,9 +1971,39 @@ const getDisplayStatus = (attempt: CandidateRecord | null | undefined): string =
color: hsl(var(--muted-foreground));
background: hsl(var(--muted) / 0.5);
border-radius: 4px;
}
.attempt-switcher {
display: inline-flex;
align-items: center;
gap: 0.375rem;
margin-left: 0.5rem;
}
.attempt-nav-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: 1px solid hsl(var(--border));
background: hsl(var(--background));
border-radius: 9999px;
color: hsl(var(--muted-foreground));
cursor: pointer;
transition: all 0.15s ease;
}
.attempt-nav-btn:hover:not(:disabled) {
background: hsl(var(--muted));
color: hsl(var(--foreground));
}
.attempt-nav-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.info-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);

View File

@@ -1,6 +1,12 @@
import { describe, expect, it } from 'vitest'
import { isUsageRecordFailed, isUsageRecordSuccessful } from '../status'
import {
isUsageRecordFailed,
isUsageRecordSuccessful,
mapRequestStatusToTimelineStatus,
normalizeRequestStatus,
resolveTimelineFinalStatus,
} from '../status'
import type { UsageRecord } from '../../types'
function buildUsageRecord(overrides: Partial<UsageRecord> = {}): UsageRecord {
@@ -40,4 +46,44 @@ describe('usage status helpers', () => {
expect(isUsageRecordFailed(record)).toBe(true)
expect(isUsageRecordSuccessful(record)).toBe(false)
})
it('treats explicit failed status with a 2xx status code as successful for display', () => {
const record = buildUsageRecord({
status: 'failed',
status_code: 200,
error_message: 'stale failure flag'
})
expect(isUsageRecordFailed(record)).toBe(false)
expect(isUsageRecordSuccessful(record)).toBe(true)
})
it('normalizes request status strings before mapping timeline status', () => {
expect(normalizeRequestStatus(' Completed ')).toBe('completed')
expect(mapRequestStatusToTimelineStatus('completed')).toBe('success')
expect(mapRequestStatusToTimelineStatus('failed')).toBe('failed')
})
it('treats explicit success status code as authoritative for the timeline', () => {
expect(resolveTimelineFinalStatus({
traceFinalStatus: 'success',
requestStatus: 'failed',
statusCode: 200,
})).toBe('success')
})
it('falls back to request lifecycle status when status code and trace are missing', () => {
expect(resolveTimelineFinalStatus({
requestStatus: 'failed',
})).toBe('failed')
})
it('uses status code only as a last fallback for timeline status', () => {
expect(resolveTimelineFinalStatus({
statusCode: 200,
})).toBe('success')
expect(resolveTimelineFinalStatus({
statusCode: 503,
})).toBe('failed')
})
})

View File

@@ -1,4 +1,8 @@
import type { UsageRecord } from '../types'
import type { RequestStatus, UsageRecord } from '../types'
export type TimelineFinalStatus = 'success' | 'failed' | 'streaming' | 'pending' | 'cancelled'
type RequestStatusLike = RequestStatus | string | null | undefined
function hasLegacyFailureSignal(
record: Pick<UsageRecord, 'status_code' | 'error_message'>
@@ -7,10 +11,32 @@ function hasLegacyFailureSignal(
(typeof record.error_message === 'string' && record.error_message.trim().length > 0)
}
function hasTerminalSuccessStatusCode(
record: Pick<UsageRecord, 'status_code'>
): boolean {
return typeof record.status_code === 'number' &&
record.status_code >= 200 &&
record.status_code < 400
}
export function isUsageRecordFailed(
record: Pick<UsageRecord, 'status' | 'status_code' | 'error_message'>
): boolean {
const status = typeof record.status === 'string' ? record.status.trim().toLowerCase() : ''
if (status) {
if (status === 'pending' || status === 'streaming' || status === 'cancelled') {
return false
}
if (status === 'completed') {
return false
}
if (status === 'failed') {
return !hasTerminalSuccessStatusCode(record)
}
}
if (hasTerminalSuccessStatusCode(record)) {
return false
}
if (status) {
return status === 'failed'
}
@@ -22,7 +48,90 @@ export function isUsageRecordSuccessful(
): boolean {
const status = typeof record.status === 'string' ? record.status.trim().toLowerCase() : ''
if (status) {
return status === 'completed'
if (status === 'completed') {
return true
}
if (status === 'failed') {
return hasTerminalSuccessStatusCode(record)
}
return false
}
if (hasTerminalSuccessStatusCode(record)) {
return true
}
return !hasLegacyFailureSignal(record)
}
export function normalizeRequestStatus(status: RequestStatusLike): RequestStatus | undefined {
const normalized = typeof status === 'string' ? status.trim().toLowerCase() : ''
switch (normalized) {
case 'pending':
case 'streaming':
case 'completed':
case 'failed':
case 'cancelled':
return normalized
default:
return undefined
}
}
export function mapRequestStatusToTimelineStatus(
status: RequestStatusLike
): TimelineFinalStatus | undefined {
switch (normalizeRequestStatus(status)) {
case 'completed':
return 'success'
case 'failed':
return 'failed'
case 'streaming':
return 'streaming'
case 'pending':
return 'pending'
case 'cancelled':
return 'cancelled'
default:
return undefined
}
}
function normalizeTimelineFinalStatus(status: string | null | undefined): TimelineFinalStatus | undefined {
const normalized = typeof status === 'string' ? status.trim().toLowerCase() : ''
switch (normalized) {
case 'success':
case 'failed':
case 'streaming':
case 'pending':
case 'cancelled':
return normalized
default:
return undefined
}
}
export function resolveTimelineFinalStatus(params: {
hasPendingCandidates?: boolean
traceFinalStatus?: string | null
requestStatus?: RequestStatusLike
statusCode?: number
}): TimelineFinalStatus {
if (params.hasPendingCandidates) {
return 'pending'
}
if (typeof params.statusCode === 'number') {
return params.statusCode >= 200 && params.statusCode < 400 ? 'success' : 'failed'
}
const traceStatus = normalizeTimelineFinalStatus(params.traceFinalStatus)
if (traceStatus) {
return traceStatus
}
const requestStatus = mapRequestStatusToTimelineStatus(params.requestStatus)
if (requestStatus) {
return requestStatus
}
return 'pending'
}