186 lines
21 KiB
TypeScript
186 lines
21 KiB
TypeScript
'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>
|
||
}
|