Merge remote-tracking branch 'upstream/main'

This commit is contained in:
AAEE86
2026-06-16 10:53:47 +08:00
168 changed files with 24971 additions and 1332 deletions
@@ -7,11 +7,13 @@ import { computed, onUnmounted, ref, watch } from 'vue'
const props = withDefaults(defineProps<{
createdAt?: string | null
responseTimeUpdatedAt?: string | null
status?: string | null
responseTimeMs?: number | null
precision?: number
}>(), {
createdAt: null,
responseTimeUpdatedAt: null,
status: null,
responseTimeMs: null,
precision: 2,
@@ -30,6 +32,10 @@ function parseCreatedAtMs(value: string | null | undefined): number {
return new Date(normalized).getTime()
}
function finiteNonNegativeMs(value: number | null | undefined): number | null {
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null
}
function stopRaf() {
if (rafId == null) return
cancelAnimationFrame(rafId)
@@ -61,8 +67,16 @@ onUnmounted(() => {
const displayText = computed(() => {
if (!isActive.value) {
if (props.responseTimeMs == null) return '-'
return `${(props.responseTimeMs / 1000).toFixed(precision.value)}s`
const responseTimeMs = finiteNonNegativeMs(props.responseTimeMs)
if (responseTimeMs == null) return '-'
return `${(responseTimeMs / 1000).toFixed(precision.value)}s`
}
const responseTimeMs = finiteNonNegativeMs(props.responseTimeMs)
const updatedAtMs = parseCreatedAtMs(props.responseTimeUpdatedAt)
if (responseTimeMs != null && !Number.isNaN(updatedAtMs)) {
const elapsedSinceUpdateMs = Math.max(0, now.value - updatedAtMs)
return `${((responseTimeMs + elapsedSinceUpdateMs) / 1000).toFixed(precision.value)}s`
}
if (!props.createdAt) return '-'
@@ -1401,9 +1401,8 @@ const currentAttemptFailureDiagnostic = computed<{
const attempt = currentAttempt.value
if (!attempt) return null
const extra = extractObject(attempt.extra_data)
const failureDiagnostic = extractObject(extra?.failure_diagnostic)
const safeToShow = failureDiagnostic?.safe_to_show !== false
const error = failureDiagnostic && safeToShow
const failureDiagnostic = extractVisibleFailureDiagnostic(extra)
const error = failureDiagnostic
? failureDiagnostic
: extractObject(extra?.request_body_build_error)
const path = typeof error?.path === 'string' && error.path.trim()
@@ -1415,19 +1414,205 @@ const currentAttemptFailureDiagnostic = computed<{
if (!path && !message) return null
return {
path: path || '$',
message: message || '请求体转换失败',
message: formatAttemptErrorMessage(message) || message || '请求体转换失败',
}
})
const isGenericExecutionRuntimeStatusMessage = (message: string): boolean =>
/execution runtime (stream )?returned non-success status \d+/i.test(message)
const isLocalSyncFinalizeDiagnostic = (message: string): boolean =>
/local sync attempt failed before terminal finalization/i.test(message)
|| /unsupported provider stream (event|finish reason)/i.test(message)
const isActionableDiagnosticMessage = (message: string): boolean =>
isLocalSyncFinalizeDiagnostic(message) || isConversionDiagnosticMessage(message)
const extractVisibleFailureDiagnostic = (
extra: Record<string, unknown> | null | undefined,
): Record<string, unknown> | null => {
const failureDiagnostic = extractObject(extra?.failure_diagnostic)
if (!failureDiagnostic || failureDiagnostic.safe_to_show === false) return null
return failureDiagnostic
}
const extractVisibleDiagnosticObjects = (
extra: Record<string, unknown> | null | undefined,
): Array<Record<string, unknown>> => [
extractVisibleFailureDiagnostic(extra),
extractObject(extra?.request_conversion_error),
extractObject(extra?.request_body_build_error),
].filter((value): value is Record<string, unknown> => Boolean(value))
const extractVisibleDiagnosticMessage = (
extra: Record<string, unknown> | null | undefined,
): string => {
for (const diagnostic of extractVisibleDiagnosticObjects(extra)) {
const message = readStringField(diagnostic, 'message')
if (message) return message
}
return ''
}
const chooseAttemptRawErrorMessage = (
flowMessage: string,
fallbackMessage: string,
diagnosticMessage: string,
): string => {
const flow = flowMessage.trim()
const fallback = fallbackMessage.trim()
const diagnostic = diagnosticMessage.trim()
if (flow && fallback && !isActionableDiagnosticMessage(flow) && isActionableDiagnosticMessage(fallback)) {
return fallback
}
if (flow && diagnostic && isGenericExecutionRuntimeStatusMessage(flow) && isActionableDiagnosticMessage(diagnostic)) {
return diagnostic
}
if (fallback && diagnostic && isGenericExecutionRuntimeStatusMessage(fallback) && isActionableDiagnosticMessage(diagnostic)) {
return diagnostic
}
return flow || fallback || diagnostic
}
const decodeRustDebugString = (value: string): string => {
try {
return JSON.parse(`"${value}"`)
} catch {
return value.replace(/\\"/g, '"')
}
}
const normalizeDiagnosticFieldPath = (field: string): string => {
const trimmed = field.trim()
if (!trimmed || trimmed === '$') return '$'
if (trimmed.startsWith('$')) return trimmed
if (trimmed.startsWith('[')) return `$${trimmed}`
return `$.${trimmed}`
}
const formatConversionPair = (source: string, target: string): string =>
`${formatApiFormat(source.trim())} → ${formatApiFormat(target.trim())}`
const extractFieldDetail = (message: string): string => {
const fieldMatch = message.match(/field\s+([^;=]+?)\s*=\s*(\"(?:\\.|[^"\\])*\"|[^;]+)/i)
const unsupportedFieldMatch = message.match(/field\s+([^;]+?)\s+is unsupported/i)
if (fieldMatch?.[1]) {
return `字段 ${normalizeDiagnosticFieldPath(fieldMatch[1])} = ${fieldMatch[2].trim()}`
}
if (unsupportedFieldMatch?.[1]) {
return `字段 ${normalizeDiagnosticFieldPath(unsupportedFieldMatch[1])} 不支持`
}
return ''
}
const formatUnsupportedStreamEventMessage = (message: string): string => {
const detail = extractFieldDetail(message)
const fieldDetail = detail ? `(${detail})` : ''
return `流式格式转换失败:上游返回了当前不支持的 stream event${fieldDetail},无法无损转换到客户端请求格式`
}
const formatUnsupportedFinishReasonMessage = (message: string): string => {
const fieldDetail = extractFieldDetail(message)
const legacyMatch = message.match(/unsupported provider stream finish reason\s+(.+?)\s+cannot be converted losslessly/i)
const detail = fieldDetail || (legacyMatch?.[1]
? `字段 $.finish_reason = ${legacyMatch[1].trim()}`
: '')
return `流式格式转换失败:上游返回了当前不支持的 finish reason${detail ? `(${detail})` : ''},无法无损转换到客户端请求格式`
}
const formatKnownConversionErrorMessage = (message: string): string => {
const lossy = message.match(/^lossy conversion blocked from\s+(\S+)\s+to\s+(\S+)\s+at\s+([^:]+):\s*(.+)$/i)
if (lossy) {
return `格式转换失败:${formatConversionPair(lossy[1], lossy[2])} 在字段 ${normalizeDiagnosticFieldPath(lossy[3])} 会丢失信息:${lossy[4].trim()}`
}
const unaudited = message.match(/^unaudited field\s+(.+?)\s+in\s+(.+?)\s+cannot be converted to\s+([^:]+):\s*(.+)$/i)
if (unaudited) {
return `格式转换失败:${formatConversionPair(unaudited[2], unaudited[3])} 的字段 ${normalizeDiagnosticFieldPath(unaudited[1])} 尚未审计,不能安全转换:${unaudited[4].trim()}`
}
const unsupportedField = message.match(/^unsupported field\s+(.+?)\s+in\s+([^:]+(?::[^:]+)?):\s*(.+)$/i)
if (unsupportedField) {
return `格式转换失败:${formatApiFormat(unsupportedField[2])} 不支持字段 ${normalizeDiagnosticFieldPath(unsupportedField[1])}:${unsupportedField[3].trim()}`
}
const invalidEnum = message.match(/^invalid enum value\s+(.+?)\s+for\s+(.+)\.([^.\s]+)$/i)
if (invalidEnum) {
return `格式转换失败:${formatApiFormat(invalidEnum[2])} 字段 ${normalizeDiagnosticFieldPath(invalidEnum[3])} 的枚举值 ${invalidEnum[1].trim()} 无效`
}
const invalidTarget = message.match(/^invalid target field\s+(.+?)\s+for\s+(.+?):\s*(.+)$/i)
if (invalidTarget) {
return `格式转换失败:目标格式 ${formatApiFormat(invalidTarget[2])} 字段 ${normalizeDiagnosticFieldPath(invalidTarget[1])} 无效:${invalidTarget[3].trim()}`
}
const unsupportedFormat = message.match(/^unsupported AI format:\s*(.+)$/i)
if (unsupportedFormat) {
return `格式转换失败:不支持的 API 格式 ${unsupportedFormat[1].trim()}`
}
const parseEmit = message.match(/^failed to\s+(parse|emit)\s+(.+?)\s+(request|response)$/i)
if (parseEmit) {
const action = parseEmit[1].toLowerCase() === 'parse' ? '解析' : '生成'
const subject = parseEmit[3].toLowerCase() === 'request' ? '请求体' : '响应体'
return `格式转换失败:无法${action} ${formatApiFormat(parseEmit[2])} ${subject}`
}
return ''
}
const isConversionDiagnosticMessage = (message: string): boolean => {
const normalized = message.trim()
if (!normalized) return false
return /conversion|converted|convertible|cannot be converted|lossy conversion|unsupported field|unaudited field|invalid enum value|invalid target field|unsupported ai format|failed to (parse|emit) .+ (request|response)|unsupported provider stream (event|finish reason)|转换|无损|字段 .*不支持/i
.test(normalized)
}
const formatAttemptErrorMessage = (message: string, statusCode?: number): string => {
const normalized = message.trim()
if (!normalized) return ''
if (/execution runtime (stream )?returned non-success status \d+/i.test(normalized)) {
const directInternal = normalized.match(/^Internal\("((?:\\.|[^"\\])*)"\)$/i)
if (directInternal?.[1]) {
return formatAttemptErrorMessage(decodeRustDebugString(directInternal[1]), statusCode)
}
const localSyncInternal = normalized.match(/local sync attempt failed before terminal finalization:\s*Internal\("((?:\\.|[^"\\])*)"\)/i)
if (localSyncInternal?.[1]) {
return formatAttemptErrorMessage(decodeRustDebugString(localSyncInternal[1]), statusCode)
}
if (/unsupported provider stream event cannot be converted losslessly/i.test(normalized)) {
return formatUnsupportedStreamEventMessage(normalized)
}
if (/unsupported provider stream finish reason/i.test(normalized)) {
return formatUnsupportedFinishReasonMessage(normalized)
}
const conversionMessage = formatKnownConversionErrorMessage(normalized)
if (conversionMessage) {
return conversionMessage
}
if (isGenericExecutionRuntimeStatusMessage(normalized)) {
return statusCode != null ? `上游返回非成功状态 ${statusCode}` : '上游返回非成功状态'
}
return normalized
}
const shouldShowAttemptMessageWithUpstreamResponse = (
rawMessage: string,
upstreamResponse: Record<string, unknown> | null,
): boolean => {
if (!upstreamResponse) return true
const normalized = rawMessage.trim()
if (!normalized) return false
if (isLocalSyncFinalizeDiagnostic(normalized)) return true
if (isConversionDiagnosticMessage(normalized)) return true
if (isGenericExecutionRuntimeStatusMessage(normalized)) return false
const hasBody = hasRenderableValue(upstreamResponse.body)
const bodyState = (readStringField(upstreamResponse, 'body_state') ?? '').toLowerCase()
return !hasBody && bodyState === 'disabled'
}
const currentAttemptRequestError = computed<{
message: string
statusCode?: number
@@ -1453,17 +1638,177 @@ const currentAttemptRequestError = computed<{
const fallbackType = typeof attempt.error_type === 'string' && attempt.error_type.trim()
? attempt.error_type.trim()
: ''
const message = formatAttemptErrorMessage(flowMessage || fallbackMessage, statusCode) || fallbackType
const diagnosticMessage = extractVisibleDiagnosticMessage(extra)
const rawMessage = chooseAttemptRawErrorMessage(flowMessage || '', fallbackMessage, diagnosticMessage)
const message = formatAttemptErrorMessage(rawMessage, statusCode) || fallbackType
const upstreamResponseDisplay = normalizeUpstreamResponseDisplay(extra?.upstream_response)
if (!message && statusCode == null && !upstreamResponseDisplay) return null
const visibleDiagnosticObjects = extractVisibleDiagnosticObjects(extra)
const shouldAttachDiagnostic = Boolean(
visibleDiagnosticObjects.length
|| isLocalSyncFinalizeDiagnostic(rawMessage)
|| isConversionDiagnosticMessage(rawMessage),
)
const diagnostic = shouldAttachDiagnostic
? buildAttemptDiagnosticPayload(
attempt,
message || fallbackType || rawMessage || '未知失败',
statusCode,
upstreamResponseDisplay,
rawMessage,
)
: null
const upstreamResponseWithDiagnostic = diagnostic
? { ...(upstreamResponseDisplay ?? {}), diagnostic }
: upstreamResponseDisplay
if (!message && statusCode == null && !upstreamResponseWithDiagnostic) return null
const showMessage = shouldShowAttemptMessageWithUpstreamResponse(
rawMessage || fallbackType,
upstreamResponseDisplay,
)
return {
message: upstreamResponseDisplay ? '' : (message || '未知错误'),
message: showMessage ? (message || '未知错误') : '',
statusCode,
upstreamResponse: upstreamResponseDisplay,
upstreamResponse: upstreamResponseWithDiagnostic,
}
})
const diagnosticPathFromObject = (value: unknown): string => {
const object = extractObject(value)
if (!object) return ''
return readStringField(object, 'path')
|| readStringField(object, 'field_path')
|| readStringField(object, 'fieldPath')
|| readStringField(object, 'field')
|| ''
}
const diagnosticFieldPathFromMessage = (message: string): string => {
const normalized = message.trim()
if (!normalized) return ''
const fieldMatch = normalized.match(/field\s+([^;=]+?)\s*(?:=|is unsupported|不支持)/i)
if (fieldMatch?.[1]) return normalizeDiagnosticFieldPath(fieldMatch[1])
const lossyMatch = normalized.match(/lossy conversion blocked from\s+\S+\s+to\s+\S+\s+at\s+([^:]+):/i)
if (lossyMatch?.[1]) return normalizeDiagnosticFieldPath(lossyMatch[1])
const invalidTargetMatch = normalized.match(/invalid target field\s+(.+?)\s+for\s+/i)
if (invalidTargetMatch?.[1]) return normalizeDiagnosticFieldPath(invalidTargetMatch[1])
const unsupportedFieldMatch = normalized.match(/unsupported field\s+(.+?)\s+in\s+/i)
if (unsupportedFieldMatch?.[1]) return normalizeDiagnosticFieldPath(unsupportedFieldMatch[1])
const invalidEnumMatch = normalized.match(/invalid enum value\s+.+?\s+for\s+.+\.([^.\s]+)$/i)
if (invalidEnumMatch?.[1]) return normalizeDiagnosticFieldPath(invalidEnumMatch[1])
return ''
}
function resolveAttemptDiagnosticBreakpoint(attempt: CandidateRecord, rawMessageOverride = ''): string {
const extra = extractObject(attempt.extra_data)
const failureDiagnostic = extractVisibleFailureDiagnostic(extra)
const requestConversionError = extractObject(extra?.request_conversion_error)
const requestBodyBuildError = extractObject(extra?.request_body_build_error)
const errorFlow = extractObject(extra?.error_flow)
const rawMessage = [
rawMessageOverride,
readStringField(errorFlow ?? {}, 'message') ?? '',
readStringField(failureDiagnostic ?? {}, 'message') ?? '',
readStringField(requestConversionError ?? {}, 'message') ?? '',
readStringField(requestBodyBuildError ?? {}, 'message') ?? '',
typeof attempt.error_message === 'string' ? attempt.error_message : '',
].find(item => item.trim()) ?? ''
return diagnosticPathFromObject(failureDiagnostic)
|| diagnosticPathFromObject(requestConversionError)
|| diagnosticPathFromObject(requestBodyBuildError)
|| diagnosticFieldPathFromMessage(rawMessage)
|| '$'
}
function buildAttemptDiagnosticPayload(
attempt: CandidateRecord,
summaryInput: string,
statusCode: number | undefined,
upstreamResponseDisplay: Record<string, unknown> | null,
rawMessageForBreakpoint = '',
): Record<string, unknown> | null {
const extra = extractObject(attempt.extra_data)
const rawFailureDiagnostic = extractVisibleFailureDiagnostic(extra)
const rawRequestConversionError = extractObject(extra?.request_conversion_error)
const rawRequestBodyBuildError = extractObject(extra?.request_body_build_error)
const hasDiagnostic = Boolean(
summaryInput
|| rawFailureDiagnostic
|| rawRequestConversionError
|| rawRequestBodyBuildError
|| attempt.error_message
|| attempt.error_type
|| attempt.skip_reason
|| upstreamResponseDisplay,
)
if (!hasDiagnostic) return null
const summary = summaryInput
|| readStringField(rawFailureDiagnostic ?? {}, 'message')
|| readStringField(rawRequestConversionError ?? {}, 'message')
|| readStringField(rawRequestBodyBuildError ?? {}, 'message')
|| (typeof attempt.error_message === 'string' ? formatAttemptErrorMessage(attempt.error_message, attempt.status_code) : '')
|| attempt.error_type
|| currentAttemptSkipReasonDisplay.value
|| '未知失败'
const breakpoint = resolveAttemptDiagnosticBreakpoint(attempt, rawMessageForBreakpoint)
const providerFormat = readStringField(extra ?? {}, 'provider_api_format')
const clientFormat = readStringField(extra ?? {}, 'client_api_format')
|| (typeof props.requestApiFormat === 'string' ? props.requestApiFormat : '')
const conversionDisplay = clientFormat || providerFormat
? `${clientFormat ? formatApiFormat(clientFormat) : '未知请求格式'} → ${providerFormat ? formatApiFormat(providerFormat) : '未知上游格式'}`
: currentAttemptFormatDisplay.value
const analysisHint = (() => {
const raw = `${summary}\n${rawMessageForBreakpoint}\n${attempt.error_message ?? ''}`.toLowerCase()
if (raw.includes('unsupported provider stream event')) {
return '断点在上游流式事件解析/转换矩阵:先按 breakpoint 对应字段确认 event type,再决定是补 canonical mapping 还是加入 known noop。'
}
if (raw.includes('finish reason')) {
return '断点在 finish_reason 映射:确认该结束原因是否可等价映射;不能无损映射时保持失败闭合。'
}
if (raw.includes('lossy conversion') || raw.includes('无损') || raw.includes('丢失信息') || raw.includes('request_conversion')) {
return '断点在请求/响应格式转换器:检查 breakpoint 字段是否能被目标格式表达,不能表达就需要拒绝、降级或新增显式映射策略。'
}
return '先从 breakpoint 字段开始回放;若 breakpoint 为 $,优先查看 raw.failure_diagnostic / raw.error_message 和 upstream_response。'
})()
const payload = {
summary,
breakpoint,
analysis_hint: analysisHint,
request: {
request_id: trace.value?.request_id ?? attempt.request_id,
path: currentAttemptRequestPathDisplay.value || null,
format_conversion: conversionDisplay || null,
client_api_format: clientFormat || null,
provider_api_format: providerFormat || null,
needs_conversion: extra?.needs_conversion ?? null,
conversion_mode: readStringField(extra ?? {}, 'conversion_mode') ?? null,
},
node: {
candidate_id: attempt.id,
candidate_index: attempt.candidate_index,
retry_index: attempt.retry_index,
provider: attempt.provider_name || attempt.provider_id || null,
key: currentAttemptKeyDisplay.value || null,
status: attempt.status,
status_code: statusCode ?? attempt.status_code ?? null,
skip_reason: attempt.skip_reason ?? null,
error_type: attempt.error_type ?? null,
error_message: attempt.error_message ?? null,
},
raw: {
failure_diagnostic: rawFailureDiagnostic,
request_conversion_error: rawRequestConversionError,
request_body_build_error: rawRequestBodyBuildError,
error_flow: extractObject(extra?.error_flow),
upstream_response: upstreamResponseDisplay ?? normalizeUpstreamResponseDisplay(extra?.upstream_response),
},
}
return payload
}
const currentAttemptExtraDataDisplay = computed<Record<string, unknown> | null>(() => {
const extra = extractObject(currentAttempt.value?.extra_data)
if (!extra) return null
@@ -1727,7 +2072,7 @@ const loadTrace = async (silent = false) => {
error.value = null
try {
internalTrace.value = await requestTraceApi.getRequestTrace(props.requestId)
internalTrace.value = await requestTraceApi.getRequestTrace(props.requestId, { attemptedOnly: true })
} catch (err: unknown) {
if (isAxiosError(err) && err.response?.status === 404) {
internalTrace.value = null
@@ -2765,6 +3110,7 @@ function getDisplayStatus(attempt: CandidateRecord | null | undefined): string {
/* 跳过原因 */
.skip-reason {
margin-top: 1rem;
padding: 0.75rem;
background: hsl(var(--muted) / 0.5);
border-radius: 8px;
display: flex;
@@ -734,6 +734,24 @@
>
<Skeleton class="h-32 w-full" />
</div>
<div
v-else-if="requestBodyLoadErrorVisible"
class="m-4 rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive"
>
<div class="flex items-start gap-2">
<AlertTriangle class="mt-0.5 h-4 w-4 flex-shrink-0" />
<span>{{ bodyLoadErrorMessage }}</span>
</div>
<Button
variant="outline"
size="sm"
class="mt-3 h-8 border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
@click="retryBodyContentLoad"
>
<RefreshCw class="mr-1.5 h-3.5 w-3.5" />
重试
</Button>
</div>
<ConversationView
v-else-if="contentViewMode === 'conversation'"
:render-result="requestRenderResult"
@@ -783,6 +801,24 @@
>
<Skeleton class="h-32 w-full" />
</div>
<div
v-else-if="responseBodyLoadErrorVisible"
class="m-4 rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive"
>
<div class="flex items-start gap-2">
<AlertTriangle class="mt-0.5 h-4 w-4 flex-shrink-0" />
<span>{{ bodyLoadErrorMessage }}</span>
</div>
<Button
variant="outline"
size="sm"
class="mt-3 h-8 border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
@click="retryBodyContentLoad"
>
<RefreshCw class="mr-1.5 h-3.5 w-3.5" />
重试
</Button>
</div>
<ConversationView
v-else-if="contentViewMode === 'conversation'"
:render-result="responseRenderResult"
@@ -843,7 +879,12 @@ import { AlertTriangle, Check, Columns2, RefreshCw, X, Monitor, Server, MessageS
import { dashboardApi, type RequestDetail, type RequestErrorDomain } from '@/api/dashboard'
import type { ImageProgress, RequestTrace } from '@/api/requestTrace'
import { formatApiFormat } from '@/api/endpoints/types/api-format'
import { formatCompactNumber, formatShortRequestId, formatTokens } from '@/utils/format'
import {
formatByteSize,
formatCompactNumber,
formatShortRequestId,
formatTokens,
} from '@/utils/format'
import { log } from '@/utils/logger'
import { getEffectiveInputTokens } from '../token-normalization'
import {
@@ -942,6 +983,8 @@ type PricingTierLike = {
type JsonRecord = Record<string, unknown>
const METADATA_BYTE_FIELD_PATTERN = /(^bytes$|_bytes$|bytes$)/i
type NormalizedErrorDomain = {
source?: string | null
status_code?: number | null
@@ -956,6 +999,29 @@ function asRecord(value: unknown): JsonRecord | null {
return value as JsonRecord
}
function shouldFormatMetadataByteField(key: string, value: unknown): value is number {
return typeof value === 'number'
&& Number.isFinite(value)
&& METADATA_BYTE_FIELD_PATTERN.test(key)
}
function formatMetadataDisplayValue(value: unknown, key = ''): unknown {
if (shouldFormatMetadataByteField(key, value)) {
return formatByteSize(value)
}
if (Array.isArray(value)) {
return value.map(item => formatMetadataDisplayValue(item))
}
if (value && typeof value === 'object') {
return Object.entries(value as Record<string, unknown>)
.reduce<Record<string, unknown>>((formatted, [childKey, childValue]) => {
formatted[childKey] = formatMetadataDisplayValue(childValue, childKey)
return formatted
}, {})
}
return value
}
function normalizeErrorDomain(domain: RequestErrorDomain | null | undefined): NormalizedErrorDomain | null {
if (!domain || typeof domain !== 'object') return null
const message = typeof domain.message === 'string' ? domain.message.trim() : ''
@@ -1130,6 +1196,7 @@ const curlCopying = ref(false)
const curlCopied = ref(false)
const replayDialogOpen = ref(false)
const bodyLoading = ref(false)
const bodyLoadError = ref<string | null>(null)
const bodiesLoadedForRequestId = ref<string | null>(null)
const showTimeline = ref(false)
const AUTO_REFRESH_INTERVAL_MS = 1000
@@ -1236,7 +1303,9 @@ const metadataPanelData = computed<Record<string, unknown> | null>(() => {
merged.settlement = detail.value.settlement
}
return Object.keys(merged).length > 0 ? merged : null
return Object.keys(merged).length > 0
? formatMetadataDisplayValue(merged) as Record<string, unknown>
: null
})
const failureNotice = computed(() => resolveRequestFailureNotice(detail.value))
@@ -1288,6 +1357,36 @@ const isResponseBodyLoading = computed(() => {
return bodyLoading.value && activeTab.value === 'response-body' && !currentResponseBody.value
})
const requestBodyLoadFailed = computed(() => {
const errors = detail.value?.body_load_errors
return Boolean(errors?.request_body || errors?.provider_request_body)
})
const responseBodyLoadFailed = computed(() => {
const errors = detail.value?.body_load_errors
return Boolean(errors?.response_body || errors?.client_response_body)
})
const bodyLoadErrorMessage = computed(() => {
return bodyLoadError.value || '正文内容加载失败,请重试'
})
const requestBodyLoadErrorVisible = computed(() => {
return Boolean(
(bodyLoadError.value || requestBodyLoadFailed.value) &&
activeTab.value === 'request-body' &&
!currentRequestBody.value
)
})
const responseBodyLoadErrorVisible = computed(() => {
return Boolean(
(bodyLoadError.value || responseBodyLoadFailed.value) &&
activeTab.value === 'response-body' &&
!currentResponseBody.value
)
})
function clearTimelineMountTimer() {
if (timelineMountTimer) {
clearTimeout(timelineMountTimer)
@@ -1918,6 +2017,15 @@ function hasContent(data: unknown): boolean {
return true
}
function hasBodyLoadErrors(errors: RequestDetail['body_load_errors'] | null | undefined): boolean {
return Boolean(
errors?.request_body ||
errors?.provider_request_body ||
errors?.response_body ||
errors?.client_response_body
)
}
function toFiniteNumber(value: unknown): number | null {
const num = Number(value)
return Number.isFinite(num) ? num : null
@@ -2118,6 +2226,7 @@ watch(() => props.isOpen, async (isOpen) => {
showTimeline.value = false
clearTimelineMountTimer()
bodyLoading.value = false
bodyLoadError.value = null
bodiesLoadedForRequestId.value = null
}
})
@@ -2131,6 +2240,7 @@ async function ensureBodyContentLoaded() {
const requestId = ++bodyLoadRequestId
bodyLoading.value = true
bodyLoadError.value = null
try {
const response = await dashboardApi.getRequestDetail(props.requestId, { includeBodies: true })
if (requestId !== bodyLoadRequestId || !detail.value) return
@@ -2144,6 +2254,7 @@ async function ensureBodyContentLoaded() {
has_provider_request_body: response.has_provider_request_body,
has_response_body: response.has_response_body,
has_client_response_body: response.has_client_response_body,
body_load_errors: response.body_load_errors,
request_error: response.request_error,
upstream_error: response.upstream_error,
client_error: response.client_error,
@@ -2152,10 +2263,13 @@ async function ensureBodyContentLoaded() {
error_flow: response.error_flow,
scheduling_failure: response.scheduling_failure,
}
bodiesLoadedForRequestId.value = cacheKey
if (!hasBodyLoadErrors(response.body_load_errors)) {
bodiesLoadedForRequestId.value = cacheKey
}
} catch (err) {
if (requestId !== bodyLoadRequestId) return
log.error('Failed to load request bodies:', err)
bodyLoadError.value = '正文内容加载失败,请重试'
} finally {
if (requestId === bodyLoadRequestId) {
bodyLoading.value = false
@@ -2163,6 +2277,11 @@ async function ensureBodyContentLoaded() {
}
}
function retryBodyContentLoad() {
bodyLoadError.value = null
void ensureBodyContentLoaded()
}
async function loadDetail(id: string, silent = false) {
if (silent && loadDetailInFlight) {
return
@@ -2178,6 +2297,7 @@ async function loadDetail(id: string, silent = false) {
clearTimelineMountTimer()
++bodyLoadRequestId
bodyLoading.value = false
bodyLoadError.value = null
}
error.value = null
try {
@@ -366,6 +366,7 @@
<ElapsedTimeText
class="text-primary"
:created-at="record.created_at"
:response-time-updated-at="record.response_time_updated_at ?? null"
:status="getDisplayStatus(record)"
:response-time-ms="record.response_time_ms ?? null"
/>
@@ -987,6 +988,7 @@
<ElapsedTimeText
class="text-primary"
:created-at="record.created_at"
:response-time-updated-at="record.response_time_updated_at ?? null"
:status="getDisplayStatus(record)"
:response-time-ms="record.response_time_ms ?? null"
/>
@@ -0,0 +1,56 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApp, h, nextTick, type App } from 'vue'
import ElapsedTimeText from '../ElapsedTimeText.vue'
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
function mountElapsedTimeText(props: Record<string, unknown>) {
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp({
render: () => h(ElapsedTimeText, props),
})
app.mount(root)
mountedApps.push({ app, root })
return root
}
afterEach(() => {
vi.useRealTimers()
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
})
describe('ElapsedTimeText', () => {
it('uses active response timing from response_time_updated_at instead of stale created_at', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-08T12:00:10.000Z'))
const root = mountElapsedTimeText({
status: 'streaming',
createdAt: '2026-06-08T11:59:00Z',
responseTimeUpdatedAt: '2026-06-08T12:00:06Z',
responseTimeMs: 1500,
})
await nextTick()
expect(root.textContent).toBe('5.50s')
})
it('falls back to created_at when active timing has not reached the backend yet', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-08T12:00:10.000Z'))
const root = mountElapsedTimeText({
status: 'pending',
createdAt: '2026-06-08T12:00:06Z',
responseTimeUpdatedAt: null,
responseTimeMs: null,
})
await nextTick()
expect(root.textContent).toBe('4.00s')
})
})
@@ -570,6 +570,176 @@ describe('HorizontalRequestTimeline', () => {
expect(root.textContent).not.toContain('该错误被标记为敏感上游错误')
})
it('keeps local sync diagnostics visible when upstream response body capture is disabled', async () => {
const trace = buildTrace([
buildCandidate({
id: 'cand-local-sync-diagnostic',
provider_id: 'provider-local-sync',
provider_name: 'Provider Local Sync',
key_id: 'key-local-sync',
key_name: 'Local Sync Key',
candidate_index: 0,
status: 'failed',
status_code: 500,
error_type: 'local_sync_attempt_aborted',
error_message: 'Local sync attempt failed before terminal finalization: Internal("Unsupported provider stream event cannot be converted losslessly: field $.type = \\"response.future.delta\\"; fields: payload, response, type")',
extra_data: {
upstream_response: {
status_code: 500,
headers: { 'content-type': 'application/json' },
body_state: 'disabled',
},
},
}),
])
const root = mountTimeline(trace)
await nextTick()
expect(root.textContent).toContain('错误信息')
expect(root.textContent).toContain('HTTP 500')
expect(root.textContent).toContain('流式格式转换失败')
expect(root.textContent).toContain('上游返回了当前不支持的 stream event')
expect(root.textContent).toContain('字段 $.type = "response.future.delta"')
const errorJsonText = root.querySelector('.error-block .error-json')?.textContent ?? ''
expect(errorJsonText).toContain('"body_state":"disabled"')
expect(errorJsonText).toContain('"diagnostic"')
expect(errorJsonText).toContain('"breakpoint":"$.type"')
expect(errorJsonText).toContain('"analysis_hint"')
expect(errorJsonText).toContain('"raw"')
})
it('formats request conversion diagnostics with field paths on skipped trace nodes', async () => {
const trace = buildTrace([
buildCandidate({
id: 'cand-request-conversion',
provider_id: 'provider-request-conversion',
provider_name: 'Provider Request Conversion',
key_id: 'key-request-conversion',
key_name: 'Request Conversion Key',
candidate_index: 0,
status: 'skipped',
skip_reason: 'provider_request_body_build_failed',
extra_data: {
failure_diagnostic: {
kind: 'request_conversion',
path: '$.n',
message: 'lossy conversion blocked from openai:chat to openai:responses at n: multiple completions cannot be represented losslessly',
safe_to_show: true,
},
},
}),
])
const root = mountTimeline(trace)
await nextTick()
expect(root.textContent).toContain('跳过原因')
expect(root.textContent).toContain('上游请求体转换失败')
expect(root.textContent).toContain('$.n')
expect(root.textContent).toContain('格式转换失败')
expect(root.textContent).toContain('OpenAI Chat → OpenAI Responses')
expect(root.textContent).toContain('字段 $.n 会丢失信息')
expect(root.querySelector('.diagnostic-json-panel')).toBeNull()
})
it('formats unsupported stream finish reasons with the failing field', async () => {
const trace = buildTrace([
buildCandidate({
id: 'cand-finish-reason',
provider_id: 'provider-finish-reason',
provider_name: 'Provider Finish Reason',
key_id: 'key-finish-reason',
key_name: 'Finish Reason Key',
candidate_index: 0,
status: 'failed',
status_code: 500,
error_message: 'Internal("Unsupported provider stream finish reason cannot be converted losslessly: field $.finish_reason = \\"future_reason\\"")',
}),
])
const root = mountTimeline(trace)
await nextTick()
expect(root.textContent).toContain('流式格式转换失败')
expect(root.textContent).toContain('finish reason')
expect(root.textContent).toContain('字段 $.finish_reason = "future_reason"')
const errorJsonText = root.querySelector('.error-block .error-json')?.textContent ?? ''
expect(errorJsonText).toContain('"diagnostic"')
expect(errorJsonText).toContain('"breakpoint":"$.finish_reason"')
})
it('uses conversion messages from error_flow as the diagnostic breakpoint source', async () => {
const trace = buildTrace([
buildCandidate({
id: 'cand-error-flow-conversion',
provider_id: 'provider-error-flow-conversion',
provider_name: 'Provider Error Flow Conversion',
key_id: 'key-error-flow-conversion',
key_name: 'Error Flow Conversion Key',
candidate_index: 0,
status: 'failed',
status_code: 500,
error_message: 'execution runtime stream returned non-success status 500',
extra_data: {
upstream_response: {
status_code: 500,
body_state: 'disabled',
},
error_flow: {
status_code: 500,
message: 'lossy conversion blocked from openai:chat to openai:responses at n: multiple completions cannot be represented losslessly',
},
},
}),
])
const root = mountTimeline(trace)
await nextTick()
expect(root.textContent).toContain('格式转换失败')
expect(root.textContent).toContain('OpenAI Chat → OpenAI Responses')
expect(root.textContent).toContain('字段 $.n 会丢失信息')
expect(root.textContent).not.toContain('上游返回非成功状态 500')
const errorJsonText = root.querySelector('.error-block .error-json')?.textContent ?? ''
expect(errorJsonText).toContain('"body_state":"disabled"')
expect(errorJsonText).toContain('"breakpoint":"$.n"')
expect(errorJsonText).toContain('断点在请求/响应格式转换器')
})
it('shows failed diagnostic messages even when the only response panel data is diagnostic metadata', async () => {
const trace = buildTrace([
buildCandidate({
id: 'cand-diagnostic-only',
provider_id: 'provider-diagnostic-only',
provider_name: 'Provider Diagnostic Only',
key_id: 'key-diagnostic-only',
key_name: 'Diagnostic Only Key',
candidate_index: 0,
status: 'failed',
error_type: 'request_conversion_failed',
extra_data: {
failure_diagnostic: {
kind: 'request_conversion',
path: '$.temperature',
message: 'unsupported field temperature in openai:responses: temperature cannot be represented',
safe_to_show: true,
},
},
}),
])
const root = mountTimeline(trace)
await nextTick()
expect(root.textContent).toContain('错误信息')
expect(root.textContent).toContain('格式转换失败')
expect(root.textContent).toContain('OpenAI Responses 不支持字段 $.temperature')
const errorJsonText = root.querySelector('.error-block .error-json')?.textContent ?? ''
expect(errorJsonText).toContain('"diagnostic"')
expect(errorJsonText).toContain('"breakpoint":"$.temperature"')
})
it('keeps the failure message when upstream response only records an empty body state', async () => {
const trace = buildTrace([
buildCandidate({