diff --git a/backend/cmd/cyrene/main.go b/backend/cmd/cyrene/main.go index 233a4c4..1a038e8 100644 --- a/backend/cmd/cyrene/main.go +++ b/backend/cmd/cyrene/main.go @@ -237,6 +237,7 @@ func main() { AuthMgr: authMgr, Orchestrator: orch, LLMLogger: &llmLoggerAdapter{Logger: llmLogger}, + ModelConfig: api.NewModelConfigStore(), PluginList: pluginNamesToMap(reg.ListNames()), } if memSvc != nil { diff --git a/backend/services/gateway/api/models.go b/backend/services/gateway/api/models.go new file mode 100644 index 0000000..be7bc25 --- /dev/null +++ b/backend/services/gateway/api/models.go @@ -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, "", " ") +} diff --git a/backend/services/gateway/api/router.go b/backend/services/gateway/api/router.go index 7a64dfd..9d7f3a1 100644 --- a/backend/services/gateway/api/router.go +++ b/backend/services/gateway/api/router.go @@ -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 --- diff --git a/frontend/web/src/App.tsx b/frontend/web/src/App.tsx index abff5d5..768da84 100644 --- a/frontend/web/src/App.tsx +++ b/frontend/web/src/App.tsx @@ -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() { } /> } /> } /> + } /> + } /> } /> diff --git a/frontend/web/src/components/admin/AdminLayout.tsx b/frontend/web/src/components/admin/AdminLayout.tsx index 1a79bcc..fbeb131 100644 --- a/frontend/web/src/components/admin/AdminLayout.tsx +++ b/frontend/web/src/components/admin/AdminLayout.tsx @@ -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: '⊞' }, ] diff --git a/frontend/web/src/pages/admin/BridgePage.tsx b/frontend/web/src/pages/admin/BridgePage.tsx new file mode 100644 index 0000000..a074833 --- /dev/null +++ b/frontend/web/src/pages/admin/BridgePage.tsx @@ -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 ( +
+

平台桥接

+

多平台消息桥接 — QQ (OBv11) / Telegram / Discord / Webhook。通过环境变量配置启用。

+ +
+ {adapters.map(a => ( +
+
+
+ + {a.name} +
+ {a.platform} +
+

{a.desc}

+
+ ))} +
+ +
+

群聊行为规则

+
    +
  • 消息最大 500 字,超出截断
  • +
  • 最短发送间隔 3 秒
  • +
  • 每轮最多 2 条消息
  • +
  • 非 @ 消息走审查模式(LLM 自主判断是否回复)
  • +
  • 闭嘴关键词:闭嘴 / 别说话 / 别说了 / 安静
  • +
+
+
+ ) +} diff --git a/frontend/web/src/pages/admin/ModelsPage.tsx b/frontend/web/src/pages/admin/ModelsPage.tsx new file mode 100644 index 0000000..8fad08f --- /dev/null +++ b/frontend/web/src/pages/admin/ModelsPage.tsx @@ -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([]) + const [models, setModels] = useState([]) + const [routing, setRouting] = useState([]) + 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 ( +
+

模型配置

+ + {/* Tabs */} +
+ {(['providers','models','routing'] as const).map(t => ( + + ))} +
+ + {/* Tab content */} + {tab === 'providers' && ( +
+ {providers.map(p => ( +
+
+

{p.name}

+

{p.base_url} · timeout {p.timeout_sec}s · retries {p.max_retries}

+
+ +
+ ))} + save('providers', name, body)} /> +
+ )} + + {tab === 'models' && ( +
+ {models.map(m => ( +
+
+

{m.name} {m.enabled ? '启用' : '禁用'}

+

{m.id} · {m.provider} · {m.description}

+
{(m.tags||[]).map(t => {t})}
+
+ +
+ ))} + save('models', name, body)} /> +
+ )} + + {tab === 'routing' && ( +
+ {routing.map(r => ( +
+
+

{r.purpose}

+

fallback: {r.fallback_chain?.join(' → ') || 'none'} {r.required ? '(required)' : ''}

+
+ +
+ ))} + save('routing', name, body)} /> +
+ )} +
+ ) +} + +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 ( +
+ 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" /> + 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" /> + +
+ ) +} + +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 ( +
+ 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" /> + 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" /> + + 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" /> + +
+ ) +} + +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 ( +
+ 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" /> + 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" /> + +
+ ) +}