chore: update tui and api docs

This commit is contained in:
lpf
2026-03-12 17:58:43 +08:00
parent 52b40ee0df
commit e405d410c9
5 changed files with 239 additions and 141 deletions

View File

@@ -157,7 +157,7 @@ make dev
WebUI:
```text
http://<host>:<port>/webui?token=<gateway.token>
http://<host>:<port>/?token=<gateway.token>
```
## 架构概览
@@ -279,7 +279,7 @@ user -> main -> worker -> main -> user
说明:
- `webrtc` 建连失败时,调度层仍会回退到现有 relay / tunnel 路径
- Dashboard、`status``/webui/api/nodes` 会显示当前 Node P2P 状态和会话摘要
- Dashboard、`status``/api/nodes` 会显示当前 Node P2P 状态和会话摘要
- 两台公网机器的实网验证流程见 [docs/node-p2p-e2e.md](/Users/lpf/Desktop/project/clawgo/docs/node-p2p-e2e.md)
## MCP 服务支持

View File

@@ -148,7 +148,7 @@ make dev
WebUI:
```text
http://<host>:<port>/webui?token=<gateway.token>
http://<host>:<port>/?token=<gateway.token>
```
## Architecture
@@ -269,7 +269,7 @@ Example:
Notes:
- when `webrtc` session setup fails, dispatch still falls back to the existing relay / tunnel path
- Dashboard, `status`, and `/webui/api/nodes` expose the current Node P2P runtime summary
- Dashboard, `status`, and `/api/nodes` expose the current Node P2P runtime summary
- a reusable public-network validation flow is documented in [docs/node-p2p-e2e.md](/Users/lpf/Desktop/project/clawgo/docs/node-p2p-e2e.md)
## MCP Server Support

View File

@@ -112,6 +112,9 @@ type tuiModel struct {
sessionCursor int
sessionFilter string
sessionActive map[string]time.Time
inputHistory []string
historyCursor int
historyDraft string
status string
globalErr string
}
@@ -153,6 +156,10 @@ func newTUIModel(client *tuiClient, runtime *tuiRuntime, opts tuiOptions) tuiMod
input.Focus()
input.CharLimit = 0
input.Prompt = " "
input.PromptStyle = lipgloss.NewStyle().Bold(true).Foreground(tuiColorShell)
input.TextStyle = lipgloss.NewStyle().Foreground(tuiColorText)
input.PlaceholderStyle = lipgloss.NewStyle().Foreground(tuiColorTextSoft)
input.Cursor.Style = lipgloss.NewStyle().Foreground(tuiColorShell)
pane := newTUIPane(1, opts.session)
if opts.noHistory {
@@ -160,16 +167,17 @@ func newTUIModel(client *tuiClient, runtime *tuiRuntime, opts tuiOptions) tuiMod
}
return tuiModel{
client: client,
runtime: runtime,
input: input,
panes: []tuiPane{pane},
focus: 0,
nextPaneID: 2,
client: client,
runtime: runtime,
input: input,
panes: []tuiPane{pane},
focus: 0,
nextPaneID: 2,
historyCursor: -1,
sessionActive: map[string]time.Time{
strings.TrimSpace(opts.session): time.Now(),
},
status: "Tab/Shift+Tab: focus • Ctrl+O: open selected session • Ctrl+R: replace pane • Enter: send • Ctrl+C: quit",
status: "Tab/Shift+Tab: focus • Ctrl+↑/↓: select session • Ctrl+O: open selected • Enter: send • Ctrl+C: quit",
}
}
@@ -267,13 +275,23 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil
case "up":
if m.recallHistory(-1) {
return m, nil
}
return m, nil
case "down":
if m.recallHistory(1) {
return m, nil
}
return m, nil
case "ctrl+up":
filtered := m.filteredSessions()
if len(filtered) > 0 && m.sessionCursor > 0 {
m.sessionCursor--
m.status = "Selected session: " + filtered[m.sessionCursor].Key
}
return m, nil
case "down":
case "ctrl+down":
filtered := m.filteredSessions()
if len(filtered) > 0 && m.sessionCursor < len(filtered)-1 {
m.sessionCursor++
@@ -323,6 +341,7 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if len(m.panes) == 0 || m.focusedPane().Sending || m.focusedPane().Loading {
return m, nil
}
m.pushInputHistory(value)
m.input.SetValue("")
if strings.HasPrefix(value, "/") {
return m.handleCommand(value)
@@ -486,7 +505,8 @@ func (m *tuiModel) resize() {
}
m.bodyHeight = bodyHeight
m.sidebarWidth = min(28, max(20, m.width/5))
availablePaneWidth := m.width - m.sidebarWidth - 1
gapSize := lipgloss.Width(m.paneGap())
availablePaneWidth := m.width - m.sidebarWidth - gapSize
if availablePaneWidth < 24 {
availablePaneWidth = 24
}
@@ -495,23 +515,23 @@ func (m *tuiModel) resize() {
case 1:
m.setPaneSize(0, availablePaneWidth, bodyHeight)
case 2:
leftWidth := (availablePaneWidth - 1) / 2
rightWidth := availablePaneWidth - 1 - leftWidth
leftWidth := (availablePaneWidth - gapSize) / 2
rightWidth := availablePaneWidth - gapSize - leftWidth
m.setPaneSize(0, leftWidth, bodyHeight)
m.setPaneSize(1, rightWidth, bodyHeight)
case 3:
leftWidth := (availablePaneWidth - 1) / 2
rightWidth := availablePaneWidth - 1 - leftWidth
topHeight := (bodyHeight - 1) / 2
bottomHeight := bodyHeight - 1 - topHeight
leftWidth := (availablePaneWidth - gapSize) / 2
rightWidth := availablePaneWidth - gapSize - leftWidth
topHeight := (bodyHeight - gapSize) / 2
bottomHeight := bodyHeight - gapSize - topHeight
m.setPaneSize(0, leftWidth, bodyHeight)
m.setPaneSize(1, rightWidth, topHeight)
m.setPaneSize(2, rightWidth, bottomHeight)
default:
leftWidth := (availablePaneWidth - 1) / 2
rightWidth := availablePaneWidth - 1 - leftWidth
topHeight := (bodyHeight - 1) / 2
bottomHeight := bodyHeight - 1 - topHeight
leftWidth := (availablePaneWidth - gapSize) / 2
rightWidth := availablePaneWidth - gapSize - leftWidth
topHeight := (bodyHeight - gapSize) / 2
bottomHeight := bodyHeight - gapSize - topHeight
m.setPaneSize(0, leftWidth, topHeight)
m.setPaneSize(1, rightWidth, topHeight)
m.setPaneSize(2, leftWidth, bottomHeight)
@@ -523,7 +543,7 @@ func (m *tuiModel) resize() {
func (m *tuiModel) renderPanes() string {
sidebar := m.renderSessionSidebar()
panes := m.renderPaneGrid()
return lipgloss.JoinHorizontal(lipgloss.Top, sidebar, tuiPaneGapStyle.Render(" "), panes)
return lipgloss.JoinHorizontal(lipgloss.Top, sidebar, tuiPaneGapStyle.Render(m.paneGap()), panes)
}
func (m *tuiModel) renderPane(index int, pane tuiPane) string {
@@ -610,7 +630,8 @@ func (m *tuiModel) renderSessionSidebar() string {
if strings.TrimSpace(m.sessionFilter) != "" {
lines = append(lines, tuiMutedStyle.Render("Filter: "+m.sessionFilter))
}
lines = append(lines, tuiMutedStyle.Render("Up/Down: select"))
lines = append(lines, tuiMutedStyle.Render("Ctrl+Up/Down: select"))
lines = append(lines, tuiMutedStyle.Render("Up/Down: input history"))
lines = append(lines, tuiMutedStyle.Render("Enter: open selected"))
lines = append(lines, tuiMutedStyle.Render("Ctrl+O: open pane"))
lines = append(lines, tuiMutedStyle.Render("Ctrl+N: new pane"))
@@ -628,45 +649,99 @@ func (m *tuiModel) renderPaneGrid() string {
if len(m.panes) == 0 {
return tuiViewportStyle.Width(max(20, m.width-m.sidebarWidth-1)).Height(m.bodyHeight).Render("No panes.")
}
gap := tuiPaneGapStyle.Render(m.paneGap())
switch len(m.panes) {
case 1:
return m.renderPane(0, m.panes[0])
case 2:
return lipgloss.JoinHorizontal(lipgloss.Top,
m.renderPane(0, m.panes[0]),
tuiPaneGapStyle.Render(" "),
gap,
m.renderPane(1, m.panes[1]),
)
case 3:
right := lipgloss.JoinVertical(lipgloss.Left,
m.renderPane(1, m.panes[1]),
tuiPaneGapStyle.Render(" "),
gap,
m.renderPane(2, m.panes[2]),
)
return lipgloss.JoinHorizontal(lipgloss.Top,
m.renderPane(0, m.panes[0]),
tuiPaneGapStyle.Render(" "),
gap,
right,
)
default:
top := lipgloss.JoinHorizontal(lipgloss.Top,
m.renderPane(0, m.panes[0]),
tuiPaneGapStyle.Render(" "),
gap,
m.renderPane(1, m.panes[1]),
)
bottom := lipgloss.JoinHorizontal(lipgloss.Top,
m.renderPane(2, m.panes[2]),
tuiPaneGapStyle.Render(" "),
gap,
m.renderPane(3, m.panes[3]),
)
return lipgloss.JoinVertical(lipgloss.Left,
top,
tuiPaneGapStyle.Render(" "),
gap,
bottom,
)
}
}
func (m *tuiModel) paneGap() string {
if len(m.panes) >= 4 {
return ""
}
return " "
}
func (m *tuiModel) pushInputHistory(value string) {
value = strings.TrimSpace(value)
if value == "" {
return
}
if n := len(m.inputHistory); n > 0 && m.inputHistory[n-1] == value {
m.historyCursor = len(m.inputHistory)
m.historyDraft = ""
return
}
m.inputHistory = append(m.inputHistory, value)
m.historyCursor = len(m.inputHistory)
m.historyDraft = ""
}
func (m *tuiModel) recallHistory(delta int) bool {
if len(m.inputHistory) == 0 {
return false
}
if m.historyCursor < 0 || m.historyCursor > len(m.inputHistory) {
m.historyCursor = len(m.inputHistory)
}
if m.historyCursor == len(m.inputHistory) {
m.historyDraft = m.input.Value()
}
next := m.historyCursor + delta
if next < 0 {
next = 0
}
if next > len(m.inputHistory) {
next = len(m.inputHistory)
}
if next == m.historyCursor {
return true
}
m.historyCursor = next
if m.historyCursor == len(m.inputHistory) {
m.input.SetValue(m.historyDraft)
} else {
m.input.SetValue(m.inputHistory[m.historyCursor])
}
m.input.CursorEnd()
m.input.Focus()
return true
}
func (m tuiModel) loadHistoryCmd(paneID int, session string) tea.Cmd {
return func() tea.Msg {
messages, err := m.client.history(context.Background(), session)
@@ -1312,108 +1387,124 @@ func (c *tuiClient) newRequest(ctx context.Context, method, path string, body io
}
var (
tuiColorBorder = lipgloss.Color("#F7B08A")
tuiColorClaw = lipgloss.Color("#FFB36B")
tuiColorShell = lipgloss.Color("#F05A28")
tuiColorShellDeep = lipgloss.Color("#D9481C")
tuiColorAccent = lipgloss.Color("#C44B21")
tuiColorAccentDeep = lipgloss.Color("#A43A18")
tuiColorText = lipgloss.Color("#472018")
tuiColorTextSoft = lipgloss.Color("#6E3B2E")
tuiColorError = lipgloss.Color("#B93815")
tuiColorSurfaceText = lipgloss.Color("#FFF4EE")
tuiHeaderStyle = lipgloss.NewStyle().
Padding(0, 1).
BorderBottom(true).
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("240"))
Foreground(tuiColorText).
BorderForeground(tuiColorBorder)
tuiTitleStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("212"))
Foreground(tuiColorShellDeep)
tuiBannerClawStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("69"))
Foreground(tuiColorClaw)
tuiBannerGoStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("204"))
Foreground(tuiColorShell)
tuiMutedStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("245"))
Foreground(tuiColorTextSoft)
tuiInputBoxStyle = lipgloss.NewStyle().
Padding(0, 1).
BorderTop(true).
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("240"))
Foreground(tuiColorText).
BorderForeground(tuiColorBorder)
tuiViewportStyle = lipgloss.NewStyle().Padding(0, 1)
tuiFooterStyle = lipgloss.NewStyle().
Padding(0, 1).
Foreground(lipgloss.Color("245"))
Foreground(tuiColorText)
tuiErrorStyle = lipgloss.NewStyle().
Padding(0, 1).
Foreground(lipgloss.Color("203"))
Foreground(tuiColorError)
tuiPaneStyle = lipgloss.NewStyle().
Padding(0, 1).
BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("240"))
Foreground(tuiColorText).
BorderForeground(tuiColorBorder)
tuiPaneActiveStyle = lipgloss.NewStyle().
Padding(0, 1).
BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("69"))
Foreground(tuiColorText).
BorderForeground(tuiColorShell)
tuiPaneTitleStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("252"))
Foreground(tuiColorAccentDeep)
tuiPaneTitleActiveStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("69"))
Foreground(tuiColorShellDeep)
tuiPaneTitleActiveBadgeStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("16")).
Background(lipgloss.Color("69"))
Foreground(tuiColorSurfaceText).
Background(tuiColorShell)
tuiPaneGapStyle = lipgloss.NewStyle()
tuiSidebarStyle = lipgloss.NewStyle().
Padding(0, 1).
BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("240"))
Foreground(tuiColorText).
BorderForeground(tuiColorBorder)
tuiSidebarItemStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("252"))
Foreground(tuiColorText)
tuiSidebarItemActiveStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("69"))
Foreground(tuiColorShellDeep)
tuiFooterHintStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241"))
Foreground(tuiColorTextSoft)
tuiPaneReplyingStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("230")).
Background(lipgloss.Color("62"))
Foreground(tuiColorSurfaceText).
Background(tuiColorShellDeep)
tuiPaneLoadingStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("16")).
Background(lipgloss.Color("221"))
Foreground(tuiColorText).
Background(tuiColorClaw)
tuiPaneErrorBadgeStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("255")).
Background(lipgloss.Color("160"))
Foreground(tuiColorSurfaceText).
Background(tuiColorError)
tuiLabelStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("249"))
Foreground(tuiColorAccent)
tuiContentStyle = lipgloss.NewStyle().PaddingLeft(1)
tuiUserStyle = lipgloss.NewStyle().PaddingLeft(1).Foreground(lipgloss.Color("87"))
tuiContentStyle = lipgloss.NewStyle().PaddingLeft(1).Foreground(tuiColorText)
tuiUserStyle = lipgloss.NewStyle().PaddingLeft(1).Foreground(tuiColorShellDeep)
tuiAssistantStyle = lipgloss.NewStyle().
PaddingLeft(1).
Foreground(lipgloss.Color("230"))
tuiSystemStyle = lipgloss.NewStyle().PaddingLeft(1).Foreground(lipgloss.Color("221"))
tuiToolStyle = lipgloss.NewStyle().PaddingLeft(1).Foreground(lipgloss.Color("141"))
Foreground(tuiColorText)
tuiSystemStyle = lipgloss.NewStyle().PaddingLeft(1).Foreground(tuiColorAccent)
tuiToolStyle = lipgloss.NewStyle().PaddingLeft(1).Foreground(tuiColorAccentDeep)
)
func renderTUIBanner(width int) string {

View File

@@ -445,51 +445,50 @@ func (s *Server) Start(ctx context.Context) error {
mux.HandleFunc("/nodes/register", s.handleRegister)
mux.HandleFunc("/nodes/heartbeat", s.handleHeartbeat)
mux.HandleFunc("/nodes/connect", s.handleNodeConnect)
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/chat/history", s.handleWebUIChatHistory)
mux.HandleFunc("/webui/api/chat/stream", s.handleWebUIChatStream)
mux.HandleFunc("/webui/api/chat/live", s.handleWebUIChatLive)
mux.HandleFunc("/webui/api/runtime", s.handleWebUIRuntime)
mux.HandleFunc("/webui/api/version", s.handleWebUIVersion)
mux.HandleFunc("/webui/api/provider/oauth/start", s.handleWebUIProviderOAuthStart)
mux.HandleFunc("/webui/api/provider/oauth/complete", s.handleWebUIProviderOAuthComplete)
mux.HandleFunc("/webui/api/provider/oauth/import", s.handleWebUIProviderOAuthImport)
mux.HandleFunc("/webui/api/provider/oauth/accounts", s.handleWebUIProviderOAuthAccounts)
mux.HandleFunc("/webui/api/provider/models", s.handleWebUIProviderModels)
mux.HandleFunc("/webui/api/provider/runtime", s.handleWebUIProviderRuntime)
mux.HandleFunc("/webui/api/provider/runtime/summary", s.handleWebUIProviderRuntimeSummary)
mux.HandleFunc("/webui/api/whatsapp/status", s.handleWebUIWhatsAppStatus)
mux.HandleFunc("/webui/api/whatsapp/logout", s.handleWebUIWhatsAppLogout)
mux.HandleFunc("/webui/api/whatsapp/qr.svg", s.handleWebUIWhatsAppQR)
mux.HandleFunc("/webui/api/upload", s.handleWebUIUpload)
mux.HandleFunc("/webui/api/nodes", s.handleWebUINodes)
mux.HandleFunc("/webui/api/node_dispatches", s.handleWebUINodeDispatches)
mux.HandleFunc("/webui/api/node_dispatches/replay", s.handleWebUINodeDispatchReplay)
mux.HandleFunc("/webui/api/node_artifacts", s.handleWebUINodeArtifacts)
mux.HandleFunc("/webui/api/node_artifacts/export", s.handleWebUINodeArtifactsExport)
mux.HandleFunc("/webui/api/node_artifacts/download", s.handleWebUINodeArtifactDownload)
mux.HandleFunc("/webui/api/node_artifacts/delete", s.handleWebUINodeArtifactDelete)
mux.HandleFunc("/webui/api/node_artifacts/prune", s.handleWebUINodeArtifactPrune)
mux.HandleFunc("/webui/api/cron", s.handleWebUICron)
mux.HandleFunc("/webui/api/skills", s.handleWebUISkills)
mux.HandleFunc("/webui/api/sessions", s.handleWebUISessions)
mux.HandleFunc("/webui/api/memory", s.handleWebUIMemory)
mux.HandleFunc("/webui/api/subagent_profiles", s.handleWebUISubagentProfiles)
mux.HandleFunc("/webui/api/subagents_runtime", s.handleWebUISubagentsRuntime)
mux.HandleFunc("/webui/api/subagents_runtime/live", s.handleWebUISubagentsRuntimeLive)
mux.HandleFunc("/webui/api/tool_allowlist_groups", s.handleWebUIToolAllowlistGroups)
mux.HandleFunc("/webui/api/tools", s.handleWebUITools)
mux.HandleFunc("/webui/api/mcp/install", s.handleWebUIMCPInstall)
mux.HandleFunc("/webui/api/task_audit", s.handleWebUITaskAudit)
mux.HandleFunc("/webui/api/task_queue", s.handleWebUITaskQueue)
mux.HandleFunc("/webui/api/ekg_stats", s.handleWebUIEKGStats)
mux.HandleFunc("/webui/api/exec_approvals", s.handleWebUIExecApprovals)
mux.HandleFunc("/webui/api/logs/stream", s.handleWebUILogsStream)
mux.HandleFunc("/webui/api/logs/live", s.handleWebUILogsLive)
mux.HandleFunc("/webui/api/logs/recent", s.handleWebUILogsRecent)
mux.HandleFunc("/", s.handleWebUIAsset)
mux.HandleFunc("/api/config", s.handleWebUIConfig)
mux.HandleFunc("/api/chat", s.handleWebUIChat)
mux.HandleFunc("/api/chat/history", s.handleWebUIChatHistory)
mux.HandleFunc("/api/chat/stream", s.handleWebUIChatStream)
mux.HandleFunc("/api/chat/live", s.handleWebUIChatLive)
mux.HandleFunc("/api/runtime", s.handleWebUIRuntime)
mux.HandleFunc("/api/version", s.handleWebUIVersion)
mux.HandleFunc("/api/provider/oauth/start", s.handleWebUIProviderOAuthStart)
mux.HandleFunc("/api/provider/oauth/complete", s.handleWebUIProviderOAuthComplete)
mux.HandleFunc("/api/provider/oauth/import", s.handleWebUIProviderOAuthImport)
mux.HandleFunc("/api/provider/oauth/accounts", s.handleWebUIProviderOAuthAccounts)
mux.HandleFunc("/api/provider/models", s.handleWebUIProviderModels)
mux.HandleFunc("/api/provider/runtime", s.handleWebUIProviderRuntime)
mux.HandleFunc("/api/provider/runtime/summary", s.handleWebUIProviderRuntimeSummary)
mux.HandleFunc("/api/whatsapp/status", s.handleWebUIWhatsAppStatus)
mux.HandleFunc("/api/whatsapp/logout", s.handleWebUIWhatsAppLogout)
mux.HandleFunc("/api/whatsapp/qr.svg", s.handleWebUIWhatsAppQR)
mux.HandleFunc("/api/upload", s.handleWebUIUpload)
mux.HandleFunc("/api/nodes", s.handleWebUINodes)
mux.HandleFunc("/api/node_dispatches", s.handleWebUINodeDispatches)
mux.HandleFunc("/api/node_dispatches/replay", s.handleWebUINodeDispatchReplay)
mux.HandleFunc("/api/node_artifacts", s.handleWebUINodeArtifacts)
mux.HandleFunc("/api/node_artifacts/export", s.handleWebUINodeArtifactsExport)
mux.HandleFunc("/api/node_artifacts/download", s.handleWebUINodeArtifactDownload)
mux.HandleFunc("/api/node_artifacts/delete", s.handleWebUINodeArtifactDelete)
mux.HandleFunc("/api/node_artifacts/prune", s.handleWebUINodeArtifactPrune)
mux.HandleFunc("/api/cron", s.handleWebUICron)
mux.HandleFunc("/api/skills", s.handleWebUISkills)
mux.HandleFunc("/api/sessions", s.handleWebUISessions)
mux.HandleFunc("/api/memory", s.handleWebUIMemory)
mux.HandleFunc("/api/subagent_profiles", s.handleWebUISubagentProfiles)
mux.HandleFunc("/api/subagents_runtime", s.handleWebUISubagentsRuntime)
mux.HandleFunc("/api/subagents_runtime/live", s.handleWebUISubagentsRuntimeLive)
mux.HandleFunc("/api/tool_allowlist_groups", s.handleWebUIToolAllowlistGroups)
mux.HandleFunc("/api/tools", s.handleWebUITools)
mux.HandleFunc("/api/mcp/install", s.handleWebUIMCPInstall)
mux.HandleFunc("/api/task_audit", s.handleWebUITaskAudit)
mux.HandleFunc("/api/task_queue", s.handleWebUITaskQueue)
mux.HandleFunc("/api/ekg_stats", s.handleWebUIEKGStats)
mux.HandleFunc("/api/exec_approvals", s.handleWebUIExecApprovals)
mux.HandleFunc("/api/logs/stream", s.handleWebUILogsStream)
mux.HandleFunc("/api/logs/live", s.handleWebUILogsLive)
mux.HandleFunc("/api/logs/recent", s.handleWebUILogsRecent)
base := strings.TrimRight(strings.TrimSpace(s.whatsAppBase), "/")
if base == "" {
base = "/whatsapp"
@@ -702,7 +701,7 @@ func (s *Server) handleWebUI(w http.ResponseWriter, r *http.Request) {
MaxAge: 86400,
})
}
if s.tryServeWebUIDist(w, r, "/webui/index.html") {
if s.tryServeWebUIDist(w, r, "/index.html") {
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
@@ -718,11 +717,19 @@ func (s *Server) handleWebUIAsset(w http.ResponseWriter, r *http.Request) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if strings.HasPrefix(r.URL.Path, "/api/") {
http.NotFound(w, r)
return
}
if r.URL.Path == "/" {
s.handleWebUI(w, r)
return
}
if s.tryServeWebUIDist(w, r, r.URL.Path) {
return
}
// SPA fallback
if s.tryServeWebUIDist(w, r, "/webui/index.html") {
if s.tryServeWebUIDist(w, r, "/index.html") {
return
}
http.NotFound(w, r)
@@ -733,8 +740,8 @@ func (s *Server) tryServeWebUIDist(w http.ResponseWriter, r *http.Request, reqPa
if dir == "" {
return false
}
p := strings.TrimPrefix(reqPath, "/webui/")
if reqPath == "/webui" || reqPath == "/webui/" || reqPath == "/webui/index.html" {
p := strings.TrimPrefix(reqPath, "/")
if reqPath == "/" || reqPath == "/index.html" {
p = "index.html"
}
p = filepath.Clean(strings.TrimPrefix(p, "/"))
@@ -1600,7 +1607,7 @@ func (s *Server) handleWebUIChatHistory(w http.ResponseWriter, r *http.Request)
func (s *Server) handleWebUIChatStream(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Deprecation", "true")
w.Header().Set("X-Clawgo-Replaced-By", "/webui/api/chat/live")
w.Header().Set("X-Clawgo-Replaced-By", "/api/chat/live")
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
@@ -3559,7 +3566,7 @@ func (s *Server) fetchRemoteNodeRegistry(ctx context.Context, node nodes.NodeInf
if baseURL == "" {
return nil, fmt.Errorf("node %s endpoint missing", strings.TrimSpace(node.ID))
}
reqURL := baseURL + "/webui/api/subagents_runtime?action=registry"
reqURL := baseURL + "/api/subagents_runtime?action=registry"
if tok := strings.TrimSpace(node.Token); tok != "" {
reqURL += "&token=" + url.QueryEscape(tok)
}
@@ -6023,7 +6030,7 @@ func (s *Server) handleWebUILogsLive(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleWebUILogsStream(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Deprecation", "true")
w.Header().Set("X-Clawgo-Replaced-By", "/webui/api/logs/live")
w.Header().Set("X-Clawgo-Replaced-By", "/api/logs/live")
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
@@ -6132,13 +6139,13 @@ const webUIHTML = `<!doctype html>
<div id="chatlog"></div>
<script>
function auth(){const t=document.getElementById('token').value.trim();return t?('?token='+encodeURIComponent(t)):''}
async function loadCfg(){let r=await fetch('/webui/api/config'+auth());document.getElementById('cfg').value=await r.text()}
async function saveCfg(){let j=JSON.parse(document.getElementById('cfg').value);let r=await fetch('/webui/api/config'+auth(),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(j)});alert(await r.text())}
async function loadCfg(){let r=await fetch('/api/config'+auth());document.getElementById('cfg').value=await r.text()}
async function saveCfg(){let j=JSON.parse(document.getElementById('cfg').value);let r=await fetch('/api/config'+auth(),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(j)});alert(await r.text())}
async function sendChat(){
let media='';const f=document.getElementById('file').files[0];
if(f){let fd=new FormData();fd.append('file',f);let ur=await fetch('/webui/api/upload'+auth(),{method:'POST',body:fd});let uj=await ur.json();media=uj.path||''}
if(f){let fd=new FormData();fd.append('file',f);let ur=await fetch('/api/upload'+auth(),{method:'POST',body:fd});let uj=await ur.json();media=uj.path||''}
const payload={session:document.getElementById('session').value,message:document.getElementById('msg').value,media};
let r=await fetch('/webui/api/chat'+auth(),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});let t=await r.text();
let r=await fetch('/api/chat'+auth(),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});let t=await r.text();
document.getElementById('chatlog').textContent += '\nUSER> '+payload.message+(media?(' [file:'+media+']'):'')+'\nBOT> '+t+'\n';
}
loadCfg();

View File

@@ -58,7 +58,7 @@ func TestHandleWebUIWhatsAppStatus(t *testing.T) {
srv := NewServer("127.0.0.1", 0, "", nil)
srv.SetConfigPath(cfgPath)
req := httptest.NewRequest(http.MethodGet, "/webui/api/whatsapp/status", nil)
req := httptest.NewRequest(http.MethodGet, "/api/whatsapp/status", nil)
rec := httptest.NewRecorder()
srv.handleWebUIWhatsAppStatus(rec, req)
@@ -108,7 +108,7 @@ func TestHandleWebUIWhatsAppQR(t *testing.T) {
srv := NewServer("127.0.0.1", 0, "", nil)
srv.SetConfigPath(cfgPath)
req := httptest.NewRequest(http.MethodGet, "/webui/api/whatsapp/qr.svg", nil)
req := httptest.NewRequest(http.MethodGet, "/api/whatsapp/qr.svg", nil)
rec := httptest.NewRecorder()
srv.handleWebUIWhatsAppQR(rec, req)
@@ -158,7 +158,7 @@ func TestHandleWebUIWhatsAppStatusWithNestedBridgePath(t *testing.T) {
srv := NewServer("127.0.0.1", 0, "", nil)
srv.SetConfigPath(cfgPath)
req := httptest.NewRequest(http.MethodGet, "/webui/api/whatsapp/status", nil)
req := httptest.NewRequest(http.MethodGet, "/api/whatsapp/status", nil)
rec := httptest.NewRecorder()
srv.handleWebUIWhatsAppStatus(rec, req)
@@ -220,7 +220,7 @@ func TestHandleWebUIWhatsAppStatusMapsLegacyBridgeURLToEmbeddedPath(t *testing.T
srv := NewServer("127.0.0.1", 0, "", nil)
srv.SetConfigPath(cfgPath)
req := httptest.NewRequest(http.MethodGet, "/webui/api/whatsapp/status", nil)
req := httptest.NewRequest(http.MethodGet, "/api/whatsapp/status", nil)
rec := httptest.NewRecorder()
srv.handleWebUIWhatsAppStatus(rec, req)
@@ -270,7 +270,7 @@ func TestHandleWebUIConfigRequiresConfirmForProviderAPIBaseChange(t *testing.T)
srv := NewServer("127.0.0.1", 0, "", nil)
srv.SetConfigPath(cfgPath)
req := httptest.NewRequest(http.MethodPost, "/webui/api/config", bytes.NewReader(body))
req := httptest.NewRequest(http.MethodPost, "/api/config", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
@@ -323,7 +323,7 @@ func TestHandleWebUIConfigRequiresConfirmForCustomProviderSecretChange(t *testin
srv := NewServer("127.0.0.1", 0, "", nil)
srv.SetConfigPath(cfgPath)
req := httptest.NewRequest(http.MethodPost, "/webui/api/config", bytes.NewReader(body))
req := httptest.NewRequest(http.MethodPost, "/api/config", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
@@ -364,7 +364,7 @@ func TestHandleWebUIConfigRunsReloadHookSynchronously(t *testing.T) {
return nil
})
req := httptest.NewRequest(http.MethodPost, "/webui/api/config", bytes.NewReader(body))
req := httptest.NewRequest(http.MethodPost, "/api/config", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
@@ -400,7 +400,7 @@ func TestHandleWebUIConfigReturnsReloadHookError(t *testing.T) {
return fmt.Errorf("reload boom")
})
req := httptest.NewRequest(http.MethodPost, "/webui/api/config", bytes.NewReader(body))
req := httptest.NewRequest(http.MethodPost, "/api/config", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
@@ -600,7 +600,7 @@ func TestHandleWebUISessionsHidesInternalSessionsByDefault(t *testing.T) {
srv := NewServer("127.0.0.1", 0, "", nil)
srv.SetWorkspacePath(filepath.Join(tmp, "workspace"))
req := httptest.NewRequest(http.MethodGet, "/webui/api/sessions", nil)
req := httptest.NewRequest(http.MethodGet, "/api/sessions", nil)
rec := httptest.NewRecorder()
srv.handleWebUISessions(rec, req)
@@ -659,11 +659,11 @@ func TestHandleWebUISubagentsRuntimeLive(t *testing.T) {
})
mux := http.NewServeMux()
mux.HandleFunc("/webui/api/subagents_runtime/live", srv.handleWebUISubagentsRuntimeLive)
mux.HandleFunc("/api/subagents_runtime/live", srv.handleWebUISubagentsRuntimeLive)
httpSrv := httptest.NewServer(mux)
defer httpSrv.Close()
wsURL := "ws" + strings.TrimPrefix(httpSrv.URL, "http") + "/webui/api/subagents_runtime/live?task_id=subagent-1&preview_task_id=subagent-1"
wsURL := "ws" + strings.TrimPrefix(httpSrv.URL, "http") + "/api/subagents_runtime/live?task_id=subagent-1&preview_task_id=subagent-1"
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("dial websocket: %v", err)
@@ -698,11 +698,11 @@ func TestHandleWebUIChatLive(t *testing.T) {
})
mux := http.NewServeMux()
mux.HandleFunc("/webui/api/chat/live", srv.handleWebUIChatLive)
mux.HandleFunc("/api/chat/live", srv.handleWebUIChatLive)
httpSrv := httptest.NewServer(mux)
defer httpSrv.Close()
wsURL := "ws" + strings.TrimPrefix(httpSrv.URL, "http") + "/webui/api/chat/live"
wsURL := "ws" + strings.TrimPrefix(httpSrv.URL, "http") + "/api/chat/live"
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("dial websocket: %v", err)
@@ -744,11 +744,11 @@ func TestHandleWebUILogsLive(t *testing.T) {
srv.SetLogFilePath(logPath)
mux := http.NewServeMux()
mux.HandleFunc("/webui/api/logs/live", srv.handleWebUILogsLive)
mux.HandleFunc("/api/logs/live", srv.handleWebUILogsLive)
httpSrv := httptest.NewServer(mux)
defer httpSrv.Close()
wsURL := "ws" + strings.TrimPrefix(httpSrv.URL, "http") + "/webui/api/logs/live"
wsURL := "ws" + strings.TrimPrefix(httpSrv.URL, "http") + "/api/logs/live"
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("dial websocket: %v", err)
@@ -805,7 +805,7 @@ func TestHandleWebUINodesIncludesP2PSummary(t *testing.T) {
}
})
req := httptest.NewRequest(http.MethodGet, "/webui/api/nodes", nil)
req := httptest.NewRequest(http.MethodGet, "/api/nodes", nil)
rec := httptest.NewRecorder()
srv.handleWebUINodes(rec, req)
if rec.Code != http.StatusOK {
@@ -855,7 +855,7 @@ func TestHandleWebUINodesEnrichesLocalNodeMetadata(t *testing.T) {
}, nil
})
req := httptest.NewRequest(http.MethodGet, "/webui/api/nodes", nil)
req := httptest.NewRequest(http.MethodGet, "/api/nodes", nil)
rec := httptest.NewRecorder()
srv.handleWebUINodes(rec, req)
if rec.Code != http.StatusOK {
@@ -909,7 +909,7 @@ func TestHandleWebUINodeDispatchReplay(t *testing.T) {
})
body := `{"node":"edge-a","action":"screen_snapshot","mode":"auto","args":{"quality":"high"}}`
req := httptest.NewRequest(http.MethodPost, "/webui/api/node_dispatches/replay", strings.NewReader(body))
req := httptest.NewRequest(http.MethodPost, "/api/node_dispatches/replay", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
@@ -940,7 +940,7 @@ func TestHandleWebUINodeArtifactsListAndDelete(t *testing.T) {
t.Fatalf("write audit: %v", err)
}
listReq := httptest.NewRequest(http.MethodGet, "/webui/api/node_artifacts", nil)
listReq := httptest.NewRequest(http.MethodGet, "/api/node_artifacts", nil)
listRec := httptest.NewRecorder()
srv.handleWebUINodeArtifacts(listRec, listReq)
if listRec.Code != http.StatusOK {
@@ -960,7 +960,7 @@ func TestHandleWebUINodeArtifactsListAndDelete(t *testing.T) {
t.Fatalf("expected artifact id, got %+v", item)
}
deleteReq := httptest.NewRequest(http.MethodPost, "/webui/api/node_artifacts/delete", strings.NewReader(fmt.Sprintf(`{"id":"%s"}`, artifactID)))
deleteReq := httptest.NewRequest(http.MethodPost, "/api/node_artifacts/delete", strings.NewReader(fmt.Sprintf(`{"id":"%s"}`, artifactID)))
deleteReq.Header.Set("Content-Type", "application/json")
deleteRec := httptest.NewRecorder()
srv.handleWebUINodeArtifactDelete(deleteRec, deleteReq)
@@ -987,7 +987,7 @@ func TestHandleWebUINodeArtifactsExport(t *testing.T) {
}
srv.mgr.Upsert(nodes.NodeInfo{ID: "edge-a", Name: "Edge A", Online: true})
req := httptest.NewRequest(http.MethodGet, "/webui/api/node_artifacts/export?node=edge-a&action=screen_snapshot&kind=text", nil)
req := httptest.NewRequest(http.MethodGet, "/api/node_artifacts/export?node=edge-a&action=screen_snapshot&kind=text", nil)
rec := httptest.NewRecorder()
srv.handleWebUINodeArtifactsExport(rec, req)
if rec.Code != http.StatusOK {
@@ -1048,7 +1048,7 @@ func TestHandleWebUINodeArtifactsPrune(t *testing.T) {
t.Fatalf("write audit: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/webui/api/node_artifacts/prune", strings.NewReader(`{"node":"edge-a","action":"screen_snapshot","kind":"text","keep_latest":1}`))
req := httptest.NewRequest(http.MethodPost, "/api/node_artifacts/prune", strings.NewReader(`{"node":"edge-a","action":"screen_snapshot","kind":"text","keep_latest":1}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
srv.handleWebUINodeArtifactPrune(rec, req)
@@ -1091,7 +1091,7 @@ func TestHandleWebUINodeArtifactsAppliesRetentionConfig(t *testing.T) {
t.Fatalf("write audit: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/webui/api/node_artifacts", nil)
req := httptest.NewRequest(http.MethodGet, "/api/node_artifacts", nil)
rec := httptest.NewRecorder()
srv.handleWebUINodeArtifacts(rec, req)
if rec.Code != http.StatusOK {
@@ -1142,7 +1142,7 @@ func TestHandleWebUINodeArtifactsAppliesRetentionDays(t *testing.T) {
t.Fatalf("write audit: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/webui/api/node_artifacts", nil)
req := httptest.NewRequest(http.MethodGet, "/api/node_artifacts", nil)
rec := httptest.NewRecorder()
srv.handleWebUINodeArtifacts(rec, req)
if rec.Code != http.StatusOK {