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
@@ -237,6 +237,7 @@ func main() {
|
||||
AuthMgr: authMgr,
|
||||
Orchestrator: orch,
|
||||
LLMLogger: &llmLoggerAdapter{Logger: llmLogger},
|
||||
ModelConfig: api.NewModelConfigStore(),
|
||||
PluginList: pluginNamesToMap(reg.ListNames()),
|
||||
}
|
||||
if memSvc != nil {
|
||||
|
||||
@@ -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, "", " ")
|
||||
}
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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