From eae7864286a2d40b686c72d3000f539fa6b65d16 Mon Sep 17 00:00:00 2001 From: lpf Date: Thu, 5 Mar 2026 16:41:14 +0800 Subject: [PATCH] feat(provider): switch to clawgo provider and improve office state handling --- README.md | 6 +- README_EN.md | 6 +- cmd/clawgo/cli_common.go | 1 + cmd/clawgo/cmd_config.go | 209 ++++++++++++++++++-- cmd/clawgo/main.go | 2 + pkg/api/server.go | 116 ++++++----- webui/src/components/office/OfficeScene.tsx | 21 +- 7 files changed, 279 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index a8b6f17..bc00544 100644 --- a/README.md +++ b/README.md @@ -55,10 +55,10 @@ curl -fsSL https://raw.githubusercontent.com/YspCoder/clawgo/main/install.sh | b clawgo onboard ``` -### 3) 配置模型 +### 3) 配置 Provider ```bash -clawgo login +clawgo provider ``` ### 4) 看状态 @@ -118,7 +118,7 @@ http://:/webui?token= ```text clawgo onboard -clawgo login +clawgo provider clawgo status clawgo agent [-m "..."] clawgo gateway [run|start|stop|restart|status] diff --git a/README_EN.md b/README_EN.md index 533ddbf..2194404 100644 --- a/README_EN.md +++ b/README_EN.md @@ -55,10 +55,10 @@ curl -fsSL https://raw.githubusercontent.com/YspCoder/clawgo/main/install.sh | b clawgo onboard ``` -### 3) Configure model/proxy +### 3) Configure provider ```bash -clawgo login +clawgo provider ``` ### 4) Check status @@ -118,7 +118,7 @@ Main pages: ```text clawgo onboard -clawgo login +clawgo provider clawgo status clawgo agent [-m "..."] clawgo gateway [run|start|stop|restart|status] diff --git a/cmd/clawgo/cli_common.go b/cmd/clawgo/cli_common.go index ac1375e..ca8780b 100644 --- a/cmd/clawgo/cli_common.go +++ b/cmd/clawgo/cli_common.go @@ -92,6 +92,7 @@ func printHelp() { fmt.Println(" agent Interact with the agent directly") fmt.Println(" gateway Register/manage gateway service") fmt.Println(" status Show clawgo status") + fmt.Println(" provider Configure provider credentials") fmt.Println(" config Get/set config values") fmt.Println(" cron Manage scheduled tasks") fmt.Println(" channel Test and manage messaging channels") diff --git a/cmd/clawgo/cmd_config.go b/cmd/clawgo/cmd_config.go index 07a98be..5e4d1d8 100644 --- a/cmd/clawgo/cmd_config.go +++ b/cmd/clawgo/cmd_config.go @@ -5,6 +5,8 @@ import ( "encoding/json" "fmt" "os" + "sort" + "strconv" "strings" "clawgo/pkg/config" @@ -26,8 +28,6 @@ func configCmd() { configCheckCmd() case "reload": configReloadCmd() - case "login": - configLoginCmd() default: fmt.Printf("Unknown config command: %s\n", os.Args[2]) configHelp() @@ -171,35 +171,216 @@ func configCheckCmd() { } } -func configLoginCmd() { +func providerCmd() { cfg, err := loadConfig() if err != nil { fmt.Printf("Error loading config: %v\n", err) os.Exit(1) } - fmt.Println("Configuring CLIProxyAPI...") - fmt.Printf("Current Base: %s\n", cfg.Providers.Proxy.APIBase) - - fmt.Print("Enter CLIProxyAPI Base URL (e.g. http://localhost:8080/v1): ") reader := bufio.NewReader(os.Stdin) - line, _ := reader.ReadString('\n') - apiBase := strings.TrimSpace(line) - if apiBase != "" { - cfg.Providers.Proxy.APIBase = apiBase + defaultProxy := strings.TrimSpace(cfg.Agents.Defaults.Proxy) + if defaultProxy == "" { + defaultProxy = "proxy" + } + available := providerNames(cfg) + fmt.Printf("Current default provider: %s\n", defaultProxy) + fmt.Printf("Available providers: %s\n", strings.Join(available, ", ")) + + argName := "" + if len(os.Args) >= 3 { + argName = strings.TrimSpace(os.Args[2]) + } + if argName == "" || strings.HasPrefix(argName, "-") { + argName = defaultProxy + } + providerName := promptLine(reader, "Provider name to configure", argName) + if providerName == "" { + providerName = defaultProxy } - fmt.Print("Enter API Key (optional): ") - fmt.Scanln(&cfg.Providers.Proxy.APIKey) + pc := providerConfigByName(cfg, providerName) + if pc.TimeoutSec <= 0 { + pc.TimeoutSec = 90 + } + if strings.TrimSpace(pc.Auth) == "" { + pc.Auth = "bearer" + } + if len(pc.Models) == 0 { + pc.Models = append([]string{}, cfg.Providers.Proxy.Models...) + } + if len(pc.Models) == 0 { + pc.Models = []string{"glm-4.7"} + } + + pc.APIBase = promptLine(reader, "api_base", pc.APIBase) + apiKey := promptLine(reader, "api_key (leave empty to keep current)", "") + if apiKey != "" { + pc.APIKey = apiKey + } + modelsRaw := promptLine(reader, "models (comma-separated)", strings.Join(pc.Models, ",")) + if models := parseCSV(modelsRaw); len(models) > 0 { + pc.Models = models + } + pc.Auth = promptLine(reader, "auth (bearer/oauth/none)", pc.Auth) + timeoutRaw := promptLine(reader, "timeout_sec", fmt.Sprintf("%d", pc.TimeoutSec)) + pc.TimeoutSec = parseIntOrDefault(timeoutRaw, pc.TimeoutSec) + pc.SupportsResponsesCompact = promptBool(reader, "supports_responses_compact", pc.SupportsResponsesCompact) + + setProviderConfigByName(cfg, providerName, pc) + + makeDefault := promptBool(reader, fmt.Sprintf("Set %s as agents.defaults.proxy", providerName), providerName == defaultProxy) + if makeDefault { + cfg.Agents.Defaults.Proxy = providerName + } + + currentFallbacks := strings.Join(cfg.Agents.Defaults.ProxyFallbacks, ",") + fallbackRaw := promptLine(reader, "agents.defaults.proxy_fallbacks (comma-separated names)", currentFallbacks) + fallbacks := parseCSV(fallbackRaw) + valid := map[string]struct{}{} + for _, name := range providerNames(cfg) { + valid[name] = struct{}{} + } + filteredFallbacks := make([]string, 0, len(fallbacks)) + seen := map[string]struct{}{} + defaultName := strings.TrimSpace(cfg.Agents.Defaults.Proxy) + for _, fb := range fallbacks { + if fb == "" || fb == defaultName { + continue + } + if _, ok := valid[fb]; !ok { + fmt.Printf("Skip unknown fallback provider: %s\n", fb) + continue + } + if _, ok := seen[fb]; ok { + continue + } + seen[fb] = struct{}{} + filteredFallbacks = append(filteredFallbacks, fb) + } + cfg.Agents.Defaults.ProxyFallbacks = filteredFallbacks if err := config.SaveConfig(getConfigPath(), cfg); err != nil { fmt.Printf("Error saving config: %v\n", err) os.Exit(1) } - fmt.Println("✓ CLIProxyAPI configuration saved.") + fmt.Println("✓ Provider configuration saved.") + running, err := triggerGatewayReload() + if err != nil { + if running { + fmt.Printf("Hot reload not applied: %v\n", err) + return + } + fmt.Printf("Gateway not running, reload skipped: %v\n", err) + return + } + fmt.Println("✓ Gateway hot reload signal sent") } +func providerNames(cfg *config.Config) []string { + names := []string{"proxy"} + for k := range cfg.Providers.Proxies { + k = strings.TrimSpace(k) + if k == "" { + continue + } + names = append(names, k) + } + sort.Strings(names) + return names +} + +func providerConfigByName(cfg *config.Config, name string) config.ProviderConfig { + name = strings.TrimSpace(name) + if name == "" || name == "proxy" { + return cfg.Providers.Proxy + } + if cfg.Providers.Proxies != nil { + if pc, ok := cfg.Providers.Proxies[name]; ok { + return pc + } + } + return config.ProviderConfig{ + APIBase: cfg.Providers.Proxy.APIBase, + TimeoutSec: cfg.Providers.Proxy.TimeoutSec, + Auth: cfg.Providers.Proxy.Auth, + Models: append([]string{}, cfg.Providers.Proxy.Models...), + } +} + +func setProviderConfigByName(cfg *config.Config, name string, pc config.ProviderConfig) { + name = strings.TrimSpace(name) + if name == "" || name == "proxy" { + cfg.Providers.Proxy = pc + return + } + if cfg.Providers.Proxies == nil { + cfg.Providers.Proxies = map[string]config.ProviderConfig{} + } + cfg.Providers.Proxies[name] = pc +} + +func promptLine(reader *bufio.Reader, label, defaultValue string) string { + label = strings.TrimSpace(label) + if defaultValue != "" { + fmt.Printf("%s [%s]: ", label, defaultValue) + } else { + fmt.Printf("%s: ", label) + } + line, _ := reader.ReadString('\n') + line = strings.TrimSpace(line) + if line == "" { + return defaultValue + } + return line +} + +func promptBool(reader *bufio.Reader, label string, defaultValue bool) bool { + def := "N" + if defaultValue { + def = "Y" + } + raw := promptLine(reader, label+" (y/n)", def) + switch strings.ToLower(strings.TrimSpace(raw)) { + case "y", "yes", "true", "1": + return true + case "n", "no", "false", "0": + return false + default: + return defaultValue + } +} + +func parseCSV(raw string) []string { + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + seen := map[string]struct{}{} + for _, p := range parts { + v := strings.TrimSpace(p) + if v == "" { + continue + } + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + out = append(out, v) + } + return out +} + +func parseIntOrDefault(raw string, def int) int { + raw = strings.TrimSpace(raw) + if raw == "" { + return def + } + v, err := strconv.Atoi(raw) + if err != nil || v <= 0 { + return def + } + return v +} func loadConfigAsMap(path string) (map[string]interface{}, error) { return configops.LoadConfigAsMap(path) diff --git a/cmd/clawgo/main.go b/cmd/clawgo/main.go index dba859e..c536ee0 100644 --- a/cmd/clawgo/main.go +++ b/cmd/clawgo/main.go @@ -59,6 +59,8 @@ func main() { gatewayCmd() case "status": statusCmd() + case "provider": + providerCmd() case "config": configCmd() case "cron": diff --git a/pkg/api/server.go b/pkg/api/server.go index 60fce14..5cf7bf2 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -2516,18 +2516,51 @@ func (s *Server) handleWebUIOfficeState(w http.ResponseWriter, r *http.Request) return st } } + officeStateForStatus := func(status string, ts time.Time) string { + st := normalizeTaskStatus(status) + switch st { + case "running": + return "working" + case "error", "blocked": + return "error" + case "suppressed": + return "syncing" + case "success": + // Briefly keep success in working pose, then fall back to idle. + if !ts.IsZero() && now.Sub(ts) <= 90*time.Second { + return "working" + } + return "idle" + default: + return "idle" + } + } + officeZoneForState := func(state string) string { + switch strings.ToLower(strings.TrimSpace(state)) { + case "working": + return "work" + case "syncing": + return "server" + case "error": + return "bug" + default: + return "breakroom" + } + } isFreshTaskState := func(status string, ts time.Time) bool { if ts.IsZero() { return false } - window := 30 * time.Minute + window := 20 * time.Minute switch status { - case "running", "waiting": - window = 2 * time.Hour - case "blocked", "error": - window = 6 * time.Hour - case "success", "suppressed": + case "running": + window = 3 * time.Hour + case "waiting": window = 30 * time.Minute + case "blocked", "error": + window = 2 * time.Hour + case "success", "suppressed": + window = 12 * time.Minute } return !ts.Before(now.Add(-window)) } @@ -2656,59 +2689,9 @@ func (s *Server) handleWebUIOfficeState(w http.ResponseWriter, r *http.Request) mainState := "idle" mainZone := "breakroom" - switch { - case stats["running"] > 0: - mainState = "executing" - mainZone = "work" - case stats["error"] > 0 || stats["blocked"] > 0: - mainState = "error" - mainZone = "bug" - case stats["suppressed"] > 0: - mainState = "syncing" - mainZone = "server" - case stats["waiting"] > 0: - mainState = "idle" - mainZone = "breakroom" - case stats["success"] > 0: - mainState = "writing" - mainZone = "work" - default: - mainState = "idle" - mainZone = "breakroom" - } - mainTaskID := "" mainDetail := "No active task" - isMainStatus := func(st string) bool { - st = strings.ToLower(strings.TrimSpace(st)) - switch mainState { - case "executing": - return st == "running" - case "error": - return st == "error" || st == "blocked" - case "syncing": - return st == "suppressed" - case "writing": - return st == "success" - default: - return st == "waiting" || st == "idle" - } - } - for _, row := range items { - st := normalizeTaskStatus(fmt.Sprintf("%v", row["status"])) - if isMainStatus(st) { - mainTaskID = strings.TrimSpace(fmt.Sprintf("%v", row["task_id"])) - mainDetail = strings.TrimSpace(fmt.Sprintf("%v", row["input_preview"])) - if mainDetail == "" { - mainDetail = strings.TrimSpace(fmt.Sprintf("%v", row["log"])) - } - if mainDetail == "" { - mainDetail = "Task " + mainTaskID - } - break - } - } - if mainTaskID == "" && len(items) > 0 { + if len(items) > 0 { mainTaskID = strings.TrimSpace(fmt.Sprintf("%v", items[0]["task_id"])) mainDetail = strings.TrimSpace(fmt.Sprintf("%v", items[0]["input_preview"])) if mainDetail == "" { @@ -2717,6 +2700,10 @@ func (s *Server) handleWebUIOfficeState(w http.ResponseWriter, r *http.Request) if mainDetail == "" { mainDetail = "Task " + mainTaskID } + st := normalizeTaskStatus(fmt.Sprintf("%v", items[0]["status"])) + ts := parseTime(fmt.Sprintf("%v", items[0]["time"])) + mainState = officeStateForStatus(st, ts) + mainZone = officeZoneForState(mainState) } nodeState := func(n nodes.NodeInfo) string { @@ -2727,16 +2714,23 @@ func (s *Server) handleWebUIOfficeState(w http.ResponseWriter, r *http.Request) if !n.LastSeenAt.IsZero() && now.Sub(n.LastSeenAt) > 20*time.Second { return "syncing" } - return "online" + if n.Capabilities.Model || n.Capabilities.Run { + return "working" + } + return "idle" } nodeZone := func(n nodes.NodeInfo) string { - if !n.Online { + st := nodeState(n) + if st == "offline" { return "bug" } - if n.Capabilities.Model || n.Capabilities.Run { + if st == "syncing" { + return "server" + } + if st == "working" && (n.Capabilities.Model || n.Capabilities.Run) { return "work" } - if n.Capabilities.Invoke || n.Capabilities.Camera || n.Capabilities.Screen || n.Capabilities.Canvas || n.Capabilities.Location { + if st == "working" { return "server" } return "breakroom" diff --git a/webui/src/components/office/OfficeScene.tsx b/webui/src/components/office/OfficeScene.tsx index f89c7d0..e72cbf0 100644 --- a/webui/src/components/office/OfficeScene.tsx +++ b/webui/src/components/office/OfficeScene.tsx @@ -448,6 +448,7 @@ const OfficeScene: React.FC = ({ main, nodes }) => { const [tick, setTick] = useState(0); const [showDebug, setShowDebug] = useState(false); const [manualState, setManualState] = useState(null); + const [manualStateUntil, setManualStateUntil] = useState(0); const [panEnabled, setPanEnabled] = useState(() => true); const [camera, setCamera] = useState({ x: 0, y: 0, zoom: 1 }); const [pointerWorld, setPointerWorld] = useState(null); @@ -461,10 +462,22 @@ const OfficeScene: React.FC = ({ main, nodes }) => { useEffect(() => { if (manualState && prevLiveStateRef.current !== liveMainState) { setManualState(null); + setManualStateUntil(0); } prevLiveStateRef.current = liveMainState; }, [liveMainState, manualState]); + useEffect(() => { + if (!manualState || manualStateUntil <= 0) return; + const timer = window.setInterval(() => { + if (Date.now() >= manualStateUntil) { + setManualState(null); + setManualStateUntil(0); + } + }, 400); + return () => window.clearInterval(timer); + }, [manualState, manualStateUntil]); + const placedNodes = useMemo(() => { const counters: Record = { breakroom: 0, work: 0, server: 0, bug: 0 }; return nodes.slice(0, 24).map((n, i) => { @@ -650,6 +663,11 @@ const OfficeScene: React.FC = ({ main, nodes }) => { const setVisualState = useCallback((state: MainVisualState | null) => { setManualState(state); + if (state === null) { + setManualStateUntil(0); + return; + } + setManualStateUntil(Date.now() + 15_000); }, []); const cycleServerMode = useCallback(() => { @@ -660,6 +678,7 @@ const OfficeScene: React.FC = ({ main, nodes }) => { const serverOn = serverMode === 'on' || (serverMode === 'auto' && effectiveMainState !== 'idle'); const serverFrame = serverOn ? frameAtTick(DECOR_SPRITES.serverroom, tick, 700) : 0; const mainFrame = frameAtTick(MAIN_SPRITES[effectiveMainState], tick); + const manualLeftSec = manualState ? Math.max(0, Math.ceil((manualStateUntil - Date.now()) / 1000)) : 0; const furnitureScale = camera.zoom; @@ -894,7 +913,7 @@ const OfficeScene: React.FC = ({ main, nodes }) => {
- state={effectiveMainState} live={liveMainState} mode={manualState ? 'manual' : 'follow'} server={serverMode} coffee={coffeePaused ? 'paused' : 'on'} + state={effectiveMainState} live={liveMainState} mode={manualState ? `manual(${manualLeftSec}s)` : 'follow'} server={serverMode} coffee={coffeePaused ? 'paused' : 'on'}
{showDebug ? (