feat(provider): switch to clawgo provider and improve office state handling

This commit is contained in:
lpf
2026-03-05 16:41:14 +08:00
parent 3553be2d53
commit eae7864286
7 changed files with 279 additions and 82 deletions

View File

@@ -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://<host>:<port>/webui?token=<gateway.token>
```text
clawgo onboard
clawgo login
clawgo provider
clawgo status
clawgo agent [-m "..."]
clawgo gateway [run|start|stop|restart|status]

View File

@@ -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]

View File

@@ -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")

View File

@@ -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)

View File

@@ -59,6 +59,8 @@ func main() {
gatewayCmd()
case "status":
statusCmd()
case "provider":
providerCmd()
case "config":
configCmd()
case "cron":

View File

@@ -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"

View File

@@ -448,6 +448,7 @@ const OfficeScene: React.FC<OfficeSceneProps> = ({ main, nodes }) => {
const [tick, setTick] = useState(0);
const [showDebug, setShowDebug] = useState(false);
const [manualState, setManualState] = useState<MainVisualState | null>(null);
const [manualStateUntil, setManualStateUntil] = useState<number>(0);
const [panEnabled, setPanEnabled] = useState<boolean>(() => true);
const [camera, setCamera] = useState<CameraState>({ x: 0, y: 0, zoom: 1 });
const [pointerWorld, setPointerWorld] = useState<Point | null>(null);
@@ -461,10 +462,22 @@ const OfficeScene: React.FC<OfficeSceneProps> = ({ 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<OfficeZone, number> = { breakroom: 0, work: 0, server: 0, bug: 0 };
return nodes.slice(0, 24).map((n, i) => {
@@ -650,6 +663,11 @@ const OfficeScene: React.FC<OfficeSceneProps> = ({ 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<OfficeSceneProps> = ({ 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<OfficeSceneProps> = ({ main, nodes }) => {
</div>
<div className="absolute right-2 top-2 z-[300] rounded-lg border border-zinc-700 bg-zinc-950/85 px-2 py-1 text-[11px] text-zinc-300">
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'}
</div>
{showDebug ? (