gongxiangp2/components/admin/AiModelManagementPanel.tsx

394 lines
49 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 { 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>
}