394 lines
49 KiB
TypeScript
394 lines
49 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||
import { Info, Plus, RefreshCw, Save, Search } from 'lucide-react'
|
||
import { useConfirmActionDialog } from '@/components/ConfirmActionDialog'
|
||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||
import { AI_MODEL_TEST_PURPOSES, aiModelTestStages, type AiModelAdminState, type AiModelCatalogEntry, type AiModelProvider, type AiModelStage, type AiModelTestPurpose, type AiModelVerificationSummary } from '@/lib/ai-model-types'
|
||
|
||
const stages: Array<{ id: AiModelStage; label: string; shortLabel: string }> = [
|
||
{ id: 'analysis', label: '资料分析', shortLabel: '分析' }, { id: 'copy', label: '卖点文案', shortLabel: '文案' }, { id: 'image', label: '图片生成', shortLabel: '生图' },
|
||
]
|
||
const combinedPurpose = { id: 'analysis-copy' as const, label: '分析与文案' }
|
||
function testPurposes(model: AiModelCatalogEntry): Array<{ id: AiModelTestPurpose; label: string }> {
|
||
return model.capabilities.combinedTextTest ? [combinedPurpose, ...stages.filter(stage => stage.id === 'image' && model.capabilities.allowedStages.includes(stage.id))]
|
||
: stages.filter(stage => model.capabilities.allowedStages.includes(stage.id))
|
||
}
|
||
function purposeLabel(purpose: AiModelTestPurpose) { return purpose === 'analysis-copy' ? combinedPurpose.label : stages.find(stage => stage.id === purpose)!.label }
|
||
const fieldClass = 'min-h-11 w-full min-w-0 rounded-lg border border-[#cfd8e3] bg-white px-3 py-2 text-base text-[#101828] placeholder:text-[#66758b] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] disabled:bg-[#f8fafc] disabled:text-[#66758b] sm:text-sm'
|
||
const buttonClass = 'inline-flex min-h-11 shrink-0 items-center justify-center gap-2 rounded-lg border border-[#cfd8e3] bg-white px-3 text-sm font-medium text-[#344054] transition-colors hover:bg-[#f8fafc] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50'
|
||
const primaryClass = `${buttonClass} !border-[#0050cb] !bg-[#0050cb] !text-white hover:!bg-[#003da3]`
|
||
const checkboxClass = 'h-4 w-4 shrink-0 accent-[#0050cb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2 disabled:cursor-not-allowed'
|
||
const capabilityLabels = { text: '对话', vision: '视觉理解', image: '生图', tts: '语音合成', asr: '语音识别', music: '音乐', video: '视频', embedding: '向量', 'image-tool': '图像工具', 'three-d': '3D', other: '其他', unknown: '待识别' } as const
|
||
const verificationLabels = { untested: '未测试', running: '测试中', passed: '已通过', failed: '测试失败', unknown: '待核查', stale: '需重新测试' } as const
|
||
const parameterLabels: Record<string, string> = { n: '生成数量', stream: '流式输出', response_format: '返回格式', size: '尺寸', aspect_ratio: '画幅比例', resolution: '分辨率', quality: '画质', background: '背景', output_format: '文件格式', version: '模型版本', thinkingLevel: '思考级别' }
|
||
function verificationLabel(result: AiModelVerificationSummary) {
|
||
return result.reconciled && result.status === 'failed' ? '已核查,可重测' : verificationLabels[result.status]
|
||
}
|
||
function verificationColor(result: AiModelVerificationSummary) {
|
||
return result.status === 'passed' ? 'text-[#087052]' : result.status === 'unknown' ? 'text-[#854a0e]' : result.status === 'failed' && !result.reconciled ? 'text-[#b42318]' : 'text-[#66758b]'
|
||
}
|
||
function needsReferenceAdaptation(model: AiModelCatalogEntry) {
|
||
return model.capabilities.kind === 'image' && !model.capabilities.allowedStages.includes('image') && model.capabilities.reason.startsWith('参考图待适配:')
|
||
}
|
||
|
||
interface ProviderForm { id: string | null; revision: number; name: string; baseUrl: string; apiKey: string }
|
||
interface RecoveryForm { providerId: string; verificationId: string; modelId: string; outcome: 'not_executed' | 'failed' | 'completed'; reference: string; note: string; confirmed: boolean; canFetchOriginal: boolean; requestId: string; requestIdLocked: boolean }
|
||
function recoveryFormFor(provider: AiModelProvider, model: AiModelCatalogEntry, purpose: AiModelTestPurpose, result: AiModelVerificationSummary): RecoveryForm {
|
||
const protocol = model.capabilities.protocols?.[purpose === 'analysis-copy' ? 'analysis' : purpose]
|
||
return { providerId: provider.id, verificationId: result.id!, modelId: model.id, outcome: 'not_executed', reference: '', note: '', confirmed: false,
|
||
canFetchOriginal: provider.enabled && provider.hasApiKey && model.present && ['openai-chat', 'anthropic-messages', 'openai-image-generate', 'openai-image-edit'].includes(protocol?.id || ''),
|
||
requestId: result.requestId || '', requestIdLocked: !!result.requestId }
|
||
}
|
||
const pendingStorageKey = 'ai-model-pending-submissions-v1'
|
||
class ModelRequestError extends Error { constructor(message: string, readonly status: number) { super(message) } }
|
||
|
||
async function request(path = '', init: RequestInit = {}): Promise<AiModelAdminState> {
|
||
const response = await fetch(`/api/admin/ai-models${path}`, { ...init, cache: 'no-store', headers: { 'Content-Type': 'application/json', ...init.headers } })
|
||
const body = await response.json().catch(() => ({}))
|
||
if (!response.ok) throw new ModelRequestError(typeof body.error === 'string' ? body.error : body.error?.message || '操作未完成,请重试', response.status)
|
||
if (!Array.isArray(body.providers)) throw new Error('未能读取服务端状态')
|
||
return body as AiModelAdminState
|
||
}
|
||
|
||
function usable(provider: AiModelProvider, model: AiModelCatalogEntry, stage: AiModelStage) {
|
||
return provider.enabled && provider.hasApiKey && model.present && model.enabled && model.verification[stage].status === 'passed' && model.stages.includes(stage) && model.capabilities.allowedStages.includes(stage)
|
||
}
|
||
|
||
export default function AiModelManagementPanel({ onStateChange }: { onStateChange?: (state: { dirty: boolean; busy: boolean }) => void }) {
|
||
const [state, setState] = useState<AiModelAdminState | null>(null)
|
||
const [draft, setDraft] = useState<AiModelAdminState | null>(null)
|
||
const [loading, setLoading] = useState(true)
|
||
const [busy, setBusy] = useState('')
|
||
const [error, setError] = useState('')
|
||
const [notice, setNotice] = useState('')
|
||
const [formError, setFormError] = useState('')
|
||
const [form, setForm] = useState<ProviderForm | null>(null)
|
||
const [recovery, setRecovery] = useState<RecoveryForm | null>(null)
|
||
const [search, setSearch] = useState('')
|
||
const [capabilityFilter, setCapabilityFilter] = useState('all')
|
||
const [statusFilter, setStatusFilter] = useState('all')
|
||
const [page, setPage] = useState(1)
|
||
const [testStages, setTestStages] = useState<Record<string, AiModelTestPurpose>>({})
|
||
const [testReferenceCounts, setTestReferenceCounts] = useState<Record<string, number>>({})
|
||
const [expandedDetails, setExpandedDetails] = useState<Record<string, boolean>>({})
|
||
const busyRef = useRef(false)
|
||
const uncertainAttempts = useRef(new Map<string, { stage: AiModelTestPurpose; previousId: string | null; requestId: string }>())
|
||
const { confirmAction, confirmActionDialog } = useConfirmActionDialog()
|
||
const dirty = !!state && !!draft && JSON.stringify(state) !== JSON.stringify(draft)
|
||
function rememberAttempts() {
|
||
try { sessionStorage.setItem(pendingStorageKey, JSON.stringify(Array.from(uncertainAttempts.current))) } catch { /* Server records remain authoritative if browser storage is unavailable. */ }
|
||
}
|
||
|
||
function accept(next: AiModelAdminState, preserveDraft = false) {
|
||
const { submissionCheck, ...snapshot } = next
|
||
if (submissionCheck && uncertainAttempts.current.get(submissionCheck.modelKey)?.requestId === submissionCheck.idempotencyKey) uncertainAttempts.current.delete(submissionCheck.modelKey)
|
||
const resolved = { ...snapshot, providers: next.providers.map((provider) => ({ ...provider, models: provider.models.map((model) => {
|
||
const attempt = uncertainAttempts.current.get(model.key)
|
||
if (!attempt) return model
|
||
const attemptedStages = aiModelTestStages(attempt.stage)
|
||
if (attemptedStages.every(stage => model.verification[stage].submissionKey === attempt.requestId)) { uncertainAttempts.current.delete(model.key); return model }
|
||
return { ...model, verification: { ...model.verification, ...Object.fromEntries(attemptedStages.map(stage => [stage, { ...model.verification[stage], purpose: attempt.stage, status: 'unknown' as const, requestId: attempt.requestId, canResume: false, canReconcile: false, message: '尚未获取本次测试记录,请核查提交后再操作' }])) } }
|
||
}) })) }
|
||
rememberAttempts()
|
||
setState(resolved)
|
||
setDraft((current) => {
|
||
if (!preserveDraft || !dirty || !current) return resolved
|
||
// Test results update independently of pending configuration. Keep original
|
||
// revisions so a concurrent configuration save still produces a conflict.
|
||
return { ...resolved, revision: current.revision, defaults: current.defaults, providers: resolved.providers.map((provider) => {
|
||
const previous = current.providers.find((entry) => entry.id === provider.id)
|
||
if (!previous) return provider
|
||
return { ...provider, revision: previous.revision, models: provider.models.map((model) => {
|
||
const pending = previous.models.find((entry) => entry.key === model.key)
|
||
return pending ? { ...model, enabled: pending.enabled, stages: [...pending.stages] } : model
|
||
}) }
|
||
}) }
|
||
})
|
||
}
|
||
const acceptRef = useRef(accept)
|
||
acceptRef.current = accept
|
||
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
try {
|
||
const saved = JSON.parse(sessionStorage.getItem(pendingStorageKey) || '[]')
|
||
if (Array.isArray(saved)) for (const entry of saved.slice(0, 20)) if (Array.isArray(entry) && typeof entry[0] === 'string' && AI_MODEL_TEST_PURPOSES.includes(entry[1]?.stage) && /^[a-f0-9-]{36}$/i.test(entry[1]?.requestId)) uncertainAttempts.current.set(entry[0], entry[1])
|
||
} catch { /* Ignore malformed browser state; no model calls are made here. */ }
|
||
request().then((next) => { if (!cancelled) acceptRef.current(next) }).catch((cause) => { if (!cancelled) setError(cause.message) }).finally(() => { if (!cancelled) setLoading(false) })
|
||
return () => { cancelled = true }
|
||
}, [])
|
||
|
||
const polling = busy.startsWith('verify:') || !!state?.providers.some(provider => provider.models.some(model => Object.values(model.verification).some(result => result.status === 'running')))
|
||
useEffect(() => {
|
||
if (!polling) return
|
||
let cancelled = false
|
||
let timer: ReturnType<typeof setTimeout>
|
||
const poll = async () => {
|
||
try { const next = await request(); if (!cancelled) acceptRef.current(next, true) } catch { /* Keep the original request; never resend a billable operation. */ }
|
||
if (!cancelled) timer = setTimeout(poll, 3000)
|
||
}
|
||
timer = setTimeout(poll, 3000)
|
||
return () => { cancelled = true; clearTimeout(timer) }
|
||
}, [polling])
|
||
|
||
useEffect(() => {
|
||
onStateChange?.({ dirty: dirty || !!form || !!recovery, busy: !!busy })
|
||
}, [dirty, form, recovery, busy, onStateChange])
|
||
|
||
useEffect(() => () => { onStateChange?.({ dirty: false, busy: false }) }, [onStateChange])
|
||
|
||
useEffect(() => {
|
||
if (!dirty && !form && !recovery && !busy.startsWith('verify:')) return
|
||
const prevent = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = '' }
|
||
window.addEventListener('beforeunload', prevent)
|
||
return () => window.removeEventListener('beforeunload', prevent)
|
||
}, [dirty, form, recovery, busy])
|
||
|
||
async function run(identity: string, action: () => Promise<AiModelAdminState>, success: string, options: { closeForm?: boolean; preserveDraft?: boolean } = {}) {
|
||
if (busyRef.current) return
|
||
busyRef.current = true; setBusy(identity); setError(''); setFormError(''); setNotice('')
|
||
try {
|
||
const next = await action()
|
||
accept(next, options.preserveDraft)
|
||
setNotice(success)
|
||
if (identity.startsWith('submission:')) setNotice(next.submissionCheck?.received ? '已找到原测试记录' : '原提交未被受理,已关闭,可以重新测试')
|
||
if (identity === 'reconcile' || identity === 'recover-result') setRecovery(null)
|
||
if (options.closeForm) setForm(null)
|
||
} catch (cause) {
|
||
const message = cause instanceof Error ? cause.message : '操作未完成,请重试'
|
||
if (identity === 'provider-save') setFormError(message)
|
||
else setError(message)
|
||
} finally { busyRef.current = false; setBusy('') }
|
||
}
|
||
|
||
function updateModel(providerId: string, key: string, change: Partial<AiModelCatalogEntry>) {
|
||
setDraft((current) => {
|
||
if (!current) return current
|
||
const defaults = { ...current.defaults }
|
||
const providers = current.providers.map((provider) => provider.id !== providerId ? provider : {
|
||
...provider,
|
||
models: provider.models.map((model) => {
|
||
if (model.key !== key) return model
|
||
const updated = { ...model, ...change }
|
||
if (!updated.stages.some((stage) => updated.capabilities.allowedStages.includes(stage))) updated.enabled = false
|
||
for (const stage of stages) if (defaults[stage.id] === key && !usable(provider, updated, stage.id)) defaults[stage.id] = null
|
||
return updated
|
||
}),
|
||
})
|
||
return { ...current, providers, defaults }
|
||
})
|
||
setError(''); setNotice('')
|
||
}
|
||
|
||
function saveProvider(event: React.FormEvent<HTMLFormElement>) {
|
||
event.preventDefault()
|
||
if (!form) return
|
||
const body = { name: form.name.trim(), baseUrl: form.baseUrl.trim(), ...(form.apiKey.trim() ? { apiKey: form.apiKey.trim() } : {}), ...(form.id ? { revision: form.revision } : {}) }
|
||
void run('provider-save', () => request(form.id ? `/providers/${encodeURIComponent(form.id)}` : '/providers', { method: form.id ? 'PATCH' : 'POST', body: JSON.stringify(body) }), form.id ? '上游已保存' : '上游已添加,请同步模型目录', { closeForm: true })
|
||
}
|
||
|
||
async function testModel(provider: AiModelProvider, model: AiModelCatalogEntry, stage: AiModelTestPurpose) {
|
||
const savedProvider = state?.providers.find((entry) => entry.id === provider.id)
|
||
const savedModel = savedProvider?.models.find((entry) => entry.key === model.key)
|
||
if (busyRef.current || form || !savedProvider?.enabled || !savedProvider.hasApiKey || !savedModel?.present || !testPurposes(savedModel).some(purpose => purpose.id === stage) || Object.values(savedModel.verification).some((result) => result.status === 'running' || result.status === 'unknown')) return
|
||
busyRef.current = true
|
||
setBusy(`confirm:${model.key}`)
|
||
setTestStages((current) => ({ ...current, [model.key]: stage }))
|
||
const label = purposeLabel(stage), attemptedStages = aiModelTestStages(stage)
|
||
const referenceImageCount = stage === 'image' && model.capabilities.protocols?.image?.referenceLimitSource === 'verification' ? testReferenceCounts[model.key] ?? model.verification.image.verifiedReferenceImageCount ?? model.capabilities.protocols.image.maxImages : undefined
|
||
const confirmation = await confirmAction({ title: `测试${label}`, description: <><span className="break-all">{model.id}</span><br />{stage === 'analysis-copy' && <>一次调用,分别验证读图和文案能力。<br /></>}{referenceImageCount && <>逐张校验 {referenceImageCount} 张参考图,输出一张拼图。<br /></>}将实际调用模型,可能产生上游费用。</>, confirmLabel: '开始测试', tone: 'primary' })
|
||
if (!confirmation.confirmed) { busyRef.current = false; setBusy(''); return }
|
||
setBusy(`verify:${model.key}:${stage}`); setError(''); setNotice('')
|
||
let idempotencyKey = ''
|
||
try {
|
||
idempotencyKey = createBrowserUuid()
|
||
uncertainAttempts.current.set(model.key, { stage, previousId: savedModel.verification[attemptedStages[0]].id, requestId: idempotencyKey }); rememberAttempts()
|
||
const next = await request(`/providers/${encodeURIComponent(provider.id)}/models/${encodeURIComponent(model.key)}/verify`, { method: 'POST', body: JSON.stringify({ stage, revision: savedProvider.revision, idempotencyKey, ...(referenceImageCount ? { referenceImageCount } : {}) }) })
|
||
uncertainAttempts.current.delete(model.key); rememberAttempts()
|
||
accept(next, true)
|
||
} catch (cause) {
|
||
// Refresh state after a lost response; never repeat a potentially billable request.
|
||
const message = cause instanceof Error ? cause.message : '测试请求未完成'
|
||
if (!idempotencyKey) {
|
||
setError(`${message}。测试尚未提交。`)
|
||
return
|
||
}
|
||
if (idempotencyKey && (!(cause instanceof ModelRequestError) || cause.status >= 500)) uncertainAttempts.current.set(model.key, { stage, previousId: savedModel.verification[attemptedStages[0]].id, requestId: idempotencyKey })
|
||
else uncertainAttempts.current.delete(model.key)
|
||
rememberAttempts()
|
||
try { accept(await request(), true) } catch {
|
||
const markUnknown = (current: AiModelAdminState | null) => current && ({ ...current, providers: current.providers.map((entry) => entry.id !== provider.id ? entry : { ...entry, models: entry.models.map((item) => item.key !== model.key ? item : { ...item, verification: { ...item.verification, ...Object.fromEntries(attemptedStages.map(purpose => [purpose, { ...item.verification[purpose], purpose: stage, status: 'unknown' as const, message: '未能获取测试结果,请刷新状态核查' }])) } }) }) })
|
||
setState(markUnknown); setDraft(markUnknown)
|
||
}
|
||
setError(`${message};已停止提交,请核查测试状态。`)
|
||
} finally { busyRef.current = false; setBusy('') }
|
||
}
|
||
|
||
const rows = useMemo(() => (draft?.providers || []).flatMap((provider) => provider.models.map((model) => ({ provider, model }))), [draft])
|
||
const visibleRows = rows.filter(({ provider, model }) => {
|
||
const query = search.trim().toLowerCase()
|
||
if (query && !`${model.id} ${provider.name}`.toLowerCase().includes(query)) return false
|
||
if (capabilityFilter !== 'all' && model.capabilities.kind !== capabilityFilter) return false
|
||
if (statusFilter === 'enabled' && !model.enabled) return false
|
||
if (statusFilter === 'disabled' && model.enabled) return false
|
||
if (statusFilter === 'missing' && model.present) return false
|
||
if (statusFilter === 'adapting' && !needsReferenceAdaptation(model)) return false
|
||
if (['untested', 'stale', 'passed'].includes(statusFilter) && !model.capabilities.allowedStages.some((stage) => model.verification[stage].status === statusFilter)) return false
|
||
if (['failed', 'unknown', 'running'].includes(statusFilter) && !Object.values(model.verification).some((result) => result.status === statusFilter && !(statusFilter === 'failed' && result.reconciled))) return false
|
||
if (statusFilter === 'reconciled' && !Object.values(model.verification).some((result) => result.reconciled && result.status === 'failed')) return false
|
||
return true
|
||
})
|
||
const providerBlocked = !!busy || dirty || !!form || !!recovery
|
||
const statusBlocked = !!busy || !!form || !!recovery
|
||
const pageCount = Math.max(1, Math.ceil(visibleRows.length / 20))
|
||
const currentPage = Math.min(page, pageCount)
|
||
const pageRows = visibleRows.slice((currentPage - 1) * 20, currentPage * 20)
|
||
const needsStatusRefresh = rows.some(({ model }) => Object.values(model.verification).some((result) => result.status === 'running' || result.status === 'unknown'))
|
||
|
||
if (loading) return <section aria-label="正在加载 AI 模型接入" className="space-y-4 p-4 sm:p-6"><div className="h-6 w-36 motion-safe:animate-pulse rounded bg-[#e8edf3]" /><div className="h-24 motion-safe:animate-pulse rounded-lg bg-[#eef2f6]" /><div className="h-44 motion-safe:animate-pulse rounded-lg bg-[#eef2f6]" /></section>
|
||
if (!draft) return <section className="p-4 sm:p-6"><p role="alert" className="mb-3 text-sm text-[#b42318]">{error || 'AI 模型配置未能加载'}</p><button type="button" className={buttonClass} disabled={!!busy} onClick={() => void run('reload', () => request(), '已重新加载')}>重新加载</button></section>
|
||
|
||
return <section aria-label="AI 模型接入" className="min-w-0 space-y-6 p-4 text-[#101828] selection:bg-[#dbeafe] selection:text-[#101828] sm:p-6">
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
<h3 className="text-base font-semibold">AI 模型接入</h3>
|
||
<div className="flex flex-wrap gap-2">
|
||
{needsStatusRefresh && <button type="button" className={buttonClass} disabled={statusBlocked} onClick={() => void run('refresh-status', () => request(), '测试状态已刷新', { preserveDraft: true })}><RefreshCw className="h-4 w-4" aria-hidden="true" />刷新测试状态</button>}
|
||
{draft.legacyAvailable && <button type="button" className={buttonClass} disabled={providerBlocked} onClick={() => void run('import', () => request('/import-environment', { method: 'POST' }), '当前服务端连接已迁入')} >{busy === 'import' ? '正在迁入…' : '迁入当前连接'}</button>}
|
||
<button type="button" className={buttonClass} disabled={providerBlocked || !draft.encryptionConfigured} onClick={() => { setForm({ id: null, revision: 0, name: '', baseUrl: '', apiKey: '' }); setFormError(''); setNotice('') }}><Plus className="h-4 w-4" aria-hidden="true" />新增上游</button>
|
||
</div>
|
||
</div>
|
||
{!draft.encryptionConfigured && <p className="text-sm text-[#854a0e]">服务端尚未配置密钥加密,暂不能新增或替换 API Key。</p>}
|
||
{(error || notice) && <p role={error ? 'alert' : 'status'} className={`break-words rounded-lg px-3 py-2.5 text-sm ${error ? 'bg-[#fff0ee] text-[#b42318]' : 'bg-[#e9f8f2] text-[#087052]'}`}>{error || notice}</p>}
|
||
{recovery && <form aria-label={`核查 ${recovery.modelId} 的原请求`} className="space-y-3 border-y border-[#e2e8f0] py-4" onSubmit={event => {
|
||
event.preventDefault()
|
||
void run('reconcile', () => request(`/providers/${encodeURIComponent(recovery.providerId)}/verifications/${encodeURIComponent(recovery.verificationId)}/reconcile`, { method: 'POST', body: JSON.stringify({ outcome: recovery.outcome, reference: recovery.reference, note: recovery.note, confirmed: recovery.confirmed }) }), '核查已记录,可以重新测试', { preserveDraft: true })
|
||
}}>
|
||
<h4 className="break-all text-sm font-semibold">核查原请求 · {recovery.modelId}</h4>
|
||
{recovery.canFetchOriginal && <div className="space-y-2">
|
||
<p className="text-xs text-[#66758b]">取回并校验原响应,不会重新调用模型。未留存不代表执行失败。</p>
|
||
<div className="flex min-w-0 flex-wrap items-end gap-2"><label className="min-w-0 flex-1 basis-64 space-y-1.5 text-sm">客户请求编号<input className={fieldClass} value={recovery.requestId} disabled={!!busy} readOnly={recovery.requestIdLocked} maxLength={36} placeholder="填写已核实的原请求编号" onChange={event => setRecovery({ ...recovery, requestId: event.target.value.trim() })} /></label><button type="button" className={buttonClass} disabled={!!busy || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(recovery.requestId)} onClick={() => void run('recover-result', () => request(`/providers/${encodeURIComponent(recovery.providerId)}/verifications/${encodeURIComponent(recovery.verificationId)}/refresh`, { method: 'POST', body: JSON.stringify({ requestId: recovery.requestId }) }), '原请求查询已完成,请查看各用途状态', { preserveDraft: true })}>{busy === 'recover-result' ? '取回中…' : '取回原结果'}</button></div>
|
||
</div>}
|
||
<p className="text-xs text-[#66758b]">先在上游核查并确认请求已结束。此操作不会通过接口测试。</p>
|
||
<div className="grid gap-3 sm:grid-cols-2"><label className="space-y-1.5 text-sm">上游核查结论<select className={fieldClass} value={recovery.outcome} disabled={!!busy} onChange={event => setRecovery({ ...recovery, outcome: event.target.value as RecoveryForm['outcome'] })}><option value="not_executed">未执行</option><option value="failed">已失败</option><option value="completed">已完成,结果未取回</option></select></label><label className="space-y-1.5 text-sm">凭证编号<input autoFocus required minLength={3} maxLength={160} placeholder="工单或上游日志编号" className={fieldClass} value={recovery.reference} disabled={!!busy} onChange={event => setRecovery({ ...recovery, reference: event.target.value })} /></label></div>
|
||
<label className="block space-y-1.5 text-sm">核查说明<textarea required minLength={10} maxLength={500} rows={2} className={fieldClass} value={recovery.note} disabled={!!busy} placeholder="填写核查来源和结果,不要填写密钥" onChange={event => setRecovery({ ...recovery, note: event.target.value })} /></label>
|
||
<div className="flex flex-wrap items-center justify-between gap-3"><label className="inline-flex min-h-11 items-center gap-2 text-sm"><input type="checkbox" required className={checkboxClass} checked={recovery.confirmed} disabled={!!busy} onChange={event => setRecovery({ ...recovery, confirmed: event.target.checked })} />已核查,原请求已结束</label><div className="flex gap-2"><button type="button" className={buttonClass} disabled={!!busy} onClick={() => setRecovery(null)}>取消核查</button><button type="submit" className={primaryClass} disabled={!!busy || !recovery.confirmed || recovery.reference.trim().length < 3 || recovery.note.trim().length < 10}>{busy === 'reconcile' ? '记录中…' : '记录核查'}</button></div></div>
|
||
</form>}
|
||
<section aria-label="上游连接">
|
||
<h4 className="mb-2 text-sm font-semibold text-[#344054]">上游连接</h4>
|
||
{!draft.providers.length && !form && <p className="py-4 text-sm text-[#66758b]">{draft.legacyAvailable ? '迁入当前连接,或新增上游后同步模型目录。' : '新增上游后同步模型目录。'}</p>}
|
||
<div className="divide-y divide-[#e2e8f0] border-y border-[#e2e8f0]">
|
||
{draft.providers.map((provider) => <div key={provider.id} className="flex min-w-0 flex-wrap items-center justify-between gap-3 py-3">
|
||
<div className="min-w-0 flex-1 basis-48"><div className="flex flex-wrap items-center gap-2"><span className="break-words text-sm font-semibold">{provider.name}</span><span className={`text-xs ${provider.enabled ? 'text-[#087052]' : 'text-[#66758b]'}`}>{provider.enabled ? '已启用' : '已停用'}</span></div><p className="mt-1 break-all text-xs text-[#66758b]">{provider.baseUrl}</p><p className="mt-1 text-xs text-[#66758b]">{provider.hasApiKey ? (provider.credentialSource === 'environment' ? '使用服务端密钥' : '密钥已安全保存') : '未配置密钥'} · {provider.models.filter((model) => model.present).length} 个目录模型{provider.syncedAt ? ` · 同步于 ${new Date(provider.syncedAt).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}` : ' · 尚未同步'}</p></div>
|
||
<div className="flex flex-wrap gap-2"><button type="button" className={buttonClass} disabled={providerBlocked} aria-label={`编辑 ${provider.name}`} onClick={() => { setForm({ id: provider.id, revision: provider.revision, name: provider.name, baseUrl: provider.baseUrl, apiKey: '' }); setFormError(''); setNotice('') }}>编辑</button><button type="button" className={buttonClass} disabled={providerBlocked || !provider.hasApiKey} aria-label={`同步 ${provider.name} 的模型目录`} onClick={() => void run(`sync:${provider.id}`, () => request(`/providers/${encodeURIComponent(provider.id)}/sync`, { method: 'POST', body: JSON.stringify({ revision: provider.revision }) }), '模型目录已同步')}><RefreshCw className="h-4 w-4" aria-hidden="true" />{busy === `sync:${provider.id}` ? '同步中…' : '同步目录'}</button><button type="button" className={buttonClass} disabled={providerBlocked} aria-label={`${provider.enabled ? '停用' : '启用'} ${provider.name}`} onClick={() => void run(`toggle:${provider.id}`, () => request(`/providers/${encodeURIComponent(provider.id)}`, { method: 'PATCH', body: JSON.stringify({ enabled: !provider.enabled, revision: provider.revision }) }), provider.enabled ? '上游已停用' : '上游已启用')}>{provider.enabled ? '停用' : '启用'}</button></div>
|
||
</div>)}
|
||
</div>
|
||
{form && <form aria-label={form.id ? '编辑上游连接' : '新增上游连接'} onSubmit={saveProvider} className="mt-4 space-y-3 border-b border-[#e2e8f0] pb-4">
|
||
<div className="grid gap-3 sm:grid-cols-2"><label className="space-y-1.5 text-sm font-medium">上游名称<input autoFocus required maxLength={100} className={fieldClass} disabled={!!busy} value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} /></label><label className="space-y-1.5 text-sm font-medium">API 地址<input type="url" required maxLength={2048} placeholder="https://api.example.com" className={fieldClass} disabled={!!busy} value={form.baseUrl} onChange={(event) => setForm({ ...form, baseUrl: event.target.value })} /></label></div>
|
||
<label className="block space-y-1.5 text-sm font-medium">API Key<input type="password" autoComplete="new-password" required={!form.id} className={fieldClass} disabled={!!busy || !draft.encryptionConfigured} value={form.apiKey} placeholder={form.id ? '留空保留当前密钥' : '仅保存到服务端'} onChange={(event) => setForm({ ...form, apiKey: event.target.value })} /></label>
|
||
{formError && <p role="alert" className="break-words text-sm text-[#b42318]">{formError}</p>}
|
||
<div className="flex justify-end gap-2"><button type="button" className={buttonClass} disabled={!!busy} onClick={() => { setForm(null); setFormError('') }}>取消</button><button type="submit" className={primaryClass} disabled={!!busy}>{busy === 'provider-save' ? '保存中…' : '保存上游'}</button></div>
|
||
</form>}
|
||
</section>
|
||
<section aria-label="用户可选模型" className="space-y-4">
|
||
<div className="flex flex-wrap items-center justify-between gap-3"><h4 className="text-sm font-semibold text-[#344054]">用户可选模型</h4><div className="flex items-center gap-2">{dirty && <span role="status" className="text-xs text-[#66758b]">有未保存修改</span>}<button type="button" className={buttonClass} disabled={!dirty || !!busy || !!form} onClick={() => { setDraft(state); setError(''); setNotice('') }}>撤销</button><button type="button" className={primaryClass} disabled={!dirty || !!busy || !!form} onClick={() => void run('configuration', () => request('/configuration', { method: 'PUT', body: JSON.stringify({ revision: draft.revision, defaults: draft.defaults, providers: draft.providers.map((provider) => ({ id: provider.id, revision: provider.revision, models: provider.models.map(({ key, enabled, stages: roles }) => ({ key, enabled, stages: roles })) })) }) }), '模型配置已保存')}><Save className="h-4 w-4" aria-hidden="true" />{busy === 'configuration' ? '保存中…' : '保存配置'}</button></div></div>
|
||
<fieldset disabled={!!busy || !!form} className="grid min-w-0 gap-3 sm:grid-cols-3">
|
||
<legend className="sr-only">阶段默认模型</legend>
|
||
{stages.map((stage) => {
|
||
const options = rows.filter(({ provider, model }) => usable(provider, model, stage.id))
|
||
const selected = draft.defaults[stage.id]
|
||
const unavailable = !!selected && !options.some(({ model }) => model.key === selected)
|
||
const selectedRow = rows.find(({ model }) => model.key === selected)
|
||
return <label key={stage.id} className="min-w-0 space-y-1.5 text-sm font-medium">{stage.label}默认模型<select className={fieldClass} value={selected || ''} onChange={(event) => { setDraft({ ...draft, defaults: { ...draft.defaults, [stage.id]: event.target.value || null } }); setError(''); setNotice('') }}><option value="">未设置</option>{unavailable && <option value={selected!}>{selectedRow?.model.id || '原默认模型'} · 当前不可用</option>}{options.map(({ provider, model }) => <option key={model.key} value={model.key}>{model.id} · {provider.name}</option>)}</select>{(!selected || unavailable) && <span className="block text-xs font-normal text-[#854a0e]">{unavailable ? '请重新选择可用模型' : '尚未设置,详情页生成暂不可用'}</span>}</label>
|
||
})}
|
||
</fieldset>
|
||
{!!rows.length && <>
|
||
<div className="grid min-w-0 grid-cols-2 gap-2 sm:flex sm:flex-wrap"><label className="relative col-span-2 min-w-0 sm:flex-1 sm:basis-48"><span className="sr-only">搜索模型或上游</span><Search className="pointer-events-none absolute left-3 top-3.5 h-4 w-4 text-[#66758b]" aria-hidden="true" /><input className={`${fieldClass} !pl-9`} value={search} onChange={(event) => { setSearch(event.target.value); setPage(1) }} placeholder="搜索模型或上游" /></label><select aria-label="按能力筛选模型" className={`${fieldClass} sm:!w-auto sm:flex-none`} value={capabilityFilter} onChange={(event) => { setCapabilityFilter(event.target.value); setPage(1) }}><option value="all">全部能力</option>{Object.entries(capabilityLabels).map(([kind, label]) => <option key={kind} value={kind}>{label}</option>)}</select><select aria-label="按状态筛选模型" className={`${fieldClass} sm:!w-auto sm:flex-none`} value={statusFilter} onChange={(event) => { setStatusFilter(event.target.value); setPage(1) }}><option value="all">全部状态</option><option value="enabled">已开放</option><option value="disabled">未开放</option><option value="adapting">待适配</option><option value="untested">未测试</option><option value="running">测试中</option><option value="passed">已通过</option><option value="failed">测试失败</option><option value="unknown">待核查</option><option value="reconciled">已核查,可重测</option><option value="stale">需重新测试</option><option value="missing">已离开目录</option></select></div>
|
||
<p className="text-xs text-[#66758b]">多模态模型一次测试分析与文案,通过的用途可勾选;开放后保存配置。</p>
|
||
<div className="hidden grid-cols-[minmax(0,1fr)_88px_200px_248px] gap-3 border-b border-[#e2e8f0] bg-[#f8fafc] px-3 py-2 text-xs font-semibold text-[#4f6077] xl:grid" aria-hidden="true"><span>模型 / 上游</span><span>开放给用户</span><span>用户可选用途</span><span>实际测试</span></div>
|
||
<div className="divide-y divide-[#e2e8f0] border-b border-[#e2e8f0]" aria-label="模型目录">
|
||
{!visibleRows.length && <p role="status" className="px-3 py-5 text-sm text-[#66758b]">没有匹配模型</p>}
|
||
{pageRows.map(({ provider, model }) => {
|
||
const referenceAdaptation = needsReferenceAdaptation(model)
|
||
const capabilityReason = referenceAdaptation ? '参考图接入待适配,完成后可测试' : model.capabilities.reason
|
||
const testableStages = testPurposes(model)
|
||
const unresolvedStage = stages.find(stage => ['unknown', 'running'].includes(model.verification[stage.id].status))
|
||
const unresolvedPurpose = unresolvedStage && (model.verification[unresolvedStage.id].purpose || unresolvedStage.id)
|
||
const selectedStage = unresolvedPurpose ? { id: unresolvedPurpose, label: purposeLabel(unresolvedPurpose) }
|
||
: testableStages.find(stage => stage.id === testStages[model.key]) || testableStages.find(stage => aiModelTestStages(stage.id).some(purpose => model.verification[purpose].status !== 'passed')) || testableStages[0]
|
||
const testedStages = selectedStage ? aiModelTestStages(selectedStage.id) : []
|
||
const resultStage = testedStages.find(stage => ['unknown', 'running'].includes(model.verification[stage].status)) || testedStages[0]
|
||
const result = resultStage ? model.verification[resultStage] : null
|
||
const protocol = resultStage ? model.capabilities.protocols?.[resultStage] : null
|
||
const detailsId = `test-details-${model.key}-${selectedStage?.id}`
|
||
const sharedRequestId = result?.requestId && testedStages.length > 1 && testedStages.every(stage => model.verification[stage].requestId === result.requestId) ? result.requestId : null
|
||
const sharedCheckedAt = result?.checkedAt && testedStages.length > 1 && testedStages.every(stage => model.verification[stage].checkedAt === result.checkedAt) ? result.checkedAt : null
|
||
const canInspect = !!protocol || testedStages.some(stage => { const value = model.verification[stage]; return !!(value.message || value.checkedAt || value.requestId) })
|
||
const retest = testedStages.some(stage => { const value = model.verification[stage]; return value.status === 'passed' || value.status === 'failed' || value.status === 'stale' || value.reconciled })
|
||
const unsettled = Object.values(model.verification).some((entry) => entry.status === 'running' || entry.status === 'unknown')
|
||
const testing = busy.startsWith(`verify:${model.key}:`)
|
||
const attempt = uncertainAttempts.current.get(model.key)
|
||
const blockedReason = busy ? testing ? '正在测试,请稍候' : '其他操作正在进行,请稍候'
|
||
: recovery ? '请先完成或取消原请求核查'
|
||
: form ? '请先保存或取消上游编辑'
|
||
: !provider.enabled ? '请先启用上游连接'
|
||
: !provider.hasApiKey ? '请先配置上游密钥'
|
||
: !model.present ? '模型已离开上游目录,请同步目录'
|
||
: unsettled ? '已有测试进行中或结果未知,请先查询状态'
|
||
: !selectedStage ? capabilityReason : ''
|
||
const testStatusId = `test-status-${model.key}`
|
||
const blockedReasonId = `test-blocked-${model.key}`
|
||
const showBlockedReason = !!blockedReason && !testing && !attempt && !unsettled
|
||
return <div key={model.key} aria-label={`${model.id} · ${provider.name}`} className={`grid min-w-0 gap-x-3 gap-y-1 px-3 py-3 sm:grid-cols-[minmax(0,1fr)_auto] xl:grid-cols-[minmax(0,1fr)_88px_200px_248px] ${expandedDetails[detailsId] ? 'xl:items-start' : 'xl:items-center'}`}>
|
||
<div className="min-w-0"><p className="break-all text-sm font-medium">{model.id}</p><p className="mt-1 break-words text-xs text-[#66758b]"><span>{capabilityLabels[model.capabilities.kind]}</span> · {provider.name}{!provider.enabled ? ' · 上游已停用' : ''}{!model.present ? ' · 已离开上游目录' : ''}</p><span id={`capability-${model.key}`} className="sr-only">{capabilityReason}</span></div>
|
||
<label className="inline-flex min-h-11 items-center gap-2 text-sm"><input type="checkbox" className={checkboxClass} checked={model.enabled} disabled={!!busy || !!form || !model.enabled && (!model.present || !provider.enabled || !provider.hasApiKey || !model.stages.length || !model.stages.every((stage) => model.capabilities.allowedStages.includes(stage) && model.verification[stage].status === 'passed'))} aria-label={`开放 ${model.id} · ${provider.name} 给用户`} onChange={(event) => updateModel(provider.id, model.key, { enabled: event.target.checked })} />开放</label>
|
||
<fieldset disabled={!!busy || !!form} className="flex min-w-0 flex-wrap gap-x-3"><legend className="sr-only">{model.id} 的用户可选用途</legend>{stages.map((stage) => {
|
||
const adapted = model.capabilities.allowedStages.includes(stage.id)
|
||
const supported = adapted && model.verification[stage.id].status === 'passed'
|
||
const checked = model.stages.includes(stage.id)
|
||
const reason = supported ? `${stage.label}测试已通过` : adapted ? `${stage.label}:${verificationLabel(model.verification[stage.id])},通过后可开放` : referenceAdaptation && stage.id === 'image' ? capabilityReason : model.capabilities.allowedStages.length || referenceAdaptation ? `当前未接入${stage.label}用途` : capabilityReason
|
||
return <label key={stage.id} title={reason} className={`inline-flex min-h-11 items-center gap-1.5 text-xs ${!supported ? 'text-[#66758b]' : ''}`}><input type="checkbox" className={checkboxClass} aria-label={`${model.id} · ${provider.name} 用于${stage.label}`} aria-describedby={`purpose-${model.key}-${stage.id}`} checked={checked} disabled={!supported && !checked} onChange={(event) => { if (!event.target.checked || supported) updateModel(provider.id, model.key, { stages: event.target.checked ? [...model.stages, stage.id] : model.stages.filter((value) => value !== stage.id) }) }} />{stage.shortLabel}<span id={`purpose-${model.key}-${stage.id}`} className="sr-only">{reason}</span></label>
|
||
})}</fieldset>
|
||
<div className="min-w-0 space-y-1.5">
|
||
{selectedStage && result ? <>
|
||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||
<select aria-label={`${model.id} · ${provider.name} 测试用途`} className={`${fieldClass} !w-auto flex-1 !px-2`} disabled={!!busy || unsettled} value={selectedStage.id} onChange={(event) => setTestStages((current) => ({ ...current, [model.key]: event.target.value as AiModelTestPurpose }))}>
|
||
{(!testableStages.some(stage => stage.id === selectedStage.id) ? [selectedStage] : testableStages).map((stage) => <option key={stage.id} value={stage.id}>{stage.label}</option>)}
|
||
</select>
|
||
{attempt ? <button type="button" className={buttonClass} disabled={statusBlocked} aria-label={`核查 ${model.id} · ${provider.name} 的测试提交`} onClick={() => void run(`submission:${model.key}`, () => request(`/providers/${encodeURIComponent(provider.id)}/models/${encodeURIComponent(model.key)}/submission`, { method: 'POST', body: JSON.stringify({ stage: attempt.stage, idempotencyKey: attempt.requestId }) }), '', { preserveDraft: true })}>核查提交</button> : result.canResume && result.id ? <button type="button" className={buttonClass} disabled={statusBlocked || !provider.enabled || !provider.hasApiKey} aria-label={`查询 ${model.id} · ${provider.name} 的${selectedStage.label}结果`} onClick={() => {
|
||
setTestStages((current) => ({ ...current, [model.key]: selectedStage.id }))
|
||
void run(`result:${model.key}`, () => request(`/providers/${encodeURIComponent(provider.id)}/verifications/${encodeURIComponent(result.id!)}/refresh`, { method: 'POST' }), '', { preserveDraft: true })
|
||
}}>{busy === `result:${model.key}` ? '查询中…' : '查询结果'}</button> : result.canReconcile && result.id ? <button type="button" className={buttonClass} disabled={statusBlocked} aria-label={`核查 ${model.id} · ${provider.name} 的${selectedStage.label}结果`} onClick={() => { setError(''); setNotice(''); setRecovery(recoveryFormFor(provider, model, selectedStage.id, result)) }}>核查结果</button> : <button type="button" className={buttonClass} disabled={!!blockedReason} aria-describedby={showBlockedReason ? blockedReasonId : testStatusId} aria-label={`测试 ${model.id} · ${provider.name} 的${selectedStage.label}`} onClick={() => void testModel(provider, model, selectedStage.id)}>{testing || result.status === 'running' ? '测试中…' : retest ? '重测' : '测试'}</button>}
|
||
{canInspect && <button type="button" className={`${buttonClass} !w-11 !px-0`} aria-label={`${model.id} · ${provider.name} ${selectedStage.label}测试详情`} aria-expanded={!!expandedDetails[detailsId]} aria-controls={detailsId} title="测试详情" onClick={() => setExpandedDetails((current) => ({ ...current, [detailsId]: !current[detailsId] }))}><Info className="h-4 w-4" aria-hidden="true" /></button>}
|
||
</div>
|
||
<p id={testStatusId} role="status" className={`flex flex-wrap gap-x-3 gap-y-1 text-xs ${verificationColor(result)}`}>{testing ? '正在调用模型,请稍候' : attempt ? '提交结果未确认,请核查' : selectedStage.id === 'analysis-copy' ? testedStages.map(stage => {
|
||
const value = model.verification[stage]
|
||
return <span key={stage} className={verificationColor(value)}>{stages.find(entry => entry.id === stage)!.shortLabel}:{verificationLabel(value)}</span>
|
||
}) : result.status === 'running' ? '测试进行中,自动更新状态' : verificationLabel(result)}</p>
|
||
{showBlockedReason && <p id={blockedReasonId} className="text-xs text-[#66758b]">{blockedReason}</p>}
|
||
{canInspect && <div id={detailsId} hidden={!expandedDetails[detailsId]} className="space-y-1 break-words pb-2 text-xs text-[#66758b]">
|
||
{protocol && <>{protocol.maxImages && <p>{protocol.referenceLimitSource === 'verification' ? result.status === 'passed' && result.verifiedReferenceImageCount ? `参考图:已逐张验证 ${result.verifiedReferenceImageCount} 张,本平台最多 ${Math.min(protocol.maxImages, result.verifiedReferenceImageCount)} 张` : `参考图:数量待测试(本平台最多测试 ${protocol.maxImages} 张)` : `参考图:最多 ${protocol.maxImages} 张`}</p>}<p className="break-all">协议:{protocol.id}</p><p className="break-all">接口:{protocol.path}</p>{(protocol.scopeId || protocol.routeId || protocol.operationId) && <p className="break-all">路由:{[protocol.scopeId, protocol.routeId, protocol.operationId].filter(Boolean).join(' / ')}</p>}</>}
|
||
{protocol?.maxPromptLength && <p>提示词:最多 {protocol.maxPromptLength} 字</p>}
|
||
{protocol?.inputField && <p className="break-all">参考图参数:{protocol.inputField}({protocol.referenceTransport === 'multipart' ? '文件上传' : '内联图片'})</p>}
|
||
{protocol?.requestParameters && <div className="space-y-1 pt-1"><p className="font-medium text-[#344054]">当前请求参数</p><dl aria-label={`${model.id} 当前请求参数`} className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-x-3 gap-y-1">{Object.entries(protocol.requestParameters).map(([name, value]) => <div key={name} className="contents"><dt title={name}>{parameterLabels[name] || name}</dt><dd className="break-all text-right">{String(value)}</dd></div>)}</dl><p>{Object.keys(protocol.requestParameters).length ? '未列出的可选参数使用上游默认值。' : '未指定尺寸、比例和画质,使用上游默认值。'}</p></div>}
|
||
{protocol?.referenceLimitSource === 'verification' && <label className="flex flex-wrap items-center gap-2"><span>本次测试数量</span><select className={`${fieldClass} !w-auto`} aria-label={`${model.id} · ${provider.name} 本次测试参考图数量`} disabled={!!busy || result.status === 'running' || result.status === 'unknown' || !!attempt} value={testReferenceCounts[model.key] ?? result.verifiedReferenceImageCount ?? protocol.maxImages} onChange={event => setTestReferenceCounts(current => ({ ...current, [model.key]: Number(event.target.value) }))}>{[1, 2, 4, 8].filter(count => count <= (protocol.maxImages ?? 8)).map(count => <option key={count} value={count}>{count} 张</option>)}</select></label>}
|
||
{sharedCheckedAt && <p>{new Date(sharedCheckedAt).toLocaleString('zh-CN')}</p>}
|
||
{sharedRequestId && <p className="break-all">请求编号:{sharedRequestId}</p>}
|
||
{testedStages.map(stage => { const value = model.verification[stage]; return <div key={stage} className="space-y-1">{selectedStage.id === 'analysis-copy' && <p className="font-medium text-[#344054]">{stages.find(entry => entry.id === stage)!.label}</p>}{value.reconciled && <p>历史请求经人工核查结束,尚需重新测试。</p>}{value.message && <p>{value.message}</p>}{value.checkedAt && !sharedCheckedAt && <p>{new Date(value.checkedAt).toLocaleString('zh-CN')}</p>}{value.requestId && !sharedRequestId && <p className="break-all">请求编号:{value.requestId}</p>}</div> })}
|
||
{result.taskId && <p className="break-all">上游任务:{result.taskId}</p>}
|
||
{result.canResume && result.canReconcile && result.id && <button type="button" className={buttonClass} disabled={statusBlocked} aria-label={`核查 ${model.id} 的原请求凭证`} onClick={() => { setError(''); setNotice(''); setRecovery(recoveryFormFor(provider, model, selectedStage.id, result)) }}>核查凭证</button>}
|
||
</div>}
|
||
</> : referenceAdaptation ? <>
|
||
<div className="flex items-center justify-between gap-2"><p role="status" className="text-xs text-[#66758b]">待适配</p><button type="button" className={`${buttonClass} !w-11 !px-0`} aria-label={`${model.id} · ${provider.name} 参考图接入详情`} aria-expanded={!!expandedDetails[detailsId]} aria-controls={detailsId} title="参考图接入详情" onClick={() => setExpandedDetails((current) => ({ ...current, [detailsId]: !current[detailsId] }))}><Info className="h-4 w-4" aria-hidden="true" /></button></div>
|
||
<div id={detailsId} hidden={!expandedDetails[detailsId]} className="space-y-1 break-words pb-2 text-xs text-[#66758b]"><p>{capabilityReason}</p><p>{model.capabilities.reason}</p></div>
|
||
</> : <p role="status" className="break-words text-xs text-[#66758b]">{blockedReason}</p>}
|
||
</div>
|
||
</div>})}
|
||
</div>
|
||
<nav aria-label="模型目录分页" className="flex flex-wrap items-center justify-between gap-3 text-sm"><span className="text-xs text-[#66758b]">{visibleRows.length} 个模型 · 第 {currentPage} / {pageCount} 页</span><div className="flex gap-2"><button type="button" className={buttonClass} disabled={currentPage <= 1} onClick={() => setPage(currentPage - 1)}>上一页</button><button type="button" className={buttonClass} disabled={currentPage >= pageCount} onClick={() => setPage(currentPage + 1)}>下一页</button></div></nav>
|
||
</>}
|
||
</section>
|
||
{confirmActionDialog}
|
||
</section>
|
||
}
|