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

+1
View File
@@ -237,6 +237,7 @@ func main() {
AuthMgr: authMgr,
Orchestrator: orch,
LLMLogger: &llmLoggerAdapter{Logger: llmLogger},
ModelConfig: api.NewModelConfigStore(),
PluginList: pluginNamesToMap(reg.ListNames()),
}
if memSvc != nil {
+135
View File
@@ -0,0 +1,135 @@
// Package api — ModelConfigStore manages LLM provider/model/routing configuration.
package api
import (
"encoding/json"
"sync"
)
// ModelProvider represents an LLM API provider.
type ModelProvider struct {
Name string `json:"name"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key,omitempty"`
Timeout int `json:"timeout_sec"`
Retries int `json:"max_retries"`
}
// ModelEntry represents a configured LLM model.
type ModelEntry struct {
ID string `json:"id"`
Name string `json:"name"`
Provider string `json:"provider"`
Description string `json:"description"`
Tags []string `json:"tags"`
Params map[string]any `json:"params"`
Enabled bool `json:"enabled"`
}
// RoutingRule defines fallback chain for a purpose.
type RoutingRule struct {
Purpose string `json:"purpose"`
FallbackChain []string `json:"fallback_chain"`
Required bool `json:"required"`
}
// ModelConfigStore is an in-memory store for model configuration.
type ModelConfigStore struct {
mu sync.RWMutex
providers map[string]*ModelProvider
models map[string]*ModelEntry
routing map[string]*RoutingRule
}
// NewModelConfigStore creates a store with sensible defaults.
func NewModelConfigStore() *ModelConfigStore {
s := &ModelConfigStore{
providers: make(map[string]*ModelProvider),
models: make(map[string]*ModelEntry),
routing: make(map[string]*RoutingRule),
}
// Default provider
s.providers["openai"] = &ModelProvider{
Name: "openai", BaseURL: "https://api.openai.com/v1",
Timeout: 120, Retries: 3,
}
// Default models
s.models["primary"] = &ModelEntry{
ID: "primary", Name: "gpt-4o", Provider: "openai",
Description: "主对话模型", Tags: []string{"chat"},
Params: map[string]any{"temperature": 0.8}, Enabled: true,
}
s.models["fallback"] = &ModelEntry{
ID: "fallback", Name: "gpt-4o-mini", Provider: "openai",
Description: "备用模型", Tags: []string{"chat"},
Params: map[string]any{"temperature": 0.8}, Enabled: true,
}
// Default routing
s.routing["chat"] = &RoutingRule{
Purpose: "chat", FallbackChain: []string{"primary", "fallback"}, Required: true,
}
return s
}
func (s *ModelConfigStore) GetProviders() []*ModelProvider {
s.mu.RLock(); defer s.mu.RUnlock()
result := make([]*ModelProvider, 0, len(s.providers))
for _, p := range s.providers { result = append(result, p) }
return result
}
func (s *ModelConfigStore) GetModels() []*ModelEntry {
s.mu.RLock(); defer s.mu.RUnlock()
result := make([]*ModelEntry, 0, len(s.models))
for _, m := range s.models { result = append(result, m) }
return result
}
func (s *ModelConfigStore) GetRouting() []*RoutingRule {
s.mu.RLock(); defer s.mu.RUnlock()
result := make([]*RoutingRule, 0, len(s.routing))
for _, r := range s.routing { result = append(result, r) }
return result
}
func (s *ModelConfigStore) SaveProvider(p *ModelProvider) {
s.mu.Lock(); defer s.mu.Unlock()
s.providers[p.Name] = p
}
func (s *ModelConfigStore) SaveModel(m *ModelEntry) {
s.mu.Lock(); defer s.mu.Unlock()
s.models[m.ID] = m
}
func (s *ModelConfigStore) SaveRouting(r *RoutingRule) {
s.mu.Lock(); defer s.mu.Unlock()
s.routing[r.Purpose] = r
}
func (s *ModelConfigStore) DeleteProvider(name string) {
s.mu.Lock(); defer s.mu.Unlock()
delete(s.providers, name)
}
func (s *ModelConfigStore) DeleteModel(id string) {
s.mu.Lock(); defer s.mu.Unlock()
delete(s.models, id)
}
func (s *ModelConfigStore) DeleteRouting(purpose string) {
s.mu.Lock(); defer s.mu.Unlock()
delete(s.routing, purpose)
}
// Import/Export for models.json compatibility
func (s *ModelConfigStore) ExportJSON() ([]byte, error) {
s.mu.RLock(); defer s.mu.RUnlock()
export := map[string]any{
"providers": s.providers,
"models": s.models,
"routing": s.routing,
}
return json.MarshalIndent(export, "", " ")
}
+92 -1
View File
@@ -6,6 +6,7 @@ import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
@@ -73,6 +74,7 @@ type Dependencies struct {
Orchestrator OrchestratorPipeline
WSHub WebSocketHub
LLMLogger LLMCallLogger
ModelConfig *ModelConfigStore
PluginList []map[string]string
}
@@ -114,6 +116,8 @@ func (r *Router) setupRoutes() {
r.mux.HandleFunc("/api/v1/admin/system", r.authMW(r.adminMW(r.systemInfoHandler)))
r.mux.HandleFunc("/api/v1/admin/plugins", r.authMW(r.adminMW(r.pluginListHandler)))
r.mux.HandleFunc("/api/v1/admin/sessions", r.authMW(r.adminMW(r.adminSessionsHandler)))
r.mux.HandleFunc("/api/v1/admin/models/config", r.authMW(r.adminMW(r.modelConfigHandler)))
r.mux.HandleFunc("/api/v1/admin/models/config/", r.authMW(r.adminMW(r.modelConfigItemHandler)))
}
// ServeHTTP implements http.Handler.
@@ -392,7 +396,94 @@ func (r *Router) pluginListHandler(w http.ResponseWriter, _ *http.Request) {
}
func (r *Router) adminSessionsHandler(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, 200, M{"sessions": []any{}, "total": r.deps.WSHub.Count()})
writeJSON(w, 200, M{"sessions": []any{}, "total": 0})
}
// --- Model Config ---
func (r *Router) modelConfigHandler(w http.ResponseWriter, req *http.Request) {
if r.deps.ModelConfig == nil {
writeJSON(w, 503, M{"error": "model config unavailable"})
return
}
switch req.Method {
case "GET":
writeJSON(w, 200, M{
"providers": r.deps.ModelConfig.GetProviders(),
"models": r.deps.ModelConfig.GetModels(),
"routing": r.deps.ModelConfig.GetRouting(),
})
default:
writeJSON(w, 405, M{"error": "method not allowed"})
}
}
func (r *Router) modelConfigItemHandler(w http.ResponseWriter, req *http.Request) {
if r.deps.ModelConfig == nil {
writeJSON(w, 503, M{"error": "model config unavailable"})
return
}
// Path: /api/v1/admin/models/config/{type}/{name}
path := strings.TrimPrefix(req.URL.Path, "/api/v1/admin/models/config/")
parts := strings.SplitN(path, "/", 2)
if len(parts) < 2 {
writeJSON(w, 400, M{"error": "invalid path"})
return
}
kind, name := parts[0], parts[1]
switch req.Method {
case "POST", "PUT":
var body map[string]any
json.NewDecoder(req.Body).Decode(&body)
switch kind {
case "providers":
r.deps.ModelConfig.SaveProvider(&ModelProvider{
Name: name, BaseURL: str(body, "base_url"),
APIKey: str(body, "api_key"), Timeout: num(body, "timeout_sec"),
Retries: num(body, "max_retries"),
})
case "models":
var tags []string
if t, ok := body["tags"].([]any); ok {
for _, v := range t { tags = append(tags, fmt.Sprint(v)) }
}
r.deps.ModelConfig.SaveModel(&ModelEntry{
ID: name, Name: str(body, "name"), Provider: str(body, "provider"),
Description: str(body, "description"), Tags: tags,
Params: body["params"].(map[string]any), Enabled: true,
})
case "routing":
var chain []string
if c, ok := body["fallback_chain"].([]any); ok {
for _, v := range c { chain = append(chain, fmt.Sprint(v)) }
}
r.deps.ModelConfig.SaveRouting(&RoutingRule{
Purpose: name, FallbackChain: chain, Required: body["required"].(bool),
})
}
writeJSON(w, 200, M{"ok": true})
case "DELETE":
switch kind {
case "providers": r.deps.ModelConfig.DeleteProvider(name)
case "models": r.deps.ModelConfig.DeleteModel(name)
case "routing": r.deps.ModelConfig.DeleteRouting(name)
}
writeJSON(w, 204, nil)
default:
writeJSON(w, 405, M{"error": "method not allowed"})
}
}
func str(m map[string]any, key string) string {
if v, ok := m[key].(string); ok { return v }
return ""
}
func num(m map[string]any, key string) int {
if v, ok := m[key].(float64); ok { return int(v) }
return 0
}
// --- Helpers ---
+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>
)
}