gongxiangp2/components/admin/AiModelManagementPanel.tsx

186 lines
21 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 { Plus, RefreshCw, Save, Search } from 'lucide-react'
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: '生图', other: '其他', unknown: '待核验' } as const
interface ProviderForm { id: string | null; revision: number; name: string; baseUrl: string; apiKey: string }
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 Error(typeof body.error === 'string' ? body.error : body.error?.message || '操作未完成,请重试')
return body as AiModelAdminState
}
function usable(provider: AiModelProvider, model: AiModelCatalogEntry, stage: AiModelStage) {
return provider.enabled && provider.hasApiKey && model.present && model.enabled && model.compatibility === 'confirmed' && 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 [search, setSearch] = useState('')
const [capabilityFilter, setCapabilityFilter] = useState('all')
const [statusFilter, setStatusFilter] = useState('all')
const [page, setPage] = useState(1)
const busyRef = useRef(false)
const dirty = !!state && !!draft && JSON.stringify(state) !== JSON.stringify(draft)
function accept(next: AiModelAdminState) { setState(next); setDraft(next) }
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) return
const prevent = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = '' }
window.addEventListener('beforeunload', prevent)
return () => window.removeEventListener('beforeunload', prevent)
}, [dirty, form])
async function run(identity: string, action: () => Promise<AiModelAdminState>, 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<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 ? '上游已保存' : '上游已添加,请同步模型目录', true)
}
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 === 'unconfirmed' && model.compatibility !== 'unconfirmed') 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)
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">
{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>}
<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, compatibility }) => ({ key, enabled, stages: roles, compatibility })) })) }) }), '模型配置已保存')}><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="unconfirmed">兼容待确认</option><option value="missing">已离开目录</option></select></div>
<p className="text-xs text-[#66758b]">用户可自行选择已开放的模型;不支持的用途不可勾选。</p>
<div className="hidden grid-cols-[minmax(0,1fr)_100px_220px_156px] 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 }) => <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)_100px_220px_156px] 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.compatibility !== 'confirmed' || !model.stages.some((stage) => model.capabilities.allowedStages.includes(stage)))} 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 supported = model.capabilities.allowedStages.includes(stage.id)
const checked = model.stages.includes(stage.id)
const reason = model.capabilities.kind === 'unknown' ? '能力待核验,暂不能开放此用途' : stage.id === 'image' && (model.capabilities.kind === 'text' || model.capabilities.kind === 'vision') ? '不支持图片输出' : '当前详情页未适配此用途'
return <label key={stage.id} title={supported ? undefined : 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={`capability-${model.key}`} 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}</label>
})}</fieldset>
<label className="inline-flex min-h-11 items-center gap-2 text-xs"><input type="checkbox" className={checkboxClass} disabled={!!busy || !!form} checked={model.compatibility === 'confirmed'} aria-label={`确认 ${model.id} · ${provider.name} 接口兼容`} onChange={(event) => updateModel(provider.id, model.key, { compatibility: event.target.checked ? 'confirmed' : 'unconfirmed', ...(!event.target.checked ? { enabled: false } : {}) })} />管理员已确认</label>
</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>
</section>
}