mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat(test,quota,failover): 模型并发测试、统一配额读取器与故障转移取消支持
- 新增 QuotaReader 抽象层,统一 Codex/Kiro/Antigravity 配额解析逻辑,
替换 pool/routes.py 中分散的配额构建函数
- 模型测试支持并发执行多候选,前端新增 useModelTest composable 统一
ModelsTab 和 ModelMappingTab 的测试逻辑
- ModelTestDialog 增加结果概览摘要、超长结果折叠、端点列和新状态支持,
删除已合并的 TestResultDialog
- FailoverEngine 新增客户端断开检测,支持取消剩余候选并标记记录
- 刷新配额改为分批执行,直连测试候选按可用性排序
- 修复 error 判断从 "error" in dict 改为 dict.get("error") 避免误判
This commit is contained in:
@@ -202,7 +202,11 @@ export async function refreshProviderQuota(
|
||||
keyIds?: string[],
|
||||
): Promise<RefreshQuotaResult> {
|
||||
const body = keyIds && keyIds.length > 0 ? { key_ids: keyIds } : undefined
|
||||
const response = await client.post(`/api/admin/endpoints/providers/${providerId}/refresh-quota`, body)
|
||||
const response = await client.post(
|
||||
`/api/admin/endpoints/providers/${providerId}/refresh-quota`,
|
||||
body,
|
||||
{ timeout: 5 * 60 * 1000 },
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
|
||||
@@ -148,6 +148,7 @@ export interface TestModelFailoverRequest {
|
||||
endpoint_id?: string
|
||||
message?: string
|
||||
request_id?: string
|
||||
concurrency?: number
|
||||
}
|
||||
|
||||
export interface TestAttemptDetail {
|
||||
@@ -159,7 +160,7 @@ export interface TestAttemptDetail {
|
||||
key_id: string
|
||||
auth_type: string
|
||||
effective_model?: string | null
|
||||
status: 'success' | 'failed' | 'skipped'
|
||||
status: 'success' | 'failed' | 'skipped' | 'cancelled' | 'pending' | 'streaming' | 'stream_interrupted' | 'available' | 'unused'
|
||||
skip_reason?: string | null
|
||||
error_message?: string | null
|
||||
status_code?: number | null
|
||||
@@ -177,9 +178,13 @@ export interface TestModelFailoverResponse {
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
export async function testModelFailover(data: TestModelFailoverRequest): Promise<TestModelFailoverResponse> {
|
||||
export async function testModelFailover(
|
||||
data: TestModelFailoverRequest,
|
||||
options: { signal?: AbortSignal } = {}
|
||||
): Promise<TestModelFailoverResponse> {
|
||||
const response = await client.post('/api/admin/provider-query/test-model-failover', data, {
|
||||
timeout: 10 * 60 * 1000,
|
||||
signal: options.signal,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
183
frontend/src/composables/useModelTest.ts
Normal file
183
frontend/src/composables/useModelTest.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { ref, onBeforeUnmount } from 'vue'
|
||||
import { isAxiosError } from 'axios'
|
||||
import { useToast } from './useToast'
|
||||
import {
|
||||
testModelFailover,
|
||||
type TestModelFailoverResponse,
|
||||
} from '@/api/endpoints/providers'
|
||||
import { requestTraceApi, type RequestTrace } from '@/api/requestTrace'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
|
||||
export interface StartTestParams {
|
||||
mode: 'global' | 'direct'
|
||||
modelName: string
|
||||
displayLabel: string
|
||||
apiFormat?: string
|
||||
endpointId?: string
|
||||
message?: string
|
||||
concurrency?: number
|
||||
onSuccess?: (result: TestModelFailoverResponse) => void
|
||||
/** Return `true` to indicate the failure has been handled; otherwise the composable sets `testResult`. */
|
||||
onFailure?: (result: TestModelFailoverResponse) => boolean | void
|
||||
/** Return `true` to indicate the error has been handled; otherwise a toast is shown and state is reset. */
|
||||
onError?: (err: unknown) => boolean | void
|
||||
}
|
||||
|
||||
export interface UseModelTestOptions {
|
||||
providerId: () => string
|
||||
pollInterval?: number
|
||||
}
|
||||
|
||||
export function useModelTest(options: UseModelTestOptions) {
|
||||
const { providerId, pollInterval = 800 } = options
|
||||
const { success: showSuccess, error: showError } = useToast()
|
||||
|
||||
const testing = ref(false)
|
||||
const testMode = ref<'global' | 'direct'>('global')
|
||||
const testResult = ref<TestModelFailoverResponse | null>(null)
|
||||
const testTrace = ref<RequestTrace | null>(null)
|
||||
const requestId = ref<string | null>(null)
|
||||
const dialogOpen = ref(false)
|
||||
|
||||
let tracePollTimer: ReturnType<typeof setInterval> | null = null
|
||||
let tracePollToken = 0
|
||||
let activeAbortController: AbortController | null = null
|
||||
|
||||
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(reqId: string, token: number) {
|
||||
try {
|
||||
const trace = await requestTraceApi.getRequestTrace(reqId, { attemptedOnly: false })
|
||||
if (tracePollToken !== token || requestId.value !== reqId) return
|
||||
testTrace.value = trace
|
||||
} catch (err: unknown) {
|
||||
if (isAxiosError(err) && err.response?.status === 404) return
|
||||
}
|
||||
}
|
||||
|
||||
function stopPolling(opts: { clearState?: boolean } = {}) {
|
||||
tracePollToken += 1
|
||||
if (tracePollTimer) {
|
||||
clearInterval(tracePollTimer)
|
||||
tracePollTimer = null
|
||||
}
|
||||
if (opts.clearState !== false) {
|
||||
requestId.value = null
|
||||
testTrace.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling(reqId: string) {
|
||||
stopPolling()
|
||||
requestId.value = reqId
|
||||
testTrace.value = null
|
||||
const token = ++tracePollToken
|
||||
void pollTestTrace(reqId, token)
|
||||
tracePollTimer = setInterval(() => {
|
||||
void pollTestTrace(reqId, token)
|
||||
}, pollInterval)
|
||||
}
|
||||
|
||||
function abortActiveRequest() {
|
||||
if (!activeAbortController) return
|
||||
activeAbortController.abort()
|
||||
activeAbortController = null
|
||||
}
|
||||
|
||||
function isRequestCancelled(err: unknown): boolean {
|
||||
if (isAxiosError(err)) {
|
||||
return err.code === 'ERR_CANCELED'
|
||||
}
|
||||
return err instanceof DOMException && err.name === 'AbortError'
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
abortActiveRequest()
|
||||
stopPolling()
|
||||
dialogOpen.value = false
|
||||
testResult.value = null
|
||||
}
|
||||
|
||||
async function startTest(params: StartTestParams) {
|
||||
abortActiveRequest()
|
||||
testing.value = true
|
||||
testMode.value = params.mode
|
||||
dialogOpen.value = true
|
||||
testResult.value = null
|
||||
|
||||
const abortController = new AbortController()
|
||||
activeAbortController = abortController
|
||||
const reqId = buildTestRequestId()
|
||||
startPolling(reqId)
|
||||
|
||||
try {
|
||||
const result = await testModelFailover({
|
||||
provider_id: providerId(),
|
||||
mode: params.mode,
|
||||
model_name: params.modelName,
|
||||
api_format: params.apiFormat,
|
||||
endpoint_id: params.endpointId,
|
||||
message: params.message ?? 'hello',
|
||||
request_id: reqId,
|
||||
concurrency: params.concurrency,
|
||||
}, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
const successAttempt = result.attempts.find(a => a.status === 'success')
|
||||
const latency = successAttempt?.latency_ms != null ? ` (${successAttempt.latency_ms}ms)` : ''
|
||||
const mapped = successAttempt?.effective_model && successAttempt.effective_model !== params.modelName
|
||||
? ` -> ${successAttempt.effective_model}`
|
||||
: ''
|
||||
params.onSuccess?.(result)
|
||||
showSuccess(`${params.displayLabel}${mapped} 测试成功${latency}`)
|
||||
resetState()
|
||||
return
|
||||
}
|
||||
|
||||
stopPolling({ clearState: false })
|
||||
const handled = params.onFailure?.(result)
|
||||
if (!handled) {
|
||||
testResult.value = result
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (isRequestCancelled(err)) {
|
||||
return
|
||||
}
|
||||
stopPolling()
|
||||
const handled = params.onError?.(err)
|
||||
if (!handled) {
|
||||
showError(`模型测试失败: ${parseApiError(err, '测试请求失败')}`)
|
||||
resetState()
|
||||
}
|
||||
} finally {
|
||||
if (activeAbortController === abortController) {
|
||||
activeAbortController = null
|
||||
}
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
return {
|
||||
testing,
|
||||
testMode,
|
||||
testResult,
|
||||
testTrace,
|
||||
requestId,
|
||||
dialogOpen,
|
||||
startTest,
|
||||
resetState,
|
||||
stopPolling,
|
||||
}
|
||||
}
|
||||
@@ -600,11 +600,25 @@ async function executeAction(): Promise<void> {
|
||||
try {
|
||||
if (selectedAction.value === 'refresh_quota') {
|
||||
const targetIds = selectedKeys.map((key) => key.key_id)
|
||||
const result = await refreshProviderQuota(props.providerId, targetIds)
|
||||
successCount = Number(result.success || 0)
|
||||
failedCount = Number(result.failed || 0)
|
||||
skippedCount = Math.max(0, targetIds.length - Number(result.total || 0))
|
||||
progressDone.value = targetIds.length
|
||||
const BATCH_SIZE = 20
|
||||
const totalBatches = Math.ceil(targetIds.length / BATCH_SIZE)
|
||||
|
||||
for (let i = 0; i < targetIds.length; i += BATCH_SIZE) {
|
||||
const batchIndex = Math.floor(i / BATCH_SIZE) + 1
|
||||
const batch = targetIds.slice(i, i + BATCH_SIZE)
|
||||
progressLabel.value = `正在${actionLabel}...(第 ${batchIndex}/${totalBatches} 批)`
|
||||
|
||||
try {
|
||||
const result = await refreshProviderQuota(props.providerId, batch)
|
||||
successCount += Number(result.success || 0)
|
||||
failedCount += Number(result.failed || 0)
|
||||
skippedCount += Math.max(0, batch.length - Number(result.total || 0))
|
||||
} catch {
|
||||
failedCount += batch.length
|
||||
}
|
||||
|
||||
progressDone.value = Math.min(i + BATCH_SIZE, targetIds.length)
|
||||
}
|
||||
} else {
|
||||
const CONCURRENCY = props.batchConcurrency || 8
|
||||
const taskForKey = (key: PoolKeyDetail): (() => Promise<'success' | 'skip'>) | null => {
|
||||
|
||||
@@ -308,31 +308,35 @@
|
||||
@cancel="deleteConfirmOpen = false"
|
||||
/>
|
||||
|
||||
<!-- 测试结果对话框(仅失败时显示) -->
|
||||
<TestResultDialog
|
||||
:result="testResult"
|
||||
<!-- 模型测试对话框 -->
|
||||
<ModelTestDialog
|
||||
:open="modelTest.dialogOpen.value"
|
||||
:result="modelTest.testResult.value"
|
||||
mode="direct"
|
||||
@close="testResult = null"
|
||||
:selecting-model-name="testingModelName"
|
||||
:testing="modelTest.testing.value"
|
||||
:trace="modelTest.testTrace.value"
|
||||
:request-id="modelTest.requestId.value"
|
||||
@close="handleTestDialogClose"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useSmartPagination } from '@/composables/useSmartPagination'
|
||||
import { useModelTest } from '@/composables/useModelTest'
|
||||
import { Tag, Plus, Edit, Trash2, ChevronRight, Loader2, Play } from 'lucide-vue-next'
|
||||
import {
|
||||
Card, Button, Badge,
|
||||
} from '@/components/ui'
|
||||
import AlertDialog from '@/components/common/AlertDialog.vue'
|
||||
import ModelMappingDialog, { type AliasGroup } from '../ModelMappingDialog.vue'
|
||||
import TestResultDialog from './TestResultDialog.vue'
|
||||
import ModelTestDialog from './ModelTestDialog.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import {
|
||||
testModelFailover,
|
||||
type Model,
|
||||
type ProviderModelAlias,
|
||||
type ProviderMappingPreviewResponse,
|
||||
type TestModelFailoverResponse
|
||||
} from '@/api/endpoints'
|
||||
import { type EndpointAPIKey } from '@/api/endpoints/keys'
|
||||
import { updateModel } from '@/api/endpoints/models'
|
||||
@@ -376,6 +380,9 @@ const emit = defineEmits<{
|
||||
|
||||
const { error: showError, success: showSuccess } = useToast()
|
||||
|
||||
// 模型测试 composable
|
||||
const modelTest = useModelTest({ providerId: () => props.provider.id })
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const dialogOpen = ref(false)
|
||||
@@ -383,7 +390,7 @@ const deleteConfirmOpen = ref(false)
|
||||
const editingGroup = ref<AliasGroup | null>(null)
|
||||
const deletingGroup = ref<AliasGroup | null>(null)
|
||||
const testingMapping = ref<string | null>(null)
|
||||
const testResult = ref<TestModelFailoverResponse | null>(null)
|
||||
const testingModelName = ref<string | null>(null)
|
||||
const preselectedModelId = ref<string | null>(null)
|
||||
|
||||
// 使用 props 传入的数据
|
||||
@@ -617,30 +624,25 @@ async function onDialogSaved() {
|
||||
emit('refresh')
|
||||
}
|
||||
|
||||
// 测试映射(直连测试,带故障转移)
|
||||
function handleTestDialogClose() {
|
||||
modelTest.resetState()
|
||||
testingModelName.value = null
|
||||
}
|
||||
|
||||
// 测试映射(直连测试,带故障转移和实时进度)
|
||||
async function runMappingTest(testingKey: string, modelName: string) {
|
||||
testingMapping.value = testingKey
|
||||
|
||||
try {
|
||||
const result = await testModelFailover({
|
||||
provider_id: props.provider.id,
|
||||
mode: 'direct',
|
||||
model_name: modelName,
|
||||
message: "hello",
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
const successAttempt = result.attempts.find(a => a.status === 'success')
|
||||
const latency = successAttempt?.latency_ms != null ? ` (${successAttempt.latency_ms}ms)` : ''
|
||||
showSuccess(`映射 "${modelName}" 测试成功${latency}`)
|
||||
} else {
|
||||
testResult.value = result
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
showError(`映射测试失败: ${parseApiError(err, '测试请求失败')}`)
|
||||
} finally {
|
||||
testingMapping.value = null
|
||||
}
|
||||
testingModelName.value = modelName
|
||||
await modelTest.startTest({
|
||||
mode: 'direct',
|
||||
modelName,
|
||||
displayLabel: `映射 "${modelName}"`,
|
||||
message: 'hello',
|
||||
onSuccess: () => {
|
||||
testingModelName.value = null
|
||||
},
|
||||
})
|
||||
testingMapping.value = null
|
||||
}
|
||||
|
||||
// 测试精确映射
|
||||
|
||||
@@ -159,7 +159,7 @@
|
||||
:variant="statusVariant(candidate.status)"
|
||||
class="text-[10px] px-1.5 py-0 shrink-0"
|
||||
>
|
||||
{{ candidate.status_code || statusLabel(candidate.status) }}
|
||||
{{ statusDisplay(candidate) }}
|
||||
</Badge>
|
||||
<span class="truncate font-medium">{{ formatTraceCandidateAccount(candidate) }}</span>
|
||||
</div>
|
||||
@@ -221,13 +221,51 @@
|
||||
{{ result.error }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="attemptSummaryItems.length > 0"
|
||||
class="rounded-md border border-border/60 bg-muted/20 p-3 space-y-2"
|
||||
>
|
||||
<div class="text-xs font-medium text-muted-foreground">
|
||||
结果概览
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div
|
||||
v-for="item in attemptSummaryItems"
|
||||
:key="item.key"
|
||||
class="flex items-center gap-2 rounded-md border border-border/60 bg-background/80 px-2.5 py-1.5 text-xs"
|
||||
>
|
||||
<Badge
|
||||
:variant="item.variant"
|
||||
class="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
{{ item.count }}x
|
||||
</Badge>
|
||||
<span class="text-muted-foreground break-all">{{ item.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="shouldCollapseAttempts"
|
||||
class="flex items-center justify-between gap-3 text-xs text-muted-foreground"
|
||||
>
|
||||
<span>仅展示前 {{ visibleAttempts.length }} 条,共 {{ resultAttempts.length }} 条</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="showAllAttempts = !showAllAttempts"
|
||||
>
|
||||
{{ showAllAttempts ? '收起详情' : `展开全部 ${resultAttempts.length} 条` }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- mobile: list layout -->
|
||||
<div
|
||||
v-if="result.attempts.length > 0"
|
||||
v-if="resultAttempts.length > 0"
|
||||
class="space-y-2 sm:hidden"
|
||||
>
|
||||
<div
|
||||
v-for="(attempt, idx) in result.attempts"
|
||||
v-for="(attempt, idx) in visibleAttempts"
|
||||
:key="'m' + idx"
|
||||
class="rounded-md border px-3 py-2 text-xs"
|
||||
:class="attemptRowClass(attempt.status)"
|
||||
@@ -239,7 +277,7 @@
|
||||
:variant="statusVariant(attempt.status)"
|
||||
class="text-[10px] px-1.5 py-0 shrink-0"
|
||||
>
|
||||
{{ attempt.status_code || statusLabel(attempt.status) }}
|
||||
{{ statusDisplay(attempt) }}
|
||||
</Badge>
|
||||
<span
|
||||
v-if="attempt.latency_ms != null"
|
||||
@@ -248,6 +286,10 @@
|
||||
{{ attempt.latency_ms }}ms
|
||||
</span>
|
||||
</div>
|
||||
<code
|
||||
v-if="showEndpointColumn"
|
||||
class="text-[11px] bg-muted px-1 py-0.5 rounded shrink-0"
|
||||
>{{ attempt.endpoint_api_format }}</code>
|
||||
</div>
|
||||
<div class="mt-1.5 space-y-0.5">
|
||||
<div
|
||||
@@ -277,16 +319,20 @@
|
||||
|
||||
<!-- desktop: table layout -->
|
||||
<div
|
||||
v-if="result.attempts.length > 0"
|
||||
v-if="resultAttempts.length > 0"
|
||||
class="border rounded-md overflow-hidden hidden sm:block"
|
||||
>
|
||||
<table class="w-full text-xs table-fixed">
|
||||
<colgroup>
|
||||
<col class="w-8">
|
||||
<col class="w-[22%]">
|
||||
<col
|
||||
v-if="showEndpointColumn"
|
||||
class="w-20"
|
||||
>
|
||||
<col
|
||||
v-if="hasEffectiveModel"
|
||||
class="w-[18%]"
|
||||
class="w-[16%]"
|
||||
>
|
||||
<col class="w-16">
|
||||
<col class="w-16">
|
||||
@@ -300,6 +346,12 @@
|
||||
<th class="px-3 py-2 text-left font-medium">
|
||||
Key
|
||||
</th>
|
||||
<th
|
||||
v-if="showEndpointColumn"
|
||||
class="px-3 py-2 text-left font-medium"
|
||||
>
|
||||
端点
|
||||
</th>
|
||||
<th
|
||||
v-if="hasEffectiveModel"
|
||||
class="px-3 py-2 text-left font-medium"
|
||||
@@ -319,7 +371,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(attempt, idx) in result.attempts"
|
||||
v-for="(attempt, idx) in visibleAttempts"
|
||||
:key="idx"
|
||||
class="border-b last:border-b-0 align-top"
|
||||
:class="attemptRowClass(attempt.status)"
|
||||
@@ -342,6 +394,12 @@
|
||||
{{ maskKey(attempt.key_id) }}
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
v-if="showEndpointColumn"
|
||||
class="px-3 py-2"
|
||||
>
|
||||
<code class="text-[11px] bg-muted px-1 py-0.5 rounded">{{ attempt.endpoint_api_format }}</code>
|
||||
</td>
|
||||
<td
|
||||
v-if="hasEffectiveModel"
|
||||
class="px-3 py-2 truncate"
|
||||
@@ -354,7 +412,7 @@
|
||||
:variant="statusVariant(attempt.status)"
|
||||
class="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
{{ attempt.status_code || statusLabel(attempt.status) }}
|
||||
{{ statusDisplay(attempt) }}
|
||||
</Badge>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-right text-muted-foreground tabular-nums">
|
||||
@@ -402,7 +460,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Loader2 } from 'lucide-vue-next'
|
||||
import { Dialog, Badge } from '@/components/ui'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
@@ -474,6 +532,67 @@ const hasEffectiveModel = computed(() => {
|
||||
return props.result.attempts.some(a => a.effective_model && a.effective_model !== props.result?.model)
|
||||
})
|
||||
|
||||
const showEndpointColumn = computed(() => {
|
||||
if (!props.result) return false
|
||||
if (props.mode === 'direct') return true
|
||||
const formats = new Set(props.result.attempts.map(a => a.endpoint_api_format))
|
||||
return formats.size > 1
|
||||
})
|
||||
|
||||
const resultAttempts = computed(() => props.result?.attempts ?? [])
|
||||
const showAllAttempts = ref(false)
|
||||
|
||||
watch(() => props.result, () => {
|
||||
showAllAttempts.value = false
|
||||
})
|
||||
|
||||
const shouldCollapseAttempts = computed(() => resultAttempts.value.length > 20)
|
||||
|
||||
const visibleAttempts = computed(() => {
|
||||
if (!shouldCollapseAttempts.value || showAllAttempts.value) {
|
||||
return resultAttempts.value
|
||||
}
|
||||
return resultAttempts.value.slice(0, 20)
|
||||
})
|
||||
|
||||
type AttemptSummaryItem = {
|
||||
key: string
|
||||
label: string
|
||||
count: number
|
||||
variant: 'success' | 'destructive' | 'secondary'
|
||||
}
|
||||
|
||||
const attemptSummaryItems = computed<AttemptSummaryItem[]>(() => {
|
||||
const groups = new Map<string, AttemptSummaryItem>()
|
||||
|
||||
for (const attempt of resultAttempts.value) {
|
||||
const label = summarizeAttempt(attempt)
|
||||
const key = `${attempt.status}:${label}`
|
||||
const existing = groups.get(key)
|
||||
if (existing) {
|
||||
existing.count += 1
|
||||
continue
|
||||
}
|
||||
groups.set(key, {
|
||||
key,
|
||||
label,
|
||||
count: 1,
|
||||
variant: statusVariant(attempt.status),
|
||||
})
|
||||
}
|
||||
|
||||
const variantRank: Record<AttemptSummaryItem['variant'], number> = {
|
||||
destructive: 0,
|
||||
secondary: 1,
|
||||
success: 2,
|
||||
}
|
||||
|
||||
return [...groups.values()].sort((left, right) => {
|
||||
if (right.count !== left.count) return right.count - left.count
|
||||
return variantRank[left.variant] - variantRank[right.variant]
|
||||
})
|
||||
})
|
||||
|
||||
const liveTraceSummary = computed(() => {
|
||||
const summary = {
|
||||
total: traceCandidates.value.length,
|
||||
@@ -552,7 +671,7 @@ const liveRecentCandidates = computed(() => {
|
||||
|
||||
function statusVariant(status: string) {
|
||||
if (status === 'success') return 'success' as const
|
||||
if (status === 'failed') return 'destructive' as const
|
||||
if (status === 'failed' || status === 'stream_interrupted') return 'destructive' as const
|
||||
return 'secondary' as const
|
||||
}
|
||||
|
||||
@@ -568,9 +687,44 @@ function statusLabel(status: string) {
|
||||
return status
|
||||
}
|
||||
|
||||
function statusDisplay(item: { status: string; status_code?: number | null }): string {
|
||||
const code = item.status_code
|
||||
const status = item.status
|
||||
if (!code) return statusLabel(status)
|
||||
// 失败但 HTTP 状态码是 2xx:显示 "200 体内错误" 以区分
|
||||
if (status === 'failed' && code >= 200 && code < 300) {
|
||||
return `${code} 体内错误`
|
||||
}
|
||||
return String(code)
|
||||
}
|
||||
|
||||
function compactDetail(value: string | null | undefined, maxLength = 64): string | null {
|
||||
if (!value) return null
|
||||
const compact = value.replace(/\s+/g, ' ').trim()
|
||||
if (!compact) return null
|
||||
return compact.length > maxLength ? `${compact.slice(0, maxLength)}…` : compact
|
||||
}
|
||||
|
||||
function summarizeAttempt(attempt: TestAttemptDetail): string {
|
||||
if (attempt.status === 'skipped') return '跳过'
|
||||
if (attempt.status === 'cancelled') return '已取消'
|
||||
if (attempt.status === 'success') return '成功'
|
||||
|
||||
const detail = compactDetail(attempt.error_message || attempt.skip_reason)
|
||||
if (attempt.status_code != null) {
|
||||
if (detail) return `${attempt.status_code} ${detail}`
|
||||
if (attempt.status === 'failed' && attempt.status_code >= 200 && attempt.status_code < 300) {
|
||||
return `${attempt.status_code} 体内错误`
|
||||
}
|
||||
return `${attempt.status_code} ${statusLabel(attempt.status)}`
|
||||
}
|
||||
return detail || statusLabel(attempt.status)
|
||||
}
|
||||
|
||||
function attemptRowClass(status: string) {
|
||||
if (status === 'success') return 'bg-green-500/5'
|
||||
if (status === 'failed') return 'bg-red-500/5'
|
||||
if (status === 'cancelled') return 'bg-amber-500/5'
|
||||
if (status === 'skipped') return 'bg-muted/20'
|
||||
return ''
|
||||
}
|
||||
@@ -612,6 +766,7 @@ function traceCandidateDetail(candidate: CandidateRecord): string {
|
||||
}
|
||||
|
||||
function attemptDetail(attempt: TestAttemptDetail): string {
|
||||
if (attempt.status === 'cancelled') return '测试已取消'
|
||||
if (attempt.skip_reason) return attempt.skip_reason
|
||||
if (attempt.error_message) return attempt.error_message
|
||||
if (attempt.status === 'success') return attempt.endpoint_base_url
|
||||
|
||||
@@ -130,11 +130,11 @@
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="测试模型"
|
||||
:disabled="testingModelId === model.id"
|
||||
:disabled="modelTest.testing.value && pendingTestModel?.id === model.id"
|
||||
@click="testModelConnection(model)"
|
||||
>
|
||||
<Loader2
|
||||
v-if="testingModelId === model.id"
|
||||
v-if="modelTest.testing.value && pendingTestModel?.id === model.id"
|
||||
class="w-3.5 h-3.5 animate-spin"
|
||||
/>
|
||||
<Play
|
||||
@@ -212,15 +212,15 @@
|
||||
</Card>
|
||||
|
||||
<ModelTestDialog
|
||||
:open="testDialogOpen"
|
||||
:result="testResult"
|
||||
:mode="testResultMode"
|
||||
:open="modelTest.dialogOpen.value"
|
||||
:result="modelTest.testResult.value"
|
||||
:mode="modelTest.testMode.value"
|
||||
:selecting-model-name="pendingTestModel ? (pendingTestModel.global_model_display_name || pendingTestModel.provider_model_name) : null"
|
||||
:endpoints="activeEndpoints"
|
||||
:selected-endpoint="selectedTestEndpoint"
|
||||
:testing="!!pendingTestModel && testingModelId === pendingTestModel.id"
|
||||
:trace="testTrace"
|
||||
:request-id="currentTestRequestId"
|
||||
:testing="modelTest.testing.value"
|
||||
:trace="modelTest.testTrace.value"
|
||||
:request-id="modelTest.requestId.value"
|
||||
:show-endpoint-selector="activeEndpoints.length > 1"
|
||||
@close="handleTestDialogClose"
|
||||
@back="handleTestDialogBack"
|
||||
@@ -229,9 +229,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onBeforeUnmount } from 'vue'
|
||||
import { isAxiosError } from 'axios'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useSmartPagination } from '@/composables/useSmartPagination'
|
||||
import { useModelTest } from '@/composables/useModelTest'
|
||||
import { Box, Edit, Layers, Power, Copy, Loader2, Play } from 'lucide-vue-next'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
@@ -239,13 +239,10 @@ import { useToast } from '@/composables/useToast'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { sortResolutionEntries } from '@/utils/form'
|
||||
import {
|
||||
testModelFailover,
|
||||
type Model,
|
||||
type ProviderEndpoint,
|
||||
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'
|
||||
@@ -266,23 +263,16 @@ const emit = defineEmits<{
|
||||
const { error: showError, success: showSuccess } = useToast()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
// 模型测试 composable
|
||||
const modelTest = useModelTest({ providerId: () => props.provider.id })
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const localModels = ref<Model[]>([])
|
||||
const togglingModelId = ref<string | null>(null)
|
||||
const testingModelId = ref<string | null>(null)
|
||||
const testResult = ref<TestModelFailoverResponse | null>(null)
|
||||
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 传入的数据,或使用本地数据
|
||||
const models = computed(() => props.models ?? localModels.value)
|
||||
// 按名称排序的模型列表
|
||||
const sortedModels = computed(() => {
|
||||
@@ -312,47 +302,6 @@ 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 '-'
|
||||
@@ -485,21 +434,15 @@ async function toggleModelActive(model: Model) {
|
||||
}
|
||||
}
|
||||
|
||||
function resetTestDialogState() {
|
||||
stopTestTracePolling()
|
||||
testDialogOpen.value = false
|
||||
function handleTestDialogClose() {
|
||||
modelTest.resetState()
|
||||
pendingTestModel.value = null
|
||||
selectedTestEndpoint.value = null
|
||||
testResult.value = null
|
||||
}
|
||||
|
||||
function handleTestDialogClose() {
|
||||
resetTestDialogState()
|
||||
}
|
||||
|
||||
function handleTestDialogBack() {
|
||||
if (testingModelId.value) return
|
||||
testResult.value = null
|
||||
if (modelTest.testing.value) return
|
||||
modelTest.testResult.value = null
|
||||
selectedTestEndpoint.value = null
|
||||
}
|
||||
|
||||
@@ -507,60 +450,33 @@ async function handleSelectTestEndpoint(endpointId: string) {
|
||||
if (!pendingTestModel.value) return
|
||||
const endpoint = activeEndpoints.value.find(item => item.id === endpointId)
|
||||
if (!endpoint) return
|
||||
await runModelTest(pendingTestModel.value, endpoint)
|
||||
selectedTestEndpoint.value = endpoint
|
||||
const model = pendingTestModel.value
|
||||
const modelName = model.global_model_name || model.provider_model_name
|
||||
const endpointPrefix = `[${formatApiFormat(endpoint.api_format)}] `
|
||||
await modelTest.startTest({
|
||||
mode: 'global',
|
||||
modelName,
|
||||
displayLabel: `${endpointPrefix}${modelName}`,
|
||||
apiFormat: endpoint.api_format,
|
||||
endpointId: endpoint.id,
|
||||
message: 'hello',
|
||||
concurrency: 5,
|
||||
onSuccess: () => {
|
||||
pendingTestModel.value = null
|
||||
selectedTestEndpoint.value = null
|
||||
},
|
||||
onError: () => {
|
||||
if (activeEndpoints.value.length > 1) {
|
||||
selectedTestEndpoint.value = null
|
||||
return true
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function runModelTest(model: Model, endpoint?: ProviderEndpoint) {
|
||||
if (testingModelId.value) return
|
||||
|
||||
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
|
||||
|
||||
const result = await testModelFailover({
|
||||
provider_id: props.provider.id,
|
||||
mode: 'global',
|
||||
model_name: modelName,
|
||||
api_format: endpoint?.api_format,
|
||||
endpoint_id: endpoint?.id,
|
||||
message: 'hello',
|
||||
request_id: requestId,
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
const successAttempt = result.attempts.find(a => a.status === 'success')
|
||||
const latency = successAttempt?.latency_ms != null ? ` (${successAttempt.latency_ms}ms)` : ''
|
||||
const mapped = successAttempt?.effective_model && successAttempt.effective_model !== modelName
|
||||
? ` -> ${successAttempt.effective_model}`
|
||||
: ''
|
||||
const endpointPrefix = endpoint ? `[${formatApiFormat(endpoint.api_format)}] ` : ''
|
||||
showSuccess(`${endpointPrefix}${modelName}${mapped} 测试成功${latency}`)
|
||||
resetTestDialogState()
|
||||
return
|
||||
}
|
||||
stopTestTracePolling({ clearState: false })
|
||||
testResultMode.value = 'global'
|
||||
testResult.value = result
|
||||
} catch (err: unknown) {
|
||||
stopTestTracePolling()
|
||||
showError(`模型测试失败: ${parseApiError(err, '测试请求失败')}`)
|
||||
if (activeEndpoints.value.length <= 1) {
|
||||
resetTestDialogState()
|
||||
return
|
||||
}
|
||||
selectedTestEndpoint.value = null
|
||||
} finally {
|
||||
testingModelId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 测试模型连接性(模拟外部请求,带故障转移)
|
||||
async function testModelConnection(model: Model) {
|
||||
if (testingModelId.value) return
|
||||
if (modelTest.testing.value) return
|
||||
|
||||
if (activeEndpoints.value.length === 0) {
|
||||
showError('暂无可用于测试的活跃端点')
|
||||
@@ -569,11 +485,11 @@ async function testModelConnection(model: Model) {
|
||||
|
||||
pendingTestModel.value = model
|
||||
selectedTestEndpoint.value = null
|
||||
testResult.value = null
|
||||
testDialogOpen.value = true
|
||||
modelTest.testResult.value = null
|
||||
modelTest.dialogOpen.value = true
|
||||
|
||||
if (activeEndpoints.value.length === 1) {
|
||||
await runModelTest(model, activeEndpoints.value[0])
|
||||
await handleSelectTestEndpoint(activeEndpoints.value[0].id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -581,8 +497,4 @@ async function testModelConnection(model: Model) {
|
||||
defineExpose({
|
||||
reload: refresh
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopTestTracePolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,301 +0,0 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:open="!!result"
|
||||
size="2xl"
|
||||
title="模型测试结果"
|
||||
@update:open="(val: boolean) => { if (!val) $emit('close') }"
|
||||
>
|
||||
<div
|
||||
v-if="result"
|
||||
class="space-y-4"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge :variant="result.success ? 'success' : 'destructive'">
|
||||
{{ result.success ? '成功' : '失败' }}
|
||||
</Badge>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ modeLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
候选 {{ result.total_candidates }} / 尝试 {{ result.total_attempts }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-sm space-y-1">
|
||||
<div>
|
||||
<span class="text-muted-foreground">请求模型: </span>
|
||||
<span class="font-medium">{{ result.model }}</span>
|
||||
</div>
|
||||
<div v-if="successEffectiveModel">
|
||||
<span class="text-muted-foreground">发送模型: </span>
|
||||
<span class="font-medium text-primary">{{ successEffectiveModel }}</span>
|
||||
<span
|
||||
v-if="successEffectiveModel !== result.model"
|
||||
class="text-xs text-muted-foreground ml-1"
|
||||
>(已映射)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="result.error && !result.success"
|
||||
class="rounded-md bg-destructive/10 border border-destructive/20 px-3 py-2 text-xs text-destructive"
|
||||
>
|
||||
{{ result.error }}
|
||||
</div>
|
||||
|
||||
<!-- mobile: list layout -->
|
||||
<div
|
||||
v-if="result.attempts.length > 0"
|
||||
class="space-y-2 sm:hidden"
|
||||
>
|
||||
<div
|
||||
v-for="(attempt, idx) in result.attempts"
|
||||
:key="'m' + idx"
|
||||
class="rounded-md border px-3 py-2 text-xs"
|
||||
:class="attemptRowClass(attempt.status)"
|
||||
>
|
||||
<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">{{ formatAttemptIndex(attempt) }}</span>
|
||||
<Badge
|
||||
:variant="statusVariant(attempt.status)"
|
||||
class="text-[10px] px-1.5 py-0 shrink-0"
|
||||
>
|
||||
{{ attempt.status_code || statusLabel(attempt.status) }}
|
||||
</Badge>
|
||||
<span
|
||||
v-if="attempt.latency_ms != null"
|
||||
class="text-muted-foreground shrink-0 tabular-nums"
|
||||
>
|
||||
{{ attempt.latency_ms }}ms
|
||||
</span>
|
||||
</div>
|
||||
<code class="text-[11px] bg-muted px-1 py-0.5 rounded shrink-0">{{ attempt.endpoint_api_format }}</code>
|
||||
</div>
|
||||
<div class="mt-1.5 space-y-0.5">
|
||||
<div
|
||||
v-if="attempt.key_name"
|
||||
class="font-medium truncate"
|
||||
>
|
||||
{{ attempt.key_name }}
|
||||
</div>
|
||||
<div class="text-muted-foreground">
|
||||
{{ maskKey(attempt.key_id) }}
|
||||
</div>
|
||||
<div
|
||||
v-if="hasEffectiveModel && attempt.effective_model"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
模型: {{ attempt.effective_model }}
|
||||
</div>
|
||||
<div
|
||||
v-if="attemptDetail(attempt) !== '-'"
|
||||
class="text-muted-foreground break-all mt-1"
|
||||
>
|
||||
{{ attemptDetail(attempt) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- desktop: table layout -->
|
||||
<div
|
||||
v-if="result.attempts.length > 0"
|
||||
class="border rounded-md overflow-hidden hidden sm:block"
|
||||
>
|
||||
<table class="w-full text-xs table-fixed">
|
||||
<colgroup>
|
||||
<col class="w-8">
|
||||
<col class="w-[22%]">
|
||||
<col class="w-20">
|
||||
<col
|
||||
v-if="hasEffectiveModel"
|
||||
class="w-[16%]"
|
||||
>
|
||||
<col class="w-16">
|
||||
<col class="w-16">
|
||||
<col>
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr class="border-b bg-muted/30">
|
||||
<th class="pl-3 pr-1 py-2 text-left font-medium">
|
||||
#
|
||||
</th>
|
||||
<th class="px-3 py-2 text-left font-medium">
|
||||
Key
|
||||
</th>
|
||||
<th class="px-3 py-2 text-left font-medium">
|
||||
端点
|
||||
</th>
|
||||
<th
|
||||
v-if="hasEffectiveModel"
|
||||
class="px-3 py-2 text-left font-medium"
|
||||
>
|
||||
发送模型
|
||||
</th>
|
||||
<th class="px-3 py-2 text-left font-medium">
|
||||
状态
|
||||
</th>
|
||||
<th class="px-3 py-2 text-right font-medium">
|
||||
延迟
|
||||
</th>
|
||||
<th class="px-3 py-2 text-left font-medium">
|
||||
详情
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(attempt, idx) in result.attempts"
|
||||
:key="idx"
|
||||
class="border-b last:border-b-0 align-top"
|
||||
:class="attemptRowClass(attempt.status)"
|
||||
>
|
||||
<td class="pl-3 pr-1 py-2 text-muted-foreground">
|
||||
{{ formatAttemptIndex(attempt) }}
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<div
|
||||
v-if="attempt.key_name"
|
||||
class="font-medium truncate"
|
||||
:title="attempt.key_name"
|
||||
>
|
||||
{{ attempt.key_name }}
|
||||
</div>
|
||||
<div
|
||||
class="text-muted-foreground truncate"
|
||||
:title="attempt.key_id"
|
||||
>
|
||||
{{ maskKey(attempt.key_id) }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<code class="text-[11px] bg-muted px-1 py-0.5 rounded">{{ attempt.endpoint_api_format }}</code>
|
||||
</td>
|
||||
<td
|
||||
v-if="hasEffectiveModel"
|
||||
class="px-3 py-2 truncate"
|
||||
:title="attempt.effective_model || '-'"
|
||||
>
|
||||
{{ attempt.effective_model || '-' }}
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<Badge
|
||||
:variant="statusVariant(attempt.status)"
|
||||
class="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
{{ attempt.status_code || statusLabel(attempt.status) }}
|
||||
</Badge>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-right text-muted-foreground tabular-nums">
|
||||
{{ attempt.latency_ms != null ? attempt.latency_ms + 'ms' : '-' }}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-muted-foreground">
|
||||
<div
|
||||
class="break-all line-clamp-2"
|
||||
:title="attemptDetail(attempt)"
|
||||
>
|
||||
{{ attemptDetail(attempt) }}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="text-center text-sm text-muted-foreground py-4"
|
||||
>
|
||||
没有可用的候选进行测试
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Dialog, Badge } from '@/components/ui'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import type { TestModelFailoverResponse, TestAttemptDetail } from '@/api/endpoints/providers'
|
||||
|
||||
const props = defineProps<{
|
||||
result: TestModelFailoverResponse | null
|
||||
mode?: 'global' | 'direct'
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const modeLabel = computed(() => {
|
||||
if (props.mode === 'global') return '模拟外部请求'
|
||||
if (props.mode === 'direct') return '直接测试'
|
||||
return ''
|
||||
})
|
||||
|
||||
const successEffectiveModel = computed(() => {
|
||||
if (!props.result) return null
|
||||
const successAttempt = props.result.attempts.find(a => a.status === 'success')
|
||||
return successAttempt?.effective_model || null
|
||||
})
|
||||
|
||||
const hasEffectiveModel = computed(() => {
|
||||
if (!props.result) return false
|
||||
return props.result.attempts.some(a => a.effective_model && a.effective_model !== props.result?.model)
|
||||
})
|
||||
|
||||
function statusVariant(status: string) {
|
||||
if (status === 'success') return 'success' as const
|
||||
if (status === 'failed') return 'destructive' as const
|
||||
return 'secondary' as const
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function attemptRowClass(status: string) {
|
||||
if (status === 'success') return 'bg-green-500/5'
|
||||
if (status === 'failed') return 'bg-red-500/5'
|
||||
if (status === 'skipped') return 'bg-muted/20'
|
||||
return ''
|
||||
}
|
||||
|
||||
function maskKey(key: string): string {
|
||||
if (key.length <= 8) return key
|
||||
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
|
||||
if (attempt.status === 'success') return attempt.endpoint_base_url
|
||||
return '-'
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user