mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-04-15 06:17:28 +08:00
webui: adopt openclaw-like left menu layout and section views
This commit is contained in:
@@ -2,15 +2,15 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
type ChatItem = { role: 'user' | 'assistant'; text: string }
|
||||
type Session = { key: string; title: string }
|
||||
type CronJob = { id: string; name: string; enabled: boolean; schedule?: { kind?: string } }
|
||||
type CronJob = { id: string; name: string; enabled: boolean }
|
||||
type Cfg = Record<string, any>
|
||||
type View = 'chat' | 'config' | 'cron' | 'nodes'
|
||||
|
||||
const defaultSessions: Session[] = [{ key: 'webui:default', title: 'Default' }]
|
||||
|
||||
function getPath(obj: any, path: string, fallback: any = '') {
|
||||
return path.split('.').reduce((acc, k) => (acc && acc[k] !== undefined ? acc[k] : undefined), obj) ?? fallback
|
||||
}
|
||||
|
||||
function setPath(obj: any, path: string, value: any) {
|
||||
const keys = path.split('.')
|
||||
const next = JSON.parse(JSON.stringify(obj || {}))
|
||||
@@ -25,6 +25,7 @@ function setPath(obj: any, path: string, value: any) {
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const [view, setView] = useState<View>('chat')
|
||||
const [token, setToken] = useState('')
|
||||
const [cfg, setCfg] = useState<Cfg>({})
|
||||
const [cfgRaw, setCfgRaw] = useState('{}')
|
||||
@@ -36,60 +37,34 @@ export function App() {
|
||||
const [nodes, setNodes] = useState<string>('[]')
|
||||
const [cron, setCron] = useState<CronJob[]>([])
|
||||
const activeChat = useMemo(() => chat[active] || [], [chat, active])
|
||||
|
||||
const q = token ? `?token=${encodeURIComponent(token)}` : ''
|
||||
|
||||
async function loadConfig() {
|
||||
const r = await fetch(`/webui/api/config${q}`)
|
||||
const txt = await r.text()
|
||||
setCfgRaw(txt)
|
||||
try {
|
||||
setCfg(JSON.parse(txt))
|
||||
} catch {
|
||||
setCfg({})
|
||||
}
|
||||
try { setCfg(JSON.parse(txt)) } catch { setCfg({}) }
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
const payload = showRaw ? JSON.parse(cfgRaw) : cfg
|
||||
const r = await fetch(`/webui/api/config${q}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
|
||||
})
|
||||
alert(await r.text())
|
||||
}
|
||||
|
||||
const bindText = (path: string) => ({
|
||||
value: String(getPath(cfg, path, '')),
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => setCfg((v) => setPath(v, path, e.target.value)),
|
||||
})
|
||||
const bindNum = (path: string) => ({
|
||||
value: Number(getPath(cfg, path, 0)),
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => setCfg((v) => setPath(v, path, Number(e.target.value || 0))),
|
||||
})
|
||||
const bindBool = (path: string) => ({
|
||||
checked: Boolean(getPath(cfg, path, false)),
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => setCfg((v) => setPath(v, path, e.target.checked)),
|
||||
})
|
||||
|
||||
async function refreshNodes() {
|
||||
const r = await fetch(`/webui/api/nodes${q}`)
|
||||
const j = await r.json()
|
||||
setNodes(JSON.stringify(j.nodes || [], null, 2))
|
||||
}
|
||||
|
||||
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',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action, id }),
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action, id }),
|
||||
})
|
||||
await refreshCron()
|
||||
}
|
||||
@@ -99,110 +74,117 @@ export function App() {
|
||||
const input = document.getElementById('file') as HTMLInputElement | null
|
||||
const f = input?.files?.[0]
|
||||
if (f) {
|
||||
const fd = new FormData()
|
||||
fd.append('file', 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 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),
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session: active, message: msg, media }),
|
||||
})
|
||||
const t = await r.text()
|
||||
setChat((prev) => ({ ...prev, [active]: [...(prev[active] || []), { role: 'assistant', text: t }] }))
|
||||
setMsg('')
|
||||
if (input) input.value = ''
|
||||
}
|
||||
|
||||
const bindText = (path: string) => ({ value: String(getPath(cfg, path, '')), onChange: (e: React.ChangeEvent<HTMLInputElement>) => setCfg((v) => setPath(v, path, e.target.value)) })
|
||||
const bindNum = (path: string) => ({ value: Number(getPath(cfg, path, 0)), onChange: (e: React.ChangeEvent<HTMLInputElement>) => setCfg((v) => setPath(v, path, Number(e.target.value || 0))) })
|
||||
const bindBool = (path: string) => ({ checked: Boolean(getPath(cfg, path, false)), onChange: (e: React.ChangeEvent<HTMLInputElement>) => setCfg((v) => setPath(v, path, e.target.checked)) })
|
||||
|
||||
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]: [] }))
|
||||
setSessions((v) => [...v, s]); setActive(n); setChat((prev) => ({ ...prev, [n]: [] }))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig().catch(() => {})
|
||||
refreshNodes().catch(() => {})
|
||||
refreshCron().catch(() => {})
|
||||
}, [])
|
||||
useEffect(() => { loadConfig().catch(() => {}); refreshNodes().catch(() => {}); refreshCron().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" />
|
||||
<div className='app'>
|
||||
<header className='topbar'>
|
||||
<strong>ClawGo Control</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>
|
||||
|
||||
<div className='shell'>
|
||||
<nav className='left-menu'>
|
||||
<button className={view==='chat'?'on':''} onClick={() => setView('chat')}>💬 Chat</button>
|
||||
<button className={view==='config'?'on':''} onClick={() => setView('config')}>⚙️ Config</button>
|
||||
<button className={view==='cron'?'on':''} onClick={() => setView('cron')}>⏱ Cron</button>
|
||||
<button className={view==='nodes'?'on':''} onClick={() => setView('nodes')}>🧩 Nodes</button>
|
||||
</nav>
|
||||
|
||||
<aside className='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>
|
||||
<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}
|
||||
<main className='main'>
|
||||
{view === 'chat' && (
|
||||
<section className='panel'>
|
||||
<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>
|
||||
<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 Form</div>
|
||||
<div className="row"><button onClick={loadConfig}>Load</button><button onClick={saveConfig}>Save+Reload</button><button onClick={() => setShowRaw(v => !v)}>{showRaw ? 'Form' : 'Raw'}</button></div>
|
||||
{!showRaw ? (
|
||||
<div className="form-grid">
|
||||
<label>gateway.host<input {...bindText('gateway.host')} /></label>
|
||||
<label>gateway.port<input type="number" {...bindNum('gateway.port')} /></label>
|
||||
<label>gateway.token<input {...bindText('gateway.token')} /></label>
|
||||
<label>agents.defaults.max_tool_iterations<input type="number" {...bindNum('agents.defaults.max_tool_iterations')} /></label>
|
||||
<label>agents.defaults.max_tokens<input type="number" {...bindNum('agents.defaults.max_tokens')} /></label>
|
||||
<label>providers.proxy.timeout_sec<input type="number" {...bindNum('providers.proxy.timeout_sec')} /></label>
|
||||
<label>tools.shell.enabled<input type="checkbox" {...bindBool('tools.shell.enabled')} /></label>
|
||||
<label>logging.enabled<input type="checkbox" {...bindBool('logging.enabled')} /></label>
|
||||
</div>
|
||||
) : (
|
||||
<textarea value={cfgRaw} onChange={(e) => setCfgRaw(e.target.value)} />
|
||||
<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>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<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>
|
||||
{view === 'config' && (
|
||||
<section className='panel'>
|
||||
<div className='panel-title'>Config Form</div>
|
||||
<div className='row'>
|
||||
<button onClick={loadConfig}>Load</button><button onClick={saveConfig}>Save+Reload</button><button onClick={() => setShowRaw(v=>!v)}>{showRaw?'Form':'Raw'}</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!showRaw ? (
|
||||
<div className='form-grid'>
|
||||
<label>gateway.host<input {...bindText('gateway.host')} /></label>
|
||||
<label>gateway.port<input type='number' {...bindNum('gateway.port')} /></label>
|
||||
<label>gateway.token<input {...bindText('gateway.token')} /></label>
|
||||
<label>agents.defaults.max_tool_iterations<input type='number' {...bindNum('agents.defaults.max_tool_iterations')} /></label>
|
||||
<label>agents.defaults.max_tokens<input type='number' {...bindNum('agents.defaults.max_tokens')} /></label>
|
||||
<label>providers.proxy.timeout_sec<input type='number' {...bindNum('providers.proxy.timeout_sec')} /></label>
|
||||
<label>tools.shell.enabled<input type='checkbox' {...bindBool('tools.shell.enabled')} /></label>
|
||||
<label>logging.enabled<input type='checkbox' {...bindBool('logging.enabled')} /></label>
|
||||
</div>
|
||||
) : <textarea value={cfgRaw} onChange={(e)=>setCfgRaw(e.target.value)} />}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="panel-title">Nodes</div>
|
||||
<div className="row"><button onClick={refreshNodes}>Refresh</button></div>
|
||||
<pre>{nodes}</pre>
|
||||
</section>
|
||||
{view === 'cron' && (
|
||||
<section className='panel'>
|
||||
<div className='panel-title'>Cron Jobs</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>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{view === 'nodes' && (
|
||||
<section className='panel'>
|
||||
<div className='panel-title'>Nodes</div>
|
||||
<div className='row'><button onClick={refreshNodes}>Refresh</button></div>
|
||||
<pre>{nodes}</pre>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,11 +2,17 @@
|
||||
.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}
|
||||
.shell{flex:1;display:grid;grid-template-columns:88px 230px 1fr;gap:10px;padding:10px;min-height:0}
|
||||
.left-menu,.sessions,.panel{background:#111827;border:1px solid #1f2937;border-radius:12px;padding:10px;min-height:0}
|
||||
.left-menu{display:flex;flex-direction:column;gap:8px}
|
||||
.left-menu button{width:100%;padding:10px 6px;border:1px solid #374151;background:#0f172a;color:#cbd5e1;border-radius:10px}
|
||||
.left-menu button.on{background:#1d4ed8;color:#fff}
|
||||
.sessions{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}
|
||||
.main{min-height:0}
|
||||
.panel{height:100%;display:flex;flex-direction:column}
|
||||
.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}
|
||||
@@ -14,16 +20,16 @@
|
||||
.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}
|
||||
.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:8px}
|
||||
.row{display:flex;gap:8px;margin-bottom:8px;flex-wrap:wrap}
|
||||
textarea{width:100%;min-height:240px;background:#0b1220;color:#e5e7eb;border:1px solid #374151;border-radius:8px;padding:8px}
|
||||
.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}
|
||||
.form-grid label{display:flex;flex-direction:column;gap:6px;font-size:12px;color:#cbd5e1}
|
||||
.form-grid input{background:#0b1220;color:#fff;border:1px solid #374151;padding:8px;border-radius:8px}
|
||||
.form-grid input[type='checkbox']{width:20px;height:20px;padding:0}
|
||||
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-list{flex:1;overflow:auto;border:1px solid #374151;border-radius:8px;padding:6px;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: 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%}.form-grid{grid-template-columns:1fr}}
|
||||
@media (max-width: 1024px){.shell{grid-template-columns:70px 170px 1fr}.topbar input{min-width:160px}}
|
||||
@media (max-width: 768px){.shell{grid-template-columns:1fr;grid-template-rows:auto auto 1fr}.left-menu{flex-direction:row;overflow:auto}.sessions{max-height:140px}.form-grid{grid-template-columns:1fr}}
|
||||
|
||||
Reference in New Issue
Block a user