mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
Add Codex image progress heartbeat
This commit is contained in:
@@ -9,6 +9,19 @@ export interface CandidateRankingMetadata {
|
||||
demoted_by?: string
|
||||
}
|
||||
|
||||
export interface ImageProgress {
|
||||
phase?: 'upstream_connecting' | 'upstream_streaming' | 'upstream_completed' | 'failed' | string
|
||||
upstream_ttfb_ms?: number | null
|
||||
upstream_sse_frame_count?: number | null
|
||||
last_upstream_event?: string | null
|
||||
last_upstream_frame_at_unix_ms?: number | null
|
||||
partial_image_count?: number | null
|
||||
last_client_visible_event?: string | null
|
||||
downstream_heartbeat_count?: number | null
|
||||
last_downstream_heartbeat_at_unix_ms?: number | null
|
||||
downstream_heartbeat_interval_ms?: number | null
|
||||
}
|
||||
|
||||
export interface CandidateRecord {
|
||||
id: string
|
||||
request_id: string
|
||||
@@ -46,6 +59,7 @@ export interface CandidateRecord {
|
||||
latency_ms?: number
|
||||
concurrent_requests?: number
|
||||
ranking?: CandidateRankingMetadata | null
|
||||
image_progress?: ImageProgress | null
|
||||
extra_data?: Record<string, unknown>
|
||||
created_at: string
|
||||
started_at?: string
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import apiClient from './client'
|
||||
import { cachedRequest, dedupedRequest, buildCacheKey } from '@/utils/cache'
|
||||
import type { ActivityHeatmap } from '@/types/activity'
|
||||
import type { ImageProgress } from './requestTrace'
|
||||
|
||||
const ACTIVITY_HEATMAP_CACHE_TTL_MS = 30 * 60 * 1000
|
||||
|
||||
@@ -321,6 +322,7 @@ export const usageApi = {
|
||||
has_format_conversion?: boolean | null
|
||||
has_fallback?: boolean | null
|
||||
target_model?: string | null
|
||||
image_progress?: ImageProgress | null
|
||||
}>
|
||||
}> {
|
||||
const params: Record<string, string | number> = {}
|
||||
|
||||
@@ -404,6 +404,67 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="currentImageProgress"
|
||||
class="image-progress-block"
|
||||
>
|
||||
<div class="image-progress-header">
|
||||
<span class="image-progress-title">图片生成进度</span>
|
||||
<span
|
||||
class="image-progress-phase"
|
||||
:class="imageProgressPhaseClass(currentImageProgress.phase)"
|
||||
>
|
||||
{{ formatImageProgressPhase(currentImageProgress.phase) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="image-progress-grid">
|
||||
<div class="image-progress-item">
|
||||
<span class="image-progress-label">上游 TTFB</span>
|
||||
<span class="image-progress-value mono">{{ formatLatency(currentImageProgress.upstream_ttfb_ms) }}</span>
|
||||
</div>
|
||||
<div class="image-progress-item">
|
||||
<span class="image-progress-label">SSE 帧数</span>
|
||||
<span class="image-progress-value mono">{{ formatProgressCount(currentImageProgress.upstream_sse_frame_count) }}</span>
|
||||
</div>
|
||||
<div class="image-progress-item">
|
||||
<span class="image-progress-label">Partial 图片</span>
|
||||
<span class="image-progress-value mono">{{ formatProgressCount(currentImageProgress.partial_image_count) }}</span>
|
||||
</div>
|
||||
<div class="image-progress-item">
|
||||
<span class="image-progress-label">最后帧</span>
|
||||
<span class="image-progress-value mono">{{ formatProgressFrameTime(currentImageProgress.last_upstream_frame_at_unix_ms) }}</span>
|
||||
</div>
|
||||
<template v-if="hasDownstreamHeartbeatProgress">
|
||||
<div class="image-progress-item">
|
||||
<span class="image-progress-label">下游心跳</span>
|
||||
<span class="image-progress-value mono">{{ formatProgressCount(currentImageProgress.downstream_heartbeat_count) }}</span>
|
||||
</div>
|
||||
<div class="image-progress-item">
|
||||
<span class="image-progress-label">心跳间隔</span>
|
||||
<span class="image-progress-value mono">{{ formatLatency(currentImageProgress.downstream_heartbeat_interval_ms) }}</span>
|
||||
</div>
|
||||
<div class="image-progress-item">
|
||||
<span class="image-progress-label">最后心跳</span>
|
||||
<span class="image-progress-value mono">{{ formatProgressFrameTime(currentImageProgress.last_downstream_heartbeat_at_unix_ms) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-if="currentImageProgress.last_upstream_event"
|
||||
class="image-progress-item full-width"
|
||||
>
|
||||
<span class="image-progress-label">上游事件</span>
|
||||
<code class="image-progress-code">{{ currentImageProgress.last_upstream_event }}</code>
|
||||
</div>
|
||||
<div
|
||||
v-if="currentImageProgress.last_client_visible_event"
|
||||
class="image-progress-item full-width"
|
||||
>
|
||||
<span class="image-progress-label">客户端可见事件</span>
|
||||
<code class="image-progress-code">{{ currentImageProgress.last_client_visible_event }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 用量与费用(仅成功节点显示) -->
|
||||
<div
|
||||
v-if="currentAttempt.status === 'success' && usageData"
|
||||
@@ -528,14 +589,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { ref, watch, computed, onBeforeUnmount } from 'vue'
|
||||
import { isAxiosError } from 'axios'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Skeleton from '@/components/ui/skeleton.vue'
|
||||
import JsonContentPanel from './JsonContentPanel.vue'
|
||||
import { ChevronLeft, ChevronRight, ExternalLink } from 'lucide-vue-next'
|
||||
import { requestTraceApi, type RequestTrace, type CandidateRecord } from '@/api/requestTrace'
|
||||
import { requestTraceApi, type RequestTrace, type CandidateRecord, type ImageProgress } from '@/api/requestTrace'
|
||||
import { log } from '@/utils/logger'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
@@ -619,7 +680,15 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectAttempt: [attempt: CandidateRecord | null]
|
||||
traceState: [state: { loaded: boolean, hasTrace: boolean }]
|
||||
traceState: [state: {
|
||||
loaded: boolean
|
||||
hasTrace: boolean
|
||||
finalStatus?: RequestTrace['final_status'] | null
|
||||
statusCode?: number | null
|
||||
latencyMs?: number | null
|
||||
imageProgress?: ImageProgress | null
|
||||
errorMessage?: string | null
|
||||
}]
|
||||
}>()
|
||||
|
||||
// 用量数据(从 props 获取)
|
||||
@@ -678,18 +747,9 @@ const selectedGroupIndex = ref(0)
|
||||
const selectedAttemptIndex = ref(0)
|
||||
const hoveredGroupIndex = ref<number | null>(null)
|
||||
const traceLoadStarted = ref(false)
|
||||
|
||||
watch(
|
||||
[trace, loading],
|
||||
([value, isLoading]) => {
|
||||
const waitingForInternalTrace = Boolean(props.requestId && !props.traceData && !traceLoadStarted.value && !value)
|
||||
emit('traceState', {
|
||||
loaded: !isLoading && !waitingForInternalTrace,
|
||||
hasTrace: Boolean(value?.candidates?.length),
|
||||
})
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
let tracePollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let traceLoadInFlight: Promise<void> | null = null
|
||||
const TRACE_POLL_INTERVAL_MS = 1000
|
||||
|
||||
// 格式化延迟(自动调整单位)
|
||||
const formatLatency = (ms: number | undefined | null): string => {
|
||||
@@ -1109,6 +1169,112 @@ const readBooleanField = (obj: Record<string, unknown>, key: string): boolean |
|
||||
return typeof value === 'boolean' ? value : undefined
|
||||
}
|
||||
|
||||
const normalizeImageProgress = (value: unknown): ImageProgress | null => {
|
||||
const raw = extractObject(value)
|
||||
if (!raw) return null
|
||||
|
||||
const progress: ImageProgress = {
|
||||
phase: readStringField(raw, 'phase'),
|
||||
upstream_ttfb_ms: readNumberField(raw, 'upstream_ttfb_ms') ?? null,
|
||||
upstream_sse_frame_count: readNumberField(raw, 'upstream_sse_frame_count') ?? null,
|
||||
last_upstream_event: readStringField(raw, 'last_upstream_event') ?? null,
|
||||
last_upstream_frame_at_unix_ms: readNumberField(raw, 'last_upstream_frame_at_unix_ms') ?? null,
|
||||
partial_image_count: readNumberField(raw, 'partial_image_count') ?? null,
|
||||
last_client_visible_event: readStringField(raw, 'last_client_visible_event') ?? null,
|
||||
downstream_heartbeat_count: readNumberField(raw, 'downstream_heartbeat_count') ?? null,
|
||||
last_downstream_heartbeat_at_unix_ms: readNumberField(raw, 'last_downstream_heartbeat_at_unix_ms') ?? null,
|
||||
downstream_heartbeat_interval_ms: readNumberField(raw, 'downstream_heartbeat_interval_ms') ?? null,
|
||||
}
|
||||
|
||||
return Object.values(progress).some(value => value !== undefined && value !== null && value !== '') ? progress : null
|
||||
}
|
||||
|
||||
const currentImageProgress = computed<ImageProgress | null>(() => {
|
||||
const attempt = currentAttempt.value
|
||||
if (!attempt) return null
|
||||
return normalizeImageProgress(attempt.image_progress)
|
||||
?? normalizeImageProgress(extractObject(attempt.extra_data)?.image_progress)
|
||||
})
|
||||
|
||||
const formatImageProgressPhase = (phase?: string | null): string => {
|
||||
const labels: Record<string, string> = {
|
||||
upstream_connecting: '连接上游',
|
||||
upstream_streaming: '上游生成中',
|
||||
upstream_completed: '上游已完成',
|
||||
failed: '失败',
|
||||
}
|
||||
if (!phase) return '未知'
|
||||
return labels[phase] || phase
|
||||
}
|
||||
|
||||
const imageProgressPhaseClass = (phase?: string | null): string => {
|
||||
if (phase === 'upstream_completed') return 'phase-completed'
|
||||
if (phase === 'failed') return 'phase-failed'
|
||||
if (phase === 'upstream_streaming') return 'phase-streaming'
|
||||
return 'phase-connecting'
|
||||
}
|
||||
|
||||
const formatProgressCount = (value?: number | null): string => {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? String(value) : '-'
|
||||
}
|
||||
|
||||
const formatProgressFrameTime = (value?: number | null): string => {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return '-'
|
||||
const date = new Date(value)
|
||||
const time = formatTime(date.toISOString())
|
||||
const ageMs = Date.now() - value
|
||||
if (ageMs >= 0 && ageMs < 60_000) {
|
||||
return `${Math.max(0, Math.round(ageMs / 1000))}s 前 (${time})`
|
||||
}
|
||||
return time
|
||||
}
|
||||
|
||||
const hasDownstreamHeartbeatProgress = computed(() => {
|
||||
const progress = currentImageProgress.value
|
||||
return typeof progress?.downstream_heartbeat_count === 'number' ||
|
||||
typeof progress?.last_downstream_heartbeat_at_unix_ms === 'number' ||
|
||||
typeof progress?.downstream_heartbeat_interval_ms === 'number'
|
||||
})
|
||||
|
||||
const latestTraceAttemptForState = computed<CandidateRecord | null>(() => {
|
||||
const candidates = rawTimeline.value
|
||||
for (let index = candidates.length - 1; index >= 0; index -= 1) {
|
||||
const candidate = candidates[index]
|
||||
if (candidate.status !== 'available' && candidate.status !== 'unused') {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const latestTraceImageProgress = computed<ImageProgress | null>(() => {
|
||||
const candidates = rawTimeline.value
|
||||
for (let index = candidates.length - 1; index >= 0; index -= 1) {
|
||||
const candidate = candidates[index]
|
||||
const progress = normalizeImageProgress(candidate.image_progress)
|
||||
?? normalizeImageProgress(extractObject(candidate.extra_data)?.image_progress)
|
||||
if (progress) return progress
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
watch(
|
||||
[trace, loading, latestTraceImageProgress, latestTraceAttemptForState, computedFinalStatus],
|
||||
([value, isLoading, imageProgress, attempt, finalStatus]) => {
|
||||
const waitingForInternalTrace = Boolean(props.requestId && !props.traceData && !traceLoadStarted.value && !value)
|
||||
emit('traceState', {
|
||||
loaded: !isLoading && !waitingForInternalTrace,
|
||||
hasTrace: Boolean(value?.candidates?.length),
|
||||
finalStatus: finalStatus ?? value?.final_status ?? null,
|
||||
statusCode: attempt?.status_code ?? null,
|
||||
latencyMs: attempt?.latency_ms ?? value?.total_latency_ms ?? null,
|
||||
imageProgress,
|
||||
errorMessage: attempt?.error_message ?? null,
|
||||
})
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const normalizeAttemptErrorFlow = (value: unknown): AttemptErrorFlow | null => {
|
||||
const raw = extractObject(value)
|
||||
if (!raw) return null
|
||||
@@ -1556,6 +1722,15 @@ const keyCapabilities = computed(() => {
|
||||
.map(([key]) => key)
|
||||
})
|
||||
|
||||
const hasActiveImageProgress = computed(() => {
|
||||
return rawTimeline.value.some((candidate) => {
|
||||
const progress = normalizeImageProgress(candidate.image_progress)
|
||||
?? normalizeImageProgress(extractObject(candidate.extra_data)?.image_progress)
|
||||
if (!progress?.phase) return false
|
||||
return progress.phase !== 'upstream_completed' && progress.phase !== 'failed'
|
||||
})
|
||||
})
|
||||
|
||||
// 判断是否为 OAuth 类型(provider_type 为具体值时也算 OAuth)
|
||||
const isOAuthType = (authType?: string): boolean => {
|
||||
if (!authType) return false
|
||||
@@ -1694,34 +1869,80 @@ const navigateAttempt = (direction: number) => {
|
||||
const isSilentRefresh = ref(false)
|
||||
const loadTrace = async (silent = false) => {
|
||||
if (!props.requestId || props.traceData) return
|
||||
if (traceLoadInFlight) return traceLoadInFlight
|
||||
|
||||
isSilentRefresh.value = silent
|
||||
traceLoadStarted.value = true
|
||||
traceLoadInFlight = (async () => {
|
||||
isSilentRefresh.value = silent
|
||||
traceLoadStarted.value = true
|
||||
|
||||
if (!silent) {
|
||||
loading.value = true
|
||||
}
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
internalTrace.value = await requestTraceApi.getRequestTrace(props.requestId)
|
||||
} catch (err: unknown) {
|
||||
if (isAxiosError(err) && err.response?.status === 404) {
|
||||
internalTrace.value = null
|
||||
error.value = null
|
||||
return
|
||||
}
|
||||
if (!silent) {
|
||||
error.value = parseApiError(err, '加载失败')
|
||||
loading.value = true
|
||||
}
|
||||
log.error('加载请求追踪失败:', err)
|
||||
} finally {
|
||||
if (!silent) {
|
||||
loading.value = false
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
internalTrace.value = await requestTraceApi.getRequestTrace(props.requestId)
|
||||
} catch (err: unknown) {
|
||||
if (isAxiosError(err) && err.response?.status === 404) {
|
||||
internalTrace.value = null
|
||||
error.value = null
|
||||
return
|
||||
}
|
||||
if (!silent) {
|
||||
error.value = parseApiError(err, '加载失败')
|
||||
}
|
||||
log.error('加载请求追踪失败:', err)
|
||||
} finally {
|
||||
if (!silent) {
|
||||
loading.value = false
|
||||
}
|
||||
traceLoadInFlight = null
|
||||
}
|
||||
})()
|
||||
|
||||
return traceLoadInFlight
|
||||
}
|
||||
|
||||
const propsRequestIsActive = computed(() => {
|
||||
const status = props.requestStatus ?? usageData.value?.status
|
||||
return status === 'pending' || status === 'streaming'
|
||||
})
|
||||
|
||||
const traceHasActiveCandidate = computed(() => {
|
||||
return rawTimeline.value.some((candidate) => {
|
||||
const status = getDisplayStatus(candidate)
|
||||
return status === 'pending' || status === 'streaming'
|
||||
})
|
||||
})
|
||||
|
||||
const traceFinalIsTerminal = computed(() => {
|
||||
const status = trace.value?.final_status
|
||||
return status === 'success' || status === 'failed' || status === 'cancelled'
|
||||
})
|
||||
|
||||
const shouldPollTrace = computed(() => {
|
||||
if (!props.requestId || props.traceData) return false
|
||||
if (traceHasActiveCandidate.value || hasActiveImageProgress.value) return true
|
||||
return propsRequestIsActive.value && !traceFinalIsTerminal.value
|
||||
})
|
||||
|
||||
const stopTracePolling = () => {
|
||||
if (tracePollTimer) {
|
||||
clearTimeout(tracePollTimer)
|
||||
tracePollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleTracePolling = () => {
|
||||
stopTracePolling()
|
||||
if (!shouldPollTrace.value) return
|
||||
|
||||
tracePollTimer = setTimeout(async () => {
|
||||
await loadTrace(true)
|
||||
scheduleTracePolling()
|
||||
}, TRACE_POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
// 监听 groupedTimeline 变化,自动选择最有意义的组
|
||||
watch(groupedTimeline, (newGroups) => {
|
||||
if (!newGroups || newGroups.length === 0) return
|
||||
@@ -1809,6 +2030,14 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(shouldPollTrace, () => {
|
||||
scheduleTracePolling()
|
||||
}, { immediate: true })
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopTracePolling()
|
||||
})
|
||||
|
||||
defineExpose({ refresh: () => loadTrace(true) })
|
||||
|
||||
// 格式化时间(详细)
|
||||
@@ -2514,6 +2743,112 @@ function getDisplayStatus(attempt: CandidateRecord | null | undefined): string {
|
||||
background: hsl(var(--primary) / 0.08);
|
||||
}
|
||||
|
||||
.image-progress-block {
|
||||
margin-top: 0.875rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid hsl(var(--border) / 0.7);
|
||||
border-radius: 8px;
|
||||
background: hsl(var(--background) / 0.72);
|
||||
}
|
||||
|
||||
.image-progress-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.625rem;
|
||||
}
|
||||
|
||||
.image-progress-title {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.image-progress-phase {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
border: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.image-progress-phase.phase-connecting,
|
||||
.image-progress-phase.phase-streaming {
|
||||
color: #2563eb;
|
||||
background: #3b82f614;
|
||||
border-color: #3b82f633;
|
||||
}
|
||||
|
||||
.image-progress-phase.phase-completed {
|
||||
color: #16a34a;
|
||||
background: #22c55e14;
|
||||
border-color: #22c55e33;
|
||||
}
|
||||
|
||||
.image-progress-phase.phase-failed {
|
||||
color: #dc2626;
|
||||
background: #ef444414;
|
||||
border-color: #ef444433;
|
||||
}
|
||||
|
||||
.image-progress-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.625rem 0.875rem;
|
||||
}
|
||||
|
||||
.image-progress-item {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.image-progress-item.full-width {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.image-progress-label {
|
||||
font-size: 0.68rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.image-progress-value {
|
||||
min-width: 0;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.image-progress-code {
|
||||
min-width: 0;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 0.12rem 0.35rem;
|
||||
border-radius: 4px;
|
||||
background: hsl(var(--muted));
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.72rem;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.image-progress-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.image-progress-item.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Provider 官网链接 */
|
||||
.provider-link {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -751,6 +751,7 @@ import Tabs from '@/components/ui/tabs.vue'
|
||||
import TabsContent from '@/components/ui/tabs-content.vue'
|
||||
import { Check, Columns2, RefreshCw, X, Monitor, Server, MessageSquareText, Code2, Terminal, Play } from 'lucide-vue-next'
|
||||
import { dashboardApi, type RequestDetail, type RequestErrorDomain } from '@/api/dashboard'
|
||||
import type { ImageProgress, RequestTrace } from '@/api/requestTrace'
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
import { formatShortRequestId } from '@/utils/format'
|
||||
import { log } from '@/utils/logger'
|
||||
@@ -792,6 +793,15 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
requestState: [state: {
|
||||
id: string
|
||||
requestId?: string | null
|
||||
status?: 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
|
||||
statusCode?: number | null
|
||||
responseTimeMs?: number | null
|
||||
imageProgress?: ImageProgress | null
|
||||
errorMessage?: string | null
|
||||
}]
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
@@ -868,9 +878,52 @@ function formatErrorDomainMeta(domain: NormalizedErrorDomain): string {
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
function handleTraceState(state: { loaded: boolean, hasTrace: boolean }) {
|
||||
function mapTraceFinalStatusToRequestStatus(
|
||||
status?: RequestTrace['final_status'] | null
|
||||
): 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled' | undefined {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return 'completed'
|
||||
case 'failed':
|
||||
return 'failed'
|
||||
case 'cancelled':
|
||||
return 'cancelled'
|
||||
case 'streaming':
|
||||
return 'streaming'
|
||||
case 'pending':
|
||||
return 'pending'
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function handleTraceState(state: {
|
||||
loaded: boolean
|
||||
hasTrace: boolean
|
||||
finalStatus?: RequestTrace['final_status'] | null
|
||||
statusCode?: number | null
|
||||
latencyMs?: number | null
|
||||
imageProgress?: ImageProgress | null
|
||||
errorMessage?: string | null
|
||||
}) {
|
||||
timelineLoaded.value = state.loaded
|
||||
timelineHasTrace.value = state.hasTrace
|
||||
const id = props.requestId
|
||||
if (!id) return
|
||||
|
||||
const status = mapTraceFinalStatusToRequestStatus(state.finalStatus)
|
||||
const imageFailed = state.imageProgress?.phase === 'failed'
|
||||
if (!status && !state.imageProgress && state.statusCode == null && state.latencyMs == null) return
|
||||
|
||||
emit('requestState', {
|
||||
id,
|
||||
requestId: detail.value?.request_id || detail.value?.id || null,
|
||||
status: imageFailed ? 'failed' : status,
|
||||
statusCode: state.statusCode ?? undefined,
|
||||
responseTimeMs: state.latencyMs ?? undefined,
|
||||
imageProgress: state.imageProgress ?? null,
|
||||
errorMessage: state.errorMessage ?? undefined,
|
||||
})
|
||||
}
|
||||
|
||||
function toNumber(value: unknown): number | null {
|
||||
|
||||
@@ -269,4 +269,42 @@ describe('HorizontalRequestTimeline', () => {
|
||||
expect(nodeDots[0].classList.contains('status-success')).toBe(false)
|
||||
expect(nodeDots[1].classList.contains('status-success')).toBe(true)
|
||||
})
|
||||
|
||||
it('renders Codex image progress from candidate image_progress', async () => {
|
||||
const trace = buildTrace([
|
||||
buildCandidate({
|
||||
id: 'cand-image-progress',
|
||||
provider_id: 'provider-image',
|
||||
provider_name: 'Codex Image',
|
||||
key_id: 'key-image',
|
||||
key_name: 'Image Key',
|
||||
candidate_index: 0,
|
||||
status: 'streaming',
|
||||
finished_at: undefined,
|
||||
image_progress: {
|
||||
phase: 'upstream_streaming',
|
||||
upstream_ttfb_ms: 3807,
|
||||
upstream_sse_frame_count: 12,
|
||||
partial_image_count: 1,
|
||||
last_upstream_event: 'response.output_item.added',
|
||||
last_upstream_frame_at_unix_ms: Date.now(),
|
||||
last_client_visible_event: 'image_generation.partial_image',
|
||||
downstream_heartbeat_count: 3,
|
||||
downstream_heartbeat_interval_ms: 15000,
|
||||
last_downstream_heartbeat_at_unix_ms: Date.now(),
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
const root = mountTimeline(trace)
|
||||
await nextTick()
|
||||
|
||||
expect(root.textContent).toContain('图片生成进度')
|
||||
expect(root.textContent).toContain('上游生成中')
|
||||
expect(root.textContent).toContain('3.81s')
|
||||
expect(root.textContent).toContain('下游心跳')
|
||||
expect(root.textContent).toContain('15.00s')
|
||||
expect(root.textContent).toContain('response.output_item.added')
|
||||
expect(root.textContent).toContain('image_generation.partial_image')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -233,6 +233,20 @@ describe('UsageRecordsTable', () => {
|
||||
expect(root.querySelector('[data-active-latency-state="waiting-first-byte"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows failed when Codex image progress fails before the usage record finalizes', () => {
|
||||
const root = mountUsageRecordsTable([buildRecord({
|
||||
status: 'pending',
|
||||
response_time_ms: null,
|
||||
first_byte_time_ms: null,
|
||||
image_progress: {
|
||||
phase: 'failed',
|
||||
},
|
||||
})])
|
||||
|
||||
expect(root.textContent).toContain('失败')
|
||||
expect(root.textContent).not.toContain('等待中')
|
||||
})
|
||||
|
||||
it('renders output TPS in the non-admin usage table', () => {
|
||||
const root = mountUsageRecordsTable([buildRecord()], { isAdmin: false })
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ImageProgress } from '@/api/requestTrace'
|
||||
|
||||
// 统计数据状态
|
||||
export interface UsageStatsState {
|
||||
total_requests: number
|
||||
@@ -116,6 +118,7 @@ export interface UsageRecord {
|
||||
created_at: string
|
||||
has_fallback?: boolean
|
||||
has_retry?: boolean
|
||||
image_progress?: ImageProgress | null
|
||||
}
|
||||
|
||||
// 日期范围参数
|
||||
|
||||
@@ -91,6 +91,20 @@ describe('usage status helpers', () => {
|
||||
expect(isUsageRecordFailed(record)).toBe(true)
|
||||
})
|
||||
|
||||
it('treats failed image progress as failed before the usage record finalizes', () => {
|
||||
const record = buildUsageRecord({
|
||||
status: 'streaming',
|
||||
status_code: undefined,
|
||||
error_message: undefined,
|
||||
image_progress: {
|
||||
phase: 'failed',
|
||||
},
|
||||
})
|
||||
|
||||
expect(resolveDisplayRequestStatus(record)).toBe('failed')
|
||||
expect(isUsageRecordFailed(record)).toBe(true)
|
||||
})
|
||||
|
||||
it('prefers terminal request lifecycle status over status code for the timeline', () => {
|
||||
expect(resolveTimelineFinalStatus({
|
||||
traceFinalStatus: 'success',
|
||||
|
||||
@@ -7,6 +7,9 @@ type RequestStatusLike = RequestStatus | string | null | undefined
|
||||
type UsageFailureSignal = {
|
||||
status_code?: number | null
|
||||
error_message?: string | null
|
||||
image_progress?: {
|
||||
phase?: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
type UsageDisplayStatusRecord = UsageFailureSignal & {
|
||||
@@ -21,6 +24,19 @@ function hasLegacyFailureSignal(
|
||||
(typeof record.error_message === 'string' && record.error_message.trim().length > 0)
|
||||
}
|
||||
|
||||
function hasImageProgressFailureSignal(
|
||||
record: UsageFailureSignal
|
||||
): boolean {
|
||||
return typeof record.image_progress?.phase === 'string' &&
|
||||
record.image_progress.phase.trim().toLowerCase() === 'failed'
|
||||
}
|
||||
|
||||
function hasAnyFailureSignal(
|
||||
record: UsageFailureSignal
|
||||
): boolean {
|
||||
return hasLegacyFailureSignal(record) || hasImageProgressFailureSignal(record)
|
||||
}
|
||||
|
||||
export function hasUsageFallback(
|
||||
record: Pick<UsageRecord, 'has_fallback'>
|
||||
): boolean {
|
||||
@@ -160,13 +176,11 @@ function hasTerminalSuccessStatusCode(
|
||||
record.status_code < 400
|
||||
}
|
||||
|
||||
export function isUsageRecordFailed(
|
||||
record: Pick<UsageRecord, 'status' | 'status_code' | 'error_message'>
|
||||
): boolean {
|
||||
export function isUsageRecordFailed(record: UsageFailureSignal & Pick<UsageRecord, 'status'>): boolean {
|
||||
const status = typeof record.status === 'string' ? record.status.trim().toLowerCase() : ''
|
||||
if (status) {
|
||||
if (status === 'pending' || status === 'streaming') {
|
||||
return !hasTerminalSuccessStatusCode(record) && hasLegacyFailureSignal(record)
|
||||
return !hasTerminalSuccessStatusCode(record) && hasAnyFailureSignal(record)
|
||||
}
|
||||
if (status === 'cancelled') {
|
||||
return false
|
||||
@@ -184,12 +198,10 @@ export function isUsageRecordFailed(
|
||||
if (status) {
|
||||
return status === 'failed'
|
||||
}
|
||||
return hasLegacyFailureSignal(record)
|
||||
return hasAnyFailureSignal(record)
|
||||
}
|
||||
|
||||
export function isUsageRecordSuccessful(
|
||||
record: Pick<UsageRecord, 'status' | 'status_code' | 'error_message'>
|
||||
): boolean {
|
||||
export function isUsageRecordSuccessful(record: UsageFailureSignal & Pick<UsageRecord, 'status'>): boolean {
|
||||
const status = typeof record.status === 'string' ? record.status.trim().toLowerCase() : ''
|
||||
if (status) {
|
||||
if (status === 'completed') {
|
||||
@@ -203,7 +215,7 @@ export function isUsageRecordSuccessful(
|
||||
if (hasTerminalSuccessStatusCode(record)) {
|
||||
return true
|
||||
}
|
||||
return !hasLegacyFailureSignal(record)
|
||||
return !hasAnyFailureSignal(record)
|
||||
}
|
||||
|
||||
export function normalizeRequestStatus(status: RequestStatusLike): RequestStatus | undefined {
|
||||
@@ -224,13 +236,13 @@ export function resolveDisplayRequestStatus(record: UsageDisplayStatusRecord): R
|
||||
const status = normalizeRequestStatus(record.status)
|
||||
if ((status === 'pending' || status === 'streaming') &&
|
||||
!hasTerminalSuccessStatusCode(record) &&
|
||||
hasLegacyFailureSignal(record)) {
|
||||
hasAnyFailureSignal(record)) {
|
||||
return 'failed'
|
||||
}
|
||||
if (status === 'streaming' && record.first_byte_time_ms == null) {
|
||||
return 'pending'
|
||||
}
|
||||
return status
|
||||
return status ?? (hasAnyFailureSignal(record) ? 'failed' : undefined)
|
||||
}
|
||||
|
||||
export function mapRequestStatusToTimelineStatus(
|
||||
|
||||
@@ -119,6 +119,7 @@
|
||||
:is-open="detailModalOpen"
|
||||
:request-id="selectedRequestId"
|
||||
@close="detailModalOpen = false"
|
||||
@request-state="handleDetailRequestState"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -129,6 +130,7 @@ import { useRoute } from 'vue-router'
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { usageApi } from '@/api/usage'
|
||||
import type { ImageProgress } from '@/api/requestTrace'
|
||||
import { usersApi } from '@/api/users'
|
||||
import { meApi } from '@/api/me'
|
||||
import { dashboardApi } from '@/api/dashboard'
|
||||
@@ -153,7 +155,7 @@ import {
|
||||
isUsageUpstreamStream,
|
||||
resolveDisplayRequestStatus,
|
||||
} from '@/features/usage/utils/status'
|
||||
import type { DateRangeParams, FilterStatusValue } from '@/features/usage/types'
|
||||
import type { DateRangeParams, FilterStatusValue, RequestStatus } from '@/features/usage/types'
|
||||
import type { UserOption } from '@/features/usage/components/UsageRecordsTable.vue'
|
||||
import { log } from '@/utils/logger'
|
||||
import type { ActivityHeatmap } from '@/types/activity'
|
||||
@@ -445,12 +447,16 @@ async function pollActiveRequests() {
|
||||
const shouldApply = newRank >= currentRank
|
||||
const updateHasFailureSignal =
|
||||
(typeof update.status_code === 'number' && update.status_code >= 400) ||
|
||||
(typeof update.error_message === 'string' && update.error_message.trim().length > 0)
|
||||
(typeof update.error_message === 'string' && update.error_message.trim().length > 0) ||
|
||||
update.image_progress?.phase === 'failed'
|
||||
const shouldApplyData = shouldApply || updateHasFailureSignal
|
||||
|
||||
if (shouldApply && record.status !== update.status) {
|
||||
record.status = update.status
|
||||
}
|
||||
if ('image_progress' in update) {
|
||||
record.image_progress = update.image_progress ?? null
|
||||
}
|
||||
if (shouldApplyData) {
|
||||
// 进行中状态也需要持续更新(provider/key/TTFB 可能在 streaming 后才落库)
|
||||
record.input_tokens = update.input_tokens
|
||||
@@ -877,6 +883,63 @@ function showRequestDetail(id: string) {
|
||||
detailModalOpen.value = true
|
||||
}
|
||||
|
||||
function sameImageProgress(left?: ImageProgress | null, right?: ImageProgress | null): boolean {
|
||||
if (!left && !right) return true
|
||||
if (!left || !right) return false
|
||||
return left.phase === right.phase &&
|
||||
left.upstream_ttfb_ms === right.upstream_ttfb_ms &&
|
||||
left.upstream_sse_frame_count === right.upstream_sse_frame_count &&
|
||||
left.last_upstream_event === right.last_upstream_event &&
|
||||
left.last_upstream_frame_at_unix_ms === right.last_upstream_frame_at_unix_ms &&
|
||||
left.partial_image_count === right.partial_image_count &&
|
||||
left.last_client_visible_event === right.last_client_visible_event &&
|
||||
left.downstream_heartbeat_count === right.downstream_heartbeat_count &&
|
||||
left.last_downstream_heartbeat_at_unix_ms === right.last_downstream_heartbeat_at_unix_ms &&
|
||||
left.downstream_heartbeat_interval_ms === right.downstream_heartbeat_interval_ms
|
||||
}
|
||||
|
||||
function handleDetailRequestState(update: {
|
||||
id: string
|
||||
status?: RequestStatus
|
||||
statusCode?: number | null
|
||||
responseTimeMs?: number | null
|
||||
imageProgress?: ImageProgress | null
|
||||
errorMessage?: string | null
|
||||
}) {
|
||||
const record = currentRecords.value.find(record => record.id === update.id)
|
||||
if (!record) return
|
||||
|
||||
const statusPriority: Record<RequestStatus, number> = {
|
||||
pending: 0,
|
||||
streaming: 1,
|
||||
completed: 2,
|
||||
failed: 2,
|
||||
cancelled: 2,
|
||||
}
|
||||
if (update.status) {
|
||||
const currentRank = record.status ? statusPriority[record.status] : 0
|
||||
const nextRank = statusPriority[update.status]
|
||||
if (nextRank >= currentRank) {
|
||||
record.status = update.status
|
||||
}
|
||||
}
|
||||
if ('statusCode' in update) {
|
||||
record.status_code = update.statusCode ?? undefined
|
||||
}
|
||||
if ('responseTimeMs' in update && update.responseTimeMs != null) {
|
||||
record.response_time_ms = update.responseTimeMs
|
||||
}
|
||||
if ('imageProgress' in update) {
|
||||
const nextProgress = update.imageProgress ?? null
|
||||
if (!sameImageProgress(record.image_progress, nextProgress)) {
|
||||
record.image_progress = nextProgress
|
||||
}
|
||||
}
|
||||
if ('errorMessage' in update) {
|
||||
record.error_message = update.errorMessage ?? undefined
|
||||
}
|
||||
}
|
||||
|
||||
function prefetchRequestDetail(id: string) {
|
||||
if (!isAdminPage.value) return
|
||||
void dashboardApi.prefetchRequestDetail(id).catch(error => {
|
||||
|
||||
Reference in New Issue
Block a user