2025-12-10 20:52:44 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<div class="minimal-request-timeline">
|
|
|
|
|
|
<!-- Loading State -->
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="loading"
|
|
|
|
|
|
class="py-4"
|
|
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<Skeleton class="h-32 w-full" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Error State -->
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<Card
|
|
|
|
|
|
v-else-if="error"
|
|
|
|
|
|
class="border-red-200 dark:border-red-800"
|
|
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<div class="p-4">
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<p class="text-sm text-red-600 dark:text-red-400">
|
|
|
|
|
|
{{ error }}
|
|
|
|
|
|
</p>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Timeline Content -->
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-else-if="trace && trace.candidates.length > 0"
|
|
|
|
|
|
class="space-y-0"
|
|
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<Card>
|
|
|
|
|
|
<div class="p-6">
|
|
|
|
|
|
<!-- 概览信息 -->
|
|
|
|
|
|
<div class="flex items-center justify-between mb-4">
|
|
|
|
|
|
<div class="flex items-center gap-3">
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<h4 class="text-sm font-semibold">
|
|
|
|
|
|
请求链路追踪
|
|
|
|
|
|
</h4>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<Badge :variant="getFinalStatusBadgeVariant(computedFinalStatus)">
|
|
|
|
|
|
{{ getFinalStatusLabel(computedFinalStatus) }}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="text-sm text-muted-foreground">
|
|
|
|
|
|
{{ formatLatency(totalTraceLatency) }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 极简时间线轨道(按组显示) -->
|
|
|
|
|
|
<div class="minimal-track">
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-for="(group, groupIndex) in groupedTimeline"
|
|
|
|
|
|
:key="group.id"
|
|
|
|
|
|
class="minimal-node-group"
|
|
|
|
|
|
:class="{
|
|
|
|
|
|
selected: isGroupSelected(group),
|
|
|
|
|
|
hovered: isGroupHovered(groupIndex) && !isGroupSelected(group)
|
|
|
|
|
|
}"
|
|
|
|
|
|
@mouseenter="hoveredGroupIndex = groupIndex"
|
|
|
|
|
|
@mouseleave="hoveredGroupIndex = null"
|
|
|
|
|
|
@click="selectGroup(group)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<!-- 节点容器 -->
|
|
|
|
|
|
<div class="node-container">
|
|
|
|
|
|
<!-- 节点名称(在节点上方) -->
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<div class="node-label">
|
|
|
|
|
|
{{ group.providerName }}
|
|
|
|
|
|
</div>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- 主节点(代表首次请求) -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="node-dot"
|
|
|
|
|
|
:class="[
|
2026-04-14 01:27:04 +08:00
|
|
|
|
getStatusColorClass(group.primaryStatus),
|
2025-12-10 20:52:44 +08:00
|
|
|
|
{ 'is-first-selected': isGroupSelected(group) && selectedAttemptIndex === 0 }
|
|
|
|
|
|
]"
|
|
|
|
|
|
@click.stop="selectFirstAttempt(group)"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
/>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- 子节点(同提供商的其他尝试,不包含首次) -->
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<div
|
2026-05-07 03:23:20 +08:00
|
|
|
|
v-if="group.retryCount > 0"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
class="sub-dots"
|
|
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<button
|
|
|
|
|
|
v-for="(attempt, idx) in group.allAttempts.slice(1)"
|
|
|
|
|
|
:key="attempt.id"
|
2026-05-07 03:23:20 +08:00
|
|
|
|
type="button"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
class="sub-dot"
|
|
|
|
|
|
:class="[
|
2026-03-03 09:22:20 +08:00
|
|
|
|
getStatusColorClass(getDisplayStatus(attempt)),
|
2026-05-07 03:23:20 +08:00
|
|
|
|
{ active: isAttemptSelected(group, idx + 1) }
|
2025-12-10 20:52:44 +08:00
|
|
|
|
]"
|
2026-05-07 03:23:20 +08:00
|
|
|
|
:title="formatAttemptDotTitle(attempt)"
|
|
|
|
|
|
:aria-label="formatAttemptDotTitle(attempt)"
|
|
|
|
|
|
@click.stop="selectAttemptInGroup(group, idx + 1)"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
/>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 连接线 -->
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="groupIndex < groupedTimeline.length - 1"
|
2026-02-11 23:24:59 +08:00
|
|
|
|
class="node-line-wrapper"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="node-line"
|
|
|
|
|
|
:class="{ 'conversion-boundary': groupIndex + 1 === conversionBoundaryIndex }"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 选中详情面板 -->
|
|
|
|
|
|
<Transition name="slide-up">
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="selectedGroup && currentAttempt"
|
|
|
|
|
|
class="detail-panel"
|
|
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<div class="panel-header">
|
|
|
|
|
|
<div class="panel-title">
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<span
|
|
|
|
|
|
class="title-dot"
|
2026-04-14 01:27:04 +08:00
|
|
|
|
:class="getStatusColorClass(currentAttemptDisplayStatus)"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
/>
|
2026-03-03 09:22:20 +08:00
|
|
|
|
<span class="title-text">{{ currentGroupTitle }}</span>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<a
|
|
|
|
|
|
v-if="currentAttempt.provider_website"
|
|
|
|
|
|
:href="currentAttempt.provider_website"
|
|
|
|
|
|
target="_blank"
|
|
|
|
|
|
rel="noopener noreferrer"
|
|
|
|
|
|
class="provider-link"
|
|
|
|
|
|
@click.stop
|
|
|
|
|
|
>
|
|
|
|
|
|
<ExternalLink class="w-3 h-3" />
|
|
|
|
|
|
</a>
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<span
|
|
|
|
|
|
class="status-tag"
|
2026-04-14 01:27:04 +08:00
|
|
|
|
:class="getStatusColorClass(currentAttemptDisplayStatus)"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
>
|
2026-04-14 01:27:04 +08:00
|
|
|
|
{{ currentAttempt.status_code || getStatusLabel(currentAttemptDisplayStatus) }}
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
<!-- 多 Key 标识 -->
|
|
|
|
|
|
<template v-if="selectedGroup.retryCount > 0">
|
2026-04-14 01:27:04 +08:00
|
|
|
|
<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>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="panel-nav">
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="nav-btn"
|
|
|
|
|
|
:disabled="selectedGroupIndex === 0"
|
|
|
|
|
|
@click.stop="navigateGroup(-1)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<ChevronLeft class="w-4 h-4" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<span class="nav-info">{{ selectedGroupIndex + 1 }} / {{ groupedTimeline.length }}</span>
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="nav-btn"
|
|
|
|
|
|
:disabled="selectedGroupIndex === groupedTimeline.length - 1"
|
|
|
|
|
|
@click.stop="navigateGroup(1)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<ChevronRight class="w-4 h-4" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="panel-body">
|
|
|
|
|
|
<!-- 核心信息网格 -->
|
|
|
|
|
|
<div class="info-grid">
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="currentAttempt.started_at"
|
|
|
|
|
|
class="info-item"
|
|
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<span class="info-label">时间范围</span>
|
|
|
|
|
|
<span class="info-value mono time-range-value">
|
|
|
|
|
|
{{ formatTime(currentAttempt.started_at) }}
|
|
|
|
|
|
<span class="time-arrow-container">
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<span
|
|
|
|
|
|
v-if="currentAttempt.finished_at"
|
|
|
|
|
|
class="time-duration"
|
|
|
|
|
|
>+{{ formatDuration(currentAttempt.started_at, currentAttempt.finished_at) }}</span>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<span class="time-arrow">→</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{{ currentAttempt.finished_at ? formatTime(currentAttempt.finished_at) : '进行中' }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
2026-01-14 16:41:58 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="currentAttempt.extra_data?.first_byte_time_ms != null"
|
|
|
|
|
|
class="info-item"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span class="info-label">首字 (TTFB)</span>
|
|
|
|
|
|
<span class="info-value mono">{{ formatLatency(currentAttempt.extra_data.first_byte_time_ms) }}</span>
|
|
|
|
|
|
</div>
|
2026-02-11 23:24:59 +08:00
|
|
|
|
<div
|
2026-03-03 09:22:20 +08:00
|
|
|
|
v-if="currentAttemptFormatDisplay"
|
2026-02-11 23:24:59 +08:00
|
|
|
|
class="info-item"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span class="info-label">格式</span>
|
|
|
|
|
|
<span class="info-value">
|
2026-03-03 09:22:20 +08:00
|
|
|
|
<code class="format-code">{{ currentAttemptFormatDisplay }}</code>
|
2026-02-11 23:24:59 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
2026-04-10 01:46:14 +08:00
|
|
|
|
<div
|
2026-05-08 22:03:17 +08:00
|
|
|
|
v-if="currentAttemptRequestPathDisplay"
|
2026-04-10 01:46:14 +08:00
|
|
|
|
class="info-item"
|
|
|
|
|
|
>
|
2026-05-08 22:03:17 +08:00
|
|
|
|
<span class="info-label">请求路径</span>
|
|
|
|
|
|
<span class="info-value">
|
|
|
|
|
|
<code class="format-code request-path-code">{{ currentAttemptRequestPathDisplay }}</code>
|
2026-04-27 12:34:03 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<div
|
2026-05-30 19:47:00 +08:00
|
|
|
|
v-if="currentAttemptKeyDisplay"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
class="info-item"
|
|
|
|
|
|
>
|
2026-02-19 14:51:08 +08:00
|
|
|
|
<span class="info-label">{{ isOAuthType(currentAttempt.key_auth_type) ? '账号' : '密钥' }}</span>
|
2026-02-11 15:33:01 +08:00
|
|
|
|
<span class="info-value info-value-stacked">
|
2026-02-19 14:51:08 +08:00
|
|
|
|
<span class="key-name">
|
2026-05-30 19:47:00 +08:00
|
|
|
|
{{ currentAttemptKeyDisplay }}
|
2026-02-19 14:51:08 +08:00
|
|
|
|
<span
|
|
|
|
|
|
v-if="currentAttempt.key_auth_type && currentAttempt.key_auth_type !== 'api_key'"
|
|
|
|
|
|
class="auth-type-tag"
|
|
|
|
|
|
>{{ formatAuthTypeWithPlan(currentAttempt.key_auth_type, currentAttempt.key_oauth_plan_type) }}</span>
|
|
|
|
|
|
</span>
|
2026-02-11 15:33:01 +08:00
|
|
|
|
<code
|
|
|
|
|
|
v-if="currentAttempt.key_preview"
|
|
|
|
|
|
class="key-preview"
|
|
|
|
|
|
>{{ currentAttempt.key_preview }}</code>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
2026-04-16 13:00:44 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="currentAttemptKeyFormatsDisplay"
|
|
|
|
|
|
class="info-item"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span class="info-label">支持端点</span>
|
|
|
|
|
|
<span class="info-value info-value-stacked">
|
|
|
|
|
|
<code class="format-code">{{ currentAttemptKeyFormatsDisplay }}</code>
|
|
|
|
|
|
<span class="text-xs text-muted-foreground">
|
2026-05-08 22:03:17 +08:00
|
|
|
|
Key 声明的可用 endpoint 格式
|
2026-04-16 13:00:44 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
2026-02-08 01:18:00 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="currentAttempt.extra_data?.proxy"
|
|
|
|
|
|
class="info-item"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span class="info-label">代理</span>
|
2026-02-11 15:33:01 +08:00
|
|
|
|
<span class="info-value info-value-stacked">
|
|
|
|
|
|
<span class="proxy-name">
|
|
|
|
|
|
{{ currentAttempt.extra_data.proxy.node_name || currentAttempt.extra_data.proxy.url || '未知' }}
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-if="currentAttempt.extra_data.proxy.source === 'system'"
|
|
|
|
|
|
class="text-xs text-muted-foreground ml-1"
|
|
|
|
|
|
>(系统)</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span class="proxy-detail">
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-if="currentAttempt.extra_data.proxy.ttfb_ms != null"
|
|
|
|
|
|
class="text-xs text-muted-foreground"
|
|
|
|
|
|
>{{ formatLatency(currentAttempt.extra_data.proxy.ttfb_ms) }}</span>
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-if="currentAttempt.extra_data.proxy.timing"
|
|
|
|
|
|
class="text-xs text-muted-foreground"
|
|
|
|
|
|
>(<!--
|
|
|
|
|
|
-->{{ proxyTimingBreakdown(currentAttempt.extra_data.proxy) }}<!--
|
|
|
|
|
|
-->)</span>
|
|
|
|
|
|
</span>
|
2026-03-19 23:52:17 +08:00
|
|
|
|
<code
|
|
|
|
|
|
v-if="typeof currentAttempt.extra_data.proxy.node_id === 'string' && currentAttempt.extra_data.proxy.node_id"
|
|
|
|
|
|
class="text-xs font-mono text-muted-foreground"
|
|
|
|
|
|
>节点 Key {{ currentAttempt.extra_data.proxy.node_id }}</code>
|
2026-02-08 01:18:00 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
2026-02-27 13:54:46 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="currentAttempt.extra_data?.pool_selection"
|
|
|
|
|
|
class="info-item"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span class="info-label">号池调度</span>
|
|
|
|
|
|
<span class="info-value info-value-stacked">
|
|
|
|
|
|
<span class="pool-reason">
|
|
|
|
|
|
<span
|
|
|
|
|
|
class="pool-reason-tag"
|
|
|
|
|
|
:class="'pool-' + currentAttempt.extra_data.pool_selection.reason"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ poolSelectionLabel(currentAttempt.extra_data.pool_selection.reason) }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-if="currentAttempt.extra_data.pool_selection.cost_soft_threshold"
|
|
|
|
|
|
class="pool-cost-warn"
|
|
|
|
|
|
>接近限额</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-if="currentAttempt.extra_data.pool_selection.cost_window_usage"
|
|
|
|
|
|
class="text-xs text-muted-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ formatNumber(currentAttempt.extra_data.pool_selection.cost_window_usage) }}
|
|
|
|
|
|
<template v-if="currentAttempt.extra_data.pool_selection.cost_limit">
|
|
|
|
|
|
/ {{ formatNumber(currentAttempt.extra_data.pool_selection.cost_limit) }}
|
|
|
|
|
|
</template>
|
|
|
|
|
|
tokens
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="currentAttempt.extra_data?.pool_skip"
|
|
|
|
|
|
class="info-item"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span class="info-label">号池跳过</span>
|
|
|
|
|
|
<span class="info-value info-value-stacked">
|
|
|
|
|
|
<span class="pool-skip-type">
|
|
|
|
|
|
{{ poolSkipLabel(currentAttempt.extra_data.pool_skip.type) }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-if="currentAttempt.extra_data.pool_skip.cooldown_reason"
|
|
|
|
|
|
class="text-xs text-muted-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ currentAttempt.extra_data.pool_skip.cooldown_reason }}
|
|
|
|
|
|
<template v-if="currentAttempt.extra_data.pool_skip.cooldown_ttl != null">
|
|
|
|
|
|
({{ currentAttempt.extra_data.pool_skip.cooldown_ttl }}s)
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-if="currentAttempt.extra_data.pool_skip.cost_window_usage"
|
|
|
|
|
|
class="text-xs text-muted-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ formatNumber(currentAttempt.extra_data.pool_skip.cost_window_usage) }} tokens
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-09 01:21:26 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<!-- 用量与费用(仅成功节点显示) -->
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="currentAttempt.status === 'success' && usageData"
|
|
|
|
|
|
class="usage-section"
|
|
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<div class="usage-grid">
|
|
|
|
|
|
<!-- 输入 输出 -->
|
|
|
|
|
|
<div class="usage-row">
|
|
|
|
|
|
<div class="usage-item">
|
|
|
|
|
|
<span class="usage-label">输入</span>
|
|
|
|
|
|
<span class="usage-tokens">{{ formatNumber(usageData.tokens.input) }}</span>
|
|
|
|
|
|
<span class="usage-cost">${{ usageData.cost.input.toFixed(6) }}</span>
|
|
|
|
|
|
</div>
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<div class="usage-divider" />
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<div class="usage-item">
|
|
|
|
|
|
<span class="usage-label">输出</span>
|
|
|
|
|
|
<span class="usage-tokens">{{ formatNumber(usageData.tokens.output) }}</span>
|
|
|
|
|
|
<span class="usage-cost">${{ usageData.cost.output.toFixed(6) }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<!-- 缓存创建 缓存读取(仅在有缓存数据时显示) -->
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="usageData.tokens.cache_creation || usageData.tokens.cache_read"
|
|
|
|
|
|
class="usage-row"
|
|
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<div class="usage-item">
|
|
|
|
|
|
<span class="usage-label">缓存创建</span>
|
|
|
|
|
|
<span class="usage-tokens">{{ formatNumber(usageData.tokens.cache_creation || 0) }}</span>
|
|
|
|
|
|
<span class="usage-cost">${{ (usageData.cost.cache_creation || 0).toFixed(6) }}</span>
|
|
|
|
|
|
</div>
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<div class="usage-divider" />
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<div class="usage-item">
|
|
|
|
|
|
<span class="usage-label">缓存读取</span>
|
|
|
|
|
|
<span class="usage-tokens">{{ formatNumber(usageData.tokens.cache_read || 0) }}</span>
|
|
|
|
|
|
<span class="usage-cost">${{ (usageData.cost.cache_read || 0).toFixed(6) }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 跳过原因 -->
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<div
|
2026-04-19 21:45:57 +08:00
|
|
|
|
v-if="currentAttemptSkipReasonDisplay"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
class="skip-reason"
|
|
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<span class="reason-label">跳过原因</span>
|
2026-04-26 01:38:37 +08:00
|
|
|
|
<span class="reason-content">
|
|
|
|
|
|
<span class="reason-value">{{ currentAttemptSkipReasonDisplay }}</span>
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-if="currentAttemptFailureDiagnostic"
|
|
|
|
|
|
class="reason-detail"
|
|
|
|
|
|
>
|
|
|
|
|
|
<code>{{ currentAttemptFailureDiagnostic.path }}</code>
|
|
|
|
|
|
{{ currentAttemptFailureDiagnostic.message }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</span>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-09 10:46:04 +08:00
|
|
|
|
<!-- 错误信息:真实上游响应合并在此处展示 -->
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<div
|
2026-04-25 16:58:17 +08:00
|
|
|
|
v-if="currentAttempt.status === 'failed' && currentAttemptRequestError"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
class="error-block"
|
|
|
|
|
|
>
|
2026-05-09 10:46:04 +08:00
|
|
|
|
<div class="error-heading">
|
|
|
|
|
|
<span class="error-type">错误信息</span>
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-if="currentAttemptRequestError.statusCode != null"
|
|
|
|
|
|
class="error-status-badge"
|
|
|
|
|
|
:class="currentAttemptRequestError.statusCode >= 400 ? 'is-error' : currentAttemptRequestError.statusCode >= 300 ? 'is-warning' : 'is-success'"
|
|
|
|
|
|
>
|
|
|
|
|
|
HTTP {{ currentAttemptRequestError.statusCode }}
|
|
|
|
|
|
</span>
|
2025-12-12 16:15:36 +08:00
|
|
|
|
</div>
|
2026-05-09 10:46:04 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="currentAttemptRequestError.message"
|
|
|
|
|
|
class="error-msg"
|
|
|
|
|
|
>
|
2026-04-25 16:58:17 +08:00
|
|
|
|
{{ currentAttemptRequestError.message }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
2026-05-09 10:46:04 +08:00
|
|
|
|
v-if="currentAttemptRequestError.upstreamResponse"
|
|
|
|
|
|
class="error-json"
|
2026-04-25 16:58:17 +08:00
|
|
|
|
>
|
2026-05-09 10:46:04 +08:00
|
|
|
|
<JsonContentPanel
|
|
|
|
|
|
:data="currentAttemptRequestError.upstreamResponse"
|
|
|
|
|
|
:is-dark="isDark"
|
|
|
|
|
|
empty-message="无上游响应信息"
|
|
|
|
|
|
/>
|
2025-12-12 16:15:36 +08:00
|
|
|
|
</div>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 额外数据 -->
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<details
|
2026-05-09 10:46:04 +08:00
|
|
|
|
v-if="currentAttemptExtraDataDisplay"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
class="extra-block"
|
|
|
|
|
|
>
|
|
|
|
|
|
<summary class="extra-toggle">
|
|
|
|
|
|
额外信息
|
|
|
|
|
|
</summary>
|
2026-04-25 11:44:01 +08:00
|
|
|
|
<JsonContentPanel
|
|
|
|
|
|
class="extra-json-panel"
|
2026-05-09 10:46:04 +08:00
|
|
|
|
:data="currentAttemptExtraDataDisplay"
|
2026-04-25 11:44:01 +08:00
|
|
|
|
:is-dark="isDark"
|
|
|
|
|
|
empty-message="无额外信息"
|
|
|
|
|
|
/>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</details>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Transition>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Empty State -->
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<Card
|
|
|
|
|
|
v-else
|
|
|
|
|
|
class="border-dashed"
|
|
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<div class="p-8 text-center">
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<p class="text-sm text-muted-foreground">
|
|
|
|
|
|
暂无追踪数据
|
|
|
|
|
|
</p>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2026-05-09 01:21:26 +08:00
|
|
|
|
import { ref, watch, computed, onBeforeUnmount } from 'vue'
|
2026-04-10 01:46:14 +08:00
|
|
|
|
import { isAxiosError } from 'axios'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
import Card from '@/components/ui/card.vue'
|
|
|
|
|
|
import Badge from '@/components/ui/badge.vue'
|
|
|
|
|
|
import Skeleton from '@/components/ui/skeleton.vue'
|
2026-04-25 11:44:01 +08:00
|
|
|
|
import JsonContentPanel from './JsonContentPanel.vue'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
import { ChevronLeft, ChevronRight, ExternalLink } from 'lucide-vue-next'
|
2026-05-09 01:21:26 +08:00
|
|
|
|
import { requestTraceApi, type RequestTrace, type CandidateRecord, type ImageProgress } from '@/api/requestTrace'
|
2025-12-12 20:22:03 +08:00
|
|
|
|
import { log } from '@/utils/logger'
|
2026-02-22 00:43:41 +08:00
|
|
|
|
import { parseApiError } from '@/utils/errorParser'
|
2026-05-22 17:11:56 +08:00
|
|
|
|
import { formatTokens } from '@/utils/format'
|
2026-02-19 14:51:08 +08:00
|
|
|
|
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
2026-05-09 10:46:04 +08:00
|
|
|
|
import { useDarkMode } from '@/composables/useDarkMode'
|
2026-04-14 01:27:04 +08:00
|
|
|
|
import { resolveTimelineFinalStatus } from '../utils/status'
|
2026-04-20 00:27:48 +08:00
|
|
|
|
import {
|
2026-04-24 10:05:55 +08:00
|
|
|
|
buildPoolGroupVisibleAttempts,
|
2026-04-20 16:59:18 +08:00
|
|
|
|
buildPoolParticipatedCandidates,
|
2026-04-20 00:27:48 +08:00
|
|
|
|
extractPoolGroupId,
|
|
|
|
|
|
makeAttemptKey,
|
|
|
|
|
|
TIMELINE_STATUS,
|
|
|
|
|
|
} from '../utils/poolTrace'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
// 节点组类型
|
|
|
|
|
|
interface NodeGroup {
|
|
|
|
|
|
id: string
|
|
|
|
|
|
providerName: string
|
|
|
|
|
|
primary: CandidateRecord
|
|
|
|
|
|
primaryStatus: string
|
2026-04-24 10:05:55 +08:00
|
|
|
|
allAttempts: CandidateRecord[] // 当前展示的尝试(含主节点)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
retryCount: number
|
|
|
|
|
|
totalLatency: number // 所有尝试的总延迟
|
|
|
|
|
|
startIndex: number
|
|
|
|
|
|
endIndex: number
|
2026-02-11 23:24:59 +08:00
|
|
|
|
hasConversion: boolean // 组内是否有格式转换候选
|
2026-04-26 20:32:55 +08:00
|
|
|
|
providerApiFormat: string | null // 提供商 API 格式(如 openai:responses)
|
2026-03-03 09:22:20 +08:00
|
|
|
|
isPoolGroup?: boolean
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 用量数据类型
|
|
|
|
|
|
interface UsageData {
|
2026-04-24 03:07:11 +08:00
|
|
|
|
status?: string | null
|
2025-12-10 20:52:44 +08:00
|
|
|
|
tokens: {
|
|
|
|
|
|
input: number
|
|
|
|
|
|
output: number
|
|
|
|
|
|
cache_creation: number
|
|
|
|
|
|
cache_read: number
|
|
|
|
|
|
}
|
|
|
|
|
|
cost: {
|
|
|
|
|
|
input: number
|
|
|
|
|
|
output: number
|
|
|
|
|
|
cache_creation: number
|
|
|
|
|
|
cache_read: number
|
|
|
|
|
|
per_request: number
|
|
|
|
|
|
total: number
|
|
|
|
|
|
}
|
|
|
|
|
|
pricing: {
|
|
|
|
|
|
input?: number
|
|
|
|
|
|
output?: number
|
|
|
|
|
|
cache_creation?: number
|
|
|
|
|
|
cache_read?: number
|
|
|
|
|
|
per_request?: number
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const props = defineProps<{
|
2026-03-19 23:52:17 +08:00
|
|
|
|
requestId?: string | null
|
2025-12-10 20:52:44 +08:00
|
|
|
|
/** 外部传入的状态码,用于覆盖 trace.final_status 的判断 */
|
|
|
|
|
|
overrideStatusCode?: number
|
2026-04-24 03:07:11 +08:00
|
|
|
|
/** 外部传入的请求状态,用于识别已失败/取消的终态请求 */
|
|
|
|
|
|
requestStatus?: string | null
|
2026-03-03 09:22:20 +08:00
|
|
|
|
/** 请求侧 API 格式(客户端入口格式) */
|
|
|
|
|
|
requestApiFormat?: string | null
|
2025-12-10 20:52:44 +08:00
|
|
|
|
/** 用量和费用数据 */
|
|
|
|
|
|
usageData?: UsageData | null
|
2026-03-03 09:22:20 +08:00
|
|
|
|
/** 请求元数据(用于号池调度组装) */
|
|
|
|
|
|
requestMetadata?: Record<string, unknown> | null
|
2026-03-19 23:52:17 +08:00
|
|
|
|
/** 已获取的追踪数据;传入时不再内部拉取 */
|
|
|
|
|
|
traceData?: RequestTrace | null
|
|
|
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
|
|
const emit = defineEmits<{
|
|
|
|
|
|
selectAttempt: [attempt: CandidateRecord | null]
|
2026-05-09 01:21:26 +08:00
|
|
|
|
traceState: [state: {
|
|
|
|
|
|
loaded: boolean
|
|
|
|
|
|
hasTrace: boolean
|
|
|
|
|
|
finalStatus?: RequestTrace['final_status'] | null
|
|
|
|
|
|
statusCode?: number | null
|
|
|
|
|
|
latencyMs?: number | null
|
|
|
|
|
|
imageProgress?: ImageProgress | null
|
|
|
|
|
|
errorMessage?: string | null
|
|
|
|
|
|
}]
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
|
|
// 用量数据(从 props 获取)
|
|
|
|
|
|
const usageData = computed(() => props.usageData)
|
|
|
|
|
|
|
|
|
|
|
|
// 格式化数字
|
|
|
|
|
|
const formatNumber = (num: number): string => {
|
2026-05-22 17:11:56 +08:00
|
|
|
|
return formatTokens(num)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取最终状态标签
|
|
|
|
|
|
const getFinalStatusLabel = (status: string) => {
|
|
|
|
|
|
const labels: Record<string, string> = {
|
|
|
|
|
|
success: '最终成功',
|
|
|
|
|
|
failed: '最终失败',
|
2026-04-14 01:27:04 +08:00
|
|
|
|
cancelled: '已取消',
|
2025-12-10 20:52:44 +08:00
|
|
|
|
streaming: '流式传输中',
|
|
|
|
|
|
pending: '进行中'
|
|
|
|
|
|
}
|
|
|
|
|
|
return labels[status] || status
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取最终状态徽章样式
|
2026-02-22 00:43:41 +08:00
|
|
|
|
type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' | 'dark'
|
|
|
|
|
|
|
|
|
|
|
|
const getFinalStatusBadgeVariant = (status: string): BadgeVariant => {
|
|
|
|
|
|
const variants: Record<string, BadgeVariant> = {
|
2025-12-10 20:52:44 +08:00
|
|
|
|
success: 'success',
|
|
|
|
|
|
failed: 'destructive',
|
2026-04-14 01:27:04 +08:00
|
|
|
|
cancelled: 'warning',
|
2025-12-10 20:52:44 +08:00
|
|
|
|
streaming: 'secondary',
|
|
|
|
|
|
pending: 'secondary'
|
|
|
|
|
|
}
|
|
|
|
|
|
return variants[status] || 'default'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const loading = ref(false)
|
|
|
|
|
|
const error = ref<string | null>(null)
|
2026-03-19 23:52:17 +08:00
|
|
|
|
const internalTrace = ref<RequestTrace | null>(null)
|
2026-05-09 10:46:04 +08:00
|
|
|
|
const { isDark } = useDarkMode()
|
2026-03-19 23:52:17 +08:00
|
|
|
|
const trace = computed(() => props.traceData ?? internalTrace.value)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
const selectedGroupIndex = ref(0)
|
|
|
|
|
|
const selectedAttemptIndex = ref(0)
|
2026-05-30 19:47:00 +08:00
|
|
|
|
const selectionPinnedByUser = ref(false)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
const hoveredGroupIndex = ref<number | null>(null)
|
2026-04-25 16:58:17 +08:00
|
|
|
|
const traceLoadStarted = ref(false)
|
2026-05-09 01:21:26 +08:00
|
|
|
|
let tracePollTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
|
|
let traceLoadInFlight: Promise<void> | null = null
|
|
|
|
|
|
const TRACE_POLL_INTERVAL_MS = 1000
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
// 格式化延迟(自动调整单位)
|
|
|
|
|
|
const formatLatency = (ms: number | undefined | null): string => {
|
|
|
|
|
|
if (ms === undefined || ms === null) return '-'
|
|
|
|
|
|
if (ms >= 1000) {
|
|
|
|
|
|
return `${(ms / 1000).toFixed(2)}s`
|
|
|
|
|
|
}
|
|
|
|
|
|
return `${ms}ms`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-11 16:34:18 +08:00
|
|
|
|
// 格式化字节大小
|
|
|
|
|
|
const formatSize = (bytes: number): string => {
|
|
|
|
|
|
if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(1)}MB`
|
|
|
|
|
|
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)}KB`
|
|
|
|
|
|
return `${bytes}B`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-11 15:33:01 +08:00
|
|
|
|
// 代理 timing 分阶段展示
|
2026-02-22 00:43:41 +08:00
|
|
|
|
const proxyTimingBreakdown = (proxy: Record<string, unknown>): string => {
|
|
|
|
|
|
const t = proxy.timing as Record<string, number | null | undefined> | undefined
|
2026-02-11 15:33:01 +08:00
|
|
|
|
if (!t) return ''
|
|
|
|
|
|
|
|
|
|
|
|
const parts: string[] = []
|
|
|
|
|
|
|
2026-02-11 17:17:45 +08:00
|
|
|
|
// 兼容旧版 timing(含 body_read_ms/decompress_ms)
|
2026-02-22 00:43:41 +08:00
|
|
|
|
const readDecompress = ((t.body_read_ms as number) || 0) + ((t.decompress_ms as number) || 0)
|
2026-02-11 16:34:18 +08:00
|
|
|
|
if (readDecompress > 0) {
|
|
|
|
|
|
let label = `读取 ${formatLatency(readDecompress)}`
|
2026-02-22 00:43:41 +08:00
|
|
|
|
if (t.decompress_ms != null && t.decompress_ms > 0 && t.wire_size != null && t.body_size != null && (t.body_size as number) > 0) {
|
|
|
|
|
|
const ratio = Math.round((1 - (t.wire_size as number) / (t.body_size as number)) * 100)
|
|
|
|
|
|
label += ` ${formatSize(t.wire_size as number)}→${formatSize(t.body_size as number)}`
|
2026-02-11 16:34:18 +08:00
|
|
|
|
if (ratio > 0) label += ` -${ratio}%`
|
|
|
|
|
|
}
|
|
|
|
|
|
parts.push(label)
|
2026-02-11 15:33:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-11 23:24:59 +08:00
|
|
|
|
const ttfbMs = t.ttfb_ms ?? t.upstream_ms
|
2026-03-06 17:55:14 +08:00
|
|
|
|
const responseWaitMs = t.response_wait_ms ?? (
|
|
|
|
|
|
t.connection_acquire_ms != null && ttfbMs != null
|
|
|
|
|
|
? Math.max(0, (ttfbMs as number) - (t.connection_acquire_ms as number))
|
|
|
|
|
|
: null
|
|
|
|
|
|
)
|
|
|
|
|
|
const legacyWaitMs = t.upstream_processing_ms ?? (
|
2026-02-11 23:24:59 +08:00
|
|
|
|
ttfbMs != null && t.connect_ms != null && t.tls_ms != null
|
2026-02-22 00:43:41 +08:00
|
|
|
|
? Math.max(0, (ttfbMs as number) - (t.connect_ms as number) - (t.tls_ms as number))
|
2026-02-11 23:24:59 +08:00
|
|
|
|
: null
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-02-22 00:43:41 +08:00
|
|
|
|
if (t.dns_ms != null && (t.dns_ms as number) > 0) {
|
|
|
|
|
|
parts.push(`DNS ${formatLatency(t.dns_ms as number)}`)
|
2026-02-11 23:24:59 +08:00
|
|
|
|
}
|
2026-03-06 17:55:14 +08:00
|
|
|
|
if (t.connection_reused === true) {
|
|
|
|
|
|
parts.push('复用连接')
|
|
|
|
|
|
}
|
2026-02-22 00:43:41 +08:00
|
|
|
|
if (t.connect_ms != null && (t.connect_ms as number) > 0) {
|
|
|
|
|
|
parts.push(`连接 ${formatLatency(t.connect_ms as number)}`)
|
2026-02-11 23:24:59 +08:00
|
|
|
|
}
|
2026-02-22 00:43:41 +08:00
|
|
|
|
if (t.tls_ms != null && (t.tls_ms as number) > 0) {
|
|
|
|
|
|
parts.push(`TLS ${formatLatency(t.tls_ms as number)}`)
|
2026-02-11 23:24:59 +08:00
|
|
|
|
}
|
2026-02-22 00:43:41 +08:00
|
|
|
|
if (ttfbMs != null && (ttfbMs as number) > 0) {
|
|
|
|
|
|
parts.push(`TTFB ${formatLatency(ttfbMs as number)}`)
|
2026-02-11 23:24:59 +08:00
|
|
|
|
}
|
2026-03-06 17:55:14 +08:00
|
|
|
|
if (responseWaitMs != null && (responseWaitMs as number) > 0) {
|
|
|
|
|
|
parts.push(`等待响应头 ${formatLatency(Math.round(responseWaitMs as number))}`)
|
|
|
|
|
|
} else if (legacyWaitMs != null && (legacyWaitMs as number) > 0) {
|
|
|
|
|
|
parts.push(`等待响应头(旧版估算) ${formatLatency(Math.round(legacyWaitMs as number))}`)
|
2026-02-11 23:24:59 +08:00
|
|
|
|
}
|
2026-02-11 15:33:01 +08:00
|
|
|
|
|
|
|
|
|
|
// 计算 Aether→代理 之间无法解释的耗时差
|
|
|
|
|
|
if (proxy.ttfb_ms != null && t.total_ms != null) {
|
2026-02-22 00:43:41 +08:00
|
|
|
|
const gap = (proxy.ttfb_ms as number) - (t.total_ms as number)
|
2026-02-11 15:33:01 +08:00
|
|
|
|
if (gap > 500) {
|
|
|
|
|
|
parts.push(`传输 ${formatLatency(Math.round(gap))}`)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return parts.join(' / ')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 09:22:20 +08:00
|
|
|
|
const STATUS_PRIORITY: Record<string, number> = {
|
|
|
|
|
|
available: 0,
|
|
|
|
|
|
unused: 0,
|
|
|
|
|
|
skipped: 1,
|
|
|
|
|
|
failed: 2,
|
|
|
|
|
|
cancelled: 2,
|
|
|
|
|
|
stream_interrupted: 2,
|
|
|
|
|
|
pending: 3,
|
|
|
|
|
|
streaming: 3,
|
|
|
|
|
|
success: 4,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-07 03:23:20 +08:00
|
|
|
|
const isParticipatedCandidate = (candidate: CandidateRecord): boolean => {
|
2026-05-12 21:05:11 +08:00
|
|
|
|
return TIMELINE_STATUS.includes(candidate.status)
|
2026-05-07 03:23:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 21:05:11 +08:00
|
|
|
|
const isLiveCandidate = (candidate: CandidateRecord): boolean => {
|
|
|
|
|
|
if (candidate.status === 'streaming') return true
|
|
|
|
|
|
return candidate.status === 'pending' && Boolean(candidate.started_at)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 计算最终状态:优先检查真正已启动的进行中状态,再使用外部状态码
|
|
|
|
|
|
const computedFinalStatus = computed(() => {
|
|
|
|
|
|
const hasPending = trace.value?.candidates?.some(isLiveCandidate)
|
|
|
|
|
|
return resolveTimelineFinalStatus({
|
|
|
|
|
|
hasPendingCandidates: hasPending,
|
|
|
|
|
|
statusCode: props.overrideStatusCode,
|
|
|
|
|
|
requestStatus: props.requestStatus ?? usageData.value?.status,
|
|
|
|
|
|
traceFinalStatus: trace.value?.final_status,
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-07 03:23:20 +08:00
|
|
|
|
const compareBySchedulingOrder = (a: CandidateRecord, b: CandidateRecord): number => {
|
|
|
|
|
|
if (a.candidate_index !== b.candidate_index) {
|
|
|
|
|
|
return a.candidate_index - b.candidate_index
|
|
|
|
|
|
}
|
|
|
|
|
|
if (a.retry_index !== b.retry_index) {
|
|
|
|
|
|
return a.retry_index - b.retry_index
|
|
|
|
|
|
}
|
|
|
|
|
|
return new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 候选时间线(按调度顺序排序;lazy 加载的跳过候选通常没有 started_at)
|
2026-03-03 09:22:20 +08:00
|
|
|
|
const rawTimeline = computed<CandidateRecord[]>(() => {
|
2025-12-10 20:52:44 +08:00
|
|
|
|
if (!trace.value) return []
|
|
|
|
|
|
return [...trace.value.candidates]
|
2026-03-03 09:22:20 +08:00
|
|
|
|
.filter(c => TIMELINE_STATUS.includes(c.status))
|
2026-05-07 03:23:20 +08:00
|
|
|
|
.sort(compareBySchedulingOrder)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-03-04 10:00:38 +08:00
|
|
|
|
|
2026-03-03 09:22:20 +08:00
|
|
|
|
const schedulingAudit = computed<Record<string, unknown> | null>(() => {
|
|
|
|
|
|
const metadata = props.requestMetadata
|
|
|
|
|
|
if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return null
|
|
|
|
|
|
const raw = metadata.scheduling_audit
|
|
|
|
|
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
|
|
|
|
|
|
return raw as Record<string, unknown>
|
|
|
|
|
|
})
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-03-03 09:22:20 +08:00
|
|
|
|
const poolAttemptCandidates = computed<CandidateRecord[]>(() => {
|
2026-04-20 16:59:18 +08:00
|
|
|
|
const auditAttempts = schedulingAudit.value?.attempts
|
|
|
|
|
|
return buildPoolParticipatedCandidates(
|
2026-04-20 00:27:48 +08:00
|
|
|
|
rawTimeline.value,
|
2026-04-20 16:59:18 +08:00
|
|
|
|
auditAttempts,
|
2026-04-20 00:27:48 +08:00
|
|
|
|
props.requestId,
|
|
|
|
|
|
)
|
2026-03-03 09:22:20 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-03-03 17:24:22 +08:00
|
|
|
|
const poolAttemptsByGroup = computed<Map<string, CandidateRecord[]>>(() => {
|
|
|
|
|
|
const grouped = new Map<string, CandidateRecord[]>()
|
|
|
|
|
|
for (const attempt of poolAttemptCandidates.value) {
|
|
|
|
|
|
const groupId =
|
|
|
|
|
|
extractPoolGroupId(attempt)
|
|
|
|
|
|
|| String(attempt.provider_id || '').trim()
|
|
|
|
|
|
|| '__pool_group__'
|
|
|
|
|
|
const existing = grouped.get(groupId)
|
|
|
|
|
|
if (existing) {
|
|
|
|
|
|
existing.push(attempt)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
grouped.set(groupId, [attempt])
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return grouped
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-03-03 09:22:20 +08:00
|
|
|
|
const poolAttemptKeySet = computed<Set<string>>(() => {
|
|
|
|
|
|
return new Set(
|
|
|
|
|
|
poolAttemptCandidates.value.map((item) => makeAttemptKey(item.candidate_index, item.retry_index)),
|
|
|
|
|
|
)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const timeline = computed<CandidateRecord[]>(() => {
|
2026-03-05 16:23:58 +08:00
|
|
|
|
if (poolAttemptCandidates.value.length === 0) return rawTimeline.value
|
|
|
|
|
|
return rawTimeline.value.filter(
|
2026-03-03 09:22:20 +08:00
|
|
|
|
(candidate) => !poolAttemptKeySet.value.has(makeAttemptKey(candidate.candidate_index, candidate.retry_index)),
|
|
|
|
|
|
)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const AUTH_TYPE_PROVIDER_LABEL_MAP: Record<string, string> = {
|
|
|
|
|
|
codex: 'Codex',
|
|
|
|
|
|
kiro: 'Kiro',
|
|
|
|
|
|
antigravity: 'Antigravity',
|
|
|
|
|
|
claude_code: 'Claude Code',
|
|
|
|
|
|
gemini_cli: 'Gemini CLI',
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-05 15:15:26 +08:00
|
|
|
|
const getProviderDisplayName = (
|
|
|
|
|
|
attempt: CandidateRecord | null | undefined,
|
|
|
|
|
|
options: { allowAuthTypeFallback?: boolean } = {},
|
|
|
|
|
|
): string => {
|
|
|
|
|
|
const allowAuthTypeFallback = options.allowAuthTypeFallback ?? true
|
2026-03-03 09:22:20 +08:00
|
|
|
|
if (!attempt) return '未知'
|
|
|
|
|
|
const providerName = String(attempt.provider_name || '').trim()
|
2026-03-09 23:17:18 +08:00
|
|
|
|
if (providerName) return providerName
|
2026-03-05 15:15:26 +08:00
|
|
|
|
if (allowAuthTypeFallback) {
|
|
|
|
|
|
const authType = String(attempt.key_auth_type || '').trim().toLowerCase()
|
|
|
|
|
|
if (authType && AUTH_TYPE_PROVIDER_LABEL_MAP[authType]) {
|
|
|
|
|
|
return AUTH_TYPE_PROVIDER_LABEL_MAP[authType]
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return '未知'
|
2026-03-03 09:22:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 11:32:41 +08:00
|
|
|
|
const normalizeProviderIdentity = (value: unknown): string => {
|
|
|
|
|
|
if (typeof value !== 'string') return ''
|
2026-03-09 23:17:18 +08:00
|
|
|
|
return value.trim().toLowerCase()
|
2026-03-03 11:32:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 09:22:20 +08:00
|
|
|
|
const buildProviderGroups = (items: CandidateRecord[]): NodeGroup[] => {
|
2025-12-10 20:52:44 +08:00
|
|
|
|
const groups: NodeGroup[] = []
|
|
|
|
|
|
let currentGroup: NodeGroup | null = null
|
|
|
|
|
|
|
2026-03-04 10:26:19 +08:00
|
|
|
|
items.forEach((candidate) => {
|
2025-12-10 20:52:44 +08:00
|
|
|
|
const providerKey = candidate.provider_name || '未知'
|
|
|
|
|
|
|
|
|
|
|
|
if (currentGroup && currentGroup.id === providerKey) {
|
|
|
|
|
|
currentGroup.allAttempts.push(candidate)
|
|
|
|
|
|
currentGroup.retryCount++
|
2026-03-04 10:26:19 +08:00
|
|
|
|
currentGroup.endIndex = candidate.candidate_index
|
2025-12-10 20:52:44 +08:00
|
|
|
|
currentGroup.totalLatency += candidate.latency_ms || 0
|
2026-02-11 23:24:59 +08:00
|
|
|
|
if (candidate.extra_data?.needs_conversion) {
|
|
|
|
|
|
currentGroup.hasConversion = true
|
|
|
|
|
|
}
|
2026-03-03 09:22:20 +08:00
|
|
|
|
const currentPriority = STATUS_PRIORITY[currentGroup.primaryStatus] ?? 0
|
2026-04-14 01:27:04 +08:00
|
|
|
|
const newPriority = STATUS_PRIORITY[getDisplayStatus(candidate)] ?? 0
|
2026-02-11 10:06:59 +08:00
|
|
|
|
if (newPriority > currentPriority) {
|
2026-04-14 01:27:04 +08:00
|
|
|
|
currentGroup.primaryStatus = getDisplayStatus(candidate)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
2026-03-03 09:22:20 +08:00
|
|
|
|
return
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
2026-03-03 09:22:20 +08:00
|
|
|
|
|
|
|
|
|
|
currentGroup = {
|
|
|
|
|
|
id: providerKey,
|
|
|
|
|
|
providerName: getProviderDisplayName(candidate),
|
|
|
|
|
|
primary: candidate,
|
2026-04-14 01:27:04 +08:00
|
|
|
|
primaryStatus: getDisplayStatus(candidate),
|
2026-03-03 09:22:20 +08:00
|
|
|
|
allAttempts: [candidate],
|
|
|
|
|
|
retryCount: 0,
|
|
|
|
|
|
totalLatency: candidate.latency_ms || 0,
|
2026-03-04 10:26:19 +08:00
|
|
|
|
startIndex: candidate.candidate_index,
|
|
|
|
|
|
endIndex: candidate.candidate_index,
|
2026-03-03 09:22:20 +08:00
|
|
|
|
hasConversion: candidate.extra_data?.needs_conversion === true,
|
|
|
|
|
|
providerApiFormat: candidate.extra_data?.provider_api_format || null,
|
|
|
|
|
|
isPoolGroup: false,
|
|
|
|
|
|
}
|
|
|
|
|
|
groups.push(currentGroup)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
return groups
|
2026-03-03 09:22:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 将相同 Provider 的所有请求合并为组(同提供商的 Key 放在子节点)
|
|
|
|
|
|
const groupedTimeline = computed<NodeGroup[]>(() => {
|
2026-05-07 03:23:20 +08:00
|
|
|
|
const providerGroups = buildProviderGroups(timeline.value.filter(isParticipatedCandidate))
|
2026-03-03 17:24:22 +08:00
|
|
|
|
if (poolAttemptsByGroup.value.size === 0) {
|
2026-03-03 09:22:20 +08:00
|
|
|
|
return providerGroups
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 17:24:22 +08:00
|
|
|
|
const poolProviderIds = new Set<string>()
|
|
|
|
|
|
const poolProviderNames = new Set<string>()
|
|
|
|
|
|
const poolGroups: NodeGroup[] = []
|
2026-03-03 09:22:20 +08:00
|
|
|
|
|
2026-03-03 17:24:22 +08:00
|
|
|
|
for (const [groupId, attemptsRaw] of poolAttemptsByGroup.value.entries()) {
|
2026-05-07 03:23:20 +08:00
|
|
|
|
const attempts = [...attemptsRaw].sort(compareBySchedulingOrder)
|
2026-03-03 17:24:22 +08:00
|
|
|
|
if (attempts.length === 0) continue
|
2026-03-03 09:22:20 +08:00
|
|
|
|
|
2026-04-24 10:05:55 +08:00
|
|
|
|
const visibleAttempts = buildPoolGroupVisibleAttempts(attempts)
|
|
|
|
|
|
if (visibleAttempts.length === 0) continue
|
|
|
|
|
|
|
|
|
|
|
|
const poolPrimaryStatus = visibleAttempts.reduce((best, current) => {
|
2026-03-03 17:24:22 +08:00
|
|
|
|
const bestPriority = STATUS_PRIORITY[best] ?? 0
|
2026-04-14 01:27:04 +08:00
|
|
|
|
const currentStatus = getDisplayStatus(current)
|
|
|
|
|
|
const currentPriority = STATUS_PRIORITY[currentStatus] ?? 0
|
|
|
|
|
|
return currentPriority > bestPriority ? currentStatus : best
|
2026-04-24 10:05:55 +08:00
|
|
|
|
}, getDisplayStatus(visibleAttempts[0]))
|
2026-03-03 17:24:22 +08:00
|
|
|
|
|
2026-04-24 10:05:55 +08:00
|
|
|
|
const successAttempt = visibleAttempts.find((item) => item.status === 'success')
|
|
|
|
|
|
const poolPrimary =
|
|
|
|
|
|
successAttempt || visibleAttempts[visibleAttempts.length - 1] || visibleAttempts[0]
|
2026-03-03 17:24:22 +08:00
|
|
|
|
const startIndex = Math.min(...attempts.map(item => item.candidate_index))
|
|
|
|
|
|
const endIndex = Math.max(...attempts.map(item => item.candidate_index))
|
|
|
|
|
|
|
|
|
|
|
|
poolGroups.push({
|
|
|
|
|
|
id: `pool:${groupId}`,
|
2026-03-05 15:15:26 +08:00
|
|
|
|
providerName: getProviderDisplayName(poolPrimary, { allowAuthTypeFallback: false }),
|
2026-03-03 17:24:22 +08:00
|
|
|
|
primary: poolPrimary,
|
|
|
|
|
|
primaryStatus: poolPrimaryStatus,
|
2026-04-24 10:05:55 +08:00
|
|
|
|
allAttempts: visibleAttempts,
|
|
|
|
|
|
retryCount: Math.max(0, visibleAttempts.length - 1),
|
|
|
|
|
|
totalLatency: visibleAttempts.reduce((sum, item) => sum + (item.latency_ms || 0), 0),
|
2026-03-03 17:24:22 +08:00
|
|
|
|
startIndex,
|
|
|
|
|
|
endIndex,
|
2026-04-24 10:05:55 +08:00
|
|
|
|
hasConversion: visibleAttempts.some((item) => item.extra_data?.needs_conversion === true),
|
2026-03-03 17:24:22 +08:00
|
|
|
|
providerApiFormat: null,
|
|
|
|
|
|
isPoolGroup: true,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
for (const attempt of attempts) {
|
|
|
|
|
|
const providerId = String(attempt.provider_id || '').trim()
|
|
|
|
|
|
if (providerId) poolProviderIds.add(providerId)
|
|
|
|
|
|
const providerName = normalizeProviderIdentity(attempt.provider_name)
|
|
|
|
|
|
if (providerName) poolProviderNames.add(providerName)
|
|
|
|
|
|
}
|
2026-03-03 09:22:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 11:32:41 +08:00
|
|
|
|
const dedupedProviderGroups = providerGroups.filter((group) => {
|
|
|
|
|
|
const sameProviderById = group.allAttempts.some((attempt) => {
|
|
|
|
|
|
const providerId = String(attempt.provider_id || '').trim()
|
|
|
|
|
|
return providerId !== '' && poolProviderIds.has(providerId)
|
|
|
|
|
|
})
|
|
|
|
|
|
if (sameProviderById) return false
|
|
|
|
|
|
|
|
|
|
|
|
const groupName = normalizeProviderIdentity(group.primary.provider_name || group.providerName)
|
|
|
|
|
|
if (groupName && poolProviderNames.has(groupName)) return false
|
|
|
|
|
|
|
|
|
|
|
|
return true
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-03-04 10:26:19 +08:00
|
|
|
|
const allGroups = [...poolGroups, ...dedupedProviderGroups]
|
|
|
|
|
|
allGroups.sort((a, b) => a.startIndex - b.startIndex)
|
|
|
|
|
|
return allGroups
|
2025-12-10 20:52:44 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-02-11 23:24:59 +08:00
|
|
|
|
// 格式转换分界点索引(首个 hasConversion=true 的 group index)
|
|
|
|
|
|
const conversionBoundaryIndex = computed(() => {
|
|
|
|
|
|
const groups = groupedTimeline.value
|
|
|
|
|
|
if (!groups || groups.length === 0) return -1
|
|
|
|
|
|
const idx = groups.findIndex(g => g.hasConversion)
|
|
|
|
|
|
// 只有当分界点不在最开头时才有意义(前面有 exact 候选)
|
|
|
|
|
|
if (idx <= 0) return -1
|
|
|
|
|
|
return idx
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2025-12-16 02:39:54 +08:00
|
|
|
|
// 计算链路总耗时(使用成功候选的 latency_ms 字段)
|
|
|
|
|
|
// 优先使用 latency_ms,因为它与 Usage.response_time_ms 使用相同的时间基准
|
|
|
|
|
|
// 避免 finished_at - started_at 带来的额外延迟(数据库操作时间)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
const totalTraceLatency = computed(() => {
|
2026-03-03 09:22:20 +08:00
|
|
|
|
if (!rawTimeline.value || rawTimeline.value.length === 0) return 0
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2025-12-16 02:39:54 +08:00
|
|
|
|
// 查找成功的候选,使用其 latency_ms
|
2026-03-03 09:22:20 +08:00
|
|
|
|
const successCandidate = rawTimeline.value.find(c => c.status === 'success')
|
2025-12-16 02:39:54 +08:00
|
|
|
|
if (successCandidate?.latency_ms != null) {
|
|
|
|
|
|
return successCandidate.latency_ms
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 如果没有成功的候选,查找失败但有 latency_ms 的候选
|
2026-03-03 09:22:20 +08:00
|
|
|
|
const failedWithLatency = rawTimeline.value.find(c => c.status === 'failed' && c.latency_ms != null)
|
2025-12-16 02:39:54 +08:00
|
|
|
|
if (failedWithLatency?.latency_ms != null) {
|
|
|
|
|
|
return failedWithLatency.latency_ms
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 回退:使用 finished_at - started_at 计算
|
2025-12-10 20:52:44 +08:00
|
|
|
|
let earliestStart: number | null = null
|
|
|
|
|
|
let latestEnd: number | null = null
|
|
|
|
|
|
|
2026-03-03 09:22:20 +08:00
|
|
|
|
for (const candidate of rawTimeline.value) {
|
2025-12-10 20:52:44 +08:00
|
|
|
|
if (candidate.started_at) {
|
|
|
|
|
|
const startTime = new Date(candidate.started_at).getTime()
|
|
|
|
|
|
if (earliestStart === null || startTime < earliestStart) {
|
|
|
|
|
|
earliestStart = startTime
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (candidate.finished_at) {
|
|
|
|
|
|
const endTime = new Date(candidate.finished_at).getTime()
|
|
|
|
|
|
if (latestEnd === null || endTime > latestEnd) {
|
|
|
|
|
|
latestEnd = endTime
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (earliestStart !== null && latestEnd !== null) {
|
|
|
|
|
|
return latestEnd - earliestStart
|
|
|
|
|
|
}
|
|
|
|
|
|
return 0
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 计算选中的组
|
|
|
|
|
|
const selectedGroup = computed(() => {
|
|
|
|
|
|
if (!groupedTimeline.value || groupedTimeline.value.length === 0) return null
|
|
|
|
|
|
return groupedTimeline.value[selectedGroupIndex.value]
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 计算当前查看的尝试
|
|
|
|
|
|
const currentAttempt = computed(() => {
|
|
|
|
|
|
if (!selectedGroup.value) return null
|
|
|
|
|
|
return selectedGroup.value.allAttempts[selectedAttemptIndex.value] || selectedGroup.value.primary
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-04-14 01:27:04 +08:00
|
|
|
|
const currentAttemptDisplayStatus = computed(() => getDisplayStatus(currentAttempt.value))
|
|
|
|
|
|
|
2026-03-19 23:52:17 +08:00
|
|
|
|
watch(currentAttempt, (attempt) => {
|
|
|
|
|
|
emit('selectAttempt', attempt ?? null)
|
|
|
|
|
|
}, { immediate: true })
|
|
|
|
|
|
|
2026-03-03 09:22:20 +08:00
|
|
|
|
const currentGroupTitle = computed(() => {
|
|
|
|
|
|
if (!selectedGroup.value || !currentAttempt.value) return ''
|
|
|
|
|
|
if (selectedGroup.value.isPoolGroup) {
|
|
|
|
|
|
return getProviderDisplayName(currentAttempt.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
return selectedGroup.value.providerName
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const normalizeFormatSignature = (value: string): string => {
|
|
|
|
|
|
return value.trim().toLowerCase()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 13:00:44 +08:00
|
|
|
|
const extractObject = (value: unknown): Record<string, unknown> | null => {
|
|
|
|
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
return value as Record<string, unknown>
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-25 16:58:17 +08:00
|
|
|
|
const readStringField = (obj: Record<string, unknown>, key: string): string | undefined => {
|
|
|
|
|
|
const value = obj[key]
|
|
|
|
|
|
return typeof value === 'string' && value.trim() ? value.trim() : undefined
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const readNumberField = (obj: Record<string, unknown>, key: string): number | undefined => {
|
|
|
|
|
|
const value = obj[key]
|
|
|
|
|
|
if (typeof value === 'number' && Number.isFinite(value)) return value
|
|
|
|
|
|
if (typeof value === 'string' && value.trim()) {
|
|
|
|
|
|
const parsed = Number(value)
|
|
|
|
|
|
if (Number.isFinite(parsed)) return parsed
|
|
|
|
|
|
}
|
|
|
|
|
|
return undefined
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 10:46:04 +08:00
|
|
|
|
const hasRenderableValue = (value: unknown): boolean => {
|
|
|
|
|
|
if (value == null) return false
|
|
|
|
|
|
if (typeof value === 'string') return value.trim().length > 0
|
|
|
|
|
|
if (typeof value === 'object') return Object.keys(value as Record<string, unknown>).length > 0
|
|
|
|
|
|
return true
|
2026-04-25 16:58:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 01:21:26 +08:00
|
|
|
|
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 },
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-09 10:46:04 +08:00
|
|
|
|
const normalizeUpstreamResponseDisplay = (value: unknown): Record<string, unknown> | null => {
|
2026-04-25 16:58:17 +08:00
|
|
|
|
const raw = extractObject(value)
|
|
|
|
|
|
if (!raw) return null
|
2026-05-09 10:46:04 +08:00
|
|
|
|
const statusCode = readNumberField(raw, 'status_code') ?? readNumberField(raw, 'statusCode')
|
|
|
|
|
|
const headers = raw.headers
|
|
|
|
|
|
const body = raw.body
|
|
|
|
|
|
const bodyRef = readStringField(raw, 'body_ref') ?? readStringField(raw, 'bodyRef')
|
|
|
|
|
|
const bodyState = readStringField(raw, 'body_state') ?? readStringField(raw, 'bodyState')
|
2026-05-25 13:38:18 +08:00
|
|
|
|
const meaningfulBodyState = bodyState && bodyState.toLowerCase() !== 'none'
|
|
|
|
|
|
? bodyState
|
|
|
|
|
|
: ''
|
2026-04-25 16:58:17 +08:00
|
|
|
|
|
2026-05-09 10:46:04 +08:00
|
|
|
|
if (
|
|
|
|
|
|
statusCode == null &&
|
|
|
|
|
|
!hasRenderableValue(headers) &&
|
|
|
|
|
|
!hasRenderableValue(body) &&
|
|
|
|
|
|
!bodyRef &&
|
2026-05-25 13:38:18 +08:00
|
|
|
|
!meaningfulBodyState
|
2026-05-09 10:46:04 +08:00
|
|
|
|
) {
|
|
|
|
|
|
return null
|
2026-04-25 16:58:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 10:46:04 +08:00
|
|
|
|
const data: Record<string, unknown> = {}
|
|
|
|
|
|
if (statusCode != null) data.status_code = statusCode
|
|
|
|
|
|
if (hasRenderableValue(headers)) data.headers = headers
|
|
|
|
|
|
if (hasRenderableValue(body)) data.body = body
|
|
|
|
|
|
if (bodyRef) data.body_ref = bodyRef
|
2026-05-25 13:38:18 +08:00
|
|
|
|
if (meaningfulBodyState) data.body_state = meaningfulBodyState
|
2026-05-09 10:46:04 +08:00
|
|
|
|
|
|
|
|
|
|
return data
|
2026-04-25 16:58:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 13:00:44 +08:00
|
|
|
|
const extractStringList = (value: unknown): string[] => {
|
|
|
|
|
|
if (Array.isArray(value)) {
|
|
|
|
|
|
return value
|
|
|
|
|
|
.map(item => typeof item === 'string' ? item.trim() : '')
|
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (typeof value === 'string') {
|
|
|
|
|
|
const raw = value.trim()
|
|
|
|
|
|
if (!raw) return []
|
|
|
|
|
|
try {
|
|
|
|
|
|
return extractStringList(JSON.parse(raw))
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return [raw]
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return []
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const resolveTransportDiagnostics = (attempt: CandidateRecord): Record<string, unknown> | null => {
|
|
|
|
|
|
const extra = extractObject(attempt.extra_data)
|
|
|
|
|
|
return extractObject(extra?.transport_diagnostics)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 09:22:20 +08:00
|
|
|
|
const currentAttemptFormatDisplay = computed(() => {
|
|
|
|
|
|
const attempt = currentAttempt.value
|
|
|
|
|
|
if (!attempt) return ''
|
2026-04-16 13:00:44 +08:00
|
|
|
|
const extra = extractObject(attempt.extra_data) ?? {}
|
2026-03-03 09:22:20 +08:00
|
|
|
|
|
|
|
|
|
|
const providerRaw = typeof extra.provider_api_format === 'string' ? extra.provider_api_format : ''
|
|
|
|
|
|
const clientRawFromExtra = typeof extra.client_api_format === 'string' ? extra.client_api_format : ''
|
|
|
|
|
|
const requestRaw = clientRawFromExtra || (typeof props.requestApiFormat === 'string' ? props.requestApiFormat : '')
|
|
|
|
|
|
|
|
|
|
|
|
if (!providerRaw && !requestRaw) return ''
|
|
|
|
|
|
|
|
|
|
|
|
const providerText = providerRaw ? formatApiFormat(providerRaw) : ''
|
|
|
|
|
|
const requestText = requestRaw ? formatApiFormat(requestRaw) : ''
|
|
|
|
|
|
const convertedByFlag = extra.needs_conversion === true
|
|
|
|
|
|
const convertedByDiff = Boolean(
|
|
|
|
|
|
providerRaw &&
|
|
|
|
|
|
requestRaw &&
|
|
|
|
|
|
normalizeFormatSignature(providerRaw) !== normalizeFormatSignature(requestRaw),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if ((convertedByFlag || convertedByDiff) && requestText && providerText) {
|
|
|
|
|
|
return `${requestText} -> ${providerText}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return providerText || requestText
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-08 22:03:17 +08:00
|
|
|
|
const normalizeQueryString = (value: string): string => {
|
|
|
|
|
|
const trimmed = value.trim()
|
|
|
|
|
|
if (!trimmed) return ''
|
|
|
|
|
|
return trimmed.startsWith('?') ? trimmed.slice(1) : trimmed
|
|
|
|
|
|
}
|
2026-04-10 01:46:14 +08:00
|
|
|
|
|
2026-05-08 22:03:17 +08:00
|
|
|
|
const resolveRequestPathFromObject = (value: unknown): string => {
|
|
|
|
|
|
const object = extractObject(value)
|
|
|
|
|
|
if (!object) return ''
|
2026-04-10 01:46:14 +08:00
|
|
|
|
|
2026-05-08 22:03:17 +08:00
|
|
|
|
const pathWithQuery = (
|
|
|
|
|
|
readStringField(object, 'request_path_and_query')
|
|
|
|
|
|
|| readStringField(object, 'public_path_and_query')
|
|
|
|
|
|
|| readStringField(object, 'path_and_query')
|
|
|
|
|
|
|| readStringField(object, 'request_uri')
|
|
|
|
|
|
|| readStringField(object, 'public_uri')
|
2026-04-10 01:46:14 +08:00
|
|
|
|
)
|
2026-05-08 22:03:17 +08:00
|
|
|
|
if (pathWithQuery) return pathWithQuery
|
2026-04-10 01:46:14 +08:00
|
|
|
|
|
2026-05-08 22:03:17 +08:00
|
|
|
|
const path = (
|
|
|
|
|
|
readStringField(object, 'request_path')
|
|
|
|
|
|
|| readStringField(object, 'public_path')
|
|
|
|
|
|
|| readStringField(object, 'path')
|
|
|
|
|
|
)
|
|
|
|
|
|
if (!path) return ''
|
2026-04-10 01:46:14 +08:00
|
|
|
|
|
2026-05-08 22:03:17 +08:00
|
|
|
|
const query = normalizeQueryString(
|
|
|
|
|
|
readStringField(object, 'request_query_string')
|
|
|
|
|
|
|| readStringField(object, 'public_query_string')
|
|
|
|
|
|
|| readStringField(object, 'query_string')
|
|
|
|
|
|
|| readStringField(object, 'query')
|
|
|
|
|
|
|| '',
|
|
|
|
|
|
)
|
|
|
|
|
|
if (!query || path.includes('?')) return path
|
|
|
|
|
|
return `${path}?${query}`
|
2026-04-27 12:34:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-08 22:03:17 +08:00
|
|
|
|
const currentAttemptRequestPathDisplay = computed(() => {
|
2026-04-27 12:34:03 +08:00
|
|
|
|
const attempt = currentAttempt.value
|
2026-05-08 22:03:17 +08:00
|
|
|
|
const fromAttempt = resolveRequestPathFromObject(attempt?.extra_data)
|
|
|
|
|
|
if (fromAttempt) return fromAttempt
|
2026-04-27 12:34:03 +08:00
|
|
|
|
|
2026-05-09 10:46:04 +08:00
|
|
|
|
const fromTrace = resolveRequestPathFromObject(trace.value)
|
|
|
|
|
|
if (fromTrace) return fromTrace
|
|
|
|
|
|
|
2026-05-08 22:03:17 +08:00
|
|
|
|
const fromRequestMetadata = resolveRequestPathFromObject(props.requestMetadata)
|
|
|
|
|
|
if (fromRequestMetadata) return fromRequestMetadata
|
2026-04-27 12:34:03 +08:00
|
|
|
|
|
2026-05-08 22:03:17 +08:00
|
|
|
|
return ''
|
2026-04-27 12:34:03 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-30 19:47:00 +08:00
|
|
|
|
const currentAttemptKeyDisplay = computed(() => {
|
|
|
|
|
|
const attempt = currentAttempt.value
|
|
|
|
|
|
if (!attempt) return ''
|
|
|
|
|
|
return attempt.key_account_label || attempt.key_name || attempt.key_id || ''
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-04-16 13:00:44 +08:00
|
|
|
|
const currentAttemptKeyFormatsDisplay = computed(() => {
|
|
|
|
|
|
const attempt = currentAttempt.value
|
|
|
|
|
|
if (!attempt) return ''
|
|
|
|
|
|
|
|
|
|
|
|
const formats = extractStringList(attempt.key_api_formats)
|
|
|
|
|
|
if (!formats.length) return ''
|
|
|
|
|
|
|
|
|
|
|
|
return formats
|
|
|
|
|
|
.map(format => formatApiFormat(format))
|
|
|
|
|
|
.join(' / ')
|
|
|
|
|
|
})
|
2026-05-27 17:00:09 +08:00
|
|
|
|
const SKIP_REASON_LABELS: Record<string, string> = {
|
|
|
|
|
|
auth_api_key_concurrency_limit_reached: '调用方 API Key 并发已达上限',
|
|
|
|
|
|
api_key_concurrency_limit_reached: '调用方 API Key 并发已达上限',
|
|
|
|
|
|
pool_key_lease_busy: '池内账号正被其他请求占用',
|
|
|
|
|
|
provider_concurrency_limit_reached: '上游提供商并发已达上限',
|
|
|
|
|
|
provider_key_concurrency_limit_reached: '上游账号并发已达上限',
|
|
|
|
|
|
provider_request_body_build_failed: '上游请求体转换失败',
|
|
|
|
|
|
provider_request_body_missing: '无法构建上游请求体',
|
|
|
|
|
|
}
|
2026-04-19 21:45:57 +08:00
|
|
|
|
const currentAttemptSkipReasonDisplay = computed(() => {
|
|
|
|
|
|
const attempt = currentAttempt.value
|
|
|
|
|
|
if (!attempt?.skip_reason) return ''
|
|
|
|
|
|
|
2026-05-27 17:00:09 +08:00
|
|
|
|
const skipReasonLabel = SKIP_REASON_LABELS[attempt.skip_reason]
|
|
|
|
|
|
if (skipReasonLabel) {
|
|
|
|
|
|
return skipReasonLabel
|
2026-04-26 01:38:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-19 21:45:57 +08:00
|
|
|
|
if (attempt.skip_reason !== 'transport_unsupported') {
|
|
|
|
|
|
return attempt.skip_reason
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const transportDiagnostics = resolveTransportDiagnostics(attempt)
|
|
|
|
|
|
const requestPair = extractObject(transportDiagnostics?.request_pair)
|
|
|
|
|
|
const detailedReason = typeof requestPair?.transport_unsupported_reason === 'string'
|
|
|
|
|
|
? requestPair.transport_unsupported_reason.trim()
|
|
|
|
|
|
: ''
|
|
|
|
|
|
|
|
|
|
|
|
return detailedReason || attempt.skip_reason
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-04-26 01:38:37 +08:00
|
|
|
|
const currentAttemptFailureDiagnostic = computed<{
|
|
|
|
|
|
path: string
|
|
|
|
|
|
message: string
|
|
|
|
|
|
} | null>(() => {
|
|
|
|
|
|
const attempt = currentAttempt.value
|
|
|
|
|
|
if (!attempt) return null
|
|
|
|
|
|
const extra = extractObject(attempt.extra_data)
|
2026-06-11 00:49:29 +08:00
|
|
|
|
const failureDiagnostic = extractVisibleFailureDiagnostic(extra)
|
|
|
|
|
|
const error = failureDiagnostic
|
2026-04-26 01:38:37 +08:00
|
|
|
|
? failureDiagnostic
|
|
|
|
|
|
: extractObject(extra?.request_body_build_error)
|
|
|
|
|
|
const path = typeof error?.path === 'string' && error.path.trim()
|
|
|
|
|
|
? error.path.trim()
|
|
|
|
|
|
: ''
|
|
|
|
|
|
const message = typeof error?.message === 'string' && error.message.trim()
|
|
|
|
|
|
? error.message.trim()
|
|
|
|
|
|
: ''
|
|
|
|
|
|
if (!path && !message) return null
|
|
|
|
|
|
return {
|
|
|
|
|
|
path: path || '$',
|
2026-06-11 00:49:29 +08:00
|
|
|
|
message: formatAttemptErrorMessage(message) || message || '请求体转换失败',
|
2026-04-26 01:38:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-06-11 00:49:29 +08:00
|
|
|
|
const isGenericExecutionRuntimeStatusMessage = (message: string): boolean =>
|
|
|
|
|
|
/execution runtime (stream )?returned non-success status \d+/i.test(message)
|
|
|
|
|
|
|
|
|
|
|
|
const isLocalSyncFinalizeDiagnostic = (message: string): boolean =>
|
|
|
|
|
|
/local sync attempt failed before terminal finalization/i.test(message)
|
|
|
|
|
|
|| /unsupported provider stream (event|finish reason)/i.test(message)
|
|
|
|
|
|
|
|
|
|
|
|
const isActionableDiagnosticMessage = (message: string): boolean =>
|
|
|
|
|
|
isLocalSyncFinalizeDiagnostic(message) || isConversionDiagnosticMessage(message)
|
|
|
|
|
|
|
|
|
|
|
|
const extractVisibleFailureDiagnostic = (
|
|
|
|
|
|
extra: Record<string, unknown> | null | undefined,
|
|
|
|
|
|
): Record<string, unknown> | null => {
|
|
|
|
|
|
const failureDiagnostic = extractObject(extra?.failure_diagnostic)
|
|
|
|
|
|
if (!failureDiagnostic || failureDiagnostic.safe_to_show === false) return null
|
|
|
|
|
|
return failureDiagnostic
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const extractVisibleDiagnosticObjects = (
|
|
|
|
|
|
extra: Record<string, unknown> | null | undefined,
|
|
|
|
|
|
): Array<Record<string, unknown>> => [
|
|
|
|
|
|
extractVisibleFailureDiagnostic(extra),
|
|
|
|
|
|
extractObject(extra?.request_conversion_error),
|
|
|
|
|
|
extractObject(extra?.request_body_build_error),
|
|
|
|
|
|
].filter((value): value is Record<string, unknown> => Boolean(value))
|
|
|
|
|
|
|
|
|
|
|
|
const extractVisibleDiagnosticMessage = (
|
|
|
|
|
|
extra: Record<string, unknown> | null | undefined,
|
|
|
|
|
|
): string => {
|
|
|
|
|
|
for (const diagnostic of extractVisibleDiagnosticObjects(extra)) {
|
|
|
|
|
|
const message = readStringField(diagnostic, 'message')
|
|
|
|
|
|
if (message) return message
|
|
|
|
|
|
}
|
|
|
|
|
|
return ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const chooseAttemptRawErrorMessage = (
|
|
|
|
|
|
flowMessage: string,
|
|
|
|
|
|
fallbackMessage: string,
|
|
|
|
|
|
diagnosticMessage: string,
|
|
|
|
|
|
): string => {
|
|
|
|
|
|
const flow = flowMessage.trim()
|
|
|
|
|
|
const fallback = fallbackMessage.trim()
|
|
|
|
|
|
const diagnostic = diagnosticMessage.trim()
|
|
|
|
|
|
|
|
|
|
|
|
if (flow && fallback && !isActionableDiagnosticMessage(flow) && isActionableDiagnosticMessage(fallback)) {
|
|
|
|
|
|
return fallback
|
|
|
|
|
|
}
|
|
|
|
|
|
if (flow && diagnostic && isGenericExecutionRuntimeStatusMessage(flow) && isActionableDiagnosticMessage(diagnostic)) {
|
|
|
|
|
|
return diagnostic
|
|
|
|
|
|
}
|
|
|
|
|
|
if (fallback && diagnostic && isGenericExecutionRuntimeStatusMessage(fallback) && isActionableDiagnosticMessage(diagnostic)) {
|
|
|
|
|
|
return diagnostic
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return flow || fallback || diagnostic
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const decodeRustDebugString = (value: string): string => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
return JSON.parse(`"${value}"`)
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return value.replace(/\\"/g, '"')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const normalizeDiagnosticFieldPath = (field: string): string => {
|
|
|
|
|
|
const trimmed = field.trim()
|
|
|
|
|
|
if (!trimmed || trimmed === '$') return '$'
|
|
|
|
|
|
if (trimmed.startsWith('$')) return trimmed
|
|
|
|
|
|
if (trimmed.startsWith('[')) return `$${trimmed}`
|
|
|
|
|
|
return `$.${trimmed}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const formatConversionPair = (source: string, target: string): string =>
|
|
|
|
|
|
`${formatApiFormat(source.trim())} → ${formatApiFormat(target.trim())}`
|
|
|
|
|
|
|
|
|
|
|
|
const extractFieldDetail = (message: string): string => {
|
|
|
|
|
|
const fieldMatch = message.match(/field\s+([^;=]+?)\s*=\s*(\"(?:\\.|[^"\\])*\"|[^;]+)/i)
|
|
|
|
|
|
const unsupportedFieldMatch = message.match(/field\s+([^;]+?)\s+is unsupported/i)
|
|
|
|
|
|
if (fieldMatch?.[1]) {
|
|
|
|
|
|
return `字段 ${normalizeDiagnosticFieldPath(fieldMatch[1])} = ${fieldMatch[2].trim()}`
|
|
|
|
|
|
}
|
|
|
|
|
|
if (unsupportedFieldMatch?.[1]) {
|
|
|
|
|
|
return `字段 ${normalizeDiagnosticFieldPath(unsupportedFieldMatch[1])} 不支持`
|
|
|
|
|
|
}
|
|
|
|
|
|
return ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const formatUnsupportedStreamEventMessage = (message: string): string => {
|
|
|
|
|
|
const detail = extractFieldDetail(message)
|
|
|
|
|
|
const fieldDetail = detail ? `(${detail})` : ''
|
|
|
|
|
|
return `流式格式转换失败:上游返回了当前不支持的 stream event${fieldDetail},无法无损转换到客户端请求格式`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const formatUnsupportedFinishReasonMessage = (message: string): string => {
|
|
|
|
|
|
const fieldDetail = extractFieldDetail(message)
|
|
|
|
|
|
const legacyMatch = message.match(/unsupported provider stream finish reason\s+(.+?)\s+cannot be converted losslessly/i)
|
|
|
|
|
|
const detail = fieldDetail || (legacyMatch?.[1]
|
|
|
|
|
|
? `字段 $.finish_reason = ${legacyMatch[1].trim()}`
|
|
|
|
|
|
: '')
|
|
|
|
|
|
return `流式格式转换失败:上游返回了当前不支持的 finish reason${detail ? `(${detail})` : ''},无法无损转换到客户端请求格式`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const formatKnownConversionErrorMessage = (message: string): string => {
|
|
|
|
|
|
const lossy = message.match(/^lossy conversion blocked from\s+(\S+)\s+to\s+(\S+)\s+at\s+([^:]+):\s*(.+)$/i)
|
|
|
|
|
|
if (lossy) {
|
|
|
|
|
|
return `格式转换失败:${formatConversionPair(lossy[1], lossy[2])} 在字段 ${normalizeDiagnosticFieldPath(lossy[3])} 会丢失信息:${lossy[4].trim()}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const unaudited = message.match(/^unaudited field\s+(.+?)\s+in\s+(.+?)\s+cannot be converted to\s+([^:]+):\s*(.+)$/i)
|
|
|
|
|
|
if (unaudited) {
|
|
|
|
|
|
return `格式转换失败:${formatConversionPair(unaudited[2], unaudited[3])} 的字段 ${normalizeDiagnosticFieldPath(unaudited[1])} 尚未审计,不能安全转换:${unaudited[4].trim()}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const unsupportedField = message.match(/^unsupported field\s+(.+?)\s+in\s+([^:]+(?::[^:]+)?):\s*(.+)$/i)
|
|
|
|
|
|
if (unsupportedField) {
|
|
|
|
|
|
return `格式转换失败:${formatApiFormat(unsupportedField[2])} 不支持字段 ${normalizeDiagnosticFieldPath(unsupportedField[1])}:${unsupportedField[3].trim()}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const invalidEnum = message.match(/^invalid enum value\s+(.+?)\s+for\s+(.+)\.([^.\s]+)$/i)
|
|
|
|
|
|
if (invalidEnum) {
|
|
|
|
|
|
return `格式转换失败:${formatApiFormat(invalidEnum[2])} 字段 ${normalizeDiagnosticFieldPath(invalidEnum[3])} 的枚举值 ${invalidEnum[1].trim()} 无效`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const invalidTarget = message.match(/^invalid target field\s+(.+?)\s+for\s+(.+?):\s*(.+)$/i)
|
|
|
|
|
|
if (invalidTarget) {
|
|
|
|
|
|
return `格式转换失败:目标格式 ${formatApiFormat(invalidTarget[2])} 字段 ${normalizeDiagnosticFieldPath(invalidTarget[1])} 无效:${invalidTarget[3].trim()}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const unsupportedFormat = message.match(/^unsupported AI format:\s*(.+)$/i)
|
|
|
|
|
|
if (unsupportedFormat) {
|
|
|
|
|
|
return `格式转换失败:不支持的 API 格式 ${unsupportedFormat[1].trim()}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const parseEmit = message.match(/^failed to\s+(parse|emit)\s+(.+?)\s+(request|response)$/i)
|
|
|
|
|
|
if (parseEmit) {
|
|
|
|
|
|
const action = parseEmit[1].toLowerCase() === 'parse' ? '解析' : '生成'
|
|
|
|
|
|
const subject = parseEmit[3].toLowerCase() === 'request' ? '请求体' : '响应体'
|
|
|
|
|
|
return `格式转换失败:无法${action} ${formatApiFormat(parseEmit[2])} ${subject}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const isConversionDiagnosticMessage = (message: string): boolean => {
|
|
|
|
|
|
const normalized = message.trim()
|
|
|
|
|
|
if (!normalized) return false
|
|
|
|
|
|
return /conversion|converted|convertible|cannot be converted|lossy conversion|unsupported field|unaudited field|invalid enum value|invalid target field|unsupported ai format|failed to (parse|emit) .+ (request|response)|unsupported provider stream (event|finish reason)|转换|无损|字段 .*不支持/i
|
|
|
|
|
|
.test(normalized)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 10:46:04 +08:00
|
|
|
|
const formatAttemptErrorMessage = (message: string, statusCode?: number): string => {
|
|
|
|
|
|
const normalized = message.trim()
|
|
|
|
|
|
if (!normalized) return ''
|
2026-06-11 00:49:29 +08:00
|
|
|
|
const directInternal = normalized.match(/^Internal\("((?:\\.|[^"\\])*)"\)$/i)
|
|
|
|
|
|
if (directInternal?.[1]) {
|
|
|
|
|
|
return formatAttemptErrorMessage(decodeRustDebugString(directInternal[1]), statusCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
const localSyncInternal = normalized.match(/local sync attempt failed before terminal finalization:\s*Internal\("((?:\\.|[^"\\])*)"\)/i)
|
|
|
|
|
|
if (localSyncInternal?.[1]) {
|
|
|
|
|
|
return formatAttemptErrorMessage(decodeRustDebugString(localSyncInternal[1]), statusCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (/unsupported provider stream event cannot be converted losslessly/i.test(normalized)) {
|
|
|
|
|
|
return formatUnsupportedStreamEventMessage(normalized)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (/unsupported provider stream finish reason/i.test(normalized)) {
|
|
|
|
|
|
return formatUnsupportedFinishReasonMessage(normalized)
|
|
|
|
|
|
}
|
|
|
|
|
|
const conversionMessage = formatKnownConversionErrorMessage(normalized)
|
|
|
|
|
|
if (conversionMessage) {
|
|
|
|
|
|
return conversionMessage
|
|
|
|
|
|
}
|
|
|
|
|
|
if (isGenericExecutionRuntimeStatusMessage(normalized)) {
|
2026-05-09 10:46:04 +08:00
|
|
|
|
return statusCode != null ? `上游返回非成功状态 ${statusCode}` : '上游返回非成功状态'
|
|
|
|
|
|
}
|
|
|
|
|
|
return normalized
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-11 00:49:29 +08:00
|
|
|
|
const shouldShowAttemptMessageWithUpstreamResponse = (
|
|
|
|
|
|
rawMessage: string,
|
|
|
|
|
|
upstreamResponse: Record<string, unknown> | null,
|
|
|
|
|
|
): boolean => {
|
|
|
|
|
|
if (!upstreamResponse) return true
|
|
|
|
|
|
const normalized = rawMessage.trim()
|
|
|
|
|
|
if (!normalized) return false
|
|
|
|
|
|
if (isLocalSyncFinalizeDiagnostic(normalized)) return true
|
|
|
|
|
|
if (isConversionDiagnosticMessage(normalized)) return true
|
|
|
|
|
|
if (isGenericExecutionRuntimeStatusMessage(normalized)) return false
|
|
|
|
|
|
|
|
|
|
|
|
const hasBody = hasRenderableValue(upstreamResponse.body)
|
|
|
|
|
|
const bodyState = (readStringField(upstreamResponse, 'body_state') ?? '').toLowerCase()
|
|
|
|
|
|
return !hasBody && bodyState === 'disabled'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-25 16:58:17 +08:00
|
|
|
|
const currentAttemptRequestError = computed<{
|
|
|
|
|
|
message: string
|
2026-05-09 10:46:04 +08:00
|
|
|
|
statusCode?: number
|
|
|
|
|
|
upstreamResponse: Record<string, unknown> | null
|
2026-04-25 16:58:17 +08:00
|
|
|
|
} | null>(() => {
|
|
|
|
|
|
const attempt = currentAttempt.value
|
|
|
|
|
|
if (!attempt || attempt.status !== 'failed') return null
|
|
|
|
|
|
|
|
|
|
|
|
const extra = extractObject(attempt.extra_data)
|
2026-05-09 10:46:04 +08:00
|
|
|
|
const upstreamResponse = extractObject(extra?.upstream_response)
|
|
|
|
|
|
const errorFlow = extractObject(extra?.error_flow)
|
|
|
|
|
|
const statusCode = readNumberField(upstreamResponse ?? {}, 'status_code')
|
|
|
|
|
|
?? readNumberField(upstreamResponse ?? {}, 'statusCode')
|
|
|
|
|
|
?? readNumberField(errorFlow ?? {}, 'status_code')
|
|
|
|
|
|
?? readNumberField(errorFlow ?? {}, 'statusCode')
|
|
|
|
|
|
?? attempt.status_code
|
|
|
|
|
|
const flowMessage = errorFlow
|
|
|
|
|
|
? readStringField(errorFlow, 'message')
|
|
|
|
|
|
: ''
|
2026-04-25 16:58:17 +08:00
|
|
|
|
const fallbackMessage = typeof attempt.error_message === 'string' && attempt.error_message.trim()
|
|
|
|
|
|
? attempt.error_message.trim()
|
|
|
|
|
|
: ''
|
|
|
|
|
|
const fallbackType = typeof attempt.error_type === 'string' && attempt.error_type.trim()
|
|
|
|
|
|
? attempt.error_type.trim()
|
|
|
|
|
|
: ''
|
2026-06-11 00:49:29 +08:00
|
|
|
|
const diagnosticMessage = extractVisibleDiagnosticMessage(extra)
|
|
|
|
|
|
const rawMessage = chooseAttemptRawErrorMessage(flowMessage || '', fallbackMessage, diagnosticMessage)
|
|
|
|
|
|
const message = formatAttemptErrorMessage(rawMessage, statusCode) || fallbackType
|
2026-05-09 10:46:04 +08:00
|
|
|
|
const upstreamResponseDisplay = normalizeUpstreamResponseDisplay(extra?.upstream_response)
|
2026-06-11 00:49:29 +08:00
|
|
|
|
const visibleDiagnosticObjects = extractVisibleDiagnosticObjects(extra)
|
|
|
|
|
|
const shouldAttachDiagnostic = Boolean(
|
|
|
|
|
|
visibleDiagnosticObjects.length
|
|
|
|
|
|
|| isLocalSyncFinalizeDiagnostic(rawMessage)
|
|
|
|
|
|
|| isConversionDiagnosticMessage(rawMessage),
|
|
|
|
|
|
)
|
|
|
|
|
|
const diagnostic = shouldAttachDiagnostic
|
|
|
|
|
|
? buildAttemptDiagnosticPayload(
|
|
|
|
|
|
attempt,
|
|
|
|
|
|
message || fallbackType || rawMessage || '未知失败',
|
|
|
|
|
|
statusCode,
|
|
|
|
|
|
upstreamResponseDisplay,
|
|
|
|
|
|
rawMessage,
|
|
|
|
|
|
)
|
|
|
|
|
|
: null
|
|
|
|
|
|
const upstreamResponseWithDiagnostic = diagnostic
|
|
|
|
|
|
? { ...(upstreamResponseDisplay ?? {}), diagnostic }
|
|
|
|
|
|
: upstreamResponseDisplay
|
|
|
|
|
|
if (!message && statusCode == null && !upstreamResponseWithDiagnostic) return null
|
|
|
|
|
|
const showMessage = shouldShowAttemptMessageWithUpstreamResponse(
|
|
|
|
|
|
rawMessage || fallbackType,
|
|
|
|
|
|
upstreamResponseDisplay,
|
|
|
|
|
|
)
|
2026-04-25 16:58:17 +08:00
|
|
|
|
|
|
|
|
|
|
return {
|
2026-06-11 00:49:29 +08:00
|
|
|
|
message: showMessage ? (message || '未知错误') : '',
|
2026-05-09 10:46:04 +08:00
|
|
|
|
statusCode,
|
2026-06-11 00:49:29 +08:00
|
|
|
|
upstreamResponse: upstreamResponseWithDiagnostic,
|
2026-04-25 16:58:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-06-11 00:49:29 +08:00
|
|
|
|
const diagnosticPathFromObject = (value: unknown): string => {
|
|
|
|
|
|
const object = extractObject(value)
|
|
|
|
|
|
if (!object) return ''
|
|
|
|
|
|
return readStringField(object, 'path')
|
|
|
|
|
|
|| readStringField(object, 'field_path')
|
|
|
|
|
|
|| readStringField(object, 'fieldPath')
|
|
|
|
|
|
|| readStringField(object, 'field')
|
|
|
|
|
|
|| ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const diagnosticFieldPathFromMessage = (message: string): string => {
|
|
|
|
|
|
const normalized = message.trim()
|
|
|
|
|
|
if (!normalized) return ''
|
|
|
|
|
|
const fieldMatch = normalized.match(/field\s+([^;=]+?)\s*(?:=|is unsupported|不支持)/i)
|
|
|
|
|
|
if (fieldMatch?.[1]) return normalizeDiagnosticFieldPath(fieldMatch[1])
|
|
|
|
|
|
const lossyMatch = normalized.match(/lossy conversion blocked from\s+\S+\s+to\s+\S+\s+at\s+([^:]+):/i)
|
|
|
|
|
|
if (lossyMatch?.[1]) return normalizeDiagnosticFieldPath(lossyMatch[1])
|
|
|
|
|
|
const invalidTargetMatch = normalized.match(/invalid target field\s+(.+?)\s+for\s+/i)
|
|
|
|
|
|
if (invalidTargetMatch?.[1]) return normalizeDiagnosticFieldPath(invalidTargetMatch[1])
|
|
|
|
|
|
const unsupportedFieldMatch = normalized.match(/unsupported field\s+(.+?)\s+in\s+/i)
|
|
|
|
|
|
if (unsupportedFieldMatch?.[1]) return normalizeDiagnosticFieldPath(unsupportedFieldMatch[1])
|
|
|
|
|
|
const invalidEnumMatch = normalized.match(/invalid enum value\s+.+?\s+for\s+.+\.([^.\s]+)$/i)
|
|
|
|
|
|
if (invalidEnumMatch?.[1]) return normalizeDiagnosticFieldPath(invalidEnumMatch[1])
|
|
|
|
|
|
return ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resolveAttemptDiagnosticBreakpoint(attempt: CandidateRecord, rawMessageOverride = ''): string {
|
|
|
|
|
|
const extra = extractObject(attempt.extra_data)
|
|
|
|
|
|
const failureDiagnostic = extractVisibleFailureDiagnostic(extra)
|
|
|
|
|
|
const requestConversionError = extractObject(extra?.request_conversion_error)
|
|
|
|
|
|
const requestBodyBuildError = extractObject(extra?.request_body_build_error)
|
|
|
|
|
|
const errorFlow = extractObject(extra?.error_flow)
|
|
|
|
|
|
const rawMessage = [
|
|
|
|
|
|
rawMessageOverride,
|
|
|
|
|
|
readStringField(errorFlow ?? {}, 'message') ?? '',
|
|
|
|
|
|
readStringField(failureDiagnostic ?? {}, 'message') ?? '',
|
|
|
|
|
|
readStringField(requestConversionError ?? {}, 'message') ?? '',
|
|
|
|
|
|
readStringField(requestBodyBuildError ?? {}, 'message') ?? '',
|
|
|
|
|
|
typeof attempt.error_message === 'string' ? attempt.error_message : '',
|
|
|
|
|
|
].find(item => item.trim()) ?? ''
|
|
|
|
|
|
|
|
|
|
|
|
return diagnosticPathFromObject(failureDiagnostic)
|
|
|
|
|
|
|| diagnosticPathFromObject(requestConversionError)
|
|
|
|
|
|
|| diagnosticPathFromObject(requestBodyBuildError)
|
|
|
|
|
|
|| diagnosticFieldPathFromMessage(rawMessage)
|
|
|
|
|
|
|| '$'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildAttemptDiagnosticPayload(
|
|
|
|
|
|
attempt: CandidateRecord,
|
|
|
|
|
|
summaryInput: string,
|
|
|
|
|
|
statusCode: number | undefined,
|
|
|
|
|
|
upstreamResponseDisplay: Record<string, unknown> | null,
|
|
|
|
|
|
rawMessageForBreakpoint = '',
|
|
|
|
|
|
): Record<string, unknown> | null {
|
|
|
|
|
|
const extra = extractObject(attempt.extra_data)
|
|
|
|
|
|
const rawFailureDiagnostic = extractVisibleFailureDiagnostic(extra)
|
|
|
|
|
|
const rawRequestConversionError = extractObject(extra?.request_conversion_error)
|
|
|
|
|
|
const rawRequestBodyBuildError = extractObject(extra?.request_body_build_error)
|
|
|
|
|
|
const hasDiagnostic = Boolean(
|
|
|
|
|
|
summaryInput
|
|
|
|
|
|
|| rawFailureDiagnostic
|
|
|
|
|
|
|| rawRequestConversionError
|
|
|
|
|
|
|| rawRequestBodyBuildError
|
|
|
|
|
|
|| attempt.error_message
|
|
|
|
|
|
|| attempt.error_type
|
|
|
|
|
|
|| attempt.skip_reason
|
|
|
|
|
|
|| upstreamResponseDisplay,
|
|
|
|
|
|
)
|
|
|
|
|
|
if (!hasDiagnostic) return null
|
|
|
|
|
|
|
|
|
|
|
|
const summary = summaryInput
|
|
|
|
|
|
|| readStringField(rawFailureDiagnostic ?? {}, 'message')
|
|
|
|
|
|
|| readStringField(rawRequestConversionError ?? {}, 'message')
|
|
|
|
|
|
|| readStringField(rawRequestBodyBuildError ?? {}, 'message')
|
|
|
|
|
|
|| (typeof attempt.error_message === 'string' ? formatAttemptErrorMessage(attempt.error_message, attempt.status_code) : '')
|
|
|
|
|
|
|| attempt.error_type
|
|
|
|
|
|
|| currentAttemptSkipReasonDisplay.value
|
|
|
|
|
|
|| '未知失败'
|
|
|
|
|
|
const breakpoint = resolveAttemptDiagnosticBreakpoint(attempt, rawMessageForBreakpoint)
|
|
|
|
|
|
const providerFormat = readStringField(extra ?? {}, 'provider_api_format')
|
|
|
|
|
|
const clientFormat = readStringField(extra ?? {}, 'client_api_format')
|
|
|
|
|
|
|| (typeof props.requestApiFormat === 'string' ? props.requestApiFormat : '')
|
|
|
|
|
|
const conversionDisplay = clientFormat || providerFormat
|
|
|
|
|
|
? `${clientFormat ? formatApiFormat(clientFormat) : '未知请求格式'} → ${providerFormat ? formatApiFormat(providerFormat) : '未知上游格式'}`
|
|
|
|
|
|
: currentAttemptFormatDisplay.value
|
|
|
|
|
|
const analysisHint = (() => {
|
|
|
|
|
|
const raw = `${summary}\n${rawMessageForBreakpoint}\n${attempt.error_message ?? ''}`.toLowerCase()
|
|
|
|
|
|
if (raw.includes('unsupported provider stream event')) {
|
|
|
|
|
|
return '断点在上游流式事件解析/转换矩阵:先按 breakpoint 对应字段确认 event type,再决定是补 canonical mapping 还是加入 known noop。'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (raw.includes('finish reason')) {
|
|
|
|
|
|
return '断点在 finish_reason 映射:确认该结束原因是否可等价映射;不能无损映射时保持失败闭合。'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (raw.includes('lossy conversion') || raw.includes('无损') || raw.includes('丢失信息') || raw.includes('request_conversion')) {
|
|
|
|
|
|
return '断点在请求/响应格式转换器:检查 breakpoint 字段是否能被目标格式表达,不能表达就需要拒绝、降级或新增显式映射策略。'
|
|
|
|
|
|
}
|
|
|
|
|
|
return '先从 breakpoint 字段开始回放;若 breakpoint 为 $,优先查看 raw.failure_diagnostic / raw.error_message 和 upstream_response。'
|
|
|
|
|
|
})()
|
|
|
|
|
|
|
|
|
|
|
|
const payload = {
|
|
|
|
|
|
summary,
|
|
|
|
|
|
breakpoint,
|
|
|
|
|
|
analysis_hint: analysisHint,
|
|
|
|
|
|
request: {
|
|
|
|
|
|
request_id: trace.value?.request_id ?? attempt.request_id,
|
|
|
|
|
|
path: currentAttemptRequestPathDisplay.value || null,
|
|
|
|
|
|
format_conversion: conversionDisplay || null,
|
|
|
|
|
|
client_api_format: clientFormat || null,
|
|
|
|
|
|
provider_api_format: providerFormat || null,
|
|
|
|
|
|
needs_conversion: extra?.needs_conversion ?? null,
|
|
|
|
|
|
conversion_mode: readStringField(extra ?? {}, 'conversion_mode') ?? null,
|
|
|
|
|
|
},
|
|
|
|
|
|
node: {
|
|
|
|
|
|
candidate_id: attempt.id,
|
|
|
|
|
|
candidate_index: attempt.candidate_index,
|
|
|
|
|
|
retry_index: attempt.retry_index,
|
|
|
|
|
|
provider: attempt.provider_name || attempt.provider_id || null,
|
|
|
|
|
|
key: currentAttemptKeyDisplay.value || null,
|
|
|
|
|
|
status: attempt.status,
|
|
|
|
|
|
status_code: statusCode ?? attempt.status_code ?? null,
|
|
|
|
|
|
skip_reason: attempt.skip_reason ?? null,
|
|
|
|
|
|
error_type: attempt.error_type ?? null,
|
|
|
|
|
|
error_message: attempt.error_message ?? null,
|
|
|
|
|
|
},
|
|
|
|
|
|
raw: {
|
|
|
|
|
|
failure_diagnostic: rawFailureDiagnostic,
|
|
|
|
|
|
request_conversion_error: rawRequestConversionError,
|
|
|
|
|
|
request_body_build_error: rawRequestBodyBuildError,
|
|
|
|
|
|
error_flow: extractObject(extra?.error_flow),
|
|
|
|
|
|
upstream_response: upstreamResponseDisplay ?? normalizeUpstreamResponseDisplay(extra?.upstream_response),
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
return payload
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 10:46:04 +08:00
|
|
|
|
const currentAttemptExtraDataDisplay = computed<Record<string, unknown> | null>(() => {
|
|
|
|
|
|
const extra = extractObject(currentAttempt.value?.extra_data)
|
|
|
|
|
|
if (!extra) return null
|
|
|
|
|
|
|
|
|
|
|
|
const display = { ...extra }
|
|
|
|
|
|
delete display.upstream_response
|
|
|
|
|
|
delete display.error_flow
|
|
|
|
|
|
delete display.client_response
|
|
|
|
|
|
delete display.provider_response
|
|
|
|
|
|
|
|
|
|
|
|
return Object.keys(display).length > 0 ? display : null
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-09 01:21:26 +08:00
|
|
|
|
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'
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-02-19 14:51:08 +08:00
|
|
|
|
// 判断是否为 OAuth 类型(provider_type 为具体值时也算 OAuth)
|
|
|
|
|
|
const isOAuthType = (authType?: string): boolean => {
|
|
|
|
|
|
if (!authType) return false
|
2026-03-01 23:32:48 +08:00
|
|
|
|
return !['api_key', 'service_account'].includes(authType)
|
2026-02-19 14:51:08 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 格式化认证类型(合并 plan 信息,避免冗余)
|
|
|
|
|
|
const formatAuthTypeWithPlan = (authType: string, planType?: string): string => {
|
|
|
|
|
|
const labels: Record<string, string> = {
|
|
|
|
|
|
'oauth': 'OAuth',
|
2026-03-01 23:32:48 +08:00
|
|
|
|
'service_account': 'Service Account',
|
2026-02-19 14:51:08 +08:00
|
|
|
|
'kiro': 'Kiro',
|
|
|
|
|
|
'codex': 'Codex',
|
|
|
|
|
|
'antigravity': 'Antigravity',
|
|
|
|
|
|
'claude_code': 'Claude Code',
|
|
|
|
|
|
'gemini_cli': 'Gemini CLI',
|
|
|
|
|
|
}
|
|
|
|
|
|
const typeName = labels[authType] || authType
|
|
|
|
|
|
if (planType) {
|
|
|
|
|
|
return `${typeName} ${planType}`
|
|
|
|
|
|
}
|
|
|
|
|
|
return typeName
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-27 13:54:46 +08:00
|
|
|
|
const poolSelectionLabel = (reason: string): string => {
|
|
|
|
|
|
const labels: Record<string, string> = {
|
|
|
|
|
|
sticky: '粘性会话',
|
|
|
|
|
|
lru: 'LRU',
|
|
|
|
|
|
random: '随机',
|
|
|
|
|
|
tiebreak: '随机 (平分)',
|
|
|
|
|
|
}
|
|
|
|
|
|
return labels[reason] || reason
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const poolSkipLabel = (type: string): string => {
|
|
|
|
|
|
const labels: Record<string, string> = {
|
|
|
|
|
|
cooldown: '冷却中',
|
|
|
|
|
|
cost_exhausted: '额度耗尽',
|
|
|
|
|
|
upstream: '上游跳过',
|
|
|
|
|
|
}
|
|
|
|
|
|
return labels[type] || type
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 检查组是否被悬浮
|
|
|
|
|
|
const isGroupHovered = (groupIndex: number) => {
|
|
|
|
|
|
return hoveredGroupIndex.value === groupIndex
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 检查组是否被选中
|
|
|
|
|
|
const isGroupSelected = (group: NodeGroup) => {
|
|
|
|
|
|
return selectedGroupIndex.value === groupedTimeline.value.findIndex(g => g.id === group.id && g.startIndex === group.startIndex)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-30 19:47:00 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 选中一个组
|
|
|
|
|
|
const selectGroup = (group: NodeGroup) => {
|
2026-05-30 19:47:00 +08:00
|
|
|
|
const index = findGroupIndex(groupedTimeline.value, group)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
if (index >= 0) {
|
2026-05-30 19:47:00 +08:00
|
|
|
|
selectionPinnedByUser.value = true
|
2025-12-10 20:52:44 +08:00
|
|
|
|
selectedGroupIndex.value = index
|
|
|
|
|
|
// 默认选中成功的尝试,或最后一个尝试
|
|
|
|
|
|
const successIdx = group.allAttempts.findIndex(a => a.status === 'success')
|
|
|
|
|
|
selectedAttemptIndex.value = successIdx >= 0 ? successIdx : group.allAttempts.length - 1
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 选中一个组的首次请求
|
|
|
|
|
|
const selectFirstAttempt = (group: NodeGroup) => {
|
2026-05-30 19:47:00 +08:00
|
|
|
|
const index = findGroupIndex(groupedTimeline.value, group)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
if (index >= 0) {
|
2026-05-30 19:47:00 +08:00
|
|
|
|
selectionPinnedByUser.value = true
|
2025-12-10 20:52:44 +08:00
|
|
|
|
selectedGroupIndex.value = index
|
|
|
|
|
|
selectedAttemptIndex.value = 0
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-07 03:23:20 +08:00
|
|
|
|
const selectAttemptInGroup = (group: NodeGroup, attemptIndex: number) => {
|
2026-05-30 19:47:00 +08:00
|
|
|
|
const groupIndex = findGroupIndex(groupedTimeline.value, group)
|
2026-05-07 03:23:20 +08:00
|
|
|
|
if (groupIndex < 0) return
|
2026-05-30 19:47:00 +08:00
|
|
|
|
selectionPinnedByUser.value = true
|
2026-05-07 03:23:20 +08:00
|
|
|
|
selectedGroupIndex.value = groupIndex
|
|
|
|
|
|
selectedAttemptIndex.value = attemptIndex
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const isAttemptSelected = (group: NodeGroup, attemptIndex: number) => {
|
|
|
|
|
|
return isGroupSelected(group) && selectedAttemptIndex.value === attemptIndex
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const formatCandidateAttemptIndex = (attempt: CandidateRecord): string => {
|
|
|
|
|
|
return attempt.retry_index > 0
|
|
|
|
|
|
? `#${attempt.candidate_index}.${attempt.retry_index}`
|
|
|
|
|
|
: `#${attempt.candidate_index}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const formatAttemptDotTitle = (attempt: CandidateRecord): string => {
|
|
|
|
|
|
const parts = [
|
|
|
|
|
|
formatCandidateAttemptIndex(attempt),
|
|
|
|
|
|
attempt.key_name || attempt.key_account_label || attempt.key_preview || '未知 Key',
|
|
|
|
|
|
getStatusLabel(getDisplayStatus(attempt)),
|
|
|
|
|
|
]
|
|
|
|
|
|
return parts.filter(Boolean).join(' · ')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 导航到上/下一组
|
|
|
|
|
|
const navigateGroup = (direction: number) => {
|
|
|
|
|
|
const newIndex = selectedGroupIndex.value + direction
|
|
|
|
|
|
if (newIndex >= 0 && newIndex < groupedTimeline.value.length) {
|
2026-05-30 19:47:00 +08:00
|
|
|
|
selectionPinnedByUser.value = true
|
2025-12-10 20:52:44 +08:00
|
|
|
|
selectedGroupIndex.value = newIndex
|
|
|
|
|
|
const group = groupedTimeline.value[newIndex]
|
|
|
|
|
|
// 默认选中成功的尝试,或最后一个尝试
|
|
|
|
|
|
const successIdx = group.allAttempts.findIndex(a => a.status === 'success')
|
|
|
|
|
|
selectedAttemptIndex.value = successIdx >= 0 ? successIdx : group.allAttempts.length - 1
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-14 01:27:04 +08:00
|
|
|
|
const navigateAttempt = (direction: number) => {
|
|
|
|
|
|
const group = selectedGroup.value
|
|
|
|
|
|
if (!group) return
|
|
|
|
|
|
const newIndex = selectedAttemptIndex.value + direction
|
|
|
|
|
|
if (newIndex >= 0 && newIndex < group.allAttempts.length) {
|
2026-05-30 19:47:00 +08:00
|
|
|
|
selectionPinnedByUser.value = true
|
2026-04-14 01:27:04 +08:00
|
|
|
|
selectedAttemptIndex.value = newIndex
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 加载请求追踪数据
|
2026-02-07 02:59:02 +08:00
|
|
|
|
const isSilentRefresh = ref(false)
|
|
|
|
|
|
const loadTrace = async (silent = false) => {
|
2026-03-19 23:52:17 +08:00
|
|
|
|
if (!props.requestId || props.traceData) return
|
2026-05-09 01:21:26 +08:00
|
|
|
|
if (traceLoadInFlight) return traceLoadInFlight
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-05-09 01:21:26 +08:00
|
|
|
|
traceLoadInFlight = (async () => {
|
|
|
|
|
|
isSilentRefresh.value = silent
|
|
|
|
|
|
traceLoadStarted.value = true
|
2026-02-07 02:59:02 +08:00
|
|
|
|
|
|
|
|
|
|
if (!silent) {
|
2026-05-09 01:21:26 +08:00
|
|
|
|
loading.value = true
|
2026-02-07 02:59:02 +08:00
|
|
|
|
}
|
2026-05-09 01:21:26 +08:00
|
|
|
|
error.value = null
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2026-06-10 09:16:15 +08:00
|
|
|
|
internalTrace.value = await requestTraceApi.getRequestTrace(props.requestId, { attemptedOnly: true })
|
2026-05-09 01:21:26 +08:00
|
|
|
|
} 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
|
2026-02-07 02:59:02 +08:00
|
|
|
|
}
|
2026-05-09 01:21:26 +08:00
|
|
|
|
})()
|
|
|
|
|
|
|
|
|
|
|
|
return traceLoadInFlight
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const propsRequestIsActive = computed(() => {
|
|
|
|
|
|
const status = props.requestStatus ?? usageData.value?.status
|
|
|
|
|
|
return status === 'pending' || status === 'streaming'
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const traceHasActiveCandidate = computed(() => {
|
2026-05-12 21:05:11 +08:00
|
|
|
|
return rawTimeline.value.some(isLiveCandidate)
|
2026-05-09 01:21:26 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 01:21:26 +08:00
|
|
|
|
const scheduleTracePolling = () => {
|
|
|
|
|
|
stopTracePolling()
|
|
|
|
|
|
if (!shouldPollTrace.value) return
|
|
|
|
|
|
|
|
|
|
|
|
tracePollTimer = setTimeout(async () => {
|
|
|
|
|
|
await loadTrace(true)
|
|
|
|
|
|
scheduleTracePolling()
|
|
|
|
|
|
}, TRACE_POLL_INTERVAL_MS)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 监听 groupedTimeline 变化,自动选择最有意义的组
|
|
|
|
|
|
watch(groupedTimeline, (newGroups) => {
|
|
|
|
|
|
if (!newGroups || newGroups.length === 0) return
|
|
|
|
|
|
|
2026-05-30 19:47:00 +08:00
|
|
|
|
// 静默刷新时保留用户手动选择;未手动选择时跟随成功/进行中的 Key。
|
2026-02-07 02:59:02 +08:00
|
|
|
|
if (isSilentRefresh.value) {
|
|
|
|
|
|
isSilentRefresh.value = false
|
2026-05-30 19:47:00 +08:00
|
|
|
|
if (selectionPinnedByUser.value && selectedAttemptFromGroups(newGroups)) {
|
2025-12-12 18:15:46 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-05-30 19:47:00 +08:00
|
|
|
|
if (selectedAttemptMatchesBestSilentState(newGroups)) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
selectMostRelevantGroup(newGroups)
|
|
|
|
|
|
return
|
2025-12-12 18:15:46 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-30 19:47:00 +08:00
|
|
|
|
selectMostRelevantGroup(newGroups)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}, { immediate: true })
|
|
|
|
|
|
|
2026-03-19 23:52:17 +08:00
|
|
|
|
// 监听 requestId / 外部 trace 变化
|
|
|
|
|
|
watch(
|
|
|
|
|
|
[() => props.requestId, () => props.traceData],
|
|
|
|
|
|
() => {
|
|
|
|
|
|
selectedGroupIndex.value = 0
|
|
|
|
|
|
selectedAttemptIndex.value = 0
|
2026-05-30 19:47:00 +08:00
|
|
|
|
selectionPinnedByUser.value = false
|
2026-04-25 16:58:17 +08:00
|
|
|
|
traceLoadStarted.value = false
|
2026-03-19 23:52:17 +08:00
|
|
|
|
|
|
|
|
|
|
if (props.traceData) {
|
|
|
|
|
|
internalTrace.value = null
|
|
|
|
|
|
loading.value = false
|
|
|
|
|
|
error.value = null
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!props.requestId) {
|
|
|
|
|
|
internalTrace.value = null
|
|
|
|
|
|
loading.value = false
|
|
|
|
|
|
error.value = null
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void loadTrace()
|
|
|
|
|
|
},
|
|
|
|
|
|
{ immediate: true },
|
|
|
|
|
|
)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-05-09 01:21:26 +08:00
|
|
|
|
watch(shouldPollTrace, () => {
|
|
|
|
|
|
scheduleTracePolling()
|
|
|
|
|
|
}, { immediate: true })
|
|
|
|
|
|
|
|
|
|
|
|
onBeforeUnmount(() => {
|
|
|
|
|
|
stopTracePolling()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-02-07 02:59:02 +08:00
|
|
|
|
defineExpose({ refresh: () => loadTrace(true) })
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 格式化时间(详细)
|
|
|
|
|
|
const formatTime = (dateStr: string) => {
|
|
|
|
|
|
const date = new Date(dateStr)
|
|
|
|
|
|
const timeStr = date.toLocaleTimeString('zh-CN', {
|
|
|
|
|
|
hour12: false,
|
|
|
|
|
|
hour: '2-digit',
|
|
|
|
|
|
minute: '2-digit',
|
|
|
|
|
|
second: '2-digit'
|
|
|
|
|
|
})
|
|
|
|
|
|
const ms = date.getMilliseconds().toString().padStart(3, '0')
|
|
|
|
|
|
return `${timeStr}.${ms}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 格式化持续时间(开始到结束)
|
|
|
|
|
|
const formatDuration = (startStr: string, endStr: string): string => {
|
|
|
|
|
|
const start = new Date(startStr).getTime()
|
|
|
|
|
|
const end = new Date(endStr).getTime()
|
|
|
|
|
|
const durationMs = end - start
|
|
|
|
|
|
if (durationMs >= 1000) {
|
|
|
|
|
|
return `${(durationMs / 1000).toFixed(2)}s`
|
|
|
|
|
|
}
|
|
|
|
|
|
return `${durationMs}ms`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取状态标签
|
|
|
|
|
|
const getStatusLabel = (status: string) => {
|
|
|
|
|
|
const labels: Record<string, string> = {
|
2026-05-12 21:05:11 +08:00
|
|
|
|
available: '可用未尝试',
|
|
|
|
|
|
unused: '未使用',
|
2026-04-14 01:27:04 +08:00
|
|
|
|
pending: '进行中',
|
2025-12-10 20:52:44 +08:00
|
|
|
|
streaming: '传输中',
|
2026-02-04 13:32:42 +08:00
|
|
|
|
stream_interrupted: '流中断',
|
2025-12-10 20:52:44 +08:00
|
|
|
|
success: '成功',
|
|
|
|
|
|
failed: '失败',
|
2026-01-20 15:40:16 +08:00
|
|
|
|
cancelled: '已取消',
|
2025-12-10 20:52:44 +08:00
|
|
|
|
skipped: '跳过'
|
|
|
|
|
|
}
|
|
|
|
|
|
return labels[status] || status
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取状态颜色类
|
|
|
|
|
|
const getStatusColorClass = (status: string) => {
|
|
|
|
|
|
const classes: Record<string, string> = {
|
|
|
|
|
|
available: 'status-available',
|
2026-02-04 13:32:42 +08:00
|
|
|
|
unused: 'status-available',
|
2025-12-10 20:52:44 +08:00
|
|
|
|
pending: 'status-pending',
|
|
|
|
|
|
streaming: 'status-pending',
|
2026-02-04 13:32:42 +08:00
|
|
|
|
stream_interrupted: 'status-failed',
|
2025-12-10 20:52:44 +08:00
|
|
|
|
success: 'status-success',
|
|
|
|
|
|
failed: 'status-failed',
|
2026-01-20 15:40:16 +08:00
|
|
|
|
cancelled: 'status-cancelled',
|
2025-12-10 20:52:44 +08:00
|
|
|
|
skipped: 'status-skipped'
|
|
|
|
|
|
}
|
|
|
|
|
|
return classes[status] || 'status-available'
|
|
|
|
|
|
}
|
2026-03-03 09:22:20 +08:00
|
|
|
|
|
|
|
|
|
|
// 展示状态:进行中态优先(包括 started 但未 finished 的中间态),再按 HTTP 状态码兜底
|
2026-05-07 03:23:20 +08:00
|
|
|
|
function getDisplayStatus(attempt: CandidateRecord | null | undefined): string {
|
2026-03-03 09:22:20 +08:00
|
|
|
|
if (!attempt) return 'available'
|
2026-05-08 22:03:17 +08:00
|
|
|
|
const code = attempt.status_code
|
|
|
|
|
|
const isTerminalSuccessCode = typeof code === 'number' && code >= 200 && code < 300
|
|
|
|
|
|
|
|
|
|
|
|
if (attempt.status === 'success') {
|
|
|
|
|
|
if (typeof code === 'number' && !isTerminalSuccessCode) {
|
|
|
|
|
|
return 'failed'
|
|
|
|
|
|
}
|
|
|
|
|
|
return 'success'
|
|
|
|
|
|
}
|
2026-05-07 03:23:20 +08:00
|
|
|
|
if (
|
|
|
|
|
|
attempt.status === 'failed' ||
|
|
|
|
|
|
attempt.status === 'cancelled' ||
|
|
|
|
|
|
attempt.status === 'skipped' ||
|
|
|
|
|
|
attempt.status === 'stream_interrupted'
|
|
|
|
|
|
) {
|
|
|
|
|
|
return attempt.status
|
|
|
|
|
|
}
|
2026-03-03 09:22:20 +08:00
|
|
|
|
const hasFinished = Boolean(attempt.finished_at)
|
|
|
|
|
|
const isExplicitPending = (attempt.status === 'pending' || attempt.status === 'streaming') && !hasFinished
|
|
|
|
|
|
const isImplicitPending = Boolean(
|
|
|
|
|
|
attempt.started_at &&
|
|
|
|
|
|
!hasFinished &&
|
|
|
|
|
|
!['failed', 'cancelled', 'skipped', 'stream_interrupted'].includes(attempt.status),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if (isExplicitPending || isImplicitPending) {
|
|
|
|
|
|
return 'pending'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (typeof code === 'number') {
|
2026-05-08 22:03:17 +08:00
|
|
|
|
if (isTerminalSuccessCode) return 'success'
|
|
|
|
|
|
if (code >= 300) return 'failed'
|
2026-03-03 09:22:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
return attempt.status
|
|
|
|
|
|
}
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
.minimal-request-timeline {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-16 01:43:52 +08:00
|
|
|
|
/* 极简轨道 - 包装器实现溢出时居左、不溢出时居中 */
|
2025-12-10 20:52:44 +08:00
|
|
|
|
.minimal-track {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
2026-01-16 01:43:52 +08:00
|
|
|
|
justify-content: safe center;
|
2026-01-11 19:26:50 +08:00
|
|
|
|
gap: 64px;
|
2026-04-14 01:27:04 +08:00
|
|
|
|
padding: 2rem 2rem 2.75rem;
|
2026-01-11 19:26:50 +08:00
|
|
|
|
overflow-x: auto;
|
|
|
|
|
|
overflow-y: hidden;
|
|
|
|
|
|
|
|
|
|
|
|
/* 优化滚动体验 */
|
|
|
|
|
|
scrollbar-width: thin; /* Firefox */
|
|
|
|
|
|
scrollbar-color: hsl(var(--border)) transparent;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* Webkit 滚动条样式 */
|
|
|
|
|
|
.minimal-track::-webkit-scrollbar {
|
|
|
|
|
|
height: 6px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.minimal-track::-webkit-scrollbar-track {
|
|
|
|
|
|
background: transparent;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.minimal-track::-webkit-scrollbar-thumb {
|
|
|
|
|
|
background: hsl(var(--border));
|
|
|
|
|
|
border-radius: 3px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.minimal-track::-webkit-scrollbar-thumb:hover {
|
|
|
|
|
|
background: hsl(var(--muted-foreground) / 0.5);
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.minimal-node-group {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 节点容器 */
|
|
|
|
|
|
.node-container {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 节点名称 - 绝对定位在节点上方 */
|
|
|
|
|
|
.node-label {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
bottom: calc(100% + 8px);
|
|
|
|
|
|
left: 50%;
|
|
|
|
|
|
transform: translateX(-50%);
|
|
|
|
|
|
font-size: 0.65rem;
|
|
|
|
|
|
color: hsl(var(--muted-foreground));
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
max-width: 80px;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 主节点 - 同心圆(外圈轮廓 + 间隙 + 内部实心圆) */
|
|
|
|
|
|
.node-dot {
|
|
|
|
|
|
width: 16px;
|
|
|
|
|
|
height: 16px;
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
transition: all 0.2s ease;
|
|
|
|
|
|
z-index: 2;
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
overflow: visible;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
/* 外圈轮廓 */
|
|
|
|
|
|
border: 2px solid currentColor;
|
|
|
|
|
|
background: transparent;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 内部实心圆 - 使用 ::before 伪元素 */
|
|
|
|
|
|
.node-dot::before {
|
|
|
|
|
|
content: '';
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
top: 50%;
|
|
|
|
|
|
left: 50%;
|
|
|
|
|
|
width: 8px;
|
|
|
|
|
|
height: 8px;
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
background: currentColor;
|
|
|
|
|
|
transform: translate(-50%, -50%);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 选中首次时的样式 */
|
|
|
|
|
|
.node-dot.is-first-selected {
|
|
|
|
|
|
transform: scale(1.1);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 子节点容器 - 绝对定位在主节点下方 */
|
|
|
|
|
|
.sub-dots {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
top: calc(100% + 8px);
|
|
|
|
|
|
left: 50%;
|
|
|
|
|
|
transform: translateX(-50%);
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 6px;
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
background: transparent;
|
2026-04-14 01:27:04 +08:00
|
|
|
|
z-index: 3;
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 子节点 - 增大点击区域 */
|
|
|
|
|
|
.sub-dot {
|
|
|
|
|
|
width: 10px;
|
|
|
|
|
|
height: 10px;
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
transition: all 0.15s ease;
|
|
|
|
|
|
opacity: 0.5;
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 扩大点击热区 */
|
|
|
|
|
|
.sub-dot::before {
|
|
|
|
|
|
content: '';
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
top: -4px;
|
|
|
|
|
|
left: -4px;
|
|
|
|
|
|
right: -4px;
|
|
|
|
|
|
bottom: -4px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.sub-dot:hover {
|
|
|
|
|
|
transform: scale(1.2);
|
|
|
|
|
|
opacity: 0.9;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.sub-dot.active {
|
|
|
|
|
|
opacity: 1;
|
|
|
|
|
|
transform: scale(1.15);
|
|
|
|
|
|
box-shadow: 0 0 0 2px hsl(var(--background)), 0 0 0 3px currentColor;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 子节点状态颜色 */
|
|
|
|
|
|
.sub-dot.status-success { background: #22c55e; color: #22c55e; }
|
|
|
|
|
|
.sub-dot.status-failed { background: #ef4444; color: #ef4444; }
|
2026-01-20 15:40:16 +08:00
|
|
|
|
.sub-dot.status-cancelled { background: #f59e0b; color: #f59e0b; }
|
2025-12-10 20:52:44 +08:00
|
|
|
|
.sub-dot.status-pending { background: #3b82f6; color: #3b82f6; }
|
2026-04-20 16:59:18 +08:00
|
|
|
|
.sub-dot.status-skipped { background: hsl(var(--foreground)); color: hsl(var(--foreground)); }
|
2025-12-10 20:52:44 +08:00
|
|
|
|
.sub-dot.status-available { background: #d1d5db; color: #d1d5db; }
|
|
|
|
|
|
|
|
|
|
|
|
/* 选中状态:呼吸动画 + 涟漪效果 */
|
|
|
|
|
|
.minimal-node-group.selected .node-dot {
|
|
|
|
|
|
animation: breathe 2s ease-in-out infinite;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.minimal-node-group.selected .node-dot::after {
|
|
|
|
|
|
content: '';
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
top: 50%;
|
|
|
|
|
|
left: 50%;
|
|
|
|
|
|
width: 16px;
|
|
|
|
|
|
height: 16px;
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
border: 2px solid currentColor;
|
|
|
|
|
|
background: transparent;
|
|
|
|
|
|
transform: translate(-50%, -50%);
|
|
|
|
|
|
animation: ripple 1.5s ease-out infinite;
|
|
|
|
|
|
z-index: -1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 悬停状态:只有放大效果 */
|
|
|
|
|
|
.minimal-node-group.hovered .node-dot {
|
|
|
|
|
|
transform: scale(1.3);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@keyframes breathe {
|
|
|
|
|
|
0%, 100% { transform: scale(1.3); }
|
|
|
|
|
|
50% { transform: scale(1.5); }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@keyframes ripple {
|
|
|
|
|
|
0% {
|
|
|
|
|
|
transform: translate(-50%, -50%) scale(1);
|
|
|
|
|
|
opacity: 0.4;
|
|
|
|
|
|
}
|
|
|
|
|
|
100% {
|
|
|
|
|
|
transform: translate(-50%, -50%) scale(2.5);
|
|
|
|
|
|
opacity: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 重试徽章 */
|
|
|
|
|
|
.retry-badge {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
top: 50%;
|
|
|
|
|
|
left: 50%;
|
|
|
|
|
|
transform: translate(-50%, -50%);
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
font-size: 0.6rem;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
z-index: 101;
|
|
|
|
|
|
line-height: 1;
|
|
|
|
|
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 状态颜色 - 同心圆使用 color */
|
|
|
|
|
|
.node-dot.status-success { color: #22c55e; }
|
|
|
|
|
|
.node-dot.status-failed { color: #ef4444; }
|
2026-01-20 15:40:16 +08:00
|
|
|
|
.node-dot.status-cancelled { color: #f59e0b; }
|
2025-12-10 20:52:44 +08:00
|
|
|
|
.node-dot.status-pending { color: #3b82f6; }
|
2026-04-20 16:59:18 +08:00
|
|
|
|
.node-dot.status-skipped { color: hsl(var(--foreground)); }
|
2025-12-10 20:52:44 +08:00
|
|
|
|
.node-dot.status-available { color: #d1d5db; }
|
|
|
|
|
|
|
2026-02-11 23:24:59 +08:00
|
|
|
|
/* 连接线容器 */
|
|
|
|
|
|
.node-line-wrapper {
|
2026-01-11 19:26:50 +08:00
|
|
|
|
position: absolute;
|
2026-01-13 19:17:53 +08:00
|
|
|
|
right: -64px;
|
|
|
|
|
|
top: 50%;
|
|
|
|
|
|
transform: translateY(-50%);
|
2025-12-10 20:52:44 +08:00
|
|
|
|
width: 64px;
|
2026-02-11 23:24:59 +08:00
|
|
|
|
z-index: 1;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.node-line {
|
|
|
|
|
|
width: 100%;
|
2025-12-10 20:52:44 +08:00
|
|
|
|
height: 2px;
|
|
|
|
|
|
background: hsl(var(--border));
|
2026-02-11 23:24:59 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 格式转换分界线 */
|
|
|
|
|
|
.node-line.conversion-boundary {
|
|
|
|
|
|
background: none;
|
|
|
|
|
|
height: 0;
|
|
|
|
|
|
border-top: 2px dashed hsl(var(--muted-foreground) / 0.4);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
/* 详情面板 */
|
|
|
|
|
|
.detail-panel {
|
|
|
|
|
|
margin-top: 1rem;
|
|
|
|
|
|
background: hsl(var(--muted) / 0.3);
|
|
|
|
|
|
border: 1px solid hsl(var(--border));
|
|
|
|
|
|
border-radius: 14px;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.panel-header {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
padding: 0.5rem 0rem;
|
|
|
|
|
|
border-bottom: 1px solid hsl(var(--border));
|
|
|
|
|
|
background: hsl(var(--muted) / 0.4);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.panel-title {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.625rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.title-dot {
|
|
|
|
|
|
width: 10px;
|
|
|
|
|
|
height: 10px;
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.title-dot.status-success { background: #22c55e; }
|
|
|
|
|
|
.title-dot.status-failed { background: #ef4444; }
|
2026-01-20 15:40:16 +08:00
|
|
|
|
.title-dot.status-cancelled { background: #f59e0b; }
|
2025-12-10 20:52:44 +08:00
|
|
|
|
.title-dot.status-pending { background: #3b82f6; }
|
2026-04-20 16:59:18 +08:00
|
|
|
|
.title-dot.status-skipped { background: hsl(var(--foreground)); }
|
2025-12-10 20:52:44 +08:00
|
|
|
|
.title-dot.status-available { background: #d1d5db; }
|
|
|
|
|
|
|
|
|
|
|
|
.title-text {
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
font-size: 0.95rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.panel-nav {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.375rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.nav-btn {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
width: 28px;
|
|
|
|
|
|
height: 28px;
|
|
|
|
|
|
border: 1px solid hsl(var(--border));
|
|
|
|
|
|
background: hsl(var(--background));
|
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
|
color: hsl(var(--muted-foreground));
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
transition: all 0.15s ease;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.nav-btn:hover:not(:disabled) {
|
|
|
|
|
|
background: hsl(var(--muted));
|
|
|
|
|
|
color: hsl(var(--foreground));
|
|
|
|
|
|
border-color: hsl(var(--muted-foreground) / 0.3);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.nav-btn:disabled {
|
|
|
|
|
|
opacity: 0.4;
|
|
|
|
|
|
cursor: not-allowed;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.nav-info {
|
|
|
|
|
|
font-size: 0.8rem;
|
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
color: hsl(var(--muted-foreground));
|
|
|
|
|
|
padding: 0 0.5rem;
|
|
|
|
|
|
min-width: 50px;
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.panel-body {
|
|
|
|
|
|
padding: 0.75rem 0rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 头部分隔符 */
|
|
|
|
|
|
.header-divider {
|
|
|
|
|
|
color: hsl(var(--border));
|
|
|
|
|
|
margin: 0 0.5rem;
|
|
|
|
|
|
font-size: 1rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 状态标签 */
|
|
|
|
|
|
.status-tag {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
min-width: 52px;
|
|
|
|
|
|
padding: 0.2rem 0.5rem;
|
|
|
|
|
|
font-size: 0.75rem;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
|
margin-left: 0.5rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.status-tag.status-success {
|
|
|
|
|
|
background: #22c55e20;
|
2026-01-23 16:10:53 +08:00
|
|
|
|
color: #22c55e;
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.status-tag.status-failed {
|
|
|
|
|
|
background: #ef444420;
|
2026-01-23 16:10:53 +08:00
|
|
|
|
color: #ef4444;
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-20 15:40:16 +08:00
|
|
|
|
.status-tag.status-cancelled {
|
|
|
|
|
|
background: #f59e0b20;
|
2026-01-23 16:10:53 +08:00
|
|
|
|
color: #f59e0b;
|
2026-01-20 15:40:16 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
.status-tag.status-pending {
|
|
|
|
|
|
background: #3b82f620;
|
2026-01-23 16:10:53 +08:00
|
|
|
|
color: #3b82f6;
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.status-tag.status-skipped {
|
2026-04-20 16:59:18 +08:00
|
|
|
|
background: hsl(var(--foreground) / 0.08);
|
|
|
|
|
|
color: hsl(var(--foreground));
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.status-tag.status-available {
|
|
|
|
|
|
background: hsl(var(--muted));
|
|
|
|
|
|
color: hsl(var(--muted-foreground));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 缓存亲和标签 */
|
|
|
|
|
|
.cache-badge {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
padding: 0.15rem 0.5rem;
|
|
|
|
|
|
font-size: 0.7rem;
|
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
color: hsl(var(--primary));
|
|
|
|
|
|
background: hsl(var(--primary) / 0.1);
|
|
|
|
|
|
border: 1px solid hsl(var(--primary) / 0.2);
|
|
|
|
|
|
border-radius: 9999px;
|
|
|
|
|
|
margin-left: 0.75rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 缓存亲和提示 */
|
|
|
|
|
|
.cache-hint {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
padding: 0.15rem 0.5rem;
|
|
|
|
|
|
font-size: 0.7rem;
|
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
color: hsl(var(--muted-foreground));
|
|
|
|
|
|
background: hsl(var(--muted) / 0.5);
|
|
|
|
|
|
border-radius: 4px;
|
2026-04-14 01:27:04 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.attempt-switcher {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.375rem;
|
2025-12-10 20:52:44 +08:00
|
|
|
|
margin-left: 0.5rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-14 01:27:04 +08:00
|
|
|
|
.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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
.info-grid {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: repeat(2, 1fr);
|
|
|
|
|
|
gap: 0.625rem 1.25rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.info-item {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 0.25rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.info-item.full-width {
|
|
|
|
|
|
grid-column: 1 / -1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.info-label {
|
|
|
|
|
|
font-size: 0.7rem;
|
|
|
|
|
|
color: hsl(var(--muted-foreground));
|
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
|
letter-spacing: 0.05em;
|
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.info-value {
|
|
|
|
|
|
font-size: 0.9rem;
|
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.info-value.highlight {
|
|
|
|
|
|
font-size: 1.1rem;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
font-family: ui-monospace, monospace;
|
|
|
|
|
|
color: hsl(var(--primary));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.info-value code {
|
|
|
|
|
|
font-size: 0.7rem;
|
|
|
|
|
|
padding: 0.15rem 0.375rem;
|
|
|
|
|
|
background: hsl(var(--muted));
|
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
color: hsl(var(--muted-foreground));
|
|
|
|
|
|
font-family: ui-monospace, monospace;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-11 15:33:01 +08:00
|
|
|
|
/* 两行堆叠布局 */
|
|
|
|
|
|
.info-value-stacked {
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
|
gap: 0.2rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-19 14:51:08 +08:00
|
|
|
|
/* 格式代码 */
|
|
|
|
|
|
.format-code {
|
|
|
|
|
|
font-size: 0.75rem;
|
|
|
|
|
|
padding: 0.1rem 0.3rem;
|
|
|
|
|
|
background: hsl(var(--muted));
|
|
|
|
|
|
border-radius: 3px;
|
|
|
|
|
|
color: hsl(var(--muted-foreground));
|
|
|
|
|
|
font-family: ui-monospace, monospace;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
/* Key 信息 */
|
|
|
|
|
|
.key-name {
|
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.key-preview {
|
2026-02-11 15:33:01 +08:00
|
|
|
|
font-size: 0.75rem;
|
2025-12-10 20:52:44 +08:00
|
|
|
|
padding: 0.1rem 0.3rem;
|
|
|
|
|
|
background: hsl(var(--muted));
|
|
|
|
|
|
border-radius: 3px;
|
|
|
|
|
|
color: hsl(var(--muted-foreground));
|
|
|
|
|
|
font-family: ui-monospace, monospace;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-19 14:51:08 +08:00
|
|
|
|
/* 认证类型标签 */
|
|
|
|
|
|
.auth-type-tag {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
padding: 0.1rem 0.35rem;
|
|
|
|
|
|
margin-left: 0.375rem;
|
|
|
|
|
|
font-size: 0.65rem;
|
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
color: hsl(var(--primary) / 0.8);
|
|
|
|
|
|
background: hsl(var(--primary) / 0.08);
|
|
|
|
|
|
border: 1px solid hsl(var(--primary) / 0.2);
|
|
|
|
|
|
border-radius: 3px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-11 15:33:01 +08:00
|
|
|
|
/* 代理信息 */
|
|
|
|
|
|
.proxy-name {
|
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.proxy-detail {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.375rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-27 13:54:46 +08:00
|
|
|
|
/* 号池调度 */
|
|
|
|
|
|
.pool-reason {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.375rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.pool-reason-tag {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
padding: 0.15rem 0.5rem;
|
|
|
|
|
|
font-size: 0.7rem;
|
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
border: 1px solid hsl(var(--border));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.pool-reason-tag.pool-sticky {
|
|
|
|
|
|
color: hsl(var(--chart-4));
|
|
|
|
|
|
border-color: hsl(var(--chart-4) / 0.3);
|
|
|
|
|
|
background: hsl(var(--chart-4) / 0.08);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.pool-reason-tag.pool-lru {
|
|
|
|
|
|
color: hsl(var(--chart-2));
|
|
|
|
|
|
border-color: hsl(var(--chart-2) / 0.3);
|
|
|
|
|
|
background: hsl(var(--chart-2) / 0.08);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.pool-reason-tag.pool-random,
|
|
|
|
|
|
.pool-reason-tag.pool-tiebreak {
|
|
|
|
|
|
color: hsl(var(--muted-foreground));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.pool-cost-warn {
|
|
|
|
|
|
font-size: 0.65rem;
|
|
|
|
|
|
color: hsl(var(--chart-5));
|
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.pool-skip-type {
|
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
color: hsl(var(--muted-foreground));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 01:21:26 +08:00
|
|
|
|
.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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
/* Provider 官网链接 */
|
|
|
|
|
|
.provider-link {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
padding: 0.25rem;
|
|
|
|
|
|
margin-left: 0.25rem;
|
|
|
|
|
|
color: hsl(var(--muted-foreground));
|
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
transition: all 0.15s ease;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.provider-link:hover {
|
|
|
|
|
|
color: hsl(var(--primary));
|
|
|
|
|
|
background: hsl(var(--primary) / 0.1);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 时间范围 */
|
|
|
|
|
|
.time-range {
|
|
|
|
|
|
margin-top: 1.25rem;
|
|
|
|
|
|
padding-top: 1rem;
|
|
|
|
|
|
border-top: 1px dashed hsl(var(--border));
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 0.375rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.time-label {
|
|
|
|
|
|
font-size: 0.7rem;
|
|
|
|
|
|
color: hsl(var(--muted-foreground));
|
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
|
letter-spacing: 0.05em;
|
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.time-value {
|
|
|
|
|
|
font-size: 0.85rem;
|
|
|
|
|
|
font-family: ui-monospace, monospace;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.time-arrow {
|
|
|
|
|
|
color: hsl(var(--muted-foreground));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 时间范围值 - 紧凑布局 */
|
|
|
|
|
|
.time-range-value {
|
|
|
|
|
|
gap: 0.25rem !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 箭头容器 - 用于定位持续时间 */
|
|
|
|
|
|
.time-arrow-container {
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 持续时间 - 显示在箭头上方 */
|
|
|
|
|
|
.time-duration {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
top: -1.1rem;
|
|
|
|
|
|
left: 50%;
|
|
|
|
|
|
transform: translateX(-50%);
|
|
|
|
|
|
font-size: 0.65rem;
|
|
|
|
|
|
color: hsl(var(--muted-foreground));
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 用量区域 */
|
|
|
|
|
|
.usage-section {
|
|
|
|
|
|
margin-top: 0.75rem;
|
|
|
|
|
|
padding-top: 0.75rem;
|
|
|
|
|
|
border-top: 1px dashed hsl(var(--border));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.usage-grid {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 0.375rem;
|
|
|
|
|
|
padding: 0.5rem 0.75rem;
|
|
|
|
|
|
background: hsl(var(--muted) / 0.2);
|
|
|
|
|
|
border: 1px solid hsl(var(--border) / 0.5);
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.usage-row {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.usage-item {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.usage-label {
|
|
|
|
|
|
font-size: 0.75rem;
|
|
|
|
|
|
color: hsl(var(--muted-foreground));
|
|
|
|
|
|
width: 56px;
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.usage-tokens {
|
|
|
|
|
|
font-size: 0.875rem;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
font-family: ui-monospace, monospace;
|
|
|
|
|
|
width: 60px;
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.usage-cost {
|
|
|
|
|
|
font-size: 0.75rem;
|
|
|
|
|
|
color: #16a34a;
|
|
|
|
|
|
font-family: ui-monospace, monospace;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.dark .usage-cost {
|
|
|
|
|
|
color: #4ade80;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.usage-divider {
|
|
|
|
|
|
width: 1px;
|
|
|
|
|
|
height: 16px;
|
|
|
|
|
|
background: hsl(var(--border));
|
|
|
|
|
|
margin: 0 1rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 跳过原因 */
|
|
|
|
|
|
.skip-reason {
|
|
|
|
|
|
margin-top: 1rem;
|
2026-06-11 00:49:29 +08:00
|
|
|
|
padding: 0.75rem;
|
2025-12-10 20:52:44 +08:00
|
|
|
|
background: hsl(var(--muted) / 0.5);
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 0.75rem;
|
|
|
|
|
|
font-size: 0.85rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.reason-label {
|
|
|
|
|
|
color: hsl(var(--muted-foreground));
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.reason-value {
|
|
|
|
|
|
color: hsl(var(--foreground));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-26 01:38:37 +08:00
|
|
|
|
.reason-content {
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 0.35rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.reason-detail {
|
|
|
|
|
|
color: hsl(var(--muted-foreground));
|
|
|
|
|
|
line-height: 1.45;
|
|
|
|
|
|
word-break: break-word;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.reason-detail code {
|
|
|
|
|
|
margin-right: 0.4rem;
|
|
|
|
|
|
padding: 0.1rem 0.35rem;
|
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
background: hsl(var(--background) / 0.8);
|
|
|
|
|
|
color: hsl(var(--foreground));
|
|
|
|
|
|
font-size: 0.8rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
/* 错误信息 */
|
|
|
|
|
|
.error-block {
|
|
|
|
|
|
margin-top: 1rem;
|
|
|
|
|
|
padding: 0.875rem;
|
|
|
|
|
|
background: #ef444410;
|
|
|
|
|
|
border: 1px solid #ef444430;
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 10:46:04 +08:00
|
|
|
|
.error-heading {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
gap: 0.75rem;
|
|
|
|
|
|
margin-bottom: 0.25rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
.error-type {
|
|
|
|
|
|
font-size: 0.75rem;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
color: #ef4444;
|
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
|
letter-spacing: 0.025em;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 10:46:04 +08:00
|
|
|
|
.error-status-badge {
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
padding: 0.125rem 0.45rem;
|
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
font-size: 0.72rem;
|
|
|
|
|
|
font-family: ui-monospace, monospace;
|
|
|
|
|
|
background: hsl(var(--muted));
|
|
|
|
|
|
color: hsl(var(--muted-foreground));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.error-status-badge.is-success {
|
|
|
|
|
|
color: #166534;
|
|
|
|
|
|
background: #22c55e18;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.error-status-badge.is-warning {
|
|
|
|
|
|
color: #92400e;
|
|
|
|
|
|
background: #f59e0b1f;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.error-status-badge.is-error {
|
|
|
|
|
|
color: #991b1b;
|
|
|
|
|
|
background: #ef44441f;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
.error-msg {
|
|
|
|
|
|
font-size: 0.85rem;
|
|
|
|
|
|
color: #dc2626;
|
|
|
|
|
|
word-break: break-word;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 10:46:04 +08:00
|
|
|
|
.error-json {
|
|
|
|
|
|
margin-top: 0.75rem;
|
2026-04-25 16:58:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 10:46:04 +08:00
|
|
|
|
.dark .error-status-badge.is-success {
|
|
|
|
|
|
color: #bbf7d0;
|
2026-04-25 16:58:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 10:46:04 +08:00
|
|
|
|
.dark .error-status-badge.is-warning {
|
|
|
|
|
|
color: #fde68a;
|
2026-04-25 16:58:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 10:46:04 +08:00
|
|
|
|
.dark .error-status-badge.is-error {
|
2026-04-25 16:58:17 +08:00
|
|
|
|
color: #fecaca;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
/* 额外信息 */
|
|
|
|
|
|
.extra-block {
|
|
|
|
|
|
margin-top: 1rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.extra-toggle {
|
|
|
|
|
|
font-size: 0.8rem;
|
|
|
|
|
|
color: hsl(var(--muted-foreground));
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
padding: 0.5rem 0;
|
|
|
|
|
|
user-select: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.extra-toggle:hover {
|
|
|
|
|
|
color: hsl(var(--foreground));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-25 11:44:01 +08:00
|
|
|
|
.extra-json-panel {
|
2025-12-10 20:52:44 +08:00
|
|
|
|
margin-top: 0.5rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 动画 */
|
|
|
|
|
|
.slide-up-enter-active,
|
|
|
|
|
|
.slide-up-leave-active {
|
|
|
|
|
|
transition: all 0.25s ease;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.slide-up-enter-from,
|
|
|
|
|
|
.slide-up-leave-to {
|
|
|
|
|
|
opacity: 0;
|
|
|
|
|
|
transform: translateY(10px);
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|