mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
refactor(gateway): 重构 ai pipeline 规划链路
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user