'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-sm text-[#101828] placeholder:text-[#66758b] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] disabled:bg-[#f8fafc] disabled:text-[#66758b]' 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 } class ModelRequestError extends Error { constructor(message: string, readonly status: number) { super(message) } } async function request(path = '', init: RequestInit = {}): Promise { 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(null) const [draft, setDraft] = useState(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(null) const [search, setSearch] = useState('') const [capabilityFilter, setCapabilityFilter] = useState('all') const [statusFilter, setStatusFilter] = useState('all') const [page, setPage] = useState(1) const [testStages, setTestStages] = useState>({}) const [expandedDetails, setExpandedDetails] = useState>({}) const busyRef = useRef(false) const uncertainAttempts = useRef(new Map()) const { confirmAction, confirmActionDialog } = useConfirmActionDialog() const dirty = !!state && !!draft && JSON.stringify(state) !== JSON.stringify(draft) function accept(next: AiModelAdminState) { const resolved = { ...next, 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.id && result.id !== attempt.previousId) { uncertainAttempts.current.delete(model.key); return model } return { ...model, verification: { ...model.verification, [attempt.stage]: { ...result, status: 'unknown' as const, requestId: attempt.requestId, canResume: false, message: '尚未获取本次测试记录,请核查请求编号后再操作' } } } }) })) } setState(resolved); setDraft(resolved) } useEffect(() => { let cancelled = false request().then((next) => { if (!cancelled) accept(next) }).catch((cause) => { if (!cancelled) setError(cause.message) }).finally(() => { if (!cancelled) setLoading(false) }) return () => { cancelled = true } }, []) useEffect(() => { onStateChange?.({ dirty: dirty || !!form, busy: !!busy }) }, [dirty, form, busy, onStateChange]) useEffect(() => () => { onStateChange?.({ dirty: false, busy: false }) }, [onStateChange]) useEffect(() => { if (!dirty && !form && !busy.startsWith('verify:')) return const prevent = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = '' } window.addEventListener('beforeunload', prevent) return () => window.removeEventListener('beforeunload', prevent) }, [dirty, form, busy]) async function run(identity: string, action: () => Promise, success: string, closeForm = false) { if (busyRef.current) return busyRef.current = true; setBusy(identity); setError(''); setFormError(''); setNotice('') try { accept(await action()) setNotice(success) if (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) { 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) { 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 ? '上游已保存' : '上游已添加,请同步模型目录', true) } async function testModel(provider: AiModelProvider, model: AiModelCatalogEntry, stage: AiModelStage) { if (busyRef.current || dirty || form || !provider.enabled || !provider.hasApiKey || !model.present || !model.capabilities.allowedStages.includes(stage) || Object.values(model.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: <>{model.id}
将实际调用模型,可能产生上游费用。, confirmLabel: '开始测试', tone: 'primary' }) if (!confirmation.confirmed) { busyRef.current = false; setBusy(''); return } setBusy(`verify:${model.key}:${stage}`); setError(''); setNotice('') let idempotencyKey = '' try { idempotencyKey = crypto.randomUUID() const next = await request(`/providers/${encodeURIComponent(provider.id)}/models/${encodeURIComponent(model.key)}/verify`, { method: 'POST', body: JSON.stringify({ stage, revision: provider.revision, idempotencyKey }) }) accept(next) } 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: model.verification[stage].id, requestId: idempotencyKey }) try { accept(await request()) } 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 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
if (!draft) return

{error || 'AI 模型配置未能加载'}

return

AI 模型接入

{needsStatusRefresh && } {draft.legacyAvailable && }
{!draft.encryptionConfigured &&

服务端尚未配置密钥加密,暂不能新增或替换 API Key。

} {(error || notice) &&

{error || notice}

}

上游连接

{!draft.providers.length && !form &&

{draft.legacyAvailable ? '迁入当前连接,或新增上游后同步模型目录。' : '新增上游后同步模型目录。'}

}
{draft.providers.map((provider) =>
{provider.name}{provider.enabled ? '已启用' : '已停用'}

{provider.baseUrl}

{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' })}` : ' · 尚未同步'}

)}
{form &&
{formError &&

{formError}

}
}

用户可选模型

{dirty && 有未保存修改}
阶段默认模型 {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 })}
{!!rows.length && <>

先测试用途,再勾选开放并保存。{dirty ? ' 测试前请先保存或撤销修改。' : ''}

{!visibleRows.length &&

没有匹配模型

} {pageRows.map(({ provider, model }) => { const testableStages = stages.filter((stage) => model.capabilities.allowedStages.includes(stage.id)) const selectedStage = testableStages.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 blockedTest = providerBlocked || !provider.enabled || !provider.hasApiKey || !model.present || !selectedStage || unsettled return

{model.id}

{capabilityLabels[model.capabilities.kind]}{model.capabilities.kind !== 'unknown' && !model.capabilities.allowedStages.length ? ' · 待适配' : ''} · {provider.name}{!provider.enabled ? ' · 上游已停用' : ''}{!model.present ? ' · 已离开上游目录' : ''}

{model.capabilities.reason}
{model.id} 的用户可选用途{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 })}
{selectedStage && result ? <>
{result.canResume && result.id ? : } {canInspect && }

{testing ? '正在调用模型,请稍候' : result.status === 'unknown' ? result.canResume ? '等待上游结果,可查询' : '结果未知,暂停核查' : result.status === 'running' ? '测试进行中,可刷新状态' : `${selectedStage.shortLabel} · ${verificationLabels[result.status]}`}

{canInspect && } :

{model.capabilities.kind === 'unknown' ? '能力待识别,暂不可测试' : '暂未接入详情页用途'}

}
})}
}
{confirmActionDialog}
}