mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 08:27:46 +08:00
3514 lines
114 KiB
Vue
3514 lines
114 KiB
Vue
<template>
|
||
<div class="minimal-request-timeline">
|
||
<!-- Loading State -->
|
||
<div
|
||
v-if="loading"
|
||
class="py-4"
|
||
>
|
||
<Skeleton class="h-32 w-full" />
|
||
</div>
|
||
|
||
<!-- Error State -->
|
||
<Card
|
||
v-else-if="error"
|
||
class="border-red-200 dark:border-red-800"
|
||
>
|
||
<div class="p-4">
|
||
<p class="text-sm text-red-600 dark:text-red-400">
|
||
{{ error }}
|
||
</p>
|
||
</div>
|
||
</Card>
|
||
|
||
<!-- Timeline Content -->
|
||
<div
|
||
v-else-if="trace && trace.candidates.length > 0"
|
||
class="space-y-0"
|
||
>
|
||
<Card>
|
||
<div class="p-6">
|
||
<!-- 概览信息 -->
|
||
<div class="flex items-center justify-between mb-4">
|
||
<div class="flex items-center gap-3">
|
||
<h4 class="text-sm font-semibold">
|
||
请求链路追踪
|
||
</h4>
|
||
<Badge :variant="getFinalStatusBadgeVariant(computedFinalStatus)">
|
||
{{ getFinalStatusLabel(computedFinalStatus) }}
|
||
</Badge>
|
||
<!-- 被跳过的候选可以收起,避免长时间线上排查时干扰视线 -->
|
||
<button
|
||
v-if="skippedTraceCandidates.length > 0"
|
||
type="button"
|
||
class="skipped-toggle"
|
||
:class="{ active: showSkippedCandidates }"
|
||
:aria-pressed="showSkippedCandidates"
|
||
:title="showSkippedCandidates
|
||
? '隐藏被跳过的候选'
|
||
: '被跳过的候选在调度阶段就被判定本次不可用,从未向上游发起请求'"
|
||
data-timeline-skipped-toggle
|
||
@click="showSkippedCandidates = !showSkippedCandidates"
|
||
>
|
||
{{ showSkippedCandidates ? '隐藏' : '显示' }}被跳过候选({{ skippedTraceCandidates.length }})
|
||
</button>
|
||
</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">
|
||
<!-- 节点名称(在节点上方) -->
|
||
<div class="node-label">
|
||
{{ group.providerName }}
|
||
</div>
|
||
|
||
<!-- 主节点(代表首次请求) -->
|
||
<div
|
||
class="node-dot"
|
||
:class="[
|
||
getStatusColorClass(group.primaryStatus),
|
||
{ 'is-first-selected': isGroupSelected(group) && selectedAttemptIndex === 0 }
|
||
]"
|
||
@click.stop="selectFirstAttempt(group)"
|
||
/>
|
||
|
||
<!-- 子节点(同提供商的其他尝试,不包含首次) -->
|
||
<div
|
||
v-if="group.retryCount > 0"
|
||
class="sub-dots"
|
||
>
|
||
<button
|
||
v-for="(attempt, idx) in group.allAttempts.slice(1)"
|
||
:key="attempt.id"
|
||
type="button"
|
||
class="sub-dot"
|
||
:class="[
|
||
getStatusColorClass(getDisplayStatus(attempt)),
|
||
{ active: isAttemptSelected(group, idx + 1) }
|
||
]"
|
||
:title="formatAttemptDotTitle(attempt)"
|
||
:aria-label="formatAttemptDotTitle(attempt)"
|
||
@click.stop="selectAttemptInGroup(group, idx + 1)"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 连接线 -->
|
||
<div
|
||
v-if="groupIndex < groupedTimeline.length - 1"
|
||
class="node-line-wrapper"
|
||
>
|
||
<div
|
||
class="node-line"
|
||
:class="{ 'conversion-boundary': groupIndex + 1 === conversionBoundaryIndex }"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 选中详情面板 -->
|
||
<Transition name="slide-up">
|
||
<div
|
||
v-if="selectedGroup && currentAttempt"
|
||
class="detail-panel"
|
||
>
|
||
<div class="panel-header">
|
||
<div class="panel-title">
|
||
<span
|
||
class="title-dot"
|
||
:class="getStatusColorClass(currentAttemptDisplayStatus)"
|
||
/>
|
||
<span class="title-text">{{ currentGroupTitle }}</span>
|
||
<a
|
||
v-if="currentAttemptProviderWebsite"
|
||
:href="currentAttemptProviderWebsite"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
class="provider-link"
|
||
@click.stop
|
||
>
|
||
<ExternalLink class="w-3 h-3" />
|
||
</a>
|
||
<span
|
||
class="status-tag"
|
||
:class="getStatusColorClass(currentAttemptDisplayStatus)"
|
||
>
|
||
{{ currentAttempt.status_code || getStatusLabel(currentAttemptDisplayStatus) }}
|
||
</span>
|
||
<!-- 多 Key 标识 -->
|
||
<template v-if="selectedGroup.retryCount > 0">
|
||
<div class="attempt-switcher">
|
||
<button
|
||
class="attempt-nav-btn"
|
||
:disabled="selectedAttemptIndex === 0"
|
||
@click.stop="navigateAttempt(-1)"
|
||
>
|
||
<ChevronLeft class="w-3 h-3" />
|
||
</button>
|
||
<span class="cache-hint">
|
||
{{ selectedAttemptIndex + 1 }}/{{ selectedGroup.allAttempts.length }}
|
||
</span>
|
||
<button
|
||
class="attempt-nav-btn"
|
||
:disabled="selectedAttemptIndex === selectedGroup.allAttempts.length - 1"
|
||
@click.stop="navigateAttempt(1)"
|
||
>
|
||
<ChevronRight class="w-3 h-3" />
|
||
</button>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
<div class="panel-nav">
|
||
<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">
|
||
<div
|
||
v-if="currentAttemptTimeRange"
|
||
class="info-item"
|
||
>
|
||
<span class="info-label">时间范围</span>
|
||
<span class="info-value mono time-range-value">
|
||
{{ formatTime(currentAttemptTimeRange.startIso) }}
|
||
<span class="time-arrow-container">
|
||
<span
|
||
v-if="currentAttemptTimeRange.endIso"
|
||
class="time-duration"
|
||
>+{{ currentAttemptTimeRange.durationLabel }}</span>
|
||
<span class="time-arrow">→</span>
|
||
</span>
|
||
{{ currentAttemptTimeRange.endIso ? formatTime(currentAttemptTimeRange.endIso) : '进行中' }}
|
||
</span>
|
||
</div>
|
||
<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>
|
||
<div
|
||
v-if="currentAttemptFormatDisplay"
|
||
class="info-item"
|
||
>
|
||
<span class="info-label">格式</span>
|
||
<span class="info-value">
|
||
<code class="format-code">{{ currentAttemptFormatDisplay }}</code>
|
||
</span>
|
||
</div>
|
||
<div
|
||
v-if="currentAttemptRequestPathDisplay"
|
||
class="info-item"
|
||
>
|
||
<span class="info-label">请求路径</span>
|
||
<span class="info-value">
|
||
<code class="format-code request-path-code">{{ currentAttemptRequestPathDisplay }}</code>
|
||
</span>
|
||
</div>
|
||
<div
|
||
v-if="currentAttemptKeyDisplay"
|
||
class="info-item"
|
||
>
|
||
<span class="info-label">{{ isOAuthType(currentAttempt.key_auth_type) ? '账号' : '密钥' }}</span>
|
||
<span class="info-value info-value-stacked">
|
||
<span class="key-name">
|
||
{{ currentAttemptKeyDisplay }}
|
||
<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>
|
||
<code
|
||
v-if="currentAttempt.key_preview"
|
||
class="key-preview"
|
||
>{{ currentAttempt.key_preview }}</code>
|
||
</span>
|
||
</div>
|
||
<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">
|
||
Key 声明的可用 endpoint 格式
|
||
</span>
|
||
</span>
|
||
</div>
|
||
<div
|
||
v-if="currentAttempt.extra_data?.proxy"
|
||
class="info-item"
|
||
>
|
||
<span class="info-label">代理</span>
|
||
<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>
|
||
<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>
|
||
</span>
|
||
</div>
|
||
<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>
|
||
</div>
|
||
|
||
<div
|
||
v-if="currentImageProgress"
|
||
class="image-progress-block"
|
||
>
|
||
<div class="image-progress-header">
|
||
<span class="image-progress-title">图片生成进度</span>
|
||
<span
|
||
class="image-progress-phase"
|
||
:class="imageProgressPhaseClass(currentImageProgress.phase)"
|
||
>
|
||
{{ formatImageProgressPhase(currentImageProgress.phase) }}
|
||
</span>
|
||
</div>
|
||
<div class="image-progress-grid">
|
||
<div class="image-progress-item">
|
||
<span class="image-progress-label">上游 TTFB</span>
|
||
<span class="image-progress-value mono">{{ formatLatency(currentImageProgress.upstream_ttfb_ms) }}</span>
|
||
</div>
|
||
<div class="image-progress-item">
|
||
<span class="image-progress-label">SSE 帧数</span>
|
||
<span class="image-progress-value mono">{{ formatProgressCount(currentImageProgress.upstream_sse_frame_count) }}</span>
|
||
</div>
|
||
<div class="image-progress-item">
|
||
<span class="image-progress-label">Partial 图片</span>
|
||
<span class="image-progress-value mono">{{ formatProgressCount(currentImageProgress.partial_image_count) }}</span>
|
||
</div>
|
||
<div class="image-progress-item">
|
||
<span class="image-progress-label">最后帧</span>
|
||
<span class="image-progress-value mono">{{ formatProgressFrameTime(currentImageProgress.last_upstream_frame_at_unix_ms) }}</span>
|
||
</div>
|
||
<template v-if="hasDownstreamHeartbeatProgress">
|
||
<div class="image-progress-item">
|
||
<span class="image-progress-label">下游心跳</span>
|
||
<span class="image-progress-value mono">{{ formatProgressCount(currentImageProgress.downstream_heartbeat_count) }}</span>
|
||
</div>
|
||
<div class="image-progress-item">
|
||
<span class="image-progress-label">心跳间隔</span>
|
||
<span class="image-progress-value mono">{{ formatLatency(currentImageProgress.downstream_heartbeat_interval_ms) }}</span>
|
||
</div>
|
||
<div class="image-progress-item">
|
||
<span class="image-progress-label">最后心跳</span>
|
||
<span class="image-progress-value mono">{{ formatProgressFrameTime(currentImageProgress.last_downstream_heartbeat_at_unix_ms) }}</span>
|
||
</div>
|
||
</template>
|
||
<div
|
||
v-if="currentImageProgress.last_upstream_event"
|
||
class="image-progress-item full-width"
|
||
>
|
||
<span class="image-progress-label">上游事件</span>
|
||
<code class="image-progress-code">{{ currentImageProgress.last_upstream_event }}</code>
|
||
</div>
|
||
<div
|
||
v-if="currentImageProgress.last_client_visible_event"
|
||
class="image-progress-item full-width"
|
||
>
|
||
<span class="image-progress-label">客户端可见事件</span>
|
||
<code class="image-progress-code">{{ currentImageProgress.last_client_visible_event }}</code>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 用量与费用(仅成功节点显示) -->
|
||
<div
|
||
v-if="currentAttempt.status === 'success' && usageData"
|
||
class="usage-section"
|
||
>
|
||
<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>
|
||
<div class="usage-divider" />
|
||
<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>
|
||
<!-- 缓存创建 缓存读取(仅在有缓存数据时显示) -->
|
||
<div
|
||
v-if="usageData.tokens.cache_creation || usageData.tokens.cache_read"
|
||
class="usage-row"
|
||
>
|
||
<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>
|
||
<div class="usage-divider" />
|
||
<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>
|
||
|
||
<!-- 跳过原因 -->
|
||
<div
|
||
v-if="currentAttemptSkipReasonDisplay"
|
||
class="skip-reason"
|
||
>
|
||
<span class="reason-label">{{ currentAttemptSkipReasonLabel }}</span>
|
||
<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>
|
||
</div>
|
||
|
||
<!-- 错误信息:将实际上游响应头和响应体作为同一个对象展示 -->
|
||
<div
|
||
v-if="currentAttempt.status === 'failed' && currentAttemptRequestError"
|
||
class="error-block"
|
||
>
|
||
<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>
|
||
</div>
|
||
<div
|
||
v-if="currentAttemptRequestError.upstreamStatusCode != null && currentAttemptRequestError.upstreamStatusCode !== currentAttemptRequestError.statusCode"
|
||
class="error-upstream-status text-xs text-muted-foreground mb-1"
|
||
>
|
||
上游响应状态:HTTP {{ currentAttemptRequestError.upstreamStatusCode }}(本次尝试状态见上方)
|
||
</div>
|
||
<div
|
||
v-if="currentAttemptRequestError.finalStatusCode != null"
|
||
class="error-final-status text-xs text-muted-foreground mb-1"
|
||
>
|
||
请求最终状态:HTTP {{ currentAttemptRequestError.finalStatusCode }}(与本次尝试状态不同)
|
||
</div>
|
||
<div
|
||
v-if="currentAttemptRequestError.message"
|
||
class="error-msg"
|
||
>
|
||
{{ currentAttemptRequestError.message }}
|
||
</div>
|
||
<div
|
||
v-if="currentAttemptRequestError.upstreamResponse"
|
||
class="error-json error-upstream-response-json"
|
||
>
|
||
<JsonContentPanel
|
||
:data="currentAttemptRequestError.upstreamResponse"
|
||
:is-dark="isDark"
|
||
title="上游响应"
|
||
empty-message="无上游响应"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
v-if="diagnosticDisplay"
|
||
class="error-json error-diagnostic-json"
|
||
>
|
||
<JsonContentPanel
|
||
:data="diagnosticDisplay"
|
||
:is-dark="isDark"
|
||
:custom-copy="true"
|
||
:copied="diagnosticCopied"
|
||
:copy-disabled="diagnosticCopying"
|
||
title="失败诊断"
|
||
empty-message="无失败诊断信息"
|
||
@copy="copyFailureDiagnostic"
|
||
/>
|
||
</div>
|
||
<p
|
||
v-if="diagnosticDisplay"
|
||
class="text-xs text-muted-foreground mt-1"
|
||
>
|
||
{{ diagnosticCopying ? '正在读取并脱敏诊断上下文…' : '复制时补取已采集的正文并脱敏;未采集、无权限或超限的信息会明确标注。分享前请检查诊断内容。' }}
|
||
</p>
|
||
|
||
<!-- 额外数据 -->
|
||
<details
|
||
v-if="currentAttemptExtraDataDisplay"
|
||
class="extra-block"
|
||
>
|
||
<summary class="extra-toggle">
|
||
额外信息
|
||
</summary>
|
||
<JsonContentPanel
|
||
class="extra-json-panel"
|
||
:data="currentAttemptExtraDataDisplay"
|
||
:is-dark="isDark"
|
||
empty-message="无额外信息"
|
||
/>
|
||
</details>
|
||
</div>
|
||
</div>
|
||
</Transition>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
|
||
<!-- Empty State -->
|
||
<Card
|
||
v-else
|
||
class="border-dashed"
|
||
>
|
||
<div class="p-8 text-center">
|
||
<p class="text-sm text-muted-foreground">
|
||
暂无追踪数据
|
||
</p>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { getI18nLocale } from '@/i18n'
|
||
import { ref, watch, computed, onBeforeUnmount } from 'vue'
|
||
import { isAxiosError } from 'axios'
|
||
import Card from '@/components/ui/card.vue'
|
||
import Badge from '@/components/ui/badge.vue'
|
||
import Skeleton from '@/components/ui/skeleton.vue'
|
||
import JsonContentPanel from './JsonContentPanel.vue'
|
||
import { ChevronLeft, ChevronRight, ExternalLink } from 'lucide-vue-next'
|
||
import { requestTraceApi, type RequestTrace, type CandidateProxy, type CandidateRecord, type ImageProgress } from '@/api/requestTrace'
|
||
import { log } from '@/utils/logger'
|
||
import { safeExternalWebUrl } from '@/utils/navigationSecurity'
|
||
import { parseApiError } from '@/utils/errorParser'
|
||
import { formatTokens } from '@/utils/format'
|
||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||
import { useDarkMode } from '@/composables/useDarkMode'
|
||
import { resolveTimelineFinalStatus } from '../utils/status'
|
||
import { useClipboard } from '@/composables/useClipboard'
|
||
import { buildFailureDiagnosticBundle, diagnosticPathFromMessage, visibleFailureRecords } from '../utils/failureDiagnostic'
|
||
import { prepareDiagnosticExport, sanitizeDiagnostic } from '../utils/diagnosticExport'
|
||
import { formatCandidateSkipReason } from '../utils/skipReason'
|
||
import {
|
||
buildPoolGroupVisibleAttempts,
|
||
buildPoolParticipatedCandidates,
|
||
extractPoolGroupId,
|
||
makeAttemptKey,
|
||
TIMELINE_STATUS,
|
||
} from '../utils/poolTrace'
|
||
|
||
// 节点组类型
|
||
interface NodeGroup {
|
||
id: string
|
||
providerName: string
|
||
primary: CandidateRecord
|
||
primaryStatus: string
|
||
allAttempts: CandidateRecord[] // 当前展示的尝试(含主节点)
|
||
retryCount: number
|
||
totalLatency: number // 所有尝试的总延迟
|
||
startIndex: number
|
||
endIndex: number
|
||
hasConversion: boolean // 组内是否有格式转换候选
|
||
providerApiFormat: string | null // 提供商 API 格式(如 openai:responses)
|
||
isPoolGroup?: boolean
|
||
}
|
||
|
||
interface AttemptTimeRange {
|
||
startIso: string
|
||
endIso?: string
|
||
durationLabel?: string
|
||
}
|
||
|
||
// 用量数据类型
|
||
interface UsageData {
|
||
status?: string | null
|
||
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<{
|
||
requestId?: string | null
|
||
/** 外部传入的状态码,用于覆盖 trace.final_status 的判断 */
|
||
overrideStatusCode?: number
|
||
/** 外部传入的请求状态,用于识别已失败/取消的终态请求 */
|
||
requestStatus?: string | null
|
||
/** 请求侧 API 格式(客户端入口格式) */
|
||
requestApiFormat?: string | null
|
||
/** 用量和费用数据 */
|
||
usageData?: UsageData | null
|
||
/** 请求元数据(用于号池调度组装) */
|
||
requestMetadata?: Record<string, unknown> | null
|
||
/** 已获取的追踪数据;传入时不再内部拉取 */
|
||
traceData?: RequestTrace | null
|
||
}>()
|
||
|
||
const emit = defineEmits<{
|
||
selectAttempt: [attempt: CandidateRecord | null]
|
||
traceState: [state: {
|
||
loaded: boolean
|
||
hasTrace: boolean
|
||
finalStatus?: RequestTrace['final_status'] | null
|
||
statusCode?: number | null
|
||
latencyMs?: number | null
|
||
imageProgress?: ImageProgress | null
|
||
errorMessage?: string | null
|
||
}]
|
||
}>()
|
||
|
||
// 用量数据(从 props 获取)
|
||
const usageData = computed(() => props.usageData)
|
||
|
||
// 格式化数字
|
||
const formatNumber = (num: number): string => {
|
||
return formatTokens(num)
|
||
}
|
||
|
||
// 获取最终状态标签
|
||
const getFinalStatusLabel = (status: string) => {
|
||
const labels: Record<string, string> = {
|
||
success: '最终成功',
|
||
failed: '最终失败',
|
||
cancelled: '已取消',
|
||
streaming: '流式传输中',
|
||
pending: '进行中'
|
||
}
|
||
return labels[status] || status
|
||
}
|
||
|
||
// 获取最终状态徽章样式
|
||
type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' | 'dark'
|
||
|
||
const getFinalStatusBadgeVariant = (status: string): BadgeVariant => {
|
||
const variants: Record<string, BadgeVariant> = {
|
||
success: 'success',
|
||
failed: 'destructive',
|
||
cancelled: 'warning',
|
||
streaming: 'secondary',
|
||
pending: 'secondary'
|
||
}
|
||
return variants[status] || 'default'
|
||
}
|
||
|
||
const loading = ref(false)
|
||
const error = ref<string | null>(null)
|
||
const internalTrace = ref<RequestTrace | null>(null)
|
||
const { isDark } = useDarkMode()
|
||
const trace = computed(() => props.traceData ?? internalTrace.value)
|
||
const selectedGroupIndex = ref(0)
|
||
const selectedAttemptIndex = ref(0)
|
||
const selectionPinnedByUser = ref(false)
|
||
const hoveredGroupIndex = ref<number | null>(null)
|
||
const traceLoadStarted = ref(false)
|
||
let tracePollTimer: ReturnType<typeof setTimeout> | null = null
|
||
let traceLoadInFlight: Promise<void> | null = null
|
||
const TRACE_POLL_INTERVAL_MS = 1000
|
||
|
||
// 格式化延迟(自动调整单位)
|
||
const formatLatency = (ms: number | undefined | null): string => {
|
||
if (ms === undefined || ms === null) return '-'
|
||
if (ms >= 1000) {
|
||
return `${(ms / 1000).toFixed(2)}s`
|
||
}
|
||
return `${ms}ms`
|
||
}
|
||
|
||
// 格式化字节大小
|
||
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`
|
||
}
|
||
|
||
// 代理 timing 分阶段展示
|
||
const proxyTimingBreakdown = (proxy: CandidateProxy): string => {
|
||
const t = proxy.timing
|
||
if (!t) return ''
|
||
|
||
const parts: string[] = []
|
||
|
||
// 兼容旧版 timing(含 body_read_ms/decompress_ms)
|
||
const readDecompress = ((t.body_read_ms as number) || 0) + ((t.decompress_ms as number) || 0)
|
||
if (readDecompress > 0) {
|
||
let label = `读取 ${formatLatency(readDecompress)}`
|
||
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)}`
|
||
if (ratio > 0) label += ` -${ratio}%`
|
||
}
|
||
parts.push(label)
|
||
}
|
||
|
||
const ttfbMs = t.ttfb_ms ?? t.upstream_ms
|
||
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 ?? (
|
||
ttfbMs != null && t.connect_ms != null && t.tls_ms != null
|
||
? Math.max(0, (ttfbMs as number) - (t.connect_ms as number) - (t.tls_ms as number))
|
||
: null
|
||
)
|
||
|
||
if (t.dns_ms != null && (t.dns_ms as number) > 0) {
|
||
parts.push(`DNS ${formatLatency(t.dns_ms as number)}`)
|
||
}
|
||
if (t.connection_reused === true) {
|
||
parts.push('复用连接')
|
||
}
|
||
if (t.connect_ms != null && (t.connect_ms as number) > 0) {
|
||
parts.push(`连接 ${formatLatency(t.connect_ms as number)}`)
|
||
}
|
||
if (t.tls_ms != null && (t.tls_ms as number) > 0) {
|
||
parts.push(`TLS ${formatLatency(t.tls_ms as number)}`)
|
||
}
|
||
if (ttfbMs != null && (ttfbMs as number) > 0) {
|
||
parts.push(`TTFB ${formatLatency(ttfbMs as number)}`)
|
||
}
|
||
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))}`)
|
||
}
|
||
|
||
// 计算 Aether→代理 之间无法解释的耗时差
|
||
if (proxy.ttfb_ms != null && t.total_ms != null) {
|
||
const gap = (proxy.ttfb_ms as number) - (t.total_ms as number)
|
||
if (gap > 500) {
|
||
parts.push(`传输 ${formatLatency(Math.round(gap))}`)
|
||
}
|
||
}
|
||
|
||
return parts.join(' / ')
|
||
}
|
||
|
||
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,
|
||
}
|
||
|
||
const isParticipatedCandidate = (candidate: CandidateRecord): boolean => {
|
||
return TIMELINE_STATUS.includes(candidate.status)
|
||
}
|
||
|
||
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,
|
||
})
|
||
})
|
||
|
||
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)
|
||
const allTraceCandidates = computed<CandidateRecord[]>(() => {
|
||
if (!trace.value) return []
|
||
return [...trace.value.candidates]
|
||
.filter(c => TIMELINE_STATUS.includes(c.status))
|
||
.sort(compareBySchedulingOrder)
|
||
})
|
||
|
||
/**
|
||
* 被跳过的候选:调度阶段就判定"这次不能用",从未真正向上游发起请求。
|
||
*
|
||
* 默认展示:它是"为什么这次没用优先级更高的提供商"的直接答案,
|
||
* 隐藏后容易让人误以为该提供商从未参与调度。开关供长时间排查时收起。
|
||
*/
|
||
const showSkippedCandidates = ref(true)
|
||
|
||
const skippedTraceCandidates = computed<CandidateRecord[]>(
|
||
() => allTraceCandidates.value.filter(c => c.status === 'skipped'),
|
||
)
|
||
|
||
const rawTimeline = computed<CandidateRecord[]>(() => {
|
||
if (showSkippedCandidates.value) return allTraceCandidates.value
|
||
return allTraceCandidates.value.filter(c => c.status !== 'skipped')
|
||
})
|
||
|
||
|
||
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>
|
||
})
|
||
|
||
const poolAttemptCandidates = computed<CandidateRecord[]>(() => {
|
||
const auditAttempts = schedulingAudit.value?.attempts
|
||
return buildPoolParticipatedCandidates(
|
||
rawTimeline.value,
|
||
auditAttempts,
|
||
props.requestId,
|
||
)
|
||
})
|
||
|
||
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
|
||
})
|
||
|
||
const poolAttemptKeySet = computed<Set<string>>(() => {
|
||
return new Set(
|
||
poolAttemptCandidates.value.map((item) => makeAttemptKey(item.candidate_index, item.retry_index)),
|
||
)
|
||
})
|
||
|
||
const timeline = computed<CandidateRecord[]>(() => {
|
||
if (poolAttemptCandidates.value.length === 0) return rawTimeline.value
|
||
return rawTimeline.value.filter(
|
||
(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',
|
||
}
|
||
|
||
const getProviderDisplayName = (
|
||
attempt: CandidateRecord | null | undefined,
|
||
options: { allowAuthTypeFallback?: boolean } = {},
|
||
): string => {
|
||
const allowAuthTypeFallback = options.allowAuthTypeFallback ?? true
|
||
if (!attempt) return '未知'
|
||
const providerName = String(attempt.provider_name || '').trim()
|
||
if (providerName) return providerName
|
||
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 '未知'
|
||
}
|
||
|
||
const normalizeProviderIdentity = (value: unknown): string => {
|
||
if (typeof value !== 'string') return ''
|
||
return value.trim().toLowerCase()
|
||
}
|
||
|
||
const buildProviderGroups = (items: CandidateRecord[]): NodeGroup[] => {
|
||
const groups: NodeGroup[] = []
|
||
let currentGroup: NodeGroup | null = null
|
||
|
||
items.forEach((candidate) => {
|
||
const providerKey = candidate.provider_name || '未知'
|
||
|
||
if (currentGroup && currentGroup.id === providerKey) {
|
||
currentGroup.allAttempts.push(candidate)
|
||
currentGroup.retryCount++
|
||
currentGroup.endIndex = candidate.candidate_index
|
||
currentGroup.totalLatency += candidate.latency_ms || 0
|
||
if (candidate.extra_data?.needs_conversion) {
|
||
currentGroup.hasConversion = true
|
||
}
|
||
const currentPriority = STATUS_PRIORITY[currentGroup.primaryStatus] ?? 0
|
||
const newPriority = STATUS_PRIORITY[getDisplayStatus(candidate)] ?? 0
|
||
if (newPriority > currentPriority) {
|
||
currentGroup.primaryStatus = getDisplayStatus(candidate)
|
||
}
|
||
return
|
||
}
|
||
|
||
currentGroup = {
|
||
id: providerKey,
|
||
providerName: getProviderDisplayName(candidate),
|
||
primary: candidate,
|
||
primaryStatus: getDisplayStatus(candidate),
|
||
allAttempts: [candidate],
|
||
retryCount: 0,
|
||
totalLatency: candidate.latency_ms || 0,
|
||
startIndex: candidate.candidate_index,
|
||
endIndex: candidate.candidate_index,
|
||
hasConversion: candidate.extra_data?.needs_conversion === true,
|
||
providerApiFormat: candidate.extra_data?.provider_api_format || null,
|
||
isPoolGroup: false,
|
||
}
|
||
groups.push(currentGroup)
|
||
})
|
||
|
||
return groups
|
||
}
|
||
|
||
// 将相同 Provider 的所有请求合并为组(同提供商的 Key 放在子节点)
|
||
const groupedTimeline = computed<NodeGroup[]>(() => {
|
||
const providerGroups = buildProviderGroups(timeline.value.filter(isParticipatedCandidate))
|
||
if (poolAttemptsByGroup.value.size === 0) {
|
||
return providerGroups
|
||
}
|
||
|
||
const poolProviderIds = new Set<string>()
|
||
const poolProviderNames = new Set<string>()
|
||
const poolGroups: NodeGroup[] = []
|
||
|
||
for (const [groupId, attemptsRaw] of poolAttemptsByGroup.value.entries()) {
|
||
const attempts = [...attemptsRaw].sort(compareBySchedulingOrder)
|
||
if (attempts.length === 0) continue
|
||
|
||
const visibleAttempts = buildPoolGroupVisibleAttempts(attempts)
|
||
if (visibleAttempts.length === 0) continue
|
||
|
||
const poolPrimaryStatus = visibleAttempts.reduce((best, current) => {
|
||
const bestPriority = STATUS_PRIORITY[best] ?? 0
|
||
const currentStatus = getDisplayStatus(current)
|
||
const currentPriority = STATUS_PRIORITY[currentStatus] ?? 0
|
||
return currentPriority > bestPriority ? currentStatus : best
|
||
}, getDisplayStatus(visibleAttempts[0]))
|
||
|
||
const successAttempt = visibleAttempts.find((item) => item.status === 'success')
|
||
const poolPrimary =
|
||
successAttempt || visibleAttempts[visibleAttempts.length - 1] || visibleAttempts[0]
|
||
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}`,
|
||
providerName: getProviderDisplayName(poolPrimary, { allowAuthTypeFallback: false }),
|
||
primary: poolPrimary,
|
||
primaryStatus: poolPrimaryStatus,
|
||
allAttempts: visibleAttempts,
|
||
retryCount: Math.max(0, visibleAttempts.length - 1),
|
||
totalLatency: visibleAttempts.reduce((sum, item) => sum + (item.latency_ms || 0), 0),
|
||
startIndex,
|
||
endIndex,
|
||
hasConversion: visibleAttempts.some((item) => item.extra_data?.needs_conversion === true),
|
||
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)
|
||
}
|
||
}
|
||
|
||
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
|
||
})
|
||
|
||
const allGroups = [...poolGroups, ...dedupedProviderGroups]
|
||
allGroups.sort((a, b) => a.startIndex - b.startIndex)
|
||
return allGroups
|
||
})
|
||
|
||
// 格式转换分界点索引(首个 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
|
||
})
|
||
|
||
// The trace aggregate includes every attempted candidate, including failed
|
||
// failover attempts. Per-candidate latency remains provider-scoped.
|
||
const totalTraceLatency = computed(() => {
|
||
if (!rawTimeline.value || rawTimeline.value.length === 0) return 0
|
||
|
||
const aggregateLatency = trace.value?.total_latency_ms
|
||
if (typeof aggregateLatency === 'number' && Number.isFinite(aggregateLatency) && aggregateLatency > 0) {
|
||
return aggregateLatency
|
||
}
|
||
|
||
const attemptedLatency = rawTimeline.value.reduce((sum, candidate) => {
|
||
const latency = normalizeLatencyMs(candidate.latency_ms)
|
||
return sum + (latency ?? 0)
|
||
}, 0)
|
||
if (attemptedLatency > 0) {
|
||
return attemptedLatency
|
||
}
|
||
|
||
// Historical transport failures may not have latency_ms. Recover the wall
|
||
// clock span from candidate timestamps for those records.
|
||
let earliestStart: number | null = null
|
||
let latestEnd: number | null = null
|
||
|
||
for (const candidate of rawTimeline.value) {
|
||
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
|
||
})
|
||
|
||
const currentAttemptProviderWebsite = computed(() =>
|
||
safeExternalWebUrl(currentAttempt.value?.provider_website),
|
||
)
|
||
|
||
const currentAttemptTimeRange = computed<AttemptTimeRange | null>(() => {
|
||
return resolveAttemptTimeRange(currentAttempt.value)
|
||
})
|
||
|
||
const currentAttemptDisplayStatus = computed(() => getDisplayStatus(currentAttempt.value))
|
||
|
||
watch(currentAttempt, (attempt) => {
|
||
emit('selectAttempt', attempt ?? null)
|
||
}, { immediate: true })
|
||
|
||
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()
|
||
}
|
||
|
||
const extractObject = (value: unknown): Record<string, unknown> | null => {
|
||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||
return null
|
||
}
|
||
return value as Record<string, unknown>
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
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' &&
|
||
candidate.status !== 'skipped'
|
||
) {
|
||
return candidate
|
||
}
|
||
}
|
||
return null
|
||
})
|
||
|
||
const latestTraceImageProgress = computed<ImageProgress | null>(() => {
|
||
const candidates = rawTimeline.value
|
||
for (let index = candidates.length - 1; index >= 0; index -= 1) {
|
||
const candidate = candidates[index]
|
||
const progress = normalizeImageProgress(candidate.image_progress)
|
||
?? normalizeImageProgress(extractObject(candidate.extra_data)?.image_progress)
|
||
if (progress) return progress
|
||
}
|
||
return null
|
||
})
|
||
|
||
watch(
|
||
[trace, loading, latestTraceImageProgress, latestTraceAttemptForState, computedFinalStatus],
|
||
([value, isLoading, imageProgress, attempt, finalStatus]) => {
|
||
const waitingForInternalTrace = Boolean(props.requestId && !props.traceData && !traceLoadStarted.value && !value)
|
||
emit('traceState', {
|
||
loaded: !isLoading && !waitingForInternalTrace,
|
||
hasTrace: Boolean(value?.candidates?.length),
|
||
finalStatus: finalStatus ?? value?.final_status ?? null,
|
||
statusCode: attempt?.status_code ?? null,
|
||
latencyMs: attempt?.latency_ms ?? value?.total_latency_ms ?? null,
|
||
imageProgress,
|
||
errorMessage: attempt?.error_message ?? null,
|
||
})
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
|
||
const normalizeUpstreamResponseDisplay = (value: unknown): Record<string, unknown> | null => {
|
||
const raw = extractObject(value)
|
||
if (!raw) return null
|
||
const statusCode = readNumberField(raw, 'status_code') ?? readNumberField(raw, 'statusCode')
|
||
const headers = raw.headers ?? raw.header
|
||
const body = raw.body
|
||
const bodyRef = readStringField(raw, 'body_ref') ?? readStringField(raw, 'bodyRef')
|
||
const bodyState = readStringField(raw, 'body_state') ?? readStringField(raw, 'bodyState')
|
||
const meaningfulBodyState = bodyState && bodyState.toLowerCase() !== 'none'
|
||
? bodyState
|
||
: ''
|
||
|
||
if (
|
||
statusCode == null &&
|
||
!hasRenderableValue(headers) &&
|
||
!hasRenderableValue(body) &&
|
||
!bodyRef &&
|
||
!meaningfulBodyState
|
||
) {
|
||
return null
|
||
}
|
||
|
||
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
|
||
if (meaningfulBodyState) data.body_state = meaningfulBodyState
|
||
|
||
return data
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
const currentAttemptFormatDisplay = computed(() => {
|
||
const attempt = currentAttempt.value
|
||
if (!attempt) return ''
|
||
const extra = extractObject(attempt.extra_data) ?? {}
|
||
|
||
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
|
||
})
|
||
|
||
const normalizeQueryString = (value: string): string => {
|
||
const trimmed = value.trim()
|
||
if (!trimmed) return ''
|
||
return trimmed.startsWith('?') ? trimmed.slice(1) : trimmed
|
||
}
|
||
|
||
const resolveRequestPathFromObject = (value: unknown): string => {
|
||
const object = extractObject(value)
|
||
if (!object) return ''
|
||
|
||
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')
|
||
)
|
||
if (pathWithQuery) return pathWithQuery
|
||
|
||
const path = (
|
||
readStringField(object, 'request_path')
|
||
|| readStringField(object, 'public_path')
|
||
|| readStringField(object, 'path')
|
||
)
|
||
if (!path) return ''
|
||
|
||
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}`
|
||
}
|
||
|
||
const currentAttemptRequestPathDisplay = computed(() => {
|
||
const attempt = currentAttempt.value
|
||
const fromAttempt = resolveRequestPathFromObject(attempt?.extra_data)
|
||
if (fromAttempt) return fromAttempt
|
||
|
||
const fromTrace = resolveRequestPathFromObject(trace.value)
|
||
if (fromTrace) return fromTrace
|
||
|
||
const fromRequestMetadata = resolveRequestPathFromObject(props.requestMetadata)
|
||
if (fromRequestMetadata) return fromRequestMetadata
|
||
|
||
return ''
|
||
})
|
||
|
||
const currentAttemptKeyDisplay = computed(() => {
|
||
const attempt = currentAttempt.value
|
||
if (!attempt) return ''
|
||
return attempt.key_account_label || attempt.key_name || attempt.key_id || ''
|
||
})
|
||
|
||
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(' / ')
|
||
})
|
||
const currentAttemptSkipReasonDisplay = computed(() => {
|
||
const attempt = currentAttempt.value
|
||
if (!attempt?.skip_reason) return ''
|
||
|
||
// transport_unsupported 这类"泛化原因"优先展示后端采集到的具体细节,便于排查。
|
||
if (attempt.skip_reason === 'transport_unsupported') {
|
||
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 || formatCandidateSkipReason(attempt.skip_reason)
|
||
}
|
||
|
||
return formatCandidateSkipReason(attempt.skip_reason)
|
||
})
|
||
|
||
/**
|
||
* 区分"跳过"与"尝试失败":被跳过的候选从未发到上游,
|
||
* 不写清楚容易被误读成"上游返回了错误"。
|
||
*/
|
||
const currentAttemptSkipReasonLabel = computed(() => {
|
||
const status = currentAttempt.value?.status
|
||
return status === 'skipped' ? '跳过原因(未向上游发起请求)' : '跳过原因'
|
||
})
|
||
|
||
const currentAttemptFailureDiagnostic = computed<{
|
||
path: string
|
||
message: string
|
||
} | null>(() => {
|
||
const attempt = currentAttempt.value
|
||
if (!attempt) return null
|
||
const extra = extractObject(attempt.extra_data)
|
||
if (extractObject(extra?.failure_diagnostic)?.safe_to_show === false) return null
|
||
const failureDiagnostic = extractVisibleFailureDiagnostic(extra)
|
||
const error = failureDiagnostic
|
||
? 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 || '$',
|
||
message: formatAttemptErrorMessage(message) || message || '请求体转换失败',
|
||
}
|
||
})
|
||
|
||
const isGenericExecutionRuntimeStatusMessage = (message: string): boolean =>
|
||
/execution runtime (stream )?returned non-success status \d+/i.test(message)
|
||
|
||
const isStreamTerminalDiagnosticMessage = (message: string): boolean =>
|
||
/(?:unsupported provider stream finish reason|upstream stream ended with finish reason)\s*:/i.test(message)
|
||
|
||
const isStreamFinishErrorDiagnostic = (message: string): boolean =>
|
||
/(?:unsupported provider stream finish reason|upstream stream ended with finish reason)\s*:\s*error(?:[ \t]*(?:$|\r?\n)|["')])/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)
|
||
|| isStreamTerminalDiagnosticMessage(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>> => [
|
||
...visibleFailureRecords(extra ?? {}),
|
||
]
|
||
|
||
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 terminalMatch = message.match(/^unsupported provider stream finish reason\s*:\s*(.+)$/i)
|
||
if (terminalMatch?.[1]) {
|
||
return `流式终态校验失败:上游返回了当前不支持的 finish reason(${terminalMatch[1].trim()}),已按失败处理`
|
||
}
|
||
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+):\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+([\w:-]+)\.(.+)$/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 || isStreamTerminalDiagnosticMessage(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)
|
||
}
|
||
|
||
const formatAttemptErrorMessage = (message: string, statusCode?: number): string => {
|
||
const normalized = message.trim()
|
||
if (!normalized) return ''
|
||
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 (isStreamFinishErrorDiagnostic(normalized)) {
|
||
return '上游流式响应异常终止:上游返回了错误结束原因(error),已按失败处理'
|
||
}
|
||
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)) {
|
||
return statusCode != null ? `上游返回非成功状态 ${statusCode}` : '上游返回非成功状态'
|
||
}
|
||
return normalized
|
||
}
|
||
|
||
const shouldShowAttemptMessageWithUpstreamResponse = (
|
||
rawMessage: string,
|
||
upstreamResponse: Record<string, unknown> | null,
|
||
): boolean => {
|
||
if (!upstreamResponse || !hasRenderableValue(upstreamResponse.body)) return true
|
||
const normalized = rawMessage.trim()
|
||
if (!normalized) return false
|
||
return isActionableDiagnosticMessage(normalized)
|
||
}
|
||
|
||
const currentAttemptRequestError = computed<{
|
||
message: string
|
||
statusCode?: number
|
||
upstreamStatusCode?: number
|
||
finalStatusCode?: number
|
||
upstreamResponse: Record<string, unknown> | null
|
||
diagnostic: Record<string, unknown> | null
|
||
} | null>(() => {
|
||
const attempt = currentAttempt.value
|
||
if (!attempt || attempt.status !== 'failed') return null
|
||
|
||
const extra = extractObject(attempt.extra_data)
|
||
const upstreamResponse = extractObject(extra?.upstream_response)
|
||
const errorFlow = extractObject(extra?.error_flow)
|
||
const upstreamStatusCode = readNumberField(upstreamResponse ?? {}, 'status_code')
|
||
?? readNumberField(upstreamResponse ?? {}, 'statusCode')
|
||
const statusCode = attempt.status_code
|
||
?? upstreamStatusCode
|
||
?? readNumberField(errorFlow ?? {}, 'status_code')
|
||
?? readNumberField(errorFlow ?? {}, 'statusCode')
|
||
const finalStatusCode = !['pending', 'streaming'].includes(computedFinalStatus.value)
|
||
&& props.overrideStatusCode != null && props.overrideStatusCode !== statusCode
|
||
? props.overrideStatusCode
|
||
: undefined
|
||
const flowMessage = errorFlow
|
||
? readStringField(errorFlow, 'message')
|
||
: ''
|
||
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()
|
||
: ''
|
||
const diagnosticMessage = extractVisibleDiagnosticMessage(extra)
|
||
const rawMessage = chooseAttemptRawErrorMessage(flowMessage || '', fallbackMessage, diagnosticMessage)
|
||
const message = formatAttemptErrorMessage(rawMessage, statusCode) || fallbackType
|
||
|| '本次尝试失败,链路追踪未包含详细错误内容。'
|
||
const upstreamResponseDisplay = normalizeUpstreamResponseDisplay(extra?.upstream_response)
|
||
const visibleDiagnosticObjects = extractVisibleDiagnosticObjects(extra)
|
||
const shouldAttachDiagnostic = Boolean(
|
||
visibleDiagnosticObjects.length
|
||
|| isActionableDiagnosticMessage(rawMessage),
|
||
)
|
||
const diagnostic = shouldAttachDiagnostic
|
||
? buildAttemptDiagnosticPayload(
|
||
attempt,
|
||
message || fallbackType || rawMessage || '未知失败',
|
||
statusCode,
|
||
upstreamResponseDisplay,
|
||
rawMessage,
|
||
)
|
||
: null
|
||
const upstreamResponseData: Record<string, unknown> = {}
|
||
const responseHeader = upstreamResponseDisplay?.headers
|
||
const responseBody = upstreamResponseDisplay?.body
|
||
if (hasRenderableValue(responseHeader)) upstreamResponseData.header = responseHeader
|
||
if (hasRenderableValue(responseBody)) upstreamResponseData.body = responseBody
|
||
const response = Object.keys(upstreamResponseData).length > 0
|
||
? upstreamResponseData
|
||
: null
|
||
const showMessage = shouldShowAttemptMessageWithUpstreamResponse(
|
||
rawMessage || fallbackType,
|
||
upstreamResponseDisplay,
|
||
)
|
||
|
||
return {
|
||
message: showMessage ? message : '',
|
||
statusCode,
|
||
upstreamStatusCode,
|
||
finalStatusCode,
|
||
upstreamResponse: response,
|
||
diagnostic,
|
||
}
|
||
})
|
||
|
||
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 = diagnosticPathFromMessage
|
||
|
||
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()) ?? ''
|
||
const streamFinishReasonPath = isStreamTerminalDiagnosticMessage(rawMessage) ? '$.finish_reason' : ''
|
||
|
||
return diagnosticPathFromObject(failureDiagnostic)
|
||
|| diagnosticPathFromObject(requestConversionError)
|
||
|| diagnosticPathFromObject(requestBodyBuildError)
|
||
|| diagnosticFieldPathFromMessage(rawMessage)
|
||
|| streamFinishReasonPath
|
||
|| '$'
|
||
}
|
||
|
||
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 (isStreamFinishErrorDiagnostic(raw)) {
|
||
return '上游通过结束原因报告了流式错误:检查原始 SSE 中的 error/message 及上游日志;保持失败状态,不要映射为正常结束。'
|
||
}
|
||
if (isStreamTerminalDiagnosticMessage(raw)) {
|
||
return '断点在上游流式终态校验:检查原始结束原因及协议兼容性;该错误不代表发生了格式转换,不能确认成功时保持失败闭合。'
|
||
}
|
||
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 / node.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: rawFailureDiagnostic?.safe_to_show === false || extractObject(extra?.failure_diagnostic)?.safe_to_show === false ? null : rawRequestConversionError,
|
||
request_body_build_error: rawFailureDiagnostic?.safe_to_show === false || extractObject(extra?.failure_diagnostic)?.safe_to_show === false ? null : rawRequestBodyBuildError,
|
||
error_flow: extractObject(extra?.error_flow),
|
||
upstream_response: upstreamResponseDisplay ?? normalizeUpstreamResponseDisplay(extra?.upstream_response),
|
||
},
|
||
}
|
||
return buildFailureDiagnosticBundle(payload, attempt, trace.value, rawMessageForBreakpoint)
|
||
}
|
||
|
||
const { copyToClipboard } = useClipboard()
|
||
const diagnosticCopying = ref(false)
|
||
const diagnosticCopied = ref(false)
|
||
const exportedDiagnostic = ref<Record<string, unknown> | null>(null)
|
||
let diagnosticController: AbortController | null = null
|
||
let diagnosticCopyTimer: ReturnType<typeof setTimeout> | undefined
|
||
const currentAttemptDiagnostic = computed(() => {
|
||
const attempt = currentAttempt.value
|
||
if (!attempt) return null
|
||
if (attempt.status === 'failed') return currentAttemptRequestError.value?.diagnostic ?? null
|
||
if (attempt.status !== 'skipped' || !currentAttemptFailureDiagnostic.value) return null
|
||
return buildAttemptDiagnosticPayload(
|
||
attempt,
|
||
currentAttemptFailureDiagnostic.value.message,
|
||
attempt.status_code,
|
||
normalizeUpstreamResponseDisplay(attempt.extra_data?.upstream_response),
|
||
extractVisibleDiagnosticMessage(attempt.extra_data),
|
||
)
|
||
})
|
||
const diagnosticDisplay = computed(() => exportedDiagnostic.value
|
||
?? (currentAttemptDiagnostic.value ? sanitizeDiagnostic(currentAttemptDiagnostic.value) as Record<string, unknown> : null))
|
||
|
||
function resetDiagnosticCopy() {
|
||
diagnosticController?.abort()
|
||
diagnosticController = null
|
||
clearTimeout(diagnosticCopyTimer)
|
||
diagnosticCopying.value = false
|
||
diagnosticCopied.value = false
|
||
exportedDiagnostic.value = null
|
||
}
|
||
|
||
async function copyFailureDiagnostic() {
|
||
if (!currentAttemptDiagnostic.value || !currentAttempt.value || diagnosticCopying.value) return
|
||
const bundle = JSON.parse(JSON.stringify(currentAttemptDiagnostic.value)) as Record<string, unknown>
|
||
const attempt = JSON.parse(JSON.stringify(currentAttempt.value)) as CandidateRecord
|
||
const currentTrace = trace.value ? { ...trace.value, candidates: [] } : null
|
||
const controller = new AbortController()
|
||
diagnosticController = controller
|
||
diagnosticCopying.value = true
|
||
diagnosticCopied.value = false
|
||
try {
|
||
const diagnostic = await prepareDiagnosticExport(bundle, attempt, currentTrace, controller.signal)
|
||
if (controller.signal.aborted) return
|
||
exportedDiagnostic.value = diagnostic
|
||
const copied = await copyToClipboard(JSON.stringify(diagnostic, null, 2))
|
||
if (controller.signal.aborted) return
|
||
diagnosticCopied.value = copied
|
||
if (copied) diagnosticCopyTimer = setTimeout(() => { diagnosticCopied.value = false }, 2000)
|
||
} catch {
|
||
if (controller.signal.aborted) return
|
||
const fallback = sanitizeDiagnostic({
|
||
...bundle,
|
||
reproduction: { status: 'insufficient_context', missing_context: ['export_failed'] },
|
||
}) as Record<string, unknown>
|
||
exportedDiagnostic.value = fallback
|
||
diagnosticCopied.value = await copyToClipboard(JSON.stringify(fallback, null, 2))
|
||
} finally {
|
||
if (diagnosticController === controller) {
|
||
diagnosticCopying.value = false
|
||
diagnosticController = null
|
||
}
|
||
}
|
||
}
|
||
|
||
watch([() => props.requestId, () => currentAttempt.value?.id, () => currentAttempt.value?.error_message], resetDiagnosticCopy)
|
||
onBeforeUnmount(resetDiagnosticCopy)
|
||
|
||
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
|
||
})
|
||
|
||
const hasActiveImageProgress = computed(() => {
|
||
return rawTimeline.value.some((candidate) => {
|
||
const progress = normalizeImageProgress(candidate.image_progress)
|
||
?? normalizeImageProgress(extractObject(candidate.extra_data)?.image_progress)
|
||
if (!progress?.phase) return false
|
||
return progress.phase !== 'upstream_completed' && progress.phase !== 'failed'
|
||
})
|
||
})
|
||
|
||
// 判断是否为 OAuth 类型(provider_type 为具体值时也算 OAuth)
|
||
const isOAuthType = (authType?: string): boolean => {
|
||
if (!authType) return false
|
||
return !['api_key', 'service_account'].includes(authType)
|
||
}
|
||
|
||
// 格式化认证类型(合并 plan 信息,避免冗余)
|
||
const formatAuthTypeWithPlan = (authType: string, planType?: string): string => {
|
||
const labels: Record<string, string> = {
|
||
'oauth': 'OAuth',
|
||
'service_account': 'Service Account',
|
||
'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
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// 检查组是否被悬浮
|
||
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)
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// 选中一个组
|
||
const selectGroup = (group: NodeGroup) => {
|
||
const index = findGroupIndex(groupedTimeline.value, group)
|
||
if (index >= 0) {
|
||
selectionPinnedByUser.value = true
|
||
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) => {
|
||
const index = findGroupIndex(groupedTimeline.value, group)
|
||
if (index >= 0) {
|
||
selectionPinnedByUser.value = true
|
||
selectedGroupIndex.value = index
|
||
selectedAttemptIndex.value = 0
|
||
}
|
||
}
|
||
|
||
const selectAttemptInGroup = (group: NodeGroup, attemptIndex: number) => {
|
||
const groupIndex = findGroupIndex(groupedTimeline.value, group)
|
||
if (groupIndex < 0) return
|
||
selectionPinnedByUser.value = true
|
||
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(' · ')
|
||
}
|
||
|
||
// 导航到上/下一组
|
||
const navigateGroup = (direction: number) => {
|
||
const newIndex = selectedGroupIndex.value + direction
|
||
if (newIndex >= 0 && newIndex < groupedTimeline.value.length) {
|
||
selectionPinnedByUser.value = true
|
||
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
|
||
}
|
||
}
|
||
|
||
const navigateAttempt = (direction: number) => {
|
||
const group = selectedGroup.value
|
||
if (!group) return
|
||
const newIndex = selectedAttemptIndex.value + direction
|
||
if (newIndex >= 0 && newIndex < group.allAttempts.length) {
|
||
selectionPinnedByUser.value = true
|
||
selectedAttemptIndex.value = newIndex
|
||
}
|
||
}
|
||
|
||
// 加载请求追踪数据
|
||
const isSilentRefresh = ref(false)
|
||
const loadTrace = async (silent = false) => {
|
||
const requestId = props.requestId
|
||
if (!requestId || props.traceData) return
|
||
if (traceLoadInFlight) return traceLoadInFlight
|
||
|
||
traceLoadInFlight = (async () => {
|
||
isSilentRefresh.value = silent
|
||
traceLoadStarted.value = true
|
||
|
||
if (!silent) {
|
||
loading.value = true
|
||
}
|
||
error.value = null
|
||
|
||
try {
|
||
// 始终拉取全部候选:被跳过的候选是"为什么没用某个提供商"的关键证据,
|
||
// 是否显示由前端开关控制,避免再发一次请求。
|
||
internalTrace.value = await requestTraceApi.getRequestTrace(requestId, { attemptedOnly: false })
|
||
} catch (err: unknown) {
|
||
if (isAxiosError(err) && err.response?.status === 404) {
|
||
internalTrace.value = null
|
||
error.value = null
|
||
return
|
||
}
|
||
if (!silent) {
|
||
error.value = parseApiError(err, '加载失败')
|
||
}
|
||
log.error('加载请求追踪失败:', err)
|
||
} finally {
|
||
if (!silent) {
|
||
loading.value = false
|
||
}
|
||
traceLoadInFlight = null
|
||
}
|
||
})()
|
||
|
||
return traceLoadInFlight
|
||
}
|
||
|
||
const propsRequestIsActive = computed(() => {
|
||
const status = props.requestStatus ?? usageData.value?.status
|
||
return status === 'pending' || status === 'streaming'
|
||
})
|
||
|
||
const traceHasActiveCandidate = computed(() => {
|
||
return rawTimeline.value.some(isLiveCandidate)
|
||
})
|
||
|
||
const traceFinalIsTerminal = computed(() => {
|
||
const status = trace.value?.final_status
|
||
return status === 'success' || status === 'failed' || status === 'cancelled'
|
||
})
|
||
|
||
const shouldPollTrace = computed(() => {
|
||
if (!props.requestId || props.traceData) return false
|
||
if (traceHasActiveCandidate.value || hasActiveImageProgress.value) return true
|
||
return propsRequestIsActive.value && !traceFinalIsTerminal.value
|
||
})
|
||
|
||
const stopTracePolling = () => {
|
||
if (tracePollTimer) {
|
||
clearTimeout(tracePollTimer)
|
||
tracePollTimer = null
|
||
}
|
||
}
|
||
|
||
const scheduleTracePolling = () => {
|
||
stopTracePolling()
|
||
if (!shouldPollTrace.value) return
|
||
|
||
tracePollTimer = setTimeout(async () => {
|
||
await loadTrace(true)
|
||
scheduleTracePolling()
|
||
}, TRACE_POLL_INTERVAL_MS)
|
||
}
|
||
|
||
// 监听 groupedTimeline 变化,自动选择最有意义的组
|
||
watch(groupedTimeline, (newGroups) => {
|
||
if (!newGroups || newGroups.length === 0) return
|
||
|
||
// 静默刷新时保留用户手动选择;未手动选择时跟随成功/进行中的 Key。
|
||
if (isSilentRefresh.value) {
|
||
isSilentRefresh.value = false
|
||
if (selectionPinnedByUser.value && selectedAttemptFromGroups(newGroups)) {
|
||
return
|
||
}
|
||
if (selectedAttemptMatchesBestSilentState(newGroups)) {
|
||
return
|
||
}
|
||
selectMostRelevantGroup(newGroups)
|
||
return
|
||
}
|
||
|
||
selectMostRelevantGroup(newGroups)
|
||
}, { immediate: true })
|
||
|
||
// 监听 requestId / 外部 trace 变化
|
||
watch(
|
||
[() => props.requestId, () => props.traceData],
|
||
() => {
|
||
selectedGroupIndex.value = 0
|
||
selectedAttemptIndex.value = 0
|
||
selectionPinnedByUser.value = false
|
||
traceLoadStarted.value = false
|
||
|
||
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 },
|
||
)
|
||
|
||
watch(shouldPollTrace, () => {
|
||
scheduleTracePolling()
|
||
}, { immediate: true })
|
||
|
||
onBeforeUnmount(() => {
|
||
stopTracePolling()
|
||
})
|
||
|
||
defineExpose({ refresh: () => loadTrace(true) })
|
||
|
||
const TERMINAL_TIME_RANGE_STATUSES = new Set([
|
||
'success',
|
||
'failed',
|
||
'cancelled',
|
||
'stream_interrupted',
|
||
])
|
||
|
||
const parseTimestampMs = (value?: string | null): number | null => {
|
||
if (!value) return null
|
||
const timestamp = new Date(value).getTime()
|
||
return Number.isFinite(timestamp) ? timestamp : null
|
||
}
|
||
|
||
const normalizeLatencyMs = (value?: number | null): number | null => {
|
||
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return null
|
||
return Math.round(value)
|
||
}
|
||
|
||
const formatDurationMs = (durationMs: number): string => {
|
||
const safeDurationMs = Math.max(0, Math.round(durationMs))
|
||
if (safeDurationMs >= 1000) {
|
||
return `${(safeDurationMs / 1000).toFixed(2)}s`
|
||
}
|
||
return `${safeDurationMs}ms`
|
||
}
|
||
|
||
const resolveAttemptTimeRange = (attempt: CandidateRecord | null | undefined): AttemptTimeRange | null => {
|
||
if (!attempt?.started_at) return null
|
||
|
||
const startMs = parseTimestampMs(attempt.started_at)
|
||
if (startMs == null) {
|
||
return {
|
||
startIso: attempt.started_at,
|
||
endIso: attempt.finished_at,
|
||
durationLabel: attempt.finished_at ? formatDuration(attempt.started_at, attempt.finished_at) : undefined,
|
||
}
|
||
}
|
||
|
||
const rawEndMs = parseTimestampMs(attempt.finished_at)
|
||
const latencyMs = normalizeLatencyMs(attempt.latency_ms)
|
||
let endMs = rawEndMs
|
||
|
||
if (
|
||
latencyMs != null &&
|
||
latencyMs > 0 &&
|
||
(endMs == null || endMs <= startMs) &&
|
||
(attempt.finished_at || TERMINAL_TIME_RANGE_STATUSES.has(attempt.status))
|
||
) {
|
||
endMs = startMs + latencyMs
|
||
}
|
||
|
||
if (endMs == null) {
|
||
return { startIso: attempt.started_at }
|
||
}
|
||
|
||
return {
|
||
startIso: attempt.started_at,
|
||
endIso: new Date(endMs).toISOString(),
|
||
durationLabel: formatDurationMs(endMs - startMs),
|
||
}
|
||
}
|
||
|
||
// 格式化时间(详细)
|
||
const formatTime = (dateStr: string) => {
|
||
const date = new Date(dateStr)
|
||
const timeStr = date.toLocaleTimeString(getI18nLocale(), {
|
||
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()
|
||
return formatDurationMs(end - start)
|
||
}
|
||
|
||
// 获取状态标签
|
||
const getStatusLabel = (status: string) => {
|
||
const labels: Record<string, string> = {
|
||
available: '可用未尝试',
|
||
unused: '未使用',
|
||
pending: '进行中',
|
||
streaming: '传输中',
|
||
stream_interrupted: '流中断',
|
||
success: '成功',
|
||
failed: '失败',
|
||
cancelled: '已取消',
|
||
skipped: '跳过'
|
||
}
|
||
return labels[status] || status
|
||
}
|
||
|
||
// 获取状态颜色类
|
||
const getStatusColorClass = (status: string) => {
|
||
const classes: Record<string, string> = {
|
||
available: 'status-available',
|
||
unused: 'status-available',
|
||
pending: 'status-pending',
|
||
streaming: 'status-pending',
|
||
stream_interrupted: 'status-failed',
|
||
success: 'status-success',
|
||
failed: 'status-failed',
|
||
cancelled: 'status-cancelled',
|
||
skipped: 'status-skipped'
|
||
}
|
||
return classes[status] || 'status-available'
|
||
}
|
||
|
||
// 展示状态:进行中态优先(包括 started 但未 finished 的中间态),再按 HTTP 状态码兜底
|
||
function getDisplayStatus(attempt: CandidateRecord | null | undefined): string {
|
||
if (!attempt) return 'available'
|
||
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'
|
||
}
|
||
if (
|
||
attempt.status === 'failed' ||
|
||
attempt.status === 'cancelled' ||
|
||
attempt.status === 'skipped' ||
|
||
attempt.status === 'stream_interrupted'
|
||
) {
|
||
return attempt.status
|
||
}
|
||
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') {
|
||
if (isTerminalSuccessCode) return 'success'
|
||
if (code >= 300) return 'failed'
|
||
}
|
||
return attempt.status
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.minimal-request-timeline {
|
||
width: 100%;
|
||
}
|
||
|
||
/* 极简轨道 - 包装器实现溢出时居左、不溢出时居中 */
|
||
.minimal-track {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: safe center;
|
||
gap: 64px;
|
||
padding: 2rem 2rem 2.75rem;
|
||
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);
|
||
}
|
||
|
||
.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;
|
||
z-index: 3;
|
||
}
|
||
|
||
/* 子节点 - 增大点击区域 */
|
||
.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; }
|
||
.sub-dot.status-cancelled { background: #f59e0b; color: #f59e0b; }
|
||
.sub-dot.status-pending { background: #3b82f6; color: #3b82f6; }
|
||
.sub-dot.status-skipped { background: hsl(var(--foreground)); color: hsl(var(--foreground)); }
|
||
.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; }
|
||
.node-dot.status-cancelled { color: #f59e0b; }
|
||
.node-dot.status-pending { color: #3b82f6; }
|
||
.node-dot.status-skipped { color: hsl(var(--foreground)); }
|
||
.node-dot.status-available { color: #d1d5db; }
|
||
|
||
/* 连接线容器 */
|
||
.node-line-wrapper {
|
||
position: absolute;
|
||
right: -64px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
width: 64px;
|
||
z-index: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.node-line {
|
||
width: 100%;
|
||
height: 2px;
|
||
background: hsl(var(--border));
|
||
}
|
||
|
||
/* 格式转换分界线 */
|
||
.node-line.conversion-boundary {
|
||
background: none;
|
||
height: 0;
|
||
border-top: 2px dashed hsl(var(--muted-foreground) / 0.4);
|
||
}
|
||
|
||
/* 详情面板 */
|
||
.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; }
|
||
.title-dot.status-cancelled { background: #f59e0b; }
|
||
.title-dot.status-pending { background: #3b82f6; }
|
||
.title-dot.status-skipped { background: hsl(var(--foreground)); }
|
||
.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;
|
||
}
|
||
|
||
/* 「显示/隐藏被跳过候选」开关:低调的描边小按钮,不抢主状态徽标的视觉重心 */
|
||
.skipped-toggle {
|
||
padding: 0.125rem 0.5rem;
|
||
border: 1px dashed hsl(var(--border));
|
||
border-radius: 9999px;
|
||
background: transparent;
|
||
color: hsl(var(--muted-foreground));
|
||
font-size: 0.75rem;
|
||
line-height: 1.5;
|
||
cursor: pointer;
|
||
transition: all 0.15s ease;
|
||
}
|
||
|
||
.skipped-toggle:hover {
|
||
color: hsl(var(--foreground));
|
||
border-color: hsl(var(--muted-foreground) / 0.5);
|
||
}
|
||
|
||
.skipped-toggle.active {
|
||
border-style: solid;
|
||
border-color: hsl(var(--primary) / 0.5);
|
||
color: hsl(var(--primary));
|
||
}
|
||
|
||
.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;
|
||
color: #22c55e;
|
||
}
|
||
|
||
.status-tag.status-failed {
|
||
background: #ef444420;
|
||
color: #ef4444;
|
||
}
|
||
|
||
.status-tag.status-cancelled {
|
||
background: #f59e0b20;
|
||
color: #f59e0b;
|
||
}
|
||
|
||
.status-tag.status-pending {
|
||
background: #3b82f620;
|
||
color: #3b82f6;
|
||
}
|
||
|
||
.status-tag.status-skipped {
|
||
background: hsl(var(--foreground) / 0.08);
|
||
color: hsl(var(--foreground));
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.attempt-switcher {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.375rem;
|
||
margin-left: 0.5rem;
|
||
}
|
||
|
||
.attempt-nav-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 20px;
|
||
height: 20px;
|
||
border: 1px solid hsl(var(--border));
|
||
background: hsl(var(--background));
|
||
border-radius: 9999px;
|
||
color: hsl(var(--muted-foreground));
|
||
cursor: pointer;
|
||
transition: all 0.15s ease;
|
||
}
|
||
|
||
.attempt-nav-btn:hover:not(:disabled) {
|
||
background: hsl(var(--muted));
|
||
color: hsl(var(--foreground));
|
||
}
|
||
|
||
.attempt-nav-btn:disabled {
|
||
opacity: 0.35;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.info-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, 1fr);
|
||
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;
|
||
}
|
||
|
||
/* 两行堆叠布局 */
|
||
.info-value-stacked {
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
gap: 0.2rem;
|
||
}
|
||
|
||
/* 格式代码 */
|
||
.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;
|
||
}
|
||
|
||
/* Key 信息 */
|
||
.key-name {
|
||
font-weight: 500;
|
||
}
|
||
|
||
.key-preview {
|
||
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;
|
||
}
|
||
|
||
/* 认证类型标签 */
|
||
.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;
|
||
}
|
||
|
||
/* 代理信息 */
|
||
.proxy-name {
|
||
font-weight: 500;
|
||
}
|
||
|
||
.proxy-detail {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.375rem;
|
||
}
|
||
|
||
/* 号池调度 */
|
||
.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));
|
||
}
|
||
|
||
.image-progress-block {
|
||
margin-top: 0.875rem;
|
||
padding: 0.75rem;
|
||
border: 1px solid hsl(var(--border) / 0.7);
|
||
border-radius: 8px;
|
||
background: hsl(var(--background) / 0.72);
|
||
}
|
||
|
||
.image-progress-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 0.75rem;
|
||
margin-bottom: 0.625rem;
|
||
}
|
||
|
||
.image-progress-title {
|
||
font-size: 0.82rem;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.image-progress-phase {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 0.15rem 0.5rem;
|
||
border-radius: 999px;
|
||
font-size: 0.7rem;
|
||
font-weight: 600;
|
||
white-space: nowrap;
|
||
border: 1px solid hsl(var(--border));
|
||
}
|
||
|
||
.image-progress-phase.phase-connecting,
|
||
.image-progress-phase.phase-streaming {
|
||
color: #2563eb;
|
||
background: #3b82f614;
|
||
border-color: #3b82f633;
|
||
}
|
||
|
||
.image-progress-phase.phase-completed {
|
||
color: #16a34a;
|
||
background: #22c55e14;
|
||
border-color: #22c55e33;
|
||
}
|
||
|
||
.image-progress-phase.phase-failed {
|
||
color: #dc2626;
|
||
background: #ef444414;
|
||
border-color: #ef444433;
|
||
}
|
||
|
||
.image-progress-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
gap: 0.625rem 0.875rem;
|
||
}
|
||
|
||
.image-progress-item {
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.2rem;
|
||
}
|
||
|
||
.image-progress-item.full-width {
|
||
grid-column: span 2;
|
||
}
|
||
|
||
.image-progress-label {
|
||
font-size: 0.68rem;
|
||
color: hsl(var(--muted-foreground));
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.image-progress-value {
|
||
min-width: 0;
|
||
font-size: 0.82rem;
|
||
font-weight: 600;
|
||
color: hsl(var(--foreground));
|
||
}
|
||
|
||
.image-progress-code {
|
||
min-width: 0;
|
||
width: fit-content;
|
||
max-width: 100%;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
padding: 0.12rem 0.35rem;
|
||
border-radius: 4px;
|
||
background: hsl(var(--muted));
|
||
color: hsl(var(--muted-foreground));
|
||
font-size: 0.72rem;
|
||
font-family: ui-monospace, monospace;
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.image-progress-grid {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
|
||
.image-progress-item.full-width {
|
||
grid-column: 1 / -1;
|
||
}
|
||
}
|
||
|
||
/* Provider 官网链接 */
|
||
.provider-link {
|
||
display: inline-flex;
|
||
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;
|
||
padding: 0.75rem;
|
||
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));
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
/* 错误信息 */
|
||
.error-block {
|
||
margin-top: 1rem;
|
||
padding: 0.875rem;
|
||
background: #ef444410;
|
||
border: 1px solid #ef444430;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.error-heading {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 0.75rem;
|
||
margin-bottom: 0.25rem;
|
||
}
|
||
|
||
.error-type {
|
||
font-size: 0.75rem;
|
||
font-weight: 600;
|
||
color: #ef4444;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.025em;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.error-msg {
|
||
font-size: 0.85rem;
|
||
color: #dc2626;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.error-json {
|
||
margin-top: 0.75rem;
|
||
}
|
||
|
||
.dark .error-status-badge.is-success {
|
||
color: #bbf7d0;
|
||
}
|
||
|
||
.dark .error-status-badge.is-warning {
|
||
color: #fde68a;
|
||
}
|
||
|
||
.dark .error-status-badge.is-error {
|
||
color: #fecaca;
|
||
}
|
||
|
||
/* 额外信息 */
|
||
.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));
|
||
}
|
||
|
||
.extra-json-panel {
|
||
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>
|