Add Codex image progress heartbeat

This commit is contained in:
Entropy.Xu
2026-05-09 01:21:26 +08:00
parent 9a84a6ff6c
commit 3b4f27f767
44 changed files with 2676 additions and 208 deletions

View File

@@ -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

View File

@@ -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> = {}

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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')
})
})

View File

@@ -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 })

View File

@@ -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
}
// 日期范围参数

View File

@@ -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',

View File

@@ -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(

View File

@@ -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 => {