diff --git a/cmd/clawgo/cmd_gateway.go b/cmd/clawgo/cmd_gateway.go index dfcea53..4e6b45b 100644 --- a/cmd/clawgo/cmd_gateway.go +++ b/cmd/clawgo/cmd_gateway.go @@ -14,6 +14,7 @@ import ( "time" "clawgo/pkg/agent" + "clawgo/pkg/api" "clawgo/pkg/autonomy" "clawgo/pkg/bus" "clawgo/pkg/channels" @@ -140,7 +141,7 @@ func gatewayCmd() { fmt.Printf("Error starting channels: %v\n", err) } - registryServer := nodes.NewRegistryServer(cfg.Gateway.Host, cfg.Gateway.Port, cfg.Gateway.Token, nodes.DefaultManager()) + registryServer := api.NewServer(cfg.Gateway.Host, cfg.Gateway.Port, cfg.Gateway.Token, nodes.DefaultManager()) registryServer.SetConfigPath(getConfigPath()) registryServer.SetWorkspacePath(cfg.WorkspacePath()) registryServer.SetLogFilePath(cfg.LogFilePath()) diff --git a/pkg/api/reload_unix.go b/pkg/api/reload_unix.go new file mode 100644 index 0000000..fb68dd3 --- /dev/null +++ b/pkg/api/reload_unix.go @@ -0,0 +1,12 @@ +//go:build !windows + +package api + +import ( + "os" + "syscall" +) + +func requestSelfReloadSignal() error { + return syscall.Kill(os.Getpid(), syscall.SIGHUP) +} diff --git a/pkg/api/reload_windows.go b/pkg/api/reload_windows.go new file mode 100644 index 0000000..d2ba80e --- /dev/null +++ b/pkg/api/reload_windows.go @@ -0,0 +1,8 @@ +//go:build windows + +package api + +// requestSelfReloadSignal is a no-op on Windows (no SIGHUP semantics). +func requestSelfReloadSignal() error { + return nil +} diff --git a/pkg/nodes/registry_server.go b/pkg/api/server.go similarity index 94% rename from pkg/nodes/registry_server.go rename to pkg/api/server.go index 56f567d..60fce14 100644 --- a/pkg/nodes/registry_server.go +++ b/pkg/api/server.go @@ -1,4 +1,4 @@ -package nodes +package api import ( "archive/tar" @@ -27,12 +27,13 @@ import ( "time" cfgpkg "clawgo/pkg/config" + "clawgo/pkg/nodes" ) -type RegistryServer struct { +type Server struct { addr string token string - mgr *Manager + mgr *nodes.Manager server *http.Server configPath string workspacePath string @@ -49,7 +50,7 @@ type RegistryServer struct { ekgCacheRows []map[string]interface{} } -func NewRegistryServer(host string, port int, token string, mgr *Manager) *RegistryServer { +func NewServer(host string, port int, token string, mgr *nodes.Manager) *Server { addr := strings.TrimSpace(host) if addr == "" { addr = "0.0.0.0" @@ -57,25 +58,25 @@ func NewRegistryServer(host string, port int, token string, mgr *Manager) *Regis if port <= 0 { port = 7788 } - return &RegistryServer{addr: fmt.Sprintf("%s:%d", addr, port), token: strings.TrimSpace(token), mgr: mgr} + return &Server{addr: fmt.Sprintf("%s:%d", addr, port), token: strings.TrimSpace(token), mgr: mgr} } -func (s *RegistryServer) SetConfigPath(path string) { s.configPath = strings.TrimSpace(path) } -func (s *RegistryServer) SetWorkspacePath(path string) { s.workspacePath = strings.TrimSpace(path) } -func (s *RegistryServer) SetLogFilePath(path string) { s.logFilePath = strings.TrimSpace(path) } -func (s *RegistryServer) SetChatHandler(fn func(ctx context.Context, sessionKey, content string) (string, error)) { +func (s *Server) SetConfigPath(path string) { s.configPath = strings.TrimSpace(path) } +func (s *Server) SetWorkspacePath(path string) { s.workspacePath = strings.TrimSpace(path) } +func (s *Server) SetLogFilePath(path string) { s.logFilePath = strings.TrimSpace(path) } +func (s *Server) SetChatHandler(fn func(ctx context.Context, sessionKey, content string) (string, error)) { s.onChat = fn } -func (s *RegistryServer) SetChatHistoryHandler(fn func(sessionKey string) []map[string]interface{}) { +func (s *Server) SetChatHistoryHandler(fn func(sessionKey string) []map[string]interface{}) { s.onChatHistory = fn } -func (s *RegistryServer) SetConfigAfterHook(fn func()) { s.onConfigAfter = fn } -func (s *RegistryServer) SetCronHandler(fn func(action string, args map[string]interface{}) (interface{}, error)) { +func (s *Server) SetConfigAfterHook(fn func()) { s.onConfigAfter = fn } +func (s *Server) SetCronHandler(fn func(action string, args map[string]interface{}) (interface{}, error)) { s.onCron = fn } -func (s *RegistryServer) SetWebUIDir(dir string) { s.webUIDir = strings.TrimSpace(dir) } +func (s *Server) SetWebUIDir(dir string) { s.webUIDir = strings.TrimSpace(dir) } -func (s *RegistryServer) Start(ctx context.Context) error { +func (s *Server) Start(ctx context.Context) error { if s.mgr == nil { return nil } @@ -119,7 +120,7 @@ func (s *RegistryServer) Start(ctx context.Context) error { return nil } -func (s *RegistryServer) handleRegister(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return @@ -128,7 +129,7 @@ func (s *RegistryServer) handleRegister(w http.ResponseWriter, r *http.Request) http.Error(w, "unauthorized", http.StatusUnauthorized) return } - var n NodeInfo + var n nodes.NodeInfo if err := json.NewDecoder(r.Body).Decode(&n); err != nil { http.Error(w, "invalid json", http.StatusBadRequest) return @@ -142,7 +143,7 @@ func (s *RegistryServer) handleRegister(w http.ResponseWriter, r *http.Request) _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "id": n.ID}) } -func (s *RegistryServer) handleHeartbeat(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleHeartbeat(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return @@ -170,7 +171,7 @@ func (s *RegistryServer) handleHeartbeat(w http.ResponseWriter, r *http.Request) _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "id": body.ID}) } -func (s *RegistryServer) handleWebUI(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleWebUI(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return @@ -196,7 +197,7 @@ func (s *RegistryServer) handleWebUI(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(webUIHTML)) } -func (s *RegistryServer) handleWebUIAsset(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleWebUIAsset(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return @@ -215,7 +216,7 @@ func (s *RegistryServer) handleWebUIAsset(w http.ResponseWriter, r *http.Request http.NotFound(w, r) } -func (s *RegistryServer) tryServeWebUIDist(w http.ResponseWriter, r *http.Request, reqPath string) bool { +func (s *Server) tryServeWebUIDist(w http.ResponseWriter, r *http.Request, reqPath string) bool { dir := strings.TrimSpace(s.webUIDir) if dir == "" { return false @@ -237,7 +238,7 @@ func (s *RegistryServer) tryServeWebUIDist(w http.ResponseWriter, r *http.Reques return true } -func (s *RegistryServer) handleWebUIConfig(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleWebUIConfig(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return @@ -404,7 +405,7 @@ func getPathValue(m map[string]interface{}, path string) interface{} { return cur } -func (s *RegistryServer) handleWebUIUpload(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleWebUIUpload(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return @@ -440,7 +441,7 @@ func (s *RegistryServer) handleWebUIUpload(w http.ResponseWriter, r *http.Reques _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "path": path, "name": h.Filename}) } -func (s *RegistryServer) handleWebUIChat(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleWebUIChat(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return @@ -484,7 +485,7 @@ func (s *RegistryServer) handleWebUIChat(w http.ResponseWriter, r *http.Request) _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "reply": resp, "session": session}) } -func (s *RegistryServer) handleWebUIChatHistory(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleWebUIChatHistory(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return @@ -504,7 +505,7 @@ func (s *RegistryServer) handleWebUIChatHistory(w http.ResponseWriter, r *http.R _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "session": session, "messages": s.onChatHistory(session)}) } -func (s *RegistryServer) handleWebUIChatStream(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleWebUIChatStream(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return @@ -566,7 +567,7 @@ func (s *RegistryServer) handleWebUIChatStream(w http.ResponseWriter, r *http.Re } } -func (s *RegistryServer) handleWebUIVersion(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleWebUIVersion(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return @@ -582,19 +583,19 @@ func (s *RegistryServer) handleWebUIVersion(w http.ResponseWriter, r *http.Reque }) } -func (s *RegistryServer) handleWebUINodes(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleWebUINodes(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } switch r.Method { case http.MethodGet: - list := []NodeInfo{} + list := []nodes.NodeInfo{} if s.mgr != nil { list = s.mgr.List() } host, _ := os.Hostname() - local := NodeInfo{ID: "local", Name: "local", Endpoint: "gateway", Version: gatewayBuildVersion(), LastSeenAt: time.Now(), Online: true} + local := nodes.NodeInfo{ID: "local", Name: "local", Endpoint: "gateway", Version: gatewayBuildVersion(), LastSeenAt: time.Now(), Online: true} if strings.TrimSpace(host) != "" { local.Name = host } @@ -623,7 +624,7 @@ func (s *RegistryServer) handleWebUINodes(w http.ResponseWriter, r *http.Request } } if !matched { - list = append([]NodeInfo{local}, list...) + list = append([]nodes.NodeInfo{local}, list...) } _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "nodes": list}) case http.MethodPost: @@ -652,7 +653,7 @@ func (s *RegistryServer) handleWebUINodes(w http.ResponseWriter, r *http.Request } } -func (s *RegistryServer) handleWebUICron(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleWebUICron(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return @@ -702,7 +703,7 @@ func (s *RegistryServer) handleWebUICron(w http.ResponseWriter, r *http.Request) } } -func (s *RegistryServer) handleWebUISkills(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleWebUISkills(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return @@ -1802,7 +1803,7 @@ func anyToString(v interface{}) string { } } -func (s *RegistryServer) handleWebUISessions(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleWebUISessions(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return @@ -1850,7 +1851,7 @@ func (s *RegistryServer) handleWebUISessions(w http.ResponseWriter, r *http.Requ _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "sessions": out}) } -func (s *RegistryServer) handleWebUIMemory(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleWebUIMemory(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return @@ -1925,7 +1926,7 @@ func (s *RegistryServer) handleWebUIMemory(w http.ResponseWriter, r *http.Reques } } -func (s *RegistryServer) handleWebUITaskAudit(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleWebUITaskAudit(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return @@ -2041,7 +2042,7 @@ func (s *RegistryServer) handleWebUITaskAudit(w http.ResponseWriter, r *http.Req _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "items": items}) } -func (s *RegistryServer) handleWebUITaskQueue(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleWebUITaskQueue(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return @@ -2258,7 +2259,7 @@ func (s *RegistryServer) handleWebUITaskQueue(w http.ResponseWriter, r *http.Req _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "running": running, "items": items, "stats": stats}) } -func (s *RegistryServer) handleWebUITaskDailySummary(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleWebUITaskDailySummary(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return @@ -2290,7 +2291,7 @@ func (s *RegistryServer) handleWebUITaskDailySummary(w http.ResponseWriter, r *h _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "date": date, "report": report}) } -func (s *RegistryServer) loadEKGRowsCached(path string, maxLines int) []map[string]interface{} { +func (s *Server) loadEKGRowsCached(path string, maxLines int) []map[string]interface{} { path = strings.TrimSpace(path) if path == "" { return nil @@ -2332,7 +2333,7 @@ func (s *RegistryServer) loadEKGRowsCached(path string, maxLines int) []map[stri return rows } -func (s *RegistryServer) handleWebUIEKGStats(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleWebUIEKGStats(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return @@ -2470,7 +2471,7 @@ func (s *RegistryServer) handleWebUIEKGStats(w http.ResponseWriter, r *http.Requ }) } -func (s *RegistryServer) handleWebUIOfficeState(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleWebUIOfficeState(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return @@ -2496,6 +2497,40 @@ func (s *RegistryServer) handleWebUIOfficeState(w http.ResponseWriter, r *http.R } return time.Time{} } + normalizeTaskStatus := func(raw string) string { + st := strings.ToLower(strings.TrimSpace(raw)) + switch st { + case "running", "doing", "in_progress", "in-progress", "executing", "processing", "active": + return "running" + case "waiting", "queued", "queue", "todo", "pending", "paused", "idle": + return "waiting" + case "blocked": + return "blocked" + case "error", "failed", "fail": + return "error" + case "success", "done", "completed", "complete": + return "success" + case "suppressed", "skip", "skipped": + return "suppressed" + default: + return st + } + } + isFreshTaskState := func(status string, ts time.Time) bool { + if ts.IsZero() { + return false + } + window := 30 * time.Minute + switch status { + case "running", "waiting": + window = 2 * time.Hour + case "blocked", "error": + window = 6 * time.Hour + case "success", "suppressed": + window = 30 * time.Minute + } + return !ts.Before(now.Add(-window)) + } ipv4Pattern := regexp.MustCompile(`\b(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\b`) maskIPv4 := func(text string) string { if strings.TrimSpace(text) == "" { @@ -2537,6 +2572,11 @@ func (s *RegistryServer) handleWebUIOfficeState(w http.ResponseWriter, r *http.R continue } t := parseTime(fmt.Sprintf("%v", row["time"])) + row["status"] = normalizeTaskStatus(fmt.Sprintf("%v", row["status"])) + st := fmt.Sprintf("%v", row["status"]) + if !isFreshTaskState(st, t) { + continue + } prev, ok := latestTimeByTask[taskID] if ok && !t.IsZero() && t.Before(prev) { continue @@ -2559,12 +2599,16 @@ func (s *RegistryServer) handleWebUIOfficeState(w http.ResponseWriter, r *http.R row := map[string]interface{}{ "task_id": id, "time": fmt.Sprintf("%v", t["updated_at"]), - "status": fmt.Sprintf("%v", t["status"]), + "status": normalizeTaskStatus(fmt.Sprintf("%v", t["status"])), "source": fmt.Sprintf("%v", t["source"]), "input_preview": fmt.Sprintf("%v", t["content"]), "log": fmt.Sprintf("%v", t["block_reason"]), } tm := parseTime(fmt.Sprintf("%v", row["time"])) + st := fmt.Sprintf("%v", row["status"]) + if !isFreshTaskState(st, tm) { + continue + } prev, ok := latestTimeByTask[id] if !ok || prev.IsZero() || (!tm.IsZero() && tm.After(prev)) { latestByTask[id] = row @@ -2604,7 +2648,7 @@ func (s *RegistryServer) handleWebUIOfficeState(w http.ResponseWriter, r *http.R "suppressed": 0, } for _, row := range items { - st := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", row["status"]))) + st := normalizeTaskStatus(fmt.Sprintf("%v", row["status"])) if _, ok := stats[st]; ok { stats[st]++ } @@ -2651,7 +2695,7 @@ func (s *RegistryServer) handleWebUIOfficeState(w http.ResponseWriter, r *http.R } } for _, row := range items { - st := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", row["status"]))) + 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"])) @@ -2675,7 +2719,7 @@ func (s *RegistryServer) handleWebUIOfficeState(w http.ResponseWriter, r *http.R } } - nodeState := func(n NodeInfo) string { + nodeState := func(n nodes.NodeInfo) string { if !n.Online { return "offline" } @@ -2685,7 +2729,7 @@ func (s *RegistryServer) handleWebUIOfficeState(w http.ResponseWriter, r *http.R } return "online" } - nodeZone := func(n NodeInfo) string { + nodeZone := func(n nodes.NodeInfo) string { if !n.Online { return "bug" } @@ -2697,7 +2741,7 @@ func (s *RegistryServer) handleWebUIOfficeState(w http.ResponseWriter, r *http.R } return "breakroom" } - nodeDetail := func(n NodeInfo) string { + nodeDetail := func(n nodes.NodeInfo) string { parts := make([]string, 0, 4) if ep := strings.TrimSpace(n.Endpoint); ep != "" { parts = append(parts, maskIPv4(ep)) @@ -2722,12 +2766,12 @@ func (s *RegistryServer) handleWebUIOfficeState(w http.ResponseWriter, r *http.R return maskIPv4(strings.Join(parts, " · ")) } - allNodes := []NodeInfo{} + allNodes := []nodes.NodeInfo{} if s.mgr != nil { allNodes = s.mgr.List() } host, _ := os.Hostname() - localNode := NodeInfo{ID: "local", Name: "local", Endpoint: "gateway", Version: gatewayBuildVersion(), LastSeenAt: now, Online: true} + localNode := nodes.NodeInfo{ID: "local", Name: "local", Endpoint: "gateway", Version: gatewayBuildVersion(), LastSeenAt: now, Online: true} if strings.TrimSpace(host) != "" { localNode.Name = strings.TrimSpace(host) } @@ -2736,7 +2780,7 @@ func (s *RegistryServer) handleWebUIOfficeState(w http.ResponseWriter, r *http.R } hostLower := strings.ToLower(strings.TrimSpace(host)) mainNode := localNode - otherNodes := make([]NodeInfo, 0, len(allNodes)) + otherNodes := make([]nodes.NodeInfo, 0, len(allNodes)) for _, n := range allNodes { idLower := strings.ToLower(strings.TrimSpace(n.ID)) nameLower := strings.ToLower(strings.TrimSpace(n.Name)) @@ -2852,7 +2896,7 @@ func (s *RegistryServer) handleWebUIOfficeState(w http.ResponseWriter, r *http.R }) } -func (s *RegistryServer) handleWebUITasks(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleWebUITasks(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return @@ -2959,7 +3003,7 @@ func (s *RegistryServer) handleWebUITasks(w http.ResponseWriter, r *http.Request _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true}) } -func (s *RegistryServer) handleWebUIExecApprovals(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleWebUIExecApprovals(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return @@ -3022,7 +3066,7 @@ func (s *RegistryServer) handleWebUIExecApprovals(w http.ResponseWriter, r *http http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } -func (s *RegistryServer) handleWebUILogsRecent(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleWebUILogsRecent(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return @@ -3073,7 +3117,7 @@ func (s *RegistryServer) handleWebUILogsRecent(w http.ResponseWriter, r *http.Re _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "logs": out}) } -func (s *RegistryServer) handleWebUILogsStream(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleWebUILogsStream(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return @@ -3132,7 +3176,7 @@ func (s *RegistryServer) handleWebUILogsStream(w http.ResponseWriter, r *http.Re } } -func (s *RegistryServer) checkAuth(r *http.Request) bool { +func (s *Server) checkAuth(r *http.Request) bool { if s.token == "" { return true } diff --git a/webui/src/components/office/OfficeScene.tsx b/webui/src/components/office/OfficeScene.tsx index c053f14..f89c7d0 100644 --- a/webui/src/components/office/OfficeScene.tsx +++ b/webui/src/components/office/OfficeScene.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { OFFICE_CANVAS, OFFICE_ZONE_POINT, OFFICE_ZONE_SLOTS, OfficeZone } from './officeLayout'; export type OfficeMainState = { @@ -24,6 +24,10 @@ type OfficeSceneProps = { nodes: OfficeNodeState[]; }; +type Point = { x: number; y: number }; +type CameraState = { x: number; y: number; zoom: number }; +type MainVisualState = 'idle' | 'working' | 'syncing' | 'error'; + type SpriteSpec = { src: string; frameW: number; @@ -42,9 +46,26 @@ type ImageSpec = { scale: number; }; -const TICK_FPS = 12; +type BubbleState = { + text: string; + expiresAt: number; +}; -const MAIN_SPRITES: Record<'idle' | 'working' | 'syncing' | 'error', SpriteSpec> = { +type DecorFrames = { + plantA: number; + plantB: number; + plantC: number; + poster: number; + flower: number; + cat: number; +}; + +const TICK_FPS = 12; +const RENDER_INTERVAL_MS = 33; +const MIN_ZOOM = 1; +const MAX_ZOOM = 2.4; + +const MAIN_SPRITES: Record = { idle: { src: '/webui/office/star-idle-v5.png', frameW: 256, @@ -162,6 +183,12 @@ const DECOR_SPRITES = { }; const DECOR_IMAGES = { + bg: { + src: '/webui/office/office_bg.webp', + width: OFFICE_CANVAS.width, + height: OFFICE_CANVAS.height, + scale: 1, + } as ImageSpec, sofaIdle: { src: '/webui/office/sofa-idle-v3.png', width: 212, @@ -188,13 +215,28 @@ const DECOR_IMAGES = { } as ImageSpec, }; +const MAIN_BUBBLE_TEXTS: Record = { + idle: ['Standby mode.', 'Waiting for the next task.', 'System is stable.'], + working: ['Working on it.', 'Executing workflow.', 'Processing tasks now.'], + syncing: ['Sync in progress.', 'Collecting updates.', 'Linking status streams.'], + error: ['Error detected.', 'Need intervention.', 'Investigating abnormal state.'], +}; + +const NODE_BUBBLE_TEXTS: Record = { + idle: ['Idle.', 'Ready.', 'No active job.'], + working: ['Working.', 'Running.', 'Task in progress.'], + syncing: ['Syncing.', 'Updating status.', 'Heartbeat active.'], + error: ['Failure.', 'Blocked.', 'Error surfaced.'], + offline: ['Disconnected.', 'No heartbeat.', 'Offline.'], +}; + function normalizeZone(z: string | undefined): OfficeZone { const v = (z || '').trim().toLowerCase(); if (v === 'work' || v === 'server' || v === 'bug' || v === 'breakroom') return v; return 'breakroom'; } -function normalizeMainSpriteState(s: string | undefined): keyof typeof MAIN_SPRITES { +function normalizeMainState(s: string | undefined): MainVisualState { const v = (s || '').trim().toLowerCase(); if (v.includes('error') || v.includes('blocked')) return 'error'; if (v.includes('sync') || v.includes('suppressed')) return 'syncing'; @@ -202,11 +244,33 @@ function normalizeMainSpriteState(s: string | undefined): keyof typeof MAIN_SPRI return 'idle'; } +function normalizeNodeState(s: string | undefined): MainVisualState | 'offline' { + const v = (s || '').trim().toLowerCase(); + if (v.includes('offline')) return 'offline'; + if (v.includes('error') || v.includes('blocked')) return 'error'; + if (v.includes('sync') || v.includes('suppressed')) return 'syncing'; + if (v.includes('run') || v.includes('execut') || v.includes('writing') || v.includes('research') || v.includes('success') || v.includes('online')) { + return 'working'; + } + return 'idle'; +} + +function zoneForMainState(state: MainVisualState): OfficeZone { + switch (state) { + case 'working': + return 'work'; + case 'syncing': + return 'server'; + case 'error': + return 'bug'; + default: + return 'breakroom'; + } +} + function textHash(input: string): number { let h = 0; - for (let i = 0; i < input.length; i += 1) { - h = (h * 31 + input.charCodeAt(i)) >>> 0; - } + for (let i = 0; i < input.length; i += 1) h = (h * 31 + input.charCodeAt(i)) >>> 0; return h; } @@ -222,21 +286,57 @@ function frameFromSeed(spec: SpriteSpec, seedText: string): number { return spec.start + (textHash(seedText) % frameCount); } -function posStyle(x: number, y: number, zIndex: number): React.CSSProperties { +function randFrame(spec: SpriteSpec): number { + const frameCount = Math.max(1, spec.end - spec.start + 1); + return spec.start + Math.floor(Math.random() * frameCount); +} + +function lerpPoint(current: Point, target: Point, alpha: number): Point { + const x = current.x + (target.x - current.x) * alpha; + const y = current.y + (target.y - current.y) * alpha; + if (Math.abs(target.x - x) < 0.3 && Math.abs(target.y - y) < 0.3) return target; + return { x, y }; +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +function clampCamera(cam: CameraState): CameraState { + const zoom = clamp(cam.zoom, MIN_ZOOM, MAX_ZOOM); + const viewW = OFFICE_CANVAS.width / zoom; + const viewH = OFFICE_CANVAS.height / zoom; + const maxX = Math.max(0, OFFICE_CANVAS.width - viewW); + const maxY = Math.max(0, OFFICE_CANVAS.height - viewH); return { - left: `${(x / OFFICE_CANVAS.width) * 100}%`, - top: `${(y / OFFICE_CANVAS.height) * 100}%`, - zIndex, + zoom, + x: clamp(cam.x, 0, maxX), + y: clamp(cam.y, 0, maxY), }; } +function worldToScreen(point: Point, camera: CameraState): Point { + const viewW = OFFICE_CANVAS.width / camera.zoom; + const viewH = OFFICE_CANVAS.height / camera.zoom; + return { + x: ((point.x - camera.x) / viewW) * 100, + y: ((point.y - camera.y) / viewH) * 100, + }; +} + +function pickRandom(arr: T[]): T | null { + if (!arr.length) return null; + return arr[Math.floor(Math.random() * arr.length)] || null; +} + type SpriteProps = { spec: SpriteSpec; frame: number; + scaleMultiplier?: number; className?: string; }; -const SpriteSheet: React.FC = ({ spec, frame, className }) => { +const SpriteSheet: React.FC = ({ spec, frame, scaleMultiplier = 1, className }) => { const col = frame % spec.cols; const row = Math.floor(frame / spec.cols); return ( @@ -249,7 +349,7 @@ const SpriteSheet: React.FC = ({ spec, frame, className }) => { backgroundRepeat: 'no-repeat', backgroundPosition: `-${col * spec.frameW}px -${row * spec.frameH}px`, imageRendering: 'pixelated', - transform: `translate(-50%, -50%) scale(${spec.scale})`, + transform: `translate(-50%, -50%) scale(${spec.scale * scaleMultiplier})`, transformOrigin: 'center center', }} /> @@ -259,147 +359,551 @@ const SpriteSheet: React.FC = ({ spec, frame, className }) => { type PlacedSpriteProps = { spec: SpriteSpec; frame: number; - x: number; - y: number; + point: Point; + camera: CameraState; zIndex: number; title?: string; + scaleMultiplier?: number; + onClick?: () => void; }; -const PlacedSprite: React.FC = ({ spec, frame, x, y, zIndex, title }) => ( -
-
- +const PlacedSprite: React.FC = ({ spec, frame, point, camera, zIndex, title, scaleMultiplier = 1, onClick }) => { + const pos = worldToScreen(point, camera); + return ( +
+
+ +
-
-); + ); +}; type PlacedImageProps = { spec: ImageSpec; - x: number; - y: number; + point: Point; + camera: CameraState; zIndex: number; title?: string; + scaleMultiplier?: number; + onClick?: () => void; }; -const PlacedImage: React.FC = ({ spec, x, y, zIndex, title }) => ( -
- -
-); +const PlacedImage: React.FC = ({ spec, point, camera, zIndex, title, scaleMultiplier = 1, onClick }) => { + const pos = worldToScreen(point, camera); + return ( +
+ +
+ ); +}; + +type BubbleProps = { + point: Point; + camera: CameraState; + text: string; + zIndex: number; +}; + +const Bubble: React.FC = ({ point, camera, text, zIndex }) => { + const p = worldToScreen({ x: point.x, y: point.y - 70 }, camera); + return ( +
+
+ {text} +
+
+ ); +}; const OfficeScene: React.FC = ({ main, nodes }) => { + const viewportRef = useRef(null); + const cameraRef = useRef({ x: 0, y: 0, zoom: 1 }); + const panRef = useRef<{ active: boolean; x: number; y: number } | null>(null); + const nextBubbleAtRef = useRef(Date.now() + 2400); + const [tick, setTick] = useState(0); + const [showDebug, setShowDebug] = useState(false); + const [manualState, setManualState] = useState(null); + const [panEnabled, setPanEnabled] = useState(() => true); + const [camera, setCamera] = useState({ x: 0, y: 0, zoom: 1 }); + const [pointerWorld, setPointerWorld] = useState(null); + + const liveMainState = normalizeMainState(main.state); + const effectiveMainState = manualState ?? liveMainState; + const mainZone = zoneForMainState(effectiveMainState); + const mainTarget = OFFICE_ZONE_POINT[mainZone]; + const prevLiveStateRef = useRef(liveMainState); useEffect(() => { - const timer = window.setInterval(() => { - setTick((v) => (v + 1) % 10000000); - }, Math.round(1000 / TICK_FPS)); - return () => window.clearInterval(timer); - }, []); + if (manualState && prevLiveStateRef.current !== liveMainState) { + setManualState(null); + } + prevLiveStateRef.current = liveMainState; + }, [liveMainState, manualState]); - const bgSrc = '/webui/office/office_bg_small.webp'; const placedNodes = useMemo(() => { const counters: Record = { breakroom: 0, work: 0, server: 0, bug: 0 }; - return nodes.slice(0, 24).map((n) => { + return nodes.slice(0, 24).map((n, i) => { const zone = normalizeZone(n.zone); const slots = OFFICE_ZONE_SLOTS[zone]; const idx = counters[zone] % slots.length; counters[zone] += 1; - const stableKey = `${n.id || ''}|${n.name || ''}|${idx}`; - const avatarSeed = textHash(stableKey); - const spriteIndex = avatarSeed % NODE_SPRITES.length; - return { ...n, zone, point: slots[idx], spriteIndex, avatarSeed }; + const key = `${n.id || 'node'}-${i}`; + const avatarSeed = textHash(`${n.id || ''}|${n.name || ''}|${idx}`); + return { + ...n, + key, + zone, + point: slots[idx], + spriteIndex: avatarSeed % NODE_SPRITES.length, + avatarSeed, + }; }); }, [nodes]); - const mainZone = normalizeZone(main.zone); - const mainPoint = OFFICE_ZONE_POINT[mainZone]; - const mainSprite = MAIN_SPRITES[normalizeMainSpriteState(main.state)]; - const mainFrame = frameAtTick(mainSprite, tick); - const mainSpriteState = normalizeMainSpriteState(main.state); + const [mainPos, setMainPos] = useState(mainTarget); + const [nodePos, setNodePos] = useState>({}); + const [mainBubble, setMainBubble] = useState(null); + const [nodeBubbles, setNodeBubbles] = useState>({}); const decorSeedBase = `${main.id || 'main'}|${main.name || ''}`; - const plantFrameA = frameFromSeed(DECOR_SPRITES.plants, `${decorSeedBase}|plantA`); - const plantFrameB = frameFromSeed(DECOR_SPRITES.plants, `${decorSeedBase}|plantB`); - const plantFrameC = frameFromSeed(DECOR_SPRITES.plants, `${decorSeedBase}|plantC`); - const posterFrame = frameFromSeed(DECOR_SPRITES.posters, `${decorSeedBase}|poster`); - const flowerFrame = frameFromSeed(DECOR_SPRITES.flowers, `${decorSeedBase}|flower`); - const catFrame = frameFromSeed(DECOR_SPRITES.cats, `${decorSeedBase}|cat`); - const coffeeFrame = frameAtTick(DECOR_SPRITES.coffeeMachine, tick, 300); - const serverFrame = mainSpriteState === 'idle' ? 0 : frameAtTick(DECOR_SPRITES.serverroom, tick, 700); + const seededDecorFrames = useMemo(() => ({ + plantA: frameFromSeed(DECOR_SPRITES.plants, `${decorSeedBase}|plantA`), + plantB: frameFromSeed(DECOR_SPRITES.plants, `${decorSeedBase}|plantB`), + plantC: frameFromSeed(DECOR_SPRITES.plants, `${decorSeedBase}|plantC`), + poster: frameFromSeed(DECOR_SPRITES.posters, `${decorSeedBase}|poster`), + flower: frameFromSeed(DECOR_SPRITES.flowers, `${decorSeedBase}|flower`), + cat: frameFromSeed(DECOR_SPRITES.cats, `${decorSeedBase}|cat`), + }), [decorSeedBase]); + + const [decorFrames, setDecorFrames] = useState(seededDecorFrames); + const [coffeePaused, setCoffeePaused] = useState(false); + const [serverMode, setServerMode] = useState<'auto' | 'on' | 'off'>('auto'); + + const targetsRef = useRef<{ main: Point; nodes: typeof placedNodes }>({ main: mainTarget, nodes: placedNodes }); + const stateRef = useRef<{ mainState: MainVisualState; nodes: typeof placedNodes }>({ mainState: effectiveMainState, nodes: placedNodes }); + + useEffect(() => { + targetsRef.current = { main: mainTarget, nodes: placedNodes }; + stateRef.current = { mainState: effectiveMainState, nodes: placedNodes }; + }, [mainTarget, placedNodes, effectiveMainState]); + + useEffect(() => { + setDecorFrames(seededDecorFrames); + }, [seededDecorFrames]); + + useEffect(() => { + setNodePos((prev) => { + const next: Record = {}; + for (const n of placedNodes) { + next[n.key] = prev[n.key] || n.point; + } + return next; + }); + }, [placedNodes]); + + useEffect(() => { + const timer = window.setInterval(() => { + setTick((v) => (v + 1) % 100000000); + setMainPos((prev) => lerpPoint(prev, targetsRef.current.main, 0.2)); + setNodePos((prev) => { + const next: Record = {}; + for (const n of targetsRef.current.nodes) { + const cur = prev[n.key] || n.point; + next[n.key] = lerpPoint(cur, n.point, 0.24); + } + return next; + }); + }, RENDER_INTERVAL_MS); + return () => window.clearInterval(timer); + }, []); + + useEffect(() => { + const timer = window.setInterval(() => { + const now = Date.now(); + setMainBubble((prev) => (prev && prev.expiresAt > now ? prev : null)); + setNodeBubbles((prev) => { + const next: Record = {}; + for (const [k, v] of Object.entries(prev)) { + if (v.expiresAt > now) next[k] = v; + } + return next; + }); + + if (now < nextBubbleAtRef.current) return; + + const mainPool = MAIN_BUBBLE_TEXTS[stateRef.current.mainState] || MAIN_BUBBLE_TEXTS.idle; + const mainLine = pickRandom(mainPool); + if (mainLine) setMainBubble({ text: mainLine, expiresAt: now + 2600 }); + + const nextNodeBubbles: Record = {}; + const candidates = [...stateRef.current.nodes]; + const bubbleCount = Math.min(2, candidates.length); + for (let i = 0; i < bubbleCount; i += 1) { + const pick = candidates.splice(Math.floor(Math.random() * candidates.length), 1)[0]; + if (!pick) continue; + const nodeState = normalizeNodeState(pick.state); + const line = pickRandom(NODE_BUBBLE_TEXTS[nodeState] || NODE_BUBBLE_TEXTS.idle); + if (line) nextNodeBubbles[pick.key] = { text: line, expiresAt: now + 2300 }; + } + if (Object.keys(nextNodeBubbles).length > 0) { + setNodeBubbles((prev) => ({ ...prev, ...nextNodeBubbles })); + } + + nextBubbleAtRef.current = now + 3200 + Math.floor(Math.random() * 2000); + }, 450); + return () => window.clearInterval(timer); + }, []); + + useEffect(() => { + cameraRef.current = camera; + }, [camera]); + + const updatePointerWorld = useCallback((clientX: number, clientY: number) => { + const rect = viewportRef.current?.getBoundingClientRect(); + if (!rect) return; + const px = clamp(clientX - rect.left, 0, rect.width); + const py = clamp(clientY - rect.top, 0, rect.height); + const cam = cameraRef.current; + const viewW = OFFICE_CANVAS.width / cam.zoom; + const viewH = OFFICE_CANVAS.height / cam.zoom; + setPointerWorld({ + x: cam.x + (px / Math.max(1, rect.width)) * viewW, + y: cam.y + (py / Math.max(1, rect.height)) * viewH, + }); + }, []); + + const handlePointerDown = useCallback((e: React.PointerEvent) => { + if (!panEnabled) return; + panRef.current = { active: true, x: e.clientX, y: e.clientY }; + (e.currentTarget as HTMLDivElement).setPointerCapture?.(e.pointerId); + }, [panEnabled]); + + const handlePointerUp = useCallback((e: React.PointerEvent) => { + panRef.current = null; + (e.currentTarget as HTMLDivElement).releasePointerCapture?.(e.pointerId); + }, []); + + const handlePointerMove = useCallback((e: React.PointerEvent) => { + updatePointerWorld(e.clientX, e.clientY); + const rect = viewportRef.current?.getBoundingClientRect(); + const pan = panRef.current; + if (!rect || !pan || !pan.active || !panEnabled) return; + + const dx = e.clientX - pan.x; + const dy = e.clientY - pan.y; + panRef.current = { active: true, x: e.clientX, y: e.clientY }; + + setCamera((prev) => { + const viewW = OFFICE_CANVAS.width / prev.zoom; + const viewH = OFFICE_CANVAS.height / prev.zoom; + const worldPerPixelX = viewW / Math.max(1, rect.width); + const worldPerPixelY = viewH / Math.max(1, rect.height); + return clampCamera({ + ...prev, + x: prev.x - dx * worldPerPixelX, + y: prev.y - dy * worldPerPixelY, + }); + }); + }, [panEnabled, updatePointerWorld]); + + const zoomBy = useCallback((delta: number) => { + setCamera((prev) => { + const nextZoom = clamp(prev.zoom + delta, MIN_ZOOM, MAX_ZOOM); + const centerX = prev.x + OFFICE_CANVAS.width / (2 * prev.zoom); + const centerY = prev.y + OFFICE_CANVAS.height / (2 * prev.zoom); + return clampCamera({ + zoom: nextZoom, + x: centerX - OFFICE_CANVAS.width / (2 * nextZoom), + y: centerY - OFFICE_CANVAS.height / (2 * nextZoom), + }); + }); + }, []); + + const resetView = useCallback(() => { + setCamera({ x: 0, y: 0, zoom: 1 }); + }, []); + + const setVisualState = useCallback((state: MainVisualState | null) => { + setManualState(state); + }, []); + + const cycleServerMode = useCallback(() => { + setServerMode((prev) => (prev === 'auto' ? 'on' : prev === 'on' ? 'off' : 'auto')); + }, []); + + const coffeeFrame = coffeePaused ? 0 : frameAtTick(DECOR_SPRITES.coffeeMachine, tick, 300); + 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 furnitureScale = camera.zoom; return ( -
+
- office -
- +
+ - - - - + - - + setDecorFrames((v) => ({ ...v, poster: randFrame(DECOR_SPRITES.posters) }))} + /> + setDecorFrames((v) => ({ ...v, plantA: randFrame(DECOR_SPRITES.plants) }))} + /> + setDecorFrames((v) => ({ ...v, plantB: randFrame(DECOR_SPRITES.plants) }))} + /> + setDecorFrames((v) => ({ ...v, plantC: randFrame(DECOR_SPRITES.plants) }))} + /> - - + + setVisualState('idle')} + /> - - + + setCoffeePaused((v) => !v)} + /> -
setVisualState('working')} + /> + setDecorFrames((v) => ({ ...v, flower: randFrame(DECOR_SPRITES.flowers) }))} + /> + + +
{ + const p = worldToScreen({ x: mainPos.x, y: mainPos.y + 62 }, camera); + return { left: `${p.x}%`, top: `${p.y}%` }; + })(), + zIndex: 55, + }} > -
- -
- {main.name || main.id || 'main'} -
-
+
{main.name || main.id || 'main'}
- {placedNodes.map((n, i) => ( -
-
- -
-
- ))} + {mainBubble && mainBubble.expiresAt > Date.now() ? ( + + ) : null} - + {placedNodes.map((n) => { + const p = nodePos[n.key] || n.point; + const nodeFrame = frameAtTick(NODE_SPRITES[n.spriteIndex], tick, n.avatarSeed % 1000); + const nodeBubble = nodeBubbles[n.key]; + return ( + + + {nodeBubble && nodeBubble.expiresAt > Date.now() ? ( + + ) : null} + + ); + })} + + setDecorFrames((v) => ({ ...v, cat: randFrame(DECOR_SPRITES.cats) }))} + /> + + {showDebug ? ( + <> + {Object.entries(OFFICE_ZONE_POINT).map(([zone, p]) => { + const s = worldToScreen(p, camera); + return ( +
+
+
{zone}
+
+ ); + })} + {Object.entries(OFFICE_ZONE_SLOTS).map(([zone, slots]) => + slots.map((p, idx) => { + const s = worldToScreen(p, camera); + return ( +
+
+
+ ); + }) + )} + + ) : null}
+ +
+ + + + + + + + + + +
+ +
+ state={effectiveMainState} live={liveMainState} mode={manualState ? 'manual' : 'follow'} server={serverMode} coffee={coffeePaused ? 'paused' : 'on'} +
+ + {showDebug ? ( +
+ cam x={camera.x.toFixed(1)} y={camera.y.toFixed(1)} zoom={camera.zoom.toFixed(2)} + {pointerWorld ? ` | pointer ${Math.round(pointerWorld.x)},${Math.round(pointerWorld.y)}` : ''} + {` | nodes ${placedNodes.length}`} +
+ ) : null}
); diff --git a/webui/src/pages/Office.tsx b/webui/src/pages/Office.tsx index 76d07df..dd66071 100644 --- a/webui/src/pages/Office.tsx +++ b/webui/src/pages/Office.tsx @@ -48,7 +48,8 @@ const Office: React.FC = () => { const fetchState = useCallback(async () => { setLoading(true); try { - const r = await fetch(`/webui/api/office_state${q}`); + const sep = q ? '&' : '?'; + const r = await fetch(`/webui/api/office_state${q}${sep}_=${Date.now()}`, { cache: 'no-store' }); if (!r.ok) throw new Error(await r.text()); const j = (await r.json()) as OfficePayload; setPayload(j || {}); @@ -61,7 +62,7 @@ const Office: React.FC = () => { useEffect(() => { fetchState(); - const timer = setInterval(fetchState, 5000); + const timer = setInterval(fetchState, 3000); return () => clearInterval(timer); }, [fetchState]);