'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 = { 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 { 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 [testReferenceCounts, setTestReferenceCounts] = 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 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 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' || 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) { 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: 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: <>{model.id}
{stage === 'analysis-copy' && <>一次调用,分别验证读图和文案能力。
}{referenceImageCount && <>逐张校验 {referenceImageCount} 张参考图,输出一张拼图。
}将实际调用模型,可能产生上游费用。, 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
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}

{recovery.canFetchOriginal &&

取回并校验原响应,不会重新调用模型。未留存不代表执行失败。

}

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