mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-05-08 10:17:30 +08:00
webui phase2: add native nodes/cron api endpoints and management UI
This commit is contained in:
@@ -189,6 +189,22 @@ func gatewayCmd() {
|
|||||||
registryServer.SetConfigAfterHook(func() {
|
registryServer.SetConfigAfterHook(func() {
|
||||||
_ = syscall.Kill(os.Getpid(), syscall.SIGHUP)
|
_ = syscall.Kill(os.Getpid(), syscall.SIGHUP)
|
||||||
})
|
})
|
||||||
|
registryServer.SetCronHandler(func(action, id string) (interface{}, error) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(action)) {
|
||||||
|
case "", "list":
|
||||||
|
return cronService.ListJobs(true), nil
|
||||||
|
case "delete":
|
||||||
|
return map[string]interface{}{"deleted": cronService.RemoveJob(strings.TrimSpace(id)), "id": strings.TrimSpace(id)}, nil
|
||||||
|
case "enable":
|
||||||
|
j := cronService.EnableJob(strings.TrimSpace(id), true)
|
||||||
|
return map[string]interface{}{"ok": j != nil, "id": strings.TrimSpace(id)}, nil
|
||||||
|
case "disable":
|
||||||
|
j := cronService.EnableJob(strings.TrimSpace(id), false)
|
||||||
|
return map[string]interface{}{"ok": j != nil, "id": strings.TrimSpace(id)}, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported cron action: %s", action)
|
||||||
|
}
|
||||||
|
})
|
||||||
if err := registryServer.Start(ctx); err != nil {
|
if err := registryServer.Start(ctx); err != nil {
|
||||||
fmt.Printf("Error starting node registry server: %v\n", err)
|
fmt.Printf("Error starting node registry server: %v\n", err)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type RegistryServer struct {
|
|||||||
configPath string
|
configPath string
|
||||||
onChat func(ctx context.Context, sessionKey, content string) (string, error)
|
onChat func(ctx context.Context, sessionKey, content string) (string, error)
|
||||||
onConfigAfter func()
|
onConfigAfter func()
|
||||||
|
onCron func(action, id string) (interface{}, error)
|
||||||
webUIDir string
|
webUIDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +41,10 @@ func (s *RegistryServer) SetChatHandler(fn func(ctx context.Context, sessionKey,
|
|||||||
s.onChat = fn
|
s.onChat = fn
|
||||||
}
|
}
|
||||||
func (s *RegistryServer) SetConfigAfterHook(fn func()) { s.onConfigAfter = fn }
|
func (s *RegistryServer) SetConfigAfterHook(fn func()) { s.onConfigAfter = fn }
|
||||||
func (s *RegistryServer) SetWebUIDir(dir string) { s.webUIDir = strings.TrimSpace(dir) }
|
func (s *RegistryServer) SetCronHandler(fn func(action, id string) (interface{}, error)) {
|
||||||
|
s.onCron = fn
|
||||||
|
}
|
||||||
|
func (s *RegistryServer) SetWebUIDir(dir string) { s.webUIDir = strings.TrimSpace(dir) }
|
||||||
|
|
||||||
func (s *RegistryServer) Start(ctx context.Context) error {
|
func (s *RegistryServer) Start(ctx context.Context) error {
|
||||||
if s.mgr == nil {
|
if s.mgr == nil {
|
||||||
@@ -58,6 +62,8 @@ func (s *RegistryServer) Start(ctx context.Context) error {
|
|||||||
mux.HandleFunc("/webui/api/config", s.handleWebUIConfig)
|
mux.HandleFunc("/webui/api/config", s.handleWebUIConfig)
|
||||||
mux.HandleFunc("/webui/api/chat", s.handleWebUIChat)
|
mux.HandleFunc("/webui/api/chat", s.handleWebUIChat)
|
||||||
mux.HandleFunc("/webui/api/upload", s.handleWebUIUpload)
|
mux.HandleFunc("/webui/api/upload", s.handleWebUIUpload)
|
||||||
|
mux.HandleFunc("/webui/api/nodes", s.handleWebUINodes)
|
||||||
|
mux.HandleFunc("/webui/api/cron", s.handleWebUICron)
|
||||||
s.server = &http.Server{Addr: s.addr, Handler: mux}
|
s.server = &http.Server{Addr: s.addr, Handler: mux}
|
||||||
go func() {
|
go func() {
|
||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
@@ -301,6 +307,60 @@ func (s *RegistryServer) handleWebUIChat(w http.ResponseWriter, r *http.Request)
|
|||||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "reply": resp, "session": session})
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "reply": resp, "session": session})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *RegistryServer) handleWebUINodes(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.checkAuth(r) {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list := []NodeInfo{}
|
||||||
|
if s.mgr != nil {
|
||||||
|
list = s.mgr.List()
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "nodes": list})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RegistryServer) handleWebUICron(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.checkAuth(r) {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.onCron == nil {
|
||||||
|
http.Error(w, "cron handler not configured", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
res, err := s.onCron("list", "")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "jobs": res})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
var body struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := s.onCron(strings.ToLower(strings.TrimSpace(body.Action)), strings.TrimSpace(body.ID))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "result": res})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *RegistryServer) checkAuth(r *http.Request) bool {
|
func (s *RegistryServer) checkAuth(r *http.Request) bool {
|
||||||
if s.token == "" {
|
if s.token == "" {
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
type ChatItem = { role: 'user' | 'assistant'; text: string }
|
type ChatItem = { role: 'user' | 'assistant'; text: string }
|
||||||
|
|
||||||
type Session = { key: string; title: string }
|
type Session = { key: string; title: string }
|
||||||
|
type CronJob = { id: string; name: string; enabled: boolean; schedule?: { kind?: string } }
|
||||||
|
|
||||||
const defaultSessions: Session[] = [{ key: 'webui:default', title: 'Default' }]
|
const defaultSessions: Session[] = [{ key: 'webui:default', title: 'Default' }]
|
||||||
|
|
||||||
@@ -14,6 +14,7 @@ export function App() {
|
|||||||
const [chat, setChat] = useState<Record<string, ChatItem[]>>({ 'webui:default': [] })
|
const [chat, setChat] = useState<Record<string, ChatItem[]>>({ 'webui:default': [] })
|
||||||
const [msg, setMsg] = useState('')
|
const [msg, setMsg] = useState('')
|
||||||
const [nodes, setNodes] = useState<string>('[]')
|
const [nodes, setNodes] = useState<string>('[]')
|
||||||
|
const [cron, setCron] = useState<CronJob[]>([])
|
||||||
const activeChat = useMemo(() => chat[active] || [], [chat, active])
|
const activeChat = useMemo(() => chat[active] || [], [chat, active])
|
||||||
|
|
||||||
const q = token ? `?token=${encodeURIComponent(token)}` : ''
|
const q = token ? `?token=${encodeURIComponent(token)}` : ''
|
||||||
@@ -34,17 +35,24 @@ export function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function refreshNodes() {
|
async function refreshNodes() {
|
||||||
const payload = {
|
const r = await fetch(`/webui/api/nodes${q}`)
|
||||||
session: active,
|
const j = await r.json()
|
||||||
message: '调用nodes工具,action=status,并输出JSON。',
|
setNodes(JSON.stringify(j.nodes || [], null, 2))
|
||||||
}
|
}
|
||||||
const r = await fetch(`/webui/api/chat${q}`, {
|
|
||||||
|
async function refreshCron() {
|
||||||
|
const r = await fetch(`/webui/api/cron${q}`)
|
||||||
|
const j = await r.json()
|
||||||
|
setCron(j.jobs || [])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cronAction(action: 'delete' | 'enable' | 'disable', id: string) {
|
||||||
|
await fetch(`/webui/api/cron${q}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify({ action, id }),
|
||||||
})
|
})
|
||||||
const t = await r.text()
|
await refreshCron()
|
||||||
setNodes(t)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function send() {
|
async function send() {
|
||||||
@@ -84,6 +92,8 @@ export function App() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadConfig().catch(() => {})
|
loadConfig().catch(() => {})
|
||||||
|
refreshNodes().catch(() => {})
|
||||||
|
refreshCron().catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -122,6 +132,19 @@ export function App() {
|
|||||||
<div className="panel-title">Config</div>
|
<div className="panel-title">Config</div>
|
||||||
<div className="row"><button onClick={loadConfig}>Load</button><button onClick={saveConfig}>Save+Reload</button></div>
|
<div className="row"><button onClick={loadConfig}>Load</button><button onClick={saveConfig}>Save+Reload</button></div>
|
||||||
<textarea value={cfgText} onChange={(e) => setCfgText(e.target.value)} />
|
<textarea value={cfgText} onChange={(e) => setCfgText(e.target.value)} />
|
||||||
|
<div className="panel-title">Cron</div>
|
||||||
|
<div className="row"><button onClick={refreshCron}>Refresh</button></div>
|
||||||
|
<div className="cron-list">
|
||||||
|
{cron.map((j) => (
|
||||||
|
<div key={j.id} className="cron-item">
|
||||||
|
<div><strong>{j.name || j.id}</strong><div className="muted">{j.id}</div></div>
|
||||||
|
<div className="row">
|
||||||
|
<button onClick={() => cronAction(j.enabled ? 'disable' : 'enable', j.id)}>{j.enabled ? 'Disable' : 'Enable'}</button>
|
||||||
|
<button onClick={() => cronAction('delete', j.id)}>Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<div className="panel-title">Nodes</div>
|
<div className="panel-title">Nodes</div>
|
||||||
<div className="row"><button onClick={refreshNodes}>Refresh</button></div>
|
<div className="row"><button onClick={refreshNodes}>Refresh</button></div>
|
||||||
<pre>{nodes}</pre>
|
<pre>{nodes}</pre>
|
||||||
|
|||||||
@@ -17,5 +17,9 @@ button{cursor:pointer;border:1px solid #374151;background:#0f172a;color:#fff;pad
|
|||||||
.row{display:flex;gap:8px;margin-bottom:8px}
|
.row{display:flex;gap:8px;margin-bottom:8px}
|
||||||
textarea{width:100%;min-height:180px;background:#0b1220;color:#e5e7eb;border:1px solid #374151;border-radius:8px;padding:8px}
|
textarea{width:100%;min-height:180px;background:#0b1220;color:#e5e7eb;border:1px solid #374151;border-radius:8px;padding:8px}
|
||||||
pre{flex:1;overflow:auto;background:#0b1220;border:1px solid #374151;border-radius:8px;padding:8px;white-space:pre-wrap}
|
pre{flex:1;overflow:auto;background:#0b1220;border:1px solid #374151;border-radius:8px;padding:8px;white-space:pre-wrap}
|
||||||
|
.cron-list{max-height:180px;overflow:auto;border:1px solid #374151;border-radius:8px;padding:6px;margin-bottom:8px;background:#0b1220}
|
||||||
|
.cron-item{display:flex;justify-content:space-between;gap:8px;align-items:center;border-bottom:1px solid #1f2937;padding:6px 0}
|
||||||
|
.cron-item:last-child{border-bottom:none}
|
||||||
|
.muted{font-size:12px;color:#9ca3af}
|
||||||
@media (max-width: 1024px){.layout{grid-template-columns:200px 1fr}.right{grid-column:1 / span 2;max-height:42vh}}
|
@media (max-width: 1024px){.layout{grid-template-columns:200px 1fr}.right{grid-column:1 / span 2;max-height:42vh}}
|
||||||
@media (max-width: 768px){.layout{grid-template-columns:1fr}.sessions{order:2}.chat{order:1;min-height:50vh}.right{order:3}.topbar input{min-width:160px;width:52%}}
|
@media (max-width: 768px){.layout{grid-template-columns:1fr}.sessions{order:2}.chat{order:1;min-height:50vh}.right{order:3}.topbar input{min-width:160px;width:52%}}
|
||||||
|
|||||||
Reference in New Issue
Block a user