'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 { 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 [recovery, setRecovery] = 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 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 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, 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) { 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 ? '上游已保存' : '上游已添加,请同步模型目录', { 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: <>{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() 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
if (!draft) return

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

return

AI 模型接入

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

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

} {(error || notice) &&

{error || notice}

} {recovery &&
{ 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 }) }}>

核查原请求 · {recovery.modelId}

先在上游核查并确认请求已结束。此操作不会通过接口测试。