333 lines
39 KiB
TypeScript
333 lines
39 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 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>
|
||
}
|