feat(admin): add model config CRUD and platform bridge pages

- Backend: ModelConfigStore with Provider/Model/Routing CRUD
- Backend: /api/v1/admin/models/config endpoints
- Frontend: ModelsPage with 3-tab editor (providers/models/routing)
- Frontend: BridgePage showing adapter status + group chat rules
- Admin nav expanded to 9 items
This commit is contained in:
Aska committed 2026-08-12 17:13:29 +08:00
1 parent 1a80ac2a00
commit 8420691438
7 files changed
+422 -1

No files matched your search

+4
View File
@@ -10,6 +10,8 @@ import PluginsPage from './pages/admin/PluginsPage'
import MemoryPage from './pages/admin/MemoryPage'
import LLMCallsPage from './pages/admin/LLMCallsPage'
import SystemPage from './pages/admin/SystemPage'
import ModelsPage from './pages/admin/ModelsPage'
import BridgePage from './pages/admin/BridgePage'
import LoginPage from './pages/LoginPage'
export default function App() {
@@ -38,6 +40,8 @@ export default function App() {
<Route path="plugins" element={<PluginsPage />} />
<Route path="memory" element={<MemoryPage />} />
<Route path="llm-calls" element={<LLMCallsPage />} />
<Route path="models" element={<ModelsPage />} />
<Route path="bridge" element={<BridgePage />} />
<Route path="system" element={<SystemPage />} />
</Route>
</Routes>
@@ -4,8 +4,10 @@ const navItems = [
{ to: '/admin/dashboard', label: '仪表盘', icon: '◉' },
{ to: '/admin/services', label: '服务管理', icon: '⚙' },
{ to: '/admin/iot', label: 'IoT 设备', icon: '⌂' },
{ to: '/admin/models', label: '模型配置', icon: '◆' },
{ to: '/admin/plugins', label: '插件管理', icon: '◇' },
{ to: '/admin/memory', label: '记忆管理', icon: '♡' },
{ to: '/admin/bridge', label: '平台桥接', icon: '↗' },
{ to: '/admin/llm-calls', label: 'LLM 日志', icon: '🧠' },
{ to: '/admin/system', label: '系统信息', icon: '⊞' },
]
@@ -0,0 +1,41 @@
export default function BridgePage() {
const adapters = [
{ name: 'qq-main', platform: 'QQ (OBv11)', status: 'ready', desc: '等待环境变量 QQ_OBV11_WS_URL 配置' },
{ name: 'tg-main', platform: 'Telegram', status: 'ready', desc: '等待环境变量 TG_BOT_TOKEN 配置' },
{ name: 'dc-main', platform: 'Discord', status: 'ready', desc: '等待环境变量 DC_BOT_TOKEN 配置' },
{ name: 'webhook-main', platform: 'Webhook', status: 'ready', desc: '等待环境变量 WEBHOOK_LISTEN_ADDR 配置' },
]
return (
<div className="p-6 space-y-4">
<h2 className="text-lg font-semibold">平台桥接</h2>
<p className="text-xs text-white/40">多平台消息桥接 — QQ (OBv11) / Telegram / Discord / Webhook。通过环境变量配置启用。</p>
<div className="space-y-2">
{adapters.map(a => (
<div key={a.name} className="bg-[#000] border border-[#404040] rounded-[18px] px-4 py-3">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-3">
<span className="w-2.5 h-2.5 rounded-full bg-[#F2F2F2]/30" />
<span className="text-sm text-[#F2F2F2]">{a.name}</span>
</div>
<span className="text-xs px-2 py-0.5 rounded-full bg-[#2B3B54]/50 text-[#4788FF]">{a.platform}</span>
</div>
<p className="text-xs text-white/30 pl-7">{a.desc}</p>
</div>
))}
</div>
<div className="bg-[#000] border border-[#404040] rounded-[18px] p-4 mt-4">
<h3 className="text-sm font-medium text-[#F2F2F2] mb-2">群聊行为规则</h3>
<ul className="text-xs text-white/40 space-y-1 list-disc list-inside">
<li>消息最大 500 字,超出截断</li>
<li>最短发送间隔 3 秒</li>
<li>每轮最多 2 条消息</li>
<li>非 @ 消息走审查模式(LLM 自主判断是否回复)</li>
<li>闭嘴关键词:闭嘴 / 别说话 / 别说了 / 安静</li>
</ul>
</div>
</div>
)
}
+147
View File
@@ -0,0 +1,147 @@
import { useEffect, useState } from 'react'
import { getToken } from '../../api/client'
type Provider = { name:string; base_url:string; timeout_sec:number; max_retries:number }
type Model = { id:string; name:string; provider:string; description:string; tags:string[]; enabled:boolean }
type Routing = { purpose:string; fallback_chain:string[]; required:boolean }
export default function ModelsPage() {
const [providers, setProviders] = useState<Provider[]>([])
const [models, setModels] = useState<Model[]>([])
const [routing, setRouting] = useState<Routing[]>([])
const [tab, setTab] = useState<'providers'|'models'|'routing'>('providers')
const fetchAll = async () => {
const token = getToken()
const res = await fetch('/api/v1/admin/models/config', { headers: { Authorization: `Bearer ${token}` } })
const data = await res.json()
setProviders(data.providers || [])
setModels(data.models || [])
setRouting(data.routing || [])
}
useEffect(() => { fetchAll() }, [])
const save = async (kind: string, name: string, body: any) => {
const token = getToken()
await fetch(`/api/v1/admin/models/config/${kind}/${name}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify(body),
})
fetchAll()
}
const remove = async (kind: string, name: string) => {
const token = getToken()
await fetch(`/api/v1/admin/models/config/${kind}/${name}`, {
method: 'DELETE', headers: { Authorization: `Bearer ${token}` },
})
fetchAll()
}
return (
<div className="p-6 space-y-4">
<h2 className="text-lg font-semibold">模型配置</h2>
{/* Tabs */}
<div className="flex gap-1 bg-[#000] rounded-full p-1 w-fit border border-[#404040]">
{(['providers','models','routing'] as const).map(t => (
<button key={t} onClick={() => setTab(t)}
className={`px-4 py-1.5 rounded-full text-sm transition-colors ${
tab === t ? 'bg-[#277AF7] text-white' : 'text-white/40 hover:text-white/70'
}`}
>{t === 'providers' ? '提供商' : t === 'models' ? '模型' : '路由'}</button>
))}
</div>
{/* Tab content */}
{tab === 'providers' && (
<div className="space-y-2">
{providers.map(p => (
<div key={p.name} className="bg-[#000] border border-[#404040] rounded-[18px] px-4 py-3 flex items-center justify-between">
<div>
<p className="text-sm text-[#F2F2F2]">{p.name}</p>
<p className="text-xs text-white/30">{p.base_url} · timeout {p.timeout_sec}s · retries {p.max_retries}</p>
</div>
<button onClick={() => remove('providers', p.name)} className="text-xs text-[#F12522]/60 hover:text-[#F12522]">删除</button>
</div>
))}
<AddProviderForm onSave={(name, body) => save('providers', name, body)} />
</div>
)}
{tab === 'models' && (
<div className="space-y-2">
{models.map(m => (
<div key={m.id} className="bg-[#000] border border-[#404040] rounded-[18px] px-4 py-3 flex items-center justify-between">
<div>
<p className="text-sm text-[#F2F2F2]">{m.name} <span className={`ml-2 text-xs ${m.enabled ? 'text-[#4788FF]' : 'text-white/30'}`}>{m.enabled ? '启用' : '禁用'}</span></p>
<p className="text-xs text-white/30">{m.id} · {m.provider} · {m.description}</p>
<div className="flex gap-1 mt-1">{(m.tags||[]).map(t => <span key={t} className="text-xs px-1.5 py-0.5 rounded bg-[#2B3B54]/50 text-[#4788FF]">{t}</span>)}</div>
</div>
<button onClick={() => remove('models', m.id)} className="text-xs text-[#F12522]/60 hover:text-[#F12522]">删除</button>
</div>
))}
<AddModelForm providers={providers} onSave={(name, body) => save('models', name, body)} />
</div>
)}
{tab === 'routing' && (
<div className="space-y-2">
{routing.map(r => (
<div key={r.purpose} className="bg-[#000] border border-[#404040] rounded-[18px] px-4 py-3 flex items-center justify-between">
<div>
<p className="text-sm text-[#F2F2F2]">{r.purpose}</p>
<p className="text-xs text-white/30">fallback: {r.fallback_chain?.join(' → ') || 'none'} {r.required ? '(required)' : ''}</p>
</div>
<button onClick={() => remove('routing', r.purpose)} className="text-xs text-[#F12522]/60 hover:text-[#F12522]">删除</button>
</div>
))}
<AddRoutingForm onSave={(name, body) => save('routing', name, body)} />
</div>
)}
</div>
)
}
function AddProviderForm({ onSave }: { onSave: (name:string, body:any) => void }) {
const [name, setName] = useState('')
const [url, setUrl] = useState('')
const save = () => { if(name&&url) { onSave(name, {base_url:url, timeout_sec:120, max_retries:3}); setName(''); setUrl('') } }
return (
<div className="flex gap-2">
<input value={name} onChange={e=>setName(e.target.value)} placeholder="名称" className="flex-1 bg-[#000] border border-[#404040] rounded-[14px] px-3 py-2 text-sm text-[#F2F2F2] placeholder:text-white/30 outline-none" />
<input value={url} onChange={e=>setUrl(e.target.value)} placeholder="Base URL" className="flex-1 bg-[#000] border border-[#404040] rounded-[14px] px-3 py-2 text-sm text-[#F2F2F2] placeholder:text-white/30 outline-none" />
<button onClick={save} className="px-4 py-2 bg-[#277AF7] text-white text-sm rounded-full font-medium">添加</button>
</div>
)
}
function AddModelForm({ providers, onSave }: { providers:Provider[]; onSave: (name:string, body:any) => void }) {
const [id, setId] = useState(''); const [name, setName] = useState('')
const [prov, setProv] = useState(providers[0]?.name||''); const [desc, setDesc] = useState('')
const save = () => { if(id&&name) { onSave(id, {name, provider:prov, description:desc, tags:[], params:{}, enabled:true}); setId(''); setName('') } }
return (
<div className="flex flex-wrap gap-2">
<input value={id} onChange={e=>setId(e.target.value)} placeholder="ID" className="bg-[#000] border border-[#404040] rounded-[14px] px-3 py-2 text-sm w-32 text-[#F2F2F2] placeholder:text-white/30 outline-none" />
<input value={name} onChange={e=>setName(e.target.value)} placeholder="模型名" className="bg-[#000] border border-[#404040] rounded-[14px] px-3 py-2 text-sm w-32 text-[#F2F2F2] placeholder:text-white/30 outline-none" />
<select value={prov} onChange={e=>setProv(e.target.value)} className="bg-[#000] border border-[#404040] rounded-[14px] px-3 py-2 text-sm text-[#F2F2F2] outline-none">
{providers.map(p => <option key={p.name}>{p.name}</option>)}
</select>
<input value={desc} onChange={e=>setDesc(e.target.value)} placeholder="描述" className="bg-[#000] border border-[#404040] rounded-[14px] px-3 py-2 text-sm flex-1 text-[#F2F2F2] placeholder:text-white/30 outline-none" />
<button onClick={save} className="px-4 py-2 bg-[#277AF7] text-white text-sm rounded-full font-medium">添加</button>
</div>
)
}
function AddRoutingForm({ onSave }: { onSave: (name:string, body:any) => void }) {
const [purpose, setPurpose] = useState('')
const [chain, setChain] = useState('')
const save = () => { if(purpose) { onSave(purpose, {fallback_chain: chain.split(',').map(s=>s.trim()).filter(Boolean), required:true}); setPurpose(''); setChain('') } }
return (
<div className="flex gap-2">
<input value={purpose} onChange={e=>setPurpose(e.target.value)} placeholder="用途 (chat/intent/thinking)" className="flex-1 bg-[#000] border border-[#404040] rounded-[14px] px-3 py-2 text-sm text-[#F2F2F2] placeholder:text-white/30 outline-none" />
<input value={chain} onChange={e=>setChain(e.target.value)} placeholder="fallback chain (逗号分隔)" className="flex-2 bg-[#000] border border-[#404040] rounded-[14px] px-3 py-2 text-sm text-[#F2F2F2] placeholder:text-white/30 outline-none" />
<button onClick={save} className="px-4 py-2 bg-[#277AF7] text-white text-sm rounded-full font-medium">添加</button>
</div>
)
}