gongxiangp2/components/admin/AiModelManagementPanel.tsx

333 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Info, Plus, RefreshCw, Save, Search } from 'lucide-react'
import { useConfirmActionDialog } from '@/components/ConfirmActionDialog'
import type { AiModelAdminState, AiModelCatalogEntry, AiModelProvider, AiModelStage } 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 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
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 }
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, AiModelStage>>({})
const [expandedDetails, setExpandedDetails] = useState<Record<string, boolean>>({})
const busyRef = useRef(false)
const uncertainAttempts = useRef(new Map<string, { stage: AiModelStage; 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 result = model.verification[attempt.stage]
if (result.submissionKey === attempt.requestId) { uncertainAttempts.current.delete(model.key); return model }
return { ...model, verification: { ...model.verification, [attempt.stage]: { ...result, 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' && stages.some(stage => stage.id === 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') 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: AiModelStage) {
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 || !savedModel.capabilities.allowedStages.includes(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 = stages.find((item) => item.id === stage)!.label
const confirmation = await confirmAction({ title: `测试${label}`, description: <><span className="break-all">{model.id}</span><br />将实际调用模型,可能产生上游费用。</>, confirmLabel: '开始测试', tone: 'primary' })
if (!confirmation.confirmed) { busyRef.current = false; setBusy(''); return }
setBusy(`verify:${model.key}:${stage}`); setError(''); setNotice('')
let idempotencyKey = ''
try {
idempotencyKey = crypto.randomUUID()
uncertainAttempts.current.set(model.key, { stage, previousId: savedModel.verification[stage].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 }) })
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 && (!(cause instanceof ModelRequestError) || cause.status >= 500)) uncertainAttempts.current.set(model.key, { stage, previousId: savedModel.verification[stage].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, [stage]: { ...item.verification[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 === 'untested' && !model.capabilities.allowedStages.some((stage) => ['untested', 'stale'].includes(model.verification[stage].status))) return false
if (statusFilter === 'attention' && !Object.values(model.verification).some((result) => ['failed', 'unknown', 'running'].includes(result.status))) 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>
<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="untested">待测试</option><option value="attention">待处理</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 testableStages = stages.filter((stage) => model.capabilities.allowedStages.includes(stage.id))
const selectedStage = stages.find((stage) => ['unknown', 'running'].includes(model.verification[stage.id].status)) || testableStages.find((stage) => stage.id === testStages[model.key]) || testableStages.find((stage) => model.verification[stage.id].status !== 'passed') || testableStages[0]
const result = selectedStage ? model.verification[selectedStage.id] : null
const protocol = selectedStage ? model.capabilities.protocols?.[selectedStage.id] : null
const detailsId = `test-details-${model.key}-${selectedStage?.id}`
const canInspect = !!protocol || !!result && !!(result.message || result.checkedAt || result.requestId)
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 ? '该用途尚未适配' : ''
const testStatusId = `test-status-${model.key}`
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] 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 title={model.capabilities.reason}>{capabilityLabels[model.capabilities.kind]}{model.capabilities.kind !== 'unknown' && !model.capabilities.allowedStages.length ? ' · 待适配' : ''}</span> · {provider.name}{!provider.enabled ? ' · 上游已停用' : ''}{!model.present ? ' · 已离开上游目录' : ''}</p><span id={`capability-${model.key}`} className="sr-only">{model.capabilities.reason}</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}:${verificationLabels[model.verification[stage.id].status]},通过后可开放` : model.capabilities.allowedStages.length ? `此模型不支持${stage.label}` : model.capabilities.reason
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 items-center gap-2">
<select aria-label={`${model.id} · ${provider.name} 测试用途`} className={`${fieldClass} !px-2`} disabled={!!busy || unsettled} value={selectedStage.id} onChange={(event) => setTestStages((current) => ({ ...current, [model.key]: event.target.value as AiModelStage }))}>
{(!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({ providerId: provider.id, verificationId: result.id!, modelId: model.id, outcome: 'not_executed', reference: '', note: '', confirmed: false }) }}>核查结果</button> : <button type="button" className={buttonClass} disabled={!!blockedReason} aria-describedby={testStatusId} aria-label={`测试 ${model.id} · ${provider.name} 的${selectedStage.label}`} onClick={() => void testModel(provider, model, selectedStage.id)}>{testing || result.status === 'running' ? '测试中…' : result.status === 'passed' || result.status === 'stale' || result.reconciled ? '重测' : '测试'}</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={`text-xs ${blockedReason ? 'text-[#66758b]' : result.status === 'passed' ? 'text-[#087052]' : result.status === 'failed' || result.status === 'unknown' ? 'text-[#b42318]' : 'text-[#66758b]'}`}>{testing ? '正在调用模型,请稍候' : attempt ? '提交结果未确认,请核查' : result.status === 'unknown' ? result.canResume ? '等待上游结果,可查询' : '结果未知,暂停核查' : result.status === 'running' ? '测试进行中,自动更新状态' : result.reconciled ? '已核查,可重测' : blockedReason || verificationLabels[result.status]}</p>
{canInspect && <div id={detailsId} hidden={!expandedDetails[detailsId]} className="space-y-1 break-words pb-2 text-xs text-[#66758b]">
{protocol && <><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>}</>}
{result.message && <p>{result.message}</p>}{result.checkedAt && <p>{new Date(result.checkedAt).toLocaleString('zh-CN')}</p>}{result.requestId && <p className="break-all">请求编号:{result.requestId}</p>}
{result.taskId && <p className="break-all">上游任务:{result.taskId}</p>}
</div>}
</> : <p className="text-xs text-[#66758b]">{model.capabilities.kind === 'unknown' ? '能力待识别,暂不可测试' : '暂未接入详情页用途'}</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>
}