mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat(test,export,headers): 模型测试复用统一运行时、实时进度展示、导出增强与请求头大小写保留
- 模型测试 failover 从手动 FailoverEngine 改为 TaskService.execute_sync_candidates 统一运行时 - 前端新增实时 trace 轮询进度展示(候选状态、测试账号、进度条) - 用户导出/导入支持明文 Key 优先(版本升至 1.2),新增 email_verified 字段 - SENSITIVE_CREDENTIAL_FIELDS 统一到 provider_ops/types.py,补充 refresh_token - 请求头大小写保留机制(resolve_header_name_case + HeaderBuilder.add 语义修改) - Codex envelope 移除合成头部,保留客户端原始请求头 - endpoint_checker 支持自定义超时透传 - 新增 x-forwarded-scheme 到上游丢弃头部列表
This commit is contained in:
@@ -34,6 +34,12 @@ export interface OAuthProviderExport {
|
||||
is_enabled?: boolean
|
||||
}
|
||||
|
||||
export interface SystemConfigExport {
|
||||
key: string
|
||||
value: unknown
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
// 配置导出数据结构
|
||||
export interface ConfigExportData {
|
||||
version: string
|
||||
@@ -42,6 +48,7 @@ export interface ConfigExportData {
|
||||
providers: ProviderExport[]
|
||||
ldap_config?: LDAPConfigExport | null
|
||||
oauth_providers?: OAuthProviderExport[]
|
||||
system_configs?: SystemConfigExport[]
|
||||
}
|
||||
|
||||
// 用户导出数据结构
|
||||
@@ -54,6 +61,7 @@ export interface UsersExportData {
|
||||
|
||||
export interface UserExport {
|
||||
email: string
|
||||
email_verified?: boolean
|
||||
username: string
|
||||
password_hash: string
|
||||
role: string
|
||||
@@ -69,6 +77,7 @@ export interface UserExport {
|
||||
}
|
||||
|
||||
export interface UserApiKeyExport {
|
||||
key?: string | null
|
||||
key_hash: string
|
||||
key_encrypted?: string | null
|
||||
name?: string | null
|
||||
@@ -105,14 +114,18 @@ export interface ProviderExport {
|
||||
name: string
|
||||
description?: string | null
|
||||
website?: string | null
|
||||
provider_type?: string
|
||||
billing_type?: string | null
|
||||
monthly_quota_usd?: number | null
|
||||
quota_reset_day?: number
|
||||
rpm_limit?: number | null
|
||||
provider_priority?: number
|
||||
keep_priority_on_conversion?: boolean
|
||||
enable_format_conversion?: boolean
|
||||
is_active: boolean
|
||||
concurrent_limit?: number | null
|
||||
max_retries?: number | null
|
||||
stream_first_byte_timeout?: number | null
|
||||
request_timeout?: number | null
|
||||
proxy?: Record<string, unknown>
|
||||
config?: Record<string, unknown>
|
||||
endpoints: EndpointExport[]
|
||||
@@ -123,19 +136,24 @@ export interface ProviderExport {
|
||||
export interface EndpointExport {
|
||||
api_format: string
|
||||
base_url: string
|
||||
headers?: Record<string, unknown>
|
||||
header_rules?: Record<string, unknown>[] | null
|
||||
body_rules?: Record<string, unknown>[] | null
|
||||
max_retries?: number
|
||||
is_active: boolean
|
||||
custom_path?: string | null
|
||||
config?: Record<string, unknown>
|
||||
format_acceptance_config?: Record<string, unknown> | null
|
||||
proxy?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface ProviderKeyExport {
|
||||
api_key: string
|
||||
auth_type?: string
|
||||
auth_config?: string | Record<string, unknown> | null
|
||||
name?: string | null
|
||||
note?: string | null
|
||||
api_formats: string[]
|
||||
supported_endpoints?: string[]
|
||||
rate_multipliers?: Record<string, number> | null
|
||||
internal_priority?: number
|
||||
global_priority_by_format?: Record<string, number> | null
|
||||
@@ -144,7 +162,13 @@ export interface ProviderKeyExport {
|
||||
capabilities?: Record<string, boolean>
|
||||
cache_ttl_minutes?: number
|
||||
max_probe_interval_minutes?: number
|
||||
auto_fetch_models?: boolean
|
||||
locked_models?: string[] | null
|
||||
model_include_patterns?: string[] | null
|
||||
model_exclude_patterns?: string[] | null
|
||||
is_active: boolean
|
||||
proxy?: Record<string, unknown> | null
|
||||
fingerprint?: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
export interface ModelExport {
|
||||
|
||||
@@ -131,7 +131,9 @@ export interface TestModelResponse {
|
||||
}
|
||||
|
||||
export async function testModel(data: TestModelRequest): Promise<TestModelResponse> {
|
||||
const response = await client.post('/api/admin/provider-query/test-model', data)
|
||||
const response = await client.post('/api/admin/provider-query/test-model', data, {
|
||||
timeout: 10 * 60 * 1000,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -145,10 +147,12 @@ export interface TestModelFailoverRequest {
|
||||
api_format?: string
|
||||
endpoint_id?: string
|
||||
message?: string
|
||||
request_id?: string
|
||||
}
|
||||
|
||||
export interface TestAttemptDetail {
|
||||
candidate_index: number
|
||||
retry_index?: number
|
||||
endpoint_api_format: string
|
||||
endpoint_base_url: string
|
||||
key_name: string | null
|
||||
@@ -174,7 +178,9 @@ export interface TestModelFailoverResponse {
|
||||
}
|
||||
|
||||
export async function testModelFailover(data: TestModelFailoverRequest): Promise<TestModelFailoverResponse> {
|
||||
const response = await client.post('/api/admin/provider-query/test-model-failover', data)
|
||||
const response = await client.post('/api/admin/provider-query/test-model-failover', data, {
|
||||
timeout: 10 * 60 * 1000,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface CandidateRecord {
|
||||
endpoint_name?: string // 端点显示名称(api_format)
|
||||
key_id?: string
|
||||
key_name?: string // 密钥名称
|
||||
key_account_label?: string // 更适合展示的测试账号标签(优先 OAuth 邮箱)
|
||||
key_preview?: string // 密钥脱敏预览(如 sk-***abc),OAuth 类型不返回
|
||||
key_auth_type?: string // 密钥认证类型(api_key, service_account, oauth 等)
|
||||
key_oauth_plan_type?: string // OAuth 账号套餐类型(free/plus/team/enterprise)
|
||||
|
||||
@@ -41,22 +41,138 @@
|
||||
|
||||
<div
|
||||
v-else-if="testing"
|
||||
class="flex flex-col items-center justify-center gap-3 py-10 text-center"
|
||||
class="space-y-4 py-6"
|
||||
>
|
||||
<Loader2 class="w-8 h-8 animate-spin text-primary" />
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium">
|
||||
正在测试模型
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ selectingModelName || '-' }}
|
||||
</p>
|
||||
<p
|
||||
v-if="selectedEndpoint"
|
||||
class="text-xs text-muted-foreground"
|
||||
<div class="flex flex-col items-center justify-center gap-3 text-center">
|
||||
<Loader2 class="w-8 h-8 animate-spin text-primary" />
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium">
|
||||
正在测试模型
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ selectingModelName || '-' }}
|
||||
</p>
|
||||
<p
|
||||
v-if="selectedEndpoint"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
端点:{{ formatApiFormat(selectedEndpoint.api_format) }} · {{ selectedEndpoint.base_url }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-border/60 bg-muted/20 p-4 space-y-4">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||
<span>实时进度</span>
|
||||
<span>{{ liveTraceSummary.completed }}/{{ liveTraceSummary.total || 0 }}</span>
|
||||
</div>
|
||||
<div class="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
class="h-full bg-primary transition-all duration-300"
|
||||
:style="{ width: `${liveProgressPercent}%` }"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
待执行 {{ liveTraceSummary.available }}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
进行中 {{ liveTraceSummary.pending }}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="success"
|
||||
class="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
成功 {{ liveTraceSummary.success }}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="destructive"
|
||||
class="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
失败 {{ liveTraceSummary.failed }}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
跳过 {{ liveTraceSummary.skipped }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div class="rounded-md border border-border/60 bg-background/80 p-3 space-y-1">
|
||||
<div class="text-xs text-muted-foreground">
|
||||
测试账号
|
||||
</div>
|
||||
<div class="text-sm font-medium break-all">
|
||||
{{ liveAccountTitle }}
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground break-all">
|
||||
{{ liveAccountMeta }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-md border border-border/60 bg-background/80 p-3 space-y-1">
|
||||
<div class="text-xs text-muted-foreground">
|
||||
实时状态
|
||||
</div>
|
||||
<div class="text-sm font-medium">
|
||||
{{ liveStatusTitle }}
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground break-all">
|
||||
{{ liveStatusDetail }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="requestId"
|
||||
class="text-[11px] text-muted-foreground break-all"
|
||||
>
|
||||
端点:{{ formatApiFormat(selectedEndpoint.api_format) }} · {{ selectedEndpoint.base_url }}
|
||||
</p>
|
||||
请求 ID:<code class="bg-muted px-1 py-0.5 rounded">{{ requestId }}</code>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="liveRecentCandidates.length > 0"
|
||||
class="space-y-2"
|
||||
>
|
||||
<div class="text-xs font-medium text-muted-foreground">
|
||||
最近状态
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="candidate in liveRecentCandidates"
|
||||
:key="`${candidate.id}-${candidate.status}`"
|
||||
class="flex items-start justify-between gap-3 rounded-md border border-border/50 bg-background/70 px-3 py-2 text-xs"
|
||||
>
|
||||
<div class="min-w-0 space-y-1">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-muted-foreground shrink-0">{{ formatTraceCandidateIndex(candidate) }}</span>
|
||||
<Badge
|
||||
:variant="statusVariant(candidate.status)"
|
||||
class="text-[10px] px-1.5 py-0 shrink-0"
|
||||
>
|
||||
{{ candidate.status_code || statusLabel(candidate.status) }}
|
||||
</Badge>
|
||||
<span class="truncate font-medium">{{ formatTraceCandidateAccount(candidate) }}</span>
|
||||
</div>
|
||||
<div class="text-muted-foreground break-all">
|
||||
{{ traceCandidateDetail(candidate) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0 text-muted-foreground tabular-nums">
|
||||
{{ candidate.latency_ms != null ? `${candidate.latency_ms}ms` : '' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -118,7 +234,7 @@
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1.5 min-w-0">
|
||||
<span class="text-muted-foreground shrink-0">#{{ attempt.candidate_index }}</span>
|
||||
<span class="text-muted-foreground shrink-0">{{ formatAttemptIndex(attempt) }}</span>
|
||||
<Badge
|
||||
:variant="statusVariant(attempt.status)"
|
||||
class="text-[10px] px-1.5 py-0 shrink-0"
|
||||
@@ -209,7 +325,7 @@
|
||||
:class="attemptRowClass(attempt.status)"
|
||||
>
|
||||
<td class="pl-3 pr-1 py-2 text-muted-foreground">
|
||||
{{ attempt.candidate_index }}
|
||||
{{ formatAttemptIndex(attempt) }}
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<div
|
||||
@@ -292,6 +408,7 @@ import { Dialog, Badge } from '@/components/ui'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
import type { TestModelFailoverResponse, TestAttemptDetail } from '@/api/endpoints/providers'
|
||||
import type { CandidateRecord, RequestTrace } from '@/api/requestTrace'
|
||||
|
||||
type TestEndpointOption = {
|
||||
id: string
|
||||
@@ -308,6 +425,8 @@ const props = defineProps<{
|
||||
endpoints?: TestEndpointOption[]
|
||||
selectedEndpoint?: TestEndpointOption | null
|
||||
testing?: boolean
|
||||
trace?: RequestTrace | null
|
||||
requestId?: string | null
|
||||
showEndpointSelector?: boolean
|
||||
}>()
|
||||
|
||||
@@ -318,6 +437,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const endpoints = computed(() => props.endpoints ?? [])
|
||||
const traceCandidates = computed(() => props.trace?.candidates ?? [])
|
||||
const showSelection = computed(() => props.open && !!props.showEndpointSelector && !props.testing && !props.result)
|
||||
const showResult = computed(() => !!props.result)
|
||||
const canReselect = computed(() => !!props.showEndpointSelector && endpoints.value.length > 1)
|
||||
@@ -354,6 +474,82 @@ const hasEffectiveModel = computed(() => {
|
||||
return props.result.attempts.some(a => a.effective_model && a.effective_model !== props.result?.model)
|
||||
})
|
||||
|
||||
const liveTraceSummary = computed(() => {
|
||||
const summary = {
|
||||
total: traceCandidates.value.length,
|
||||
available: 0,
|
||||
pending: 0,
|
||||
success: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
completed: 0,
|
||||
}
|
||||
|
||||
for (const candidate of traceCandidates.value) {
|
||||
if (candidate.status === 'available' || candidate.status === 'unused') summary.available += 1
|
||||
if (candidate.status === 'pending' || candidate.status === 'streaming') summary.pending += 1
|
||||
if (candidate.status === 'success') summary.success += 1
|
||||
if (candidate.status === 'failed' || candidate.status === 'cancelled' || candidate.status === 'stream_interrupted') summary.failed += 1
|
||||
if (candidate.status === 'skipped') summary.skipped += 1
|
||||
}
|
||||
|
||||
summary.completed = summary.success + summary.failed + summary.skipped
|
||||
return summary
|
||||
})
|
||||
|
||||
const liveProgressPercent = computed(() => {
|
||||
if (liveTraceSummary.value.total <= 0) return 6
|
||||
const raw = Math.round((liveTraceSummary.value.completed / liveTraceSummary.value.total) * 100)
|
||||
return Math.min(100, Math.max(raw, liveTraceSummary.value.pending > 0 ? 12 : 6))
|
||||
})
|
||||
|
||||
const activeTraceCandidate = computed(() => {
|
||||
const preferredStatuses = ['pending', 'streaming', 'failed', 'success', 'skipped', 'cancelled']
|
||||
for (let index = traceCandidates.value.length - 1; index >= 0; index -= 1) {
|
||||
const candidate = traceCandidates.value[index]
|
||||
if (preferredStatuses.includes(candidate.status)) return candidate
|
||||
}
|
||||
return traceCandidates.value[0] ?? null
|
||||
})
|
||||
|
||||
const liveAccountTitle = computed(() => {
|
||||
const candidate = activeTraceCandidate.value
|
||||
if (!candidate) return '等待分配测试账号'
|
||||
return candidate.key_account_label || candidate.key_name || candidate.key_preview || '等待分配测试账号'
|
||||
})
|
||||
|
||||
const liveAccountMeta = computed(() => {
|
||||
const candidate = activeTraceCandidate.value
|
||||
if (!candidate) return '候选创建后会显示测试账号和认证方式'
|
||||
const parts: string[] = []
|
||||
if (candidate.key_auth_type) parts.push(formatAuthType(candidate.key_auth_type))
|
||||
if (candidate.key_oauth_plan_type) parts.push(candidate.key_oauth_plan_type)
|
||||
if (candidate.key_preview && candidate.key_preview !== candidate.key_account_label) parts.push(candidate.key_preview)
|
||||
return parts.join(' · ') || '正在等待候选进入执行阶段'
|
||||
})
|
||||
|
||||
const liveStatusTitle = computed(() => {
|
||||
const candidate = activeTraceCandidate.value
|
||||
if (!candidate) return '正在创建测试请求'
|
||||
if (candidate.status === 'pending' || candidate.status === 'streaming') {
|
||||
return `正在测试 ${formatTraceCandidateIndex(candidate)}`
|
||||
}
|
||||
return statusLabel(candidate.status)
|
||||
})
|
||||
|
||||
const liveStatusDetail = computed(() => {
|
||||
const candidate = activeTraceCandidate.value
|
||||
if (!candidate) return '等待后端写入候选状态'
|
||||
return traceCandidateDetail(candidate)
|
||||
})
|
||||
|
||||
const liveRecentCandidates = computed(() => {
|
||||
return traceCandidates.value
|
||||
.filter(candidate => !['available', 'unused'].includes(candidate.status))
|
||||
.slice(-4)
|
||||
.reverse()
|
||||
})
|
||||
|
||||
function statusVariant(status: string) {
|
||||
if (status === 'success') return 'success' as const
|
||||
if (status === 'failed') return 'destructive' as const
|
||||
@@ -364,6 +560,11 @@ function statusLabel(status: string) {
|
||||
if (status === 'success') return '成功'
|
||||
if (status === 'failed') return '失败'
|
||||
if (status === 'skipped') return '跳过'
|
||||
if (status === 'pending') return '等待中'
|
||||
if (status === 'streaming') return '测试中'
|
||||
if (status === 'cancelled') return '已取消'
|
||||
if (status === 'stream_interrupted') return '流中断'
|
||||
if (status === 'available') return '待执行'
|
||||
return status
|
||||
}
|
||||
|
||||
@@ -379,6 +580,37 @@ function maskKey(key: string): string {
|
||||
return `${key.slice(0, 4)}...${key.slice(-4)}`
|
||||
}
|
||||
|
||||
function formatAuthType(authType: string): string {
|
||||
const lowered = authType.toLowerCase()
|
||||
if (lowered === 'api_key') return 'API Key'
|
||||
if (lowered === 'service_account') return 'Service Account'
|
||||
if (lowered === 'oauth') return 'OAuth'
|
||||
if (lowered === 'codex') return 'Codex OAuth'
|
||||
if (lowered === 'antigravity') return 'Antigravity OAuth'
|
||||
if (lowered === 'kiro') return 'Kiro OAuth'
|
||||
return authType
|
||||
}
|
||||
|
||||
function formatAttemptIndex(attempt: TestAttemptDetail): string {
|
||||
const retryIndex = attempt.retry_index ?? 0
|
||||
return retryIndex > 0 ? `#${attempt.candidate_index}.${retryIndex}` : `#${attempt.candidate_index}`
|
||||
}
|
||||
|
||||
function formatTraceCandidateIndex(candidate: CandidateRecord): string {
|
||||
return candidate.retry_index > 0 ? `#${candidate.candidate_index}.${candidate.retry_index}` : `#${candidate.candidate_index}`
|
||||
}
|
||||
|
||||
function formatTraceCandidateAccount(candidate: CandidateRecord): string {
|
||||
return candidate.key_account_label || candidate.key_name || candidate.key_preview || '待分配账号'
|
||||
}
|
||||
|
||||
function traceCandidateDetail(candidate: CandidateRecord): string {
|
||||
if (candidate.skip_reason) return candidate.skip_reason
|
||||
if (candidate.error_message) return candidate.error_message
|
||||
if (candidate.endpoint_name) return `端点:${formatApiFormat(candidate.endpoint_name)}`
|
||||
return '等待响应中…'
|
||||
}
|
||||
|
||||
function attemptDetail(attempt: TestAttemptDetail): string {
|
||||
if (attempt.skip_reason) return attempt.skip_reason
|
||||
if (attempt.error_message) return attempt.error_message
|
||||
|
||||
@@ -219,6 +219,8 @@
|
||||
:endpoints="activeEndpoints"
|
||||
:selected-endpoint="selectedTestEndpoint"
|
||||
:testing="!!pendingTestModel && testingModelId === pendingTestModel.id"
|
||||
:trace="testTrace"
|
||||
:request-id="currentTestRequestId"
|
||||
:show-endpoint-selector="activeEndpoints.length > 1"
|
||||
@close="handleTestDialogClose"
|
||||
@back="handleTestDialogBack"
|
||||
@@ -227,7 +229,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, onBeforeUnmount } from 'vue'
|
||||
import { isAxiosError } from 'axios'
|
||||
import { useSmartPagination } from '@/composables/useSmartPagination'
|
||||
import { Box, Edit, Layers, Power, Copy, Loader2, Play } from 'lucide-vue-next'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
@@ -242,6 +245,7 @@ import {
|
||||
type TestModelFailoverResponse,
|
||||
} from '@/api/endpoints'
|
||||
import { updateModel } from '@/api/endpoints/models'
|
||||
import { requestTraceApi, type RequestTrace } from '@/api/requestTrace'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
|
||||
@@ -272,6 +276,10 @@ const testResultMode = ref<'global' | 'direct'>('global')
|
||||
const testDialogOpen = ref(false)
|
||||
const pendingTestModel = ref<Model | null>(null)
|
||||
const selectedTestEndpoint = ref<ProviderEndpoint | null>(null)
|
||||
const currentTestRequestId = ref<string | null>(null)
|
||||
const testTrace = ref<RequestTrace | null>(null)
|
||||
let tracePollTimer: ReturnType<typeof setInterval> | null = null
|
||||
let tracePollToken = 0
|
||||
// 使用 props 传入的数据,或使用本地数据
|
||||
const activeEndpoints = computed(() => (props.endpoints ?? []).filter(endpoint => endpoint.is_active))
|
||||
// 使用 props 传入的数据,或使用本地数据
|
||||
@@ -304,6 +312,47 @@ function refresh() {
|
||||
emit('refresh')
|
||||
}
|
||||
|
||||
function buildTestRequestId(): string {
|
||||
const randomUUID = globalThis.crypto?.randomUUID?.bind(globalThis.crypto)
|
||||
if (randomUUID) {
|
||||
return `provider-test-${randomUUID().replace(/-/g, '').slice(0, 20)}`
|
||||
}
|
||||
return `provider-test-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
|
||||
async function pollTestTrace(requestId: string, token: number) {
|
||||
try {
|
||||
const trace = await requestTraceApi.getRequestTrace(requestId, { attemptedOnly: false })
|
||||
if (tracePollToken !== token || currentTestRequestId.value !== requestId) return
|
||||
testTrace.value = trace
|
||||
} catch (err: unknown) {
|
||||
if (isAxiosError(err) && err.response?.status === 404) return
|
||||
}
|
||||
}
|
||||
|
||||
function stopTestTracePolling(options: { clearState?: boolean } = {}) {
|
||||
tracePollToken += 1
|
||||
if (tracePollTimer) {
|
||||
clearInterval(tracePollTimer)
|
||||
tracePollTimer = null
|
||||
}
|
||||
if (options.clearState !== false) {
|
||||
currentTestRequestId.value = null
|
||||
testTrace.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function startTestTracePolling(requestId: string) {
|
||||
stopTestTracePolling()
|
||||
currentTestRequestId.value = requestId
|
||||
testTrace.value = null
|
||||
const token = ++tracePollToken
|
||||
void pollTestTrace(requestId, token)
|
||||
tracePollTimer = setInterval(() => {
|
||||
void pollTestTrace(requestId, token)
|
||||
}, 800)
|
||||
}
|
||||
|
||||
// 格式化价格显示
|
||||
function formatPrice(price: number | null | undefined): string {
|
||||
if (price === null || price === undefined) return '-'
|
||||
@@ -437,6 +486,7 @@ async function toggleModelActive(model: Model) {
|
||||
}
|
||||
|
||||
function resetTestDialogState() {
|
||||
stopTestTracePolling()
|
||||
testDialogOpen.value = false
|
||||
pendingTestModel.value = null
|
||||
selectedTestEndpoint.value = null
|
||||
@@ -466,6 +516,8 @@ async function runModelTest(model: Model, endpoint?: ProviderEndpoint) {
|
||||
testingModelId.value = model.id
|
||||
testDialogOpen.value = true
|
||||
selectedTestEndpoint.value = endpoint ?? null
|
||||
const requestId = buildTestRequestId()
|
||||
startTestTracePolling(requestId)
|
||||
try {
|
||||
const modelName = model.global_model_name || model.provider_model_name
|
||||
|
||||
@@ -476,6 +528,7 @@ async function runModelTest(model: Model, endpoint?: ProviderEndpoint) {
|
||||
api_format: endpoint?.api_format,
|
||||
endpoint_id: endpoint?.id,
|
||||
message: 'hello',
|
||||
request_id: requestId,
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
@@ -489,9 +542,11 @@ async function runModelTest(model: Model, endpoint?: ProviderEndpoint) {
|
||||
resetTestDialogState()
|
||||
return
|
||||
}
|
||||
stopTestTracePolling({ clearState: false })
|
||||
testResultMode.value = 'global'
|
||||
testResult.value = result
|
||||
} catch (err: unknown) {
|
||||
stopTestTracePolling()
|
||||
showError(`模型测试失败: ${parseApiError(err, '测试请求失败')}`)
|
||||
if (activeEndpoints.value.length <= 1) {
|
||||
resetTestDialogState()
|
||||
@@ -526,4 +581,8 @@ async function testModelConnection(model: Model) {
|
||||
defineExpose({
|
||||
reload: refresh
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopTestTracePolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1.5 min-w-0">
|
||||
<span class="text-muted-foreground shrink-0">#{{ attempt.candidate_index }}</span>
|
||||
<span class="text-muted-foreground shrink-0">{{ formatAttemptIndex(attempt) }}</span>
|
||||
<Badge
|
||||
:variant="statusVariant(attempt.status)"
|
||||
class="text-[10px] px-1.5 py-0 shrink-0"
|
||||
@@ -154,7 +154,7 @@
|
||||
:class="attemptRowClass(attempt.status)"
|
||||
>
|
||||
<td class="pl-3 pr-1 py-2 text-muted-foreground">
|
||||
{{ attempt.candidate_index }}
|
||||
{{ formatAttemptIndex(attempt) }}
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<div
|
||||
@@ -267,6 +267,11 @@ function statusLabel(status: string) {
|
||||
if (status === 'success') return '成功'
|
||||
if (status === 'failed') return '失败'
|
||||
if (status === 'skipped') return '跳过'
|
||||
if (status === 'pending') return '等待中'
|
||||
if (status === 'streaming') return '测试中'
|
||||
if (status === 'cancelled') return '已取消'
|
||||
if (status === 'stream_interrupted') return '流中断'
|
||||
if (status === 'available') return '待执行'
|
||||
return status
|
||||
}
|
||||
|
||||
@@ -282,6 +287,11 @@ function maskKey(key: string): string {
|
||||
return `${key.slice(0, 4)}...${key.slice(-4)}`
|
||||
}
|
||||
|
||||
function formatAttemptIndex(attempt: TestAttemptDetail): string {
|
||||
const retryIndex = attempt.retry_index ?? 0
|
||||
return retryIndex > 0 ? `#${attempt.candidate_index}.${retryIndex}` : `#${attempt.candidate_index}`
|
||||
}
|
||||
|
||||
function attemptDetail(attempt: TestAttemptDetail): string {
|
||||
if (attempt.skip_reason) return attempt.skip_reason
|
||||
if (attempt.error_message) return attempt.error_message
|
||||
|
||||
Reference in New Issue
Block a user