mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-04-13 05:37:29 +08:00
feat webui react+vite responsive app with clawgo api adapter
This commit is contained in:
@@ -179,6 +179,7 @@ func gatewayCmd() {
|
||||
|
||||
registryServer := nodes.NewRegistryServer(cfg.Gateway.Host, cfg.Gateway.Port, cfg.Gateway.Token, nodes.DefaultManager())
|
||||
registryServer.SetConfigPath(getConfigPath())
|
||||
registryServer.SetWebUIDir(filepath.Join(cfg.WorkspacePath(), "webui-dist"))
|
||||
registryServer.SetChatHandler(func(cctx context.Context, sessionKey, content string) (string, error) {
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return "", nil
|
||||
|
||||
@@ -21,6 +21,7 @@ type RegistryServer struct {
|
||||
configPath string
|
||||
onChat func(ctx context.Context, sessionKey, content string) (string, error)
|
||||
onConfigAfter func()
|
||||
webUIDir string
|
||||
}
|
||||
|
||||
func NewRegistryServer(host string, port int, token string, mgr *Manager) *RegistryServer {
|
||||
@@ -39,6 +40,7 @@ func (s *RegistryServer) SetChatHandler(fn func(ctx context.Context, sessionKey,
|
||||
s.onChat = fn
|
||||
}
|
||||
func (s *RegistryServer) SetConfigAfterHook(fn func()) { s.onConfigAfter = fn }
|
||||
func (s *RegistryServer) SetWebUIDir(dir string) { s.webUIDir = strings.TrimSpace(dir) }
|
||||
|
||||
func (s *RegistryServer) Start(ctx context.Context) error {
|
||||
if s.mgr == nil {
|
||||
@@ -52,6 +54,7 @@ func (s *RegistryServer) Start(ctx context.Context) error {
|
||||
mux.HandleFunc("/nodes/register", s.handleRegister)
|
||||
mux.HandleFunc("/nodes/heartbeat", s.handleHeartbeat)
|
||||
mux.HandleFunc("/webui", s.handleWebUI)
|
||||
mux.HandleFunc("/webui/", s.handleWebUIAsset)
|
||||
mux.HandleFunc("/webui/api/config", s.handleWebUIConfig)
|
||||
mux.HandleFunc("/webui/api/chat", s.handleWebUIChat)
|
||||
mux.HandleFunc("/webui/api/upload", s.handleWebUIUpload)
|
||||
@@ -124,10 +127,54 @@ func (s *RegistryServer) handleWebUI(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if s.tryServeWebUIDist(w, r, "/webui/index.html") {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(webUIHTML))
|
||||
}
|
||||
|
||||
func (s *RegistryServer) handleWebUIAsset(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if !s.checkAuth(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if s.tryServeWebUIDist(w, r, r.URL.Path) {
|
||||
return
|
||||
}
|
||||
// SPA fallback
|
||||
if s.tryServeWebUIDist(w, r, "/webui/index.html") {
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
func (s *RegistryServer) tryServeWebUIDist(w http.ResponseWriter, r *http.Request, reqPath string) bool {
|
||||
dir := strings.TrimSpace(s.webUIDir)
|
||||
if dir == "" {
|
||||
return false
|
||||
}
|
||||
p := strings.TrimPrefix(reqPath, "/webui/")
|
||||
if reqPath == "/webui" || reqPath == "/webui/" || reqPath == "/webui/index.html" {
|
||||
p = "index.html"
|
||||
}
|
||||
p = filepath.Clean(strings.TrimPrefix(p, "/"))
|
||||
if strings.HasPrefix(p, "..") {
|
||||
return false
|
||||
}
|
||||
full := filepath.Join(dir, p)
|
||||
fi, err := os.Stat(full)
|
||||
if err != nil || fi.IsDir() {
|
||||
return false
|
||||
}
|
||||
http.ServeFile(w, r, full)
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *RegistryServer) handleWebUIConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.checkAuth(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
|
||||
2
webui/.gitignore
vendored
Normal file
2
webui/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
12
webui/index.html
Normal file
12
webui/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ClawGo WebUI</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1729
webui/package-lock.json
generated
Normal file
1729
webui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
webui/package.json
Normal file
22
webui/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "clawgo-webui",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"typescript": "^5.6.2",
|
||||
"vite": "^5.4.8"
|
||||
}
|
||||
}
|
||||
132
webui/src/App.tsx
Normal file
132
webui/src/App.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
type ChatItem = { role: 'user' | 'assistant'; text: string }
|
||||
|
||||
type Session = { key: string; title: string }
|
||||
|
||||
const defaultSessions: Session[] = [{ key: 'webui:default', title: 'Default' }]
|
||||
|
||||
export function App() {
|
||||
const [token, setToken] = useState('')
|
||||
const [cfgText, setCfgText] = useState('{}')
|
||||
const [sessions, setSessions] = useState<Session[]>(defaultSessions)
|
||||
const [active, setActive] = useState('webui:default')
|
||||
const [chat, setChat] = useState<Record<string, ChatItem[]>>({ 'webui:default': [] })
|
||||
const [msg, setMsg] = useState('')
|
||||
const [nodes, setNodes] = useState<string>('[]')
|
||||
const activeChat = useMemo(() => chat[active] || [], [chat, active])
|
||||
|
||||
const q = token ? `?token=${encodeURIComponent(token)}` : ''
|
||||
|
||||
async function loadConfig() {
|
||||
const r = await fetch(`/webui/api/config${q}`)
|
||||
setCfgText(await r.text())
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
const parsed = JSON.parse(cfgText)
|
||||
const r = await fetch(`/webui/api/config${q}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(parsed),
|
||||
})
|
||||
alert(await r.text())
|
||||
}
|
||||
|
||||
async function refreshNodes() {
|
||||
const payload = {
|
||||
session: active,
|
||||
message: '调用nodes工具,action=status,并输出JSON。',
|
||||
}
|
||||
const r = await fetch(`/webui/api/chat${q}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
const t = await r.text()
|
||||
setNodes(t)
|
||||
}
|
||||
|
||||
async function send() {
|
||||
let media = ''
|
||||
const input = document.getElementById('file') as HTMLInputElement | null
|
||||
const f = input?.files?.[0]
|
||||
if (f) {
|
||||
const fd = new FormData()
|
||||
fd.append('file', f)
|
||||
const ur = await fetch(`/webui/api/upload${q}`, { method: 'POST', body: fd })
|
||||
const uj = await ur.json()
|
||||
media = uj.path || ''
|
||||
}
|
||||
|
||||
const userText = msg + (media ? ` [file:${media}]` : '')
|
||||
setChat((prev) => ({ ...prev, [active]: [...(prev[active] || []), { role: 'user', text: userText }] }))
|
||||
const payload = { session: active, message: msg, media }
|
||||
setMsg('')
|
||||
|
||||
const r = await fetch(`/webui/api/chat${q}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
const t = await r.text()
|
||||
setChat((prev) => ({ ...prev, [active]: [...(prev[active] || []), { role: 'assistant', text: t }] }))
|
||||
if (input) input.value = ''
|
||||
}
|
||||
|
||||
function addSession() {
|
||||
const n = `webui:${Date.now()}`
|
||||
const s = { key: n, title: `Session-${sessions.length + 1}` }
|
||||
setSessions((v) => [...v, s])
|
||||
setActive(n)
|
||||
setChat((prev) => ({ ...prev, [n]: [] }))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig().catch(() => {})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="topbar">
|
||||
<strong>ClawGo WebUI (React/Vite)</strong>
|
||||
<input value={token} onChange={(e) => setToken(e.target.value)} placeholder="gateway token" />
|
||||
</header>
|
||||
<div className="layout">
|
||||
<aside className="panel sessions">
|
||||
<div className="panel-title">Sessions <button onClick={addSession}>+</button></div>
|
||||
{sessions.map((s) => (
|
||||
<button key={s.key} className={s.key === active ? 'active' : ''} onClick={() => setActive(s.key)}>
|
||||
{s.title}
|
||||
</button>
|
||||
))}
|
||||
</aside>
|
||||
|
||||
<main className="panel chat">
|
||||
<div className="panel-title">Chat</div>
|
||||
<div className="chatlog">
|
||||
{activeChat.map((m, i) => (
|
||||
<div key={i} className={`bubble ${m.role}`}>
|
||||
{m.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="composer">
|
||||
<input value={msg} onChange={(e) => setMsg(e.target.value)} placeholder="Type message..." />
|
||||
<input id="file" type="file" />
|
||||
<button onClick={send}>Send</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<section className="panel right">
|
||||
<div className="panel-title">Config</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)} />
|
||||
<div className="panel-title">Nodes</div>
|
||||
<div className="row"><button onClick={refreshNodes}>Refresh</button></div>
|
||||
<pre>{nodes}</pre>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
10
webui/src/main.tsx
Normal file
10
webui/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { App } from './App'
|
||||
import './styles.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
21
webui/src/styles.css
Normal file
21
webui/src/styles.css
Normal file
@@ -0,0 +1,21 @@
|
||||
*{box-sizing:border-box} body{margin:0;font-family:Inter,system-ui,Segoe UI,Arial;background:#0b1020;color:#e5e7eb}
|
||||
.app{height:100vh;display:flex;flex-direction:column}
|
||||
.topbar{display:flex;gap:12px;align-items:center;padding:10px 14px;background:#111827;border-bottom:1px solid #1f2937}
|
||||
.topbar input{margin-left:auto;min-width:260px;background:#0b1220;color:#fff;border:1px solid #374151;padding:6px 8px;border-radius:8px}
|
||||
.layout{flex:1;display:grid;grid-template-columns:220px 1fr 360px;gap:10px;padding:10px;min-height:0}
|
||||
.panel{background:#111827;border:1px solid #1f2937;border-radius:12px;padding:10px;min-height:0;display:flex;flex-direction:column}
|
||||
.panel-title{font-weight:700;margin-bottom:8px;display:flex;justify-content:space-between;align-items:center}
|
||||
.sessions button{width:100%;margin-bottom:6px;background:#0f172a;color:#d1d5db;border:1px solid #374151;padding:8px;border-radius:8px;text-align:left}
|
||||
.sessions button.active{background:#1d4ed8;color:white}
|
||||
.chatlog{flex:1;overflow:auto;padding:6px;background:#0b1220;border:1px solid #1f2937;border-radius:10px}
|
||||
.bubble{max-width:88%;padding:8px 10px;border-radius:10px;margin:6px 0;white-space:pre-wrap}
|
||||
.bubble.user{margin-left:auto;background:#1d4ed8}
|
||||
.bubble.assistant{margin-right:auto;background:#1f2937}
|
||||
.composer{display:grid;grid-template-columns:1fr auto auto;gap:8px;margin-top:8px}
|
||||
.composer input{background:#0b1220;color:#fff;border:1px solid #374151;padding:8px;border-radius:8px}
|
||||
button{cursor:pointer;border:1px solid #374151;background:#0f172a;color:#fff;padding:6px 10px;border-radius: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}
|
||||
pre{flex:1;overflow:auto;background:#0b1220;border:1px solid #374151;border-radius:8px;padding:8px;white-space:pre-wrap}
|
||||
@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%}}
|
||||
17
webui/tsconfig.json
Normal file
17
webui/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
16
webui/vite.config.ts
Normal file
16
webui/vite.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
base: '/webui/',
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/webui/api': {
|
||||
target: 'http://127.0.0.1:18790',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user