2 Commits

Author SHA1 Message Date
lpf
7e67619826 fix(webui): split route bundles 2026-03-08 15:54:46 +08:00
lpf
8d66f6a901 fix: tighten release review regressions 2026-03-08 15:48:36 +08:00
7 changed files with 292 additions and 44 deletions

View File

@@ -54,14 +54,14 @@ func statusCmd() {
}
fmt.Printf("Model: %s\n", activeModel)
fmt.Printf("Proxy: %s\n", activeProxyName)
fmt.Printf("CLIProxyAPI Base: %s\n", cfg.Providers.Proxy.APIBase)
fmt.Printf("Provider API Base: %s\n", activeProvider.APIBase)
fmt.Printf("Supports /v1/responses/compact: %v\n", providers.ProviderSupportsResponsesCompact(cfg, activeProxyName))
hasKey := cfg.Providers.Proxy.APIKey != ""
hasKey := strings.TrimSpace(activeProvider.APIKey) != ""
status := "not set"
if hasKey {
status = "✓"
}
fmt.Printf("CLIProxyAPI Key: %s\n", status)
fmt.Printf("Provider API Key: %s\n", status)
fmt.Printf("Logging: %v\n", cfg.Logging.Enabled)
if cfg.Logging.Enabled {
fmt.Printf("Log File: %s\n", cfg.LogFilePath())

View File

@@ -0,0 +1,71 @@
package main
import (
"bytes"
"io"
"os"
"path/filepath"
"strings"
"testing"
"clawgo/pkg/config"
)
func TestStatusCmdUsesActiveProviderDetails(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfgPath := filepath.Join(tmp, "config.json")
workspace := filepath.Join(tmp, "workspace")
if err := os.MkdirAll(workspace, 0755); err != nil {
t.Fatalf("mkdir workspace: %v", err)
}
cfg := config.DefaultConfig()
cfg.Logging.Enabled = false
cfg.Agents.Defaults.Workspace = workspace
cfg.Agents.Defaults.Proxy = "backup"
cfg.Providers.Proxy.APIBase = "https://primary.example/v1"
cfg.Providers.Proxy.APIKey = ""
cfg.Providers.Proxies["backup"] = config.ProviderConfig{
APIBase: "https://backup.example/v1",
APIKey: "backup-key",
Models: []string{"backup-model"},
Auth: "bearer",
TimeoutSec: 30,
}
if err := config.SaveConfig(cfgPath, cfg); err != nil {
t.Fatalf("save config: %v", err)
}
prev := globalConfigPathOverride
globalConfigPathOverride = cfgPath
defer func() { globalConfigPathOverride = prev }()
oldStdout := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("pipe: %v", err)
}
os.Stdout = w
defer func() { os.Stdout = oldStdout }()
statusCmd()
_ = w.Close()
var buf bytes.Buffer
if _, err := io.Copy(&buf, r); err != nil {
t.Fatalf("read stdout: %v", err)
}
out := buf.String()
if !strings.Contains(out, "Proxy: backup") {
t.Fatalf("expected backup proxy in output, got: %s", out)
}
if !strings.Contains(out, "Provider API Base: https://backup.example/v1") {
t.Fatalf("expected active provider api base in output, got: %s", out)
}
if !strings.Contains(out, "Provider API Key: ✓") {
t.Fatalf("expected active provider api key status in output, got: %s", out)
}
}

View File

@@ -311,15 +311,7 @@ func (s *Server) handleWebUIConfig(w http.ResponseWriter, r *http.Request) {
var oldMap map[string]interface{}
_ = json.Unmarshal(oldCfgRaw, &oldMap)
riskyPaths := []string{
"channels.telegram.token",
"channels.telegram.allow_from",
"channels.telegram.allow_chats",
"providers.proxy.base_url",
"providers.proxy.api_key",
"gateway.token",
"gateway.port",
}
riskyPaths := collectRiskyConfigPaths(oldMap, body)
changedRisky := make([]string, 0)
for _, p := range riskyPaths {
if fmt.Sprintf("%v", getPathValue(oldMap, p)) != fmt.Sprintf("%v", getPathValue(body, p)) {
@@ -418,6 +410,50 @@ func getPathValue(m map[string]interface{}, path string) interface{} {
return cur
}
func collectRiskyConfigPaths(oldMap, newMap map[string]interface{}) []string {
paths := []string{
"channels.telegram.token",
"channels.telegram.allow_from",
"channels.telegram.allow_chats",
"providers.proxy.api_base",
"providers.proxy.api_key",
"gateway.token",
"gateway.port",
}
seen := map[string]bool{}
for _, path := range paths {
seen[path] = true
}
for _, name := range collectProviderProxyNames(oldMap, newMap) {
for _, field := range []string{"api_base", "api_key"} {
path := "providers.proxies." + name + "." + field
if !seen[path] {
paths = append(paths, path)
seen[path] = true
}
}
}
return paths
}
func collectProviderProxyNames(maps ...map[string]interface{}) []string {
seen := map[string]bool{}
names := make([]string, 0)
for _, root := range maps {
providers, _ := root["providers"].(map[string]interface{})
proxies, _ := providers["proxies"].(map[string]interface{})
for name := range proxies {
if strings.TrimSpace(name) == "" || seen[name] {
continue
}
seen[name] = true
names = append(names, name)
}
}
sort.Strings(names)
return names
}
func (s *Server) handleWebUIUpload(w http.ResponseWriter, r *http.Request) {
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)

109
pkg/api/server_test.go Normal file
View File

@@ -0,0 +1,109 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
cfgpkg "clawgo/pkg/config"
)
func TestHandleWebUIConfigRequiresConfirmForProviderAPIBaseChange(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfgPath := filepath.Join(tmp, "config.json")
cfg := cfgpkg.DefaultConfig()
cfg.Logging.Enabled = false
cfg.Providers.Proxy.APIBase = "https://old.example/v1"
cfg.Providers.Proxy.APIKey = "test-key"
if err := cfgpkg.SaveConfig(cfgPath, cfg); err != nil {
t.Fatalf("save config: %v", err)
}
bodyCfg := cfgpkg.DefaultConfig()
bodyCfg.Logging.Enabled = false
bodyCfg.Providers.Proxy.APIBase = "https://new.example/v1"
bodyCfg.Providers.Proxy.APIKey = "test-key"
body, err := json.Marshal(bodyCfg)
if err != nil {
t.Fatalf("marshal body: %v", err)
}
srv := NewServer("127.0.0.1", 0, "", nil)
srv.SetConfigPath(cfgPath)
req := httptest.NewRequest(http.MethodPost, "/webui/api/config", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
srv.handleWebUIConfig(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `"requires_confirm":true`) {
t.Fatalf("expected requires_confirm response, got: %s", rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `providers.proxy.api_base`) {
t.Fatalf("expected providers.proxy.api_base in changed_fields, got: %s", rec.Body.String())
}
}
func TestHandleWebUIConfigRequiresConfirmForCustomProviderSecretChange(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfgPath := filepath.Join(tmp, "config.json")
cfg := cfgpkg.DefaultConfig()
cfg.Logging.Enabled = false
cfg.Providers.Proxies["backup"] = cfgpkg.ProviderConfig{
APIBase: "https://backup.example/v1",
APIKey: "old-secret",
Models: []string{"backup-model"},
Auth: "bearer",
TimeoutSec: 30,
}
if err := cfgpkg.SaveConfig(cfgPath, cfg); err != nil {
t.Fatalf("save config: %v", err)
}
bodyCfg := cfgpkg.DefaultConfig()
bodyCfg.Logging.Enabled = false
bodyCfg.Providers.Proxies["backup"] = cfgpkg.ProviderConfig{
APIBase: "https://backup.example/v1",
APIKey: "new-secret",
Models: []string{"backup-model"},
Auth: "bearer",
TimeoutSec: 30,
}
body, err := json.Marshal(bodyCfg)
if err != nil {
t.Fatalf("marshal body: %v", err)
}
srv := NewServer("127.0.0.1", 0, "", nil)
srv.SetConfigPath(cfgPath)
req := httptest.NewRequest(http.MethodPost, "/webui/api/config", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
srv.handleWebUIConfig(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `"requires_confirm":true`) {
t.Fatalf("expected requires_confirm response, got: %s", rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `providers.proxies.backup.api_key`) {
t.Fatalf("expected providers.proxies.backup.api_key in changed_fields, got: %s", rec.Body.String())
}
}

View File

@@ -673,7 +673,15 @@ func waitSubagentDone(t *testing.T, manager *SubagentManager, timeout time.Durat
tasks := manager.ListTasks()
if len(tasks) > 0 {
task := tasks[0]
if task.Status != "running" {
for _, candidate := range tasks[1:] {
if candidate.Created > task.Created || (candidate.Created == task.Created && candidate.ID > task.ID) {
task = candidate
}
}
manager.mu.RLock()
_, stillRunning := manager.cancelFuncs[task.ID]
manager.mu.RUnlock()
if task.Status != "running" && !stillRunning {
return task
}
}

View File

@@ -1,44 +1,53 @@
import React from 'react';
import React, { Suspense, lazy } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { AppProvider } from './context/AppContext';
import { UIProvider } from './context/UIContext';
import Layout from './components/Layout';
import Dashboard from './pages/Dashboard';
import Chat from './pages/Chat';
import Config from './pages/Config';
import Cron from './pages/Cron';
import Logs from './pages/Logs';
import Skills from './pages/Skills';
import MCP from './pages/MCP';
import Memory from './pages/Memory';
import TaskAudit from './pages/TaskAudit';
import EKG from './pages/EKG';
import LogCodes from './pages/LogCodes';
import SubagentProfiles from './pages/SubagentProfiles';
import Subagents from './pages/Subagents';
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Chat = lazy(() => import('./pages/Chat'));
const Config = lazy(() => import('./pages/Config'));
const Cron = lazy(() => import('./pages/Cron'));
const Logs = lazy(() => import('./pages/Logs'));
const Skills = lazy(() => import('./pages/Skills'));
const MCP = lazy(() => import('./pages/MCP'));
const Memory = lazy(() => import('./pages/Memory'));
const TaskAudit = lazy(() => import('./pages/TaskAudit'));
const EKG = lazy(() => import('./pages/EKG'));
const LogCodes = lazy(() => import('./pages/LogCodes'));
const SubagentProfiles = lazy(() => import('./pages/SubagentProfiles'));
const Subagents = lazy(() => import('./pages/Subagents'));
const pageFallback = (
<div className="absolute inset-0 flex items-center justify-center bg-zinc-950/80 text-xs tracking-[0.3em] text-zinc-500 uppercase">
Loading
</div>
);
export default function App() {
return (
<AppProvider>
<UIProvider>
<BrowserRouter basename="/webui">
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Dashboard />} />
<Route path="chat" element={<Chat />} />
<Route path="logs" element={<Logs />} />
<Route path="log-codes" element={<LogCodes />} />
<Route path="skills" element={<Skills />} />
<Route path="mcp" element={<MCP />} />
<Route path="config" element={<Config />} />
<Route path="cron" element={<Cron />} />
<Route path="memory" element={<Memory />} />
<Route path="task-audit" element={<TaskAudit />} />
<Route path="ekg" element={<EKG />} />
<Route path="subagent-profiles" element={<SubagentProfiles />} />
<Route path="subagents" element={<Subagents />} />
</Route>
</Routes>
<Suspense fallback={pageFallback}>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Dashboard />} />
<Route path="chat" element={<Chat />} />
<Route path="logs" element={<Logs />} />
<Route path="log-codes" element={<LogCodes />} />
<Route path="skills" element={<Skills />} />
<Route path="mcp" element={<MCP />} />
<Route path="config" element={<Config />} />
<Route path="cron" element={<Cron />} />
<Route path="memory" element={<Memory />} />
<Route path="task-audit" element={<TaskAudit />} />
<Route path="ekg" element={<EKG />} />
<Route path="subagent-profiles" element={<SubagentProfiles />} />
<Route path="subagents" element={<Subagents />} />
</Route>
</Routes>
</Suspense>
</BrowserRouter>
</UIProvider>
</AppProvider>

View File

@@ -16,5 +16,20 @@ export default defineConfig(({mode}) => {
server: {
hmr: process.env.DISABLE_HMR !== 'true',
},
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (!id.includes('node_modules')) return undefined;
if (id.includes('react-router-dom') || id.includes('react-dom') || id.includes('/react/')) {
return 'react-vendor';
}
if (id.includes('motion')) return 'motion';
if (id.includes('lucide-react')) return 'icons';
return undefined;
},
},
},
},
};
});