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:
1 parent
1a80ac2a00
commit
8420691438
7 files changed
+422
-1
No files matched your search
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user