Build automation and development improvements
- Add build-without-make.bat script for Windows builds without make - Update .gitignore to exclude .gocache and dist directories - Fix scripts/build-without-make.bat to properly handle repository root resolution - Add CALL directive for pnpm commands in batch scripts - Agent system improvements and health server enhancements - Add agent cockpit UI components and memory graph visualization - API improvements for PicoClaw web backend - Update frontend routing and app sidebar
This commit is contained in:
parent
03dae1301d
commit
b8b231964c
17 changed files with 1928 additions and 5 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -11,6 +11,8 @@ build/
|
||||||
/picoclaw
|
/picoclaw
|
||||||
/picoclaw-test
|
/picoclaw-test
|
||||||
cmd/**/workspace
|
cmd/**/workspace
|
||||||
|
.gocache/
|
||||||
|
dist/
|
||||||
|
|
||||||
# Picoclaw specific
|
# Picoclaw specific
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/routing"
|
"github.com/sipeed/picoclaw/pkg/routing"
|
||||||
"github.com/sipeed/picoclaw/pkg/session"
|
"github.com/sipeed/picoclaw/pkg/session"
|
||||||
"github.com/sipeed/picoclaw/pkg/state"
|
"github.com/sipeed/picoclaw/pkg/state"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -57,6 +58,7 @@ type AgentLoop struct {
|
||||||
mcp mcpRuntime
|
mcp mcpRuntime
|
||||||
hookRuntime hookRuntime
|
hookRuntime hookRuntime
|
||||||
steering *steeringQueue
|
steering *steeringQueue
|
||||||
|
subagents *tools.SubagentManager
|
||||||
pendingSkills sync.Map
|
pendingSkills sync.Map
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -242,6 +242,9 @@ func registerSharedTools(
|
||||||
if (spawnEnabled || spawnStatusEnabled) && cfg.Tools.IsToolEnabled("subagent") {
|
if (spawnEnabled || spawnStatusEnabled) && cfg.Tools.IsToolEnabled("subagent") {
|
||||||
subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace)
|
subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace)
|
||||||
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
|
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
|
||||||
|
if agentID == "main" {
|
||||||
|
al.subagents = subagentManager
|
||||||
|
}
|
||||||
|
|
||||||
// Inject a media resolver so the legacy RunToolLoop fallback path can
|
// Inject a media resolver so the legacy RunToolLoop fallback path can
|
||||||
// resolve media:// refs in the same way the main AgentLoop does.
|
// resolve media:// refs in the same way the main AgentLoop does.
|
||||||
|
|
|
||||||
|
|
@ -119,3 +119,29 @@ func (al *AgentLoop) GetStartupInfo() map[string]any {
|
||||||
|
|
||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) GetMainSubagentTasks(channel, chatID string) []tools.SubagentTask {
|
||||||
|
al.mu.RLock()
|
||||||
|
manager := al.subagents
|
||||||
|
al.mu.RUnlock()
|
||||||
|
if manager == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
all := manager.ListTaskCopies()
|
||||||
|
if channel == "" && chatID == "" {
|
||||||
|
return all
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := make([]tools.SubagentTask, 0, len(all))
|
||||||
|
for _, task := range all {
|
||||||
|
if channel != "" && task.OriginChannel != "" && task.OriginChannel != channel {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if chatID != "" && task.OriginChatID != "" && task.OriginChatID != chatID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, task)
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -246,6 +246,13 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) (runEr
|
||||||
runningServices.HealthServer.SetPermissionGrantFunc(func(agentID, path, duration string) error {
|
runningServices.HealthServer.SetPermissionGrantFunc(func(agentID, path, duration string) error {
|
||||||
return agentLoop.GrantPermission(agentID, path, duration)
|
return agentLoop.GrantPermission(agentID, path, duration)
|
||||||
})
|
})
|
||||||
|
runningServices.HealthServer.SetSubagentStatusFunc(func(channel, chatID string) (any, error) {
|
||||||
|
return map[string]any{
|
||||||
|
"channel": channel,
|
||||||
|
"chat_id": chatID,
|
||||||
|
"tasks": agentLoop.GetMainSubagentTasks(channel, chatID),
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
|
||||||
for _, bindHost := range listenResult.BindHosts {
|
for _, bindHost := range listenResult.BindHosts {
|
||||||
fmt.Printf("✓ Gateway started on %s\n", net.JoinHostPort(bindHost, strconv.Itoa(cfg.Gateway.Port)))
|
fmt.Printf("✓ Gateway started on %s\n", net.JoinHostPort(bindHost, strconv.Itoa(cfg.Gateway.Port)))
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,9 @@ type Server struct {
|
||||||
ready bool
|
ready bool
|
||||||
checks map[string]Check
|
checks map[string]Check
|
||||||
startTime time.Time
|
startTime time.Time
|
||||||
reloadFunc func() error
|
reloadFunc func() error
|
||||||
permissionGrantFunc func(agentID, path, duration string) error
|
permissionGrantFunc func(agentID, path, duration string) error
|
||||||
|
subagentStatusFunc func(channel, chatID string) (any, error)
|
||||||
authToken string // optional bearer token for protected endpoints
|
authToken string // optional bearer token for protected endpoints
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -51,6 +52,7 @@ func NewServer(host string, port int, token string) *Server {
|
||||||
mux.HandleFunc("/ready", s.readyHandler)
|
mux.HandleFunc("/ready", s.readyHandler)
|
||||||
mux.HandleFunc("/reload", s.reloadHandler)
|
mux.HandleFunc("/reload", s.reloadHandler)
|
||||||
mux.HandleFunc("/internal/permission/grant", s.permissionGrantHandler)
|
mux.HandleFunc("/internal/permission/grant", s.permissionGrantHandler)
|
||||||
|
mux.HandleFunc("/internal/subagents/status", s.subagentStatusHandler)
|
||||||
|
|
||||||
addr := net.JoinHostPort(host, strconv.Itoa(port))
|
addr := net.JoinHostPort(host, strconv.Itoa(port))
|
||||||
s.server = &http.Server{
|
s.server = &http.Server{
|
||||||
|
|
@ -128,6 +130,12 @@ func (s *Server) SetPermissionGrantFunc(fn func(agentID, path, duration string)
|
||||||
s.permissionGrantFunc = fn
|
s.permissionGrantFunc = fn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) SetSubagentStatusFunc(fn func(channel, chatID string) (any, error)) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.subagentStatusFunc = fn
|
||||||
|
}
|
||||||
|
|
||||||
// permissionGrantHandler handles POST /internal/permission/grant requests.
|
// permissionGrantHandler handles POST /internal/permission/grant requests.
|
||||||
func (s *Server) permissionGrantHandler(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) permissionGrantHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
|
|
@ -241,6 +249,49 @@ func (s *Server) reloadHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
json.NewEncoder(w).Encode(map[string]string{"status": "reload triggered"})
|
json.NewEncoder(w).Encode(map[string]string{"status": "reload triggered"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) subagentStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": "method not allowed, use GET"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.RLock()
|
||||||
|
requiredToken := s.authToken
|
||||||
|
statusFunc := s.subagentStatusFunc
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
if requiredToken != "" {
|
||||||
|
given := extractBearerToken(r.Header.Get("Authorization"))
|
||||||
|
if given == "" || subtle.ConstantTimeCompare([]byte(given), []byte(requiredToken)) != 1 {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if statusFunc == nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": "subagent status not configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := statusFunc(r.URL.Query().Get("channel"), r.URL.Query().Get("chat_id"))
|
||||||
|
if err != nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(payload)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
@ -307,6 +358,8 @@ func (s *Server) RegisterOnMux(mux HandlerMux) {
|
||||||
mux.HandleFunc("/health", s.healthHandler)
|
mux.HandleFunc("/health", s.healthHandler)
|
||||||
mux.HandleFunc("/ready", s.readyHandler)
|
mux.HandleFunc("/ready", s.readyHandler)
|
||||||
mux.HandleFunc("/reload", s.reloadHandler)
|
mux.HandleFunc("/reload", s.reloadHandler)
|
||||||
|
mux.HandleFunc("/internal/permission/grant", s.permissionGrantHandler)
|
||||||
|
mux.HandleFunc("/internal/subagents/status", s.subagentStatusHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
func statusString(ok bool) string {
|
func statusString(ok bool) string {
|
||||||
|
|
|
||||||
|
|
@ -215,6 +215,60 @@ func TestReloadHandler_Error(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSubagentStatusHandler_Success(t *testing.T) {
|
||||||
|
s := newTestServer()
|
||||||
|
s.SetSubagentStatusFunc(func(channel, chatID string) (any, error) {
|
||||||
|
return map[string]any{
|
||||||
|
"channel": channel,
|
||||||
|
"chat_id": chatID,
|
||||||
|
"tasks": []map[string]any{
|
||||||
|
{"id": "subagent-1", "status": "running"},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/internal/subagents/status?channel=pico&chat_id=session-1", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer test")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
s.subagentStatusHandler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d, body=%s", w.Code, http.StatusOK, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Channel string `json:"channel"`
|
||||||
|
ChatID string `json:"chat_id"`
|
||||||
|
Tasks []map[string]any `json:"tasks"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Channel != "pico" || resp.ChatID != "session-1" {
|
||||||
|
t.Fatalf("response = %#v, want channel/chat_id preserved", resp)
|
||||||
|
}
|
||||||
|
if len(resp.Tasks) != 1 {
|
||||||
|
t.Fatalf("tasks len = %d, want 1", len(resp.Tasks))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubagentStatusHandler_RequiresAuth(t *testing.T) {
|
||||||
|
s := newTestServer()
|
||||||
|
s.SetSubagentStatusFunc(func(channel, chatID string) (any, error) {
|
||||||
|
return map[string]any{}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/internal/subagents/status", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
s.subagentStatusHandler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("status = %d, want %d", w.Code, http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSetReady_Toggle(t *testing.T) {
|
func TestSetReady_Toggle(t *testing.T) {
|
||||||
s := newTestServer()
|
s := newTestServer()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,17 @@ REM Usage: scripts\build-without-make.bat
|
||||||
|
|
||||||
SETLOCAL ENABLEEXTENSIONS
|
SETLOCAL ENABLEEXTENSIONS
|
||||||
|
|
||||||
SET "REPO_ROOT=%~dp0.."
|
|
||||||
SET "REPO_ROOT=%REPO_ROOT:~0,-1%"
|
|
||||||
SET "GO_TAGS=goolm,stdjson"
|
SET "GO_TAGS=goolm,stdjson"
|
||||||
|
|
||||||
|
REM Resolve the repository root directory from the script location.
|
||||||
|
PUSHD "%~dp0.."
|
||||||
|
IF ERRORLEVEL 1 (
|
||||||
|
echo ERROR: Failed to resolve repository root from "%~dp0..".
|
||||||
|
EXIT /B 1
|
||||||
|
)
|
||||||
|
SET "REPO_ROOT=%CD%"
|
||||||
|
POPD
|
||||||
|
|
||||||
REM Ensure Go is available
|
REM Ensure Go is available
|
||||||
where go >nul 2>&1
|
where go >nul 2>&1
|
||||||
IF ERRORLEVEL 1 (
|
IF ERRORLEVEL 1 (
|
||||||
|
|
@ -57,14 +64,14 @@ IF ERRORLEVEL 1 (
|
||||||
POPD
|
POPD
|
||||||
EXIT /B 1
|
EXIT /B 1
|
||||||
)
|
)
|
||||||
pnpm install --frozen-lockfile
|
CALL pnpm install --frozen-lockfile
|
||||||
IF ERRORLEVEL 1 (
|
IF ERRORLEVEL 1 (
|
||||||
echo ERROR: pnpm install failed.
|
echo ERROR: pnpm install failed.
|
||||||
POPD
|
POPD
|
||||||
POPD
|
POPD
|
||||||
EXIT /B 1
|
EXIT /B 1
|
||||||
)
|
)
|
||||||
pnpm build:backend
|
CALL pnpm build:backend
|
||||||
IF ERRORLEVEL 1 (
|
IF ERRORLEVEL 1 (
|
||||||
echo ERROR: pnpm build:backend failed.
|
echo ERROR: pnpm build:backend failed.
|
||||||
POPD
|
POPD
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,14 @@ import (
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
|
@ -17,6 +23,8 @@ import (
|
||||||
// registerPicoRoutes binds Pico Channel management endpoints to the ServeMux.
|
// registerPicoRoutes binds Pico Channel management endpoints to the ServeMux.
|
||||||
func (h *Handler) registerPicoRoutes(mux *http.ServeMux) {
|
func (h *Handler) registerPicoRoutes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("GET /api/pico/info", h.handleGetPicoInfo)
|
mux.HandleFunc("GET /api/pico/info", h.handleGetPicoInfo)
|
||||||
|
mux.HandleFunc("GET /api/pico/memory-graph", h.handleGetPicoMemoryGraph)
|
||||||
|
mux.HandleFunc("GET /api/pico/subagents", h.handleGetPicoSubagents)
|
||||||
mux.HandleFunc("POST /api/pico/token", h.handleRegenPicoToken)
|
mux.HandleFunc("POST /api/pico/token", h.handleRegenPicoToken)
|
||||||
mux.HandleFunc("POST /api/pico/setup", h.handlePicoSetup)
|
mux.HandleFunc("POST /api/pico/setup", h.handlePicoSetup)
|
||||||
|
|
||||||
|
|
@ -28,6 +36,43 @@ func (h *Handler) registerPicoRoutes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("HEAD /pico/media/{id}", h.handlePicoMediaProxy())
|
mux.HandleFunc("HEAD /pico/media/{id}", h.handlePicoMediaProxy())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type picoSubagentStatusItem struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Label string `json:"label,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Created int64 `json:"created"`
|
||||||
|
Result string `json:"result,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type picoSubagentStatusResponse struct {
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
Channel string `json:"channel"`
|
||||||
|
ChatID string `json:"chat_id"`
|
||||||
|
Tasks []picoSubagentStatusItem `json:"tasks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type picoMemoryGraphNode struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Group string `json:"group"`
|
||||||
|
Preview string `json:"preview,omitempty"`
|
||||||
|
Weight int `json:"weight,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type picoMemoryGraphEdge struct {
|
||||||
|
Source string `json:"source"`
|
||||||
|
Target string `json:"target"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type picoMemoryGraphResponse struct {
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
GeneratedAt string `json:"generated_at"`
|
||||||
|
Nodes []picoMemoryGraphNode `json:"nodes"`
|
||||||
|
Edges []picoMemoryGraphEdge `json:"edges"`
|
||||||
|
}
|
||||||
|
|
||||||
// createWsProxy creates a reverse proxy to the current gateway WebSocket endpoint.
|
// createWsProxy creates a reverse proxy to the current gateway WebSocket endpoint.
|
||||||
// The gateway bind host and port are resolved from the latest configuration.
|
// The gateway bind host and port are resolved from the latest configuration.
|
||||||
func (h *Handler) createWsProxy(origProtocol string, upstreamProtocol string) *httputil.ReverseProxy {
|
func (h *Handler) createWsProxy(origProtocol string, upstreamProtocol string) *httputil.ReverseProxy {
|
||||||
|
|
@ -207,6 +252,433 @@ func (h *Handler) handleGetPicoInfo(w http.ResponseWriter, r *http.Request) {
|
||||||
h.writePicoInfoResponse(w, r, cfg, nil)
|
h.writePicoInfoResponse(w, r, cfg, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleGetPicoMemoryGraph(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sessionID := strings.TrimSpace(r.URL.Query().Get("session_id"))
|
||||||
|
if sessionID == "" {
|
||||||
|
http.Error(w, "session_id is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(h.configPath)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "failed to load config", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionDir := resolveSessionsDir(cfg.Agents.Defaults.Workspace)
|
||||||
|
toolFeedbackMaxArgsLength := cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength()
|
||||||
|
workspaceDir := resolveWorkspaceDir(cfg.Agents.Defaults.Workspace)
|
||||||
|
|
||||||
|
ref, refErr := h.findPicoJSONLSession(sessionDir, sessionID)
|
||||||
|
var sess sessionFile
|
||||||
|
err = refErr
|
||||||
|
if refErr == nil {
|
||||||
|
sess, err = h.readJSONLSession(sessionDir, ref.Key)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "session not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes, edges := buildPicoMemoryGraph(
|
||||||
|
sessionID,
|
||||||
|
detailSessionMessages(sess.Messages, toolFeedbackMaxArgsLength),
|
||||||
|
workspaceDir,
|
||||||
|
)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(picoMemoryGraphResponse{
|
||||||
|
SessionID: sessionID,
|
||||||
|
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
Nodes: nodes,
|
||||||
|
Edges: edges,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleGetPicoSubagents(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sessionID := strings.TrimSpace(r.URL.Query().Get("session_id"))
|
||||||
|
if sessionID == "" {
|
||||||
|
http.Error(w, "session_id is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !h.gatewayAvailableForProxy() {
|
||||||
|
logger.Warnf("Gateway not available for Pico subagent status proxy")
|
||||||
|
http.Error(w, "Gateway not available", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
gateway.mu.Lock()
|
||||||
|
pidData := gateway.pidData
|
||||||
|
gateway.mu.Unlock()
|
||||||
|
|
||||||
|
if pidData == nil || pidData.Token == "" {
|
||||||
|
logger.Warnf("Gateway auth token not available for Pico subagent status proxy")
|
||||||
|
http.Error(w, "Gateway auth token not available", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
target := h.gatewayProxyURL()
|
||||||
|
target.Path = "/internal/subagents/status"
|
||||||
|
query := target.Query()
|
||||||
|
query.Set("channel", "pico")
|
||||||
|
query.Set("chat_id", sessionID)
|
||||||
|
target.RawQuery = query.Encode()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to create subagent status request", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+pidData.Token)
|
||||||
|
|
||||||
|
resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to fetch Pico subagent status: %v", err)
|
||||||
|
http.Error(w, "Gateway unavailable: "+err.Error(), http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
w.WriteHeader(resp.StatusCode)
|
||||||
|
_, _ = io.Copy(w, resp.Body)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var upstream struct {
|
||||||
|
Channel string `json:"channel"`
|
||||||
|
ChatID string `json:"chat_id"`
|
||||||
|
Tasks []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Created int64 `json:"created"`
|
||||||
|
Result string `json:"result"`
|
||||||
|
} `json:"tasks"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&upstream); err != nil {
|
||||||
|
http.Error(w, "Failed to decode subagent status response", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks := make([]picoSubagentStatusItem, 0, len(upstream.Tasks))
|
||||||
|
for _, task := range upstream.Tasks {
|
||||||
|
tasks = append(tasks, picoSubagentStatusItem{
|
||||||
|
ID: task.ID,
|
||||||
|
Label: task.Label,
|
||||||
|
Status: task.Status,
|
||||||
|
Created: task.Created,
|
||||||
|
Result: summarizeSubagentResult(task.Result),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(picoSubagentStatusResponse{
|
||||||
|
SessionID: sessionID,
|
||||||
|
Channel: upstream.Channel,
|
||||||
|
ChatID: upstream.ChatID,
|
||||||
|
Tasks: tasks,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func summarizeSubagentResult(result string) string {
|
||||||
|
result = strings.TrimSpace(result)
|
||||||
|
if result == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
const maxRunes = 180
|
||||||
|
runes := []rune(result)
|
||||||
|
if len(runes) <= maxRunes {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
return string(runes[:maxRunes]) + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveWorkspaceDir(workspace string) string {
|
||||||
|
if workspace == "" {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
workspace = filepath.Join(home, ".picoclaw", "workspace")
|
||||||
|
}
|
||||||
|
if len(workspace) > 0 && workspace[0] == '~' {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
if len(workspace) > 1 && workspace[1] == '/' {
|
||||||
|
workspace = home + workspace[1:]
|
||||||
|
} else {
|
||||||
|
workspace = home
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return workspace
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPicoMemoryGraph(sessionID string, messages []sessionChatMessage, workspaceDir string) ([]picoMemoryGraphNode, []picoMemoryGraphEdge) {
|
||||||
|
nodes := make([]picoMemoryGraphNode, 0, 32)
|
||||||
|
edges := make([]picoMemoryGraphEdge, 0, 48)
|
||||||
|
seenNodes := make(map[string]struct{})
|
||||||
|
seenEdges := make(map[string]struct{})
|
||||||
|
|
||||||
|
addNode := func(node picoMemoryGraphNode) {
|
||||||
|
if _, exists := seenNodes[node.ID]; exists {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seenNodes[node.ID] = struct{}{}
|
||||||
|
nodes = append(nodes, node)
|
||||||
|
}
|
||||||
|
|
||||||
|
addEdge := func(edge picoMemoryGraphEdge) {
|
||||||
|
key := edge.Source + "\x00" + edge.Target + "\x00" + edge.Kind
|
||||||
|
if _, exists := seenEdges[key]; exists {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seenEdges[key] = struct{}{}
|
||||||
|
edges = append(edges, edge)
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
memoryRootID = "memory-root"
|
||||||
|
sessionRootID = "session-root"
|
||||||
|
)
|
||||||
|
|
||||||
|
addNode(picoMemoryGraphNode{
|
||||||
|
ID: memoryRootID,
|
||||||
|
Label: "Workspace Memory",
|
||||||
|
Kind: "root",
|
||||||
|
Group: "memory",
|
||||||
|
Preview: "Long-term memory and recent notes",
|
||||||
|
Weight: 5,
|
||||||
|
})
|
||||||
|
addNode(picoMemoryGraphNode{
|
||||||
|
ID: sessionRootID,
|
||||||
|
Label: "Active Session",
|
||||||
|
Kind: "root",
|
||||||
|
Group: "session",
|
||||||
|
Preview: sessionID,
|
||||||
|
Weight: 5,
|
||||||
|
})
|
||||||
|
addEdge(picoMemoryGraphEdge{Source: memoryRootID, Target: sessionRootID, Kind: "context"})
|
||||||
|
|
||||||
|
appendMemoryDocumentGraph(memoryRootID, filepath.Join(workspaceDir, "memory", "MEMORY.md"), "memory", "Long-term Memory", 8, addNode, addEdge)
|
||||||
|
appendRecentDailyNoteGraph(memoryRootID, filepath.Join(workspaceDir, "memory"), addNode, addEdge)
|
||||||
|
appendSessionGraph(sessionRootID, messages, addNode, addEdge)
|
||||||
|
|
||||||
|
sort.Slice(nodes, func(i, j int) bool {
|
||||||
|
return nodes[i].ID < nodes[j].ID
|
||||||
|
})
|
||||||
|
sort.Slice(edges, func(i, j int) bool {
|
||||||
|
if edges[i].Source == edges[j].Source {
|
||||||
|
if edges[i].Target == edges[j].Target {
|
||||||
|
return edges[i].Kind < edges[j].Kind
|
||||||
|
}
|
||||||
|
return edges[i].Target < edges[j].Target
|
||||||
|
}
|
||||||
|
return edges[i].Source < edges[j].Source
|
||||||
|
})
|
||||||
|
|
||||||
|
return nodes, edges
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendMemoryDocumentGraph(
|
||||||
|
rootID string,
|
||||||
|
path string,
|
||||||
|
group string,
|
||||||
|
title string,
|
||||||
|
maxItems int,
|
||||||
|
addNode func(picoMemoryGraphNode),
|
||||||
|
addEdge func(picoMemoryGraphEdge),
|
||||||
|
) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil || len(data) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
docID := group + ":document"
|
||||||
|
addNode(picoMemoryGraphNode{
|
||||||
|
ID: docID,
|
||||||
|
Label: title,
|
||||||
|
Kind: "document",
|
||||||
|
Group: group,
|
||||||
|
Preview: filepath.Base(path),
|
||||||
|
Weight: 4,
|
||||||
|
})
|
||||||
|
addEdge(picoMemoryGraphEdge{Source: rootID, Target: docID, Kind: "contains"})
|
||||||
|
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
currentParent := docID
|
||||||
|
items := 0
|
||||||
|
headingIndex := 0
|
||||||
|
noteIndex := 0
|
||||||
|
|
||||||
|
for _, rawLine := range lines {
|
||||||
|
line := strings.TrimSpace(rawLine)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(line, "#") {
|
||||||
|
headingText := summarizeGraphText(strings.TrimSpace(strings.TrimLeft(line, "#")), 48)
|
||||||
|
if headingText == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
headingIndex++
|
||||||
|
headingID := group + ":heading:" + strconv.Itoa(headingIndex)
|
||||||
|
addNode(picoMemoryGraphNode{
|
||||||
|
ID: headingID,
|
||||||
|
Label: headingText,
|
||||||
|
Kind: "heading",
|
||||||
|
Group: group,
|
||||||
|
Preview: headingText,
|
||||||
|
Weight: 3,
|
||||||
|
})
|
||||||
|
addEdge(picoMemoryGraphEdge{Source: docID, Target: headingID, Kind: "section"})
|
||||||
|
currentParent = headingID
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
trimmed := strings.TrimSpace(strings.TrimLeft(line, "-*0123456789. "))
|
||||||
|
if trimmed == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
noteIndex++
|
||||||
|
noteID := group + ":note:" + strconv.Itoa(noteIndex)
|
||||||
|
addNode(picoMemoryGraphNode{
|
||||||
|
ID: noteID,
|
||||||
|
Label: summarizeGraphText(trimmed, 38),
|
||||||
|
Kind: "note",
|
||||||
|
Group: group,
|
||||||
|
Preview: summarizeGraphText(trimmed, 140),
|
||||||
|
Weight: 2,
|
||||||
|
})
|
||||||
|
addEdge(picoMemoryGraphEdge{Source: currentParent, Target: noteID, Kind: "note"})
|
||||||
|
items++
|
||||||
|
if items >= maxItems {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendRecentDailyNoteGraph(
|
||||||
|
rootID string,
|
||||||
|
memoryDir string,
|
||||||
|
addNode func(picoMemoryGraphNode),
|
||||||
|
addEdge func(picoMemoryGraphEdge),
|
||||||
|
) {
|
||||||
|
matches, err := filepath.Glob(filepath.Join(memoryDir, "*", "*.md"))
|
||||||
|
if err != nil || len(matches) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sort.Slice(matches, func(i, j int) bool {
|
||||||
|
return matches[i] > matches[j]
|
||||||
|
})
|
||||||
|
|
||||||
|
for idx, match := range matches {
|
||||||
|
if idx >= 3 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
appendMemoryDocumentGraph(
|
||||||
|
rootID,
|
||||||
|
match,
|
||||||
|
"daily-"+strconv.Itoa(idx+1),
|
||||||
|
"Daily Note "+filepath.Base(match),
|
||||||
|
4,
|
||||||
|
addNode,
|
||||||
|
addEdge,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendSessionGraph(
|
||||||
|
rootID string,
|
||||||
|
messages []sessionChatMessage,
|
||||||
|
addNode func(picoMemoryGraphNode),
|
||||||
|
addEdge func(picoMemoryGraphEdge),
|
||||||
|
) {
|
||||||
|
if len(messages) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
start := 0
|
||||||
|
if len(messages) > 8 {
|
||||||
|
start = len(messages) - 8
|
||||||
|
}
|
||||||
|
recent := messages[start:]
|
||||||
|
previousID := rootID
|
||||||
|
|
||||||
|
for index, message := range recent {
|
||||||
|
messageID := "session:message:" + strconv.Itoa(index)
|
||||||
|
label := strings.ToUpper(message.Role)
|
||||||
|
if preview := summarizeGraphText(message.Content, 34); preview != "" {
|
||||||
|
label += ": " + preview
|
||||||
|
}
|
||||||
|
if label == strings.ToUpper(message.Role) && len(message.Attachments) > 0 {
|
||||||
|
label += ": attachment"
|
||||||
|
}
|
||||||
|
|
||||||
|
addNode(picoMemoryGraphNode{
|
||||||
|
ID: messageID,
|
||||||
|
Label: label,
|
||||||
|
Kind: "message",
|
||||||
|
Group: "session",
|
||||||
|
Preview: summarizeGraphText(message.Content, 160),
|
||||||
|
Weight: 3,
|
||||||
|
})
|
||||||
|
addEdge(picoMemoryGraphEdge{Source: previousID, Target: messageID, Kind: "flow"})
|
||||||
|
previousID = messageID
|
||||||
|
|
||||||
|
for toolIndex, toolCall := range message.ToolCalls {
|
||||||
|
if toolCall.Function == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(toolCall.Function.Name)
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
toolID := messageID + ":tool:" + strconv.Itoa(toolIndex)
|
||||||
|
addNode(picoMemoryGraphNode{
|
||||||
|
ID: toolID,
|
||||||
|
Label: name,
|
||||||
|
Kind: "tool",
|
||||||
|
Group: "tool",
|
||||||
|
Preview: summarizeGraphText(toolCall.Function.Arguments, 120),
|
||||||
|
Weight: 2,
|
||||||
|
})
|
||||||
|
addEdge(picoMemoryGraphEdge{Source: messageID, Target: toolID, Kind: "tool"})
|
||||||
|
}
|
||||||
|
|
||||||
|
for attachmentIndex, attachment := range message.Attachments {
|
||||||
|
name := strings.TrimSpace(attachment.Filename)
|
||||||
|
if name == "" {
|
||||||
|
name = attachment.Type
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = "attachment"
|
||||||
|
}
|
||||||
|
attachmentID := messageID + ":attachment:" + strconv.Itoa(attachmentIndex)
|
||||||
|
addNode(picoMemoryGraphNode{
|
||||||
|
ID: attachmentID,
|
||||||
|
Label: summarizeGraphText(name, 34),
|
||||||
|
Kind: "attachment",
|
||||||
|
Group: "media",
|
||||||
|
Preview: summarizeGraphText(attachment.URL, 120),
|
||||||
|
Weight: 1,
|
||||||
|
})
|
||||||
|
addEdge(picoMemoryGraphEdge{Source: messageID, Target: attachmentID, Kind: "attachment"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func summarizeGraphText(text string, maxRunes int) string {
|
||||||
|
trimmed := strings.Join(strings.Fields(strings.TrimSpace(text)), " ")
|
||||||
|
if trimmed == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
runes := []rune(trimmed)
|
||||||
|
if len(runes) <= maxRunes {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
return string(runes[:maxRunes-1]) + "…"
|
||||||
|
}
|
||||||
|
|
||||||
// handleRegenPicoToken rotates the raw Pico WebSocket token and returns
|
// handleRegenPicoToken rotates the raw Pico WebSocket token and returns
|
||||||
// non-secret connection info for the launcher UI.
|
// non-secret connection info for the launcher UI.
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,12 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/memory"
|
||||||
ppid "github.com/sipeed/picoclaw/pkg/pid"
|
ppid "github.com/sipeed/picoclaw/pkg/pid"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newPicoProxyRequest(method, path string) *http.Request {
|
func newPicoProxyRequest(method, path string) *http.Request {
|
||||||
|
|
@ -821,6 +824,188 @@ func TestHandlePicoMediaProxyUsesRawBearerToken(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleGetPicoMemoryGraph_BuildsWorkspaceAndSessionGraph(t *testing.T) {
|
||||||
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
workspaceDir := filepath.Join(t.TempDir(), "workspace")
|
||||||
|
sessionsDir := filepath.Join(workspaceDir, "sessions")
|
||||||
|
memoryDir := filepath.Join(workspaceDir, "memory")
|
||||||
|
|
||||||
|
if err := os.MkdirAll(memoryDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll(memoryDir) error = %v", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(memoryDir, time.Now().Format("200601")), 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll(dailyNoteDir) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Agents.Defaults.Workspace = workspaceDir
|
||||||
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||||
|
t.Fatalf("SaveConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
store, err := memory.NewJSONLStore(sessionsDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewJSONLStore() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionKey := legacyPicoSessionPrefix + "graph-session"
|
||||||
|
for _, msg := range []providers.Message{
|
||||||
|
{Role: "user", Content: "Remember the Nairobi deployment notes."},
|
||||||
|
{Role: "assistant", Content: "Saved the deployment note and linked it to workspace memory."},
|
||||||
|
} {
|
||||||
|
if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
|
||||||
|
t.Fatalf("AddFullMessage() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := store.SetSummary(nil, sessionKey, "Graph session"); err != nil {
|
||||||
|
t.Fatalf("SetSummary() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(
|
||||||
|
filepath.Join(memoryDir, "MEMORY.md"),
|
||||||
|
[]byte("# Preferences\n- User prefers network graph views\n# Projects\n- PicoClaw cockpit integration"),
|
||||||
|
0o644,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("WriteFile(MEMORY.md) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
todayPath := filepath.Join(memoryDir, time.Now().Format("200601"), time.Now().Format("20060102")+".md")
|
||||||
|
if err := os.WriteFile(
|
||||||
|
todayPath,
|
||||||
|
[]byte("# Daily\n- Reviewed active session memory graph"),
|
||||||
|
0o644,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("WriteFile(todayPath) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/pico/memory-graph?session_id=graph-session", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp picoMemoryGraphResponse
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("Decode() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.SessionID != "graph-session" {
|
||||||
|
t.Fatalf("resp.SessionID = %q, want %q", resp.SessionID, "graph-session")
|
||||||
|
}
|
||||||
|
if len(resp.Nodes) < 5 {
|
||||||
|
t.Fatalf("len(resp.Nodes) = %d, want at least 5", len(resp.Nodes))
|
||||||
|
}
|
||||||
|
|
||||||
|
foundMemoryRoot := false
|
||||||
|
foundSessionMessage := false
|
||||||
|
for _, node := range resp.Nodes {
|
||||||
|
if node.ID == "memory-root" {
|
||||||
|
foundMemoryRoot = true
|
||||||
|
}
|
||||||
|
if strings.Contains(node.Label, "NAIROBI") || strings.Contains(node.Preview, "Nairobi") {
|
||||||
|
foundSessionMessage = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundMemoryRoot {
|
||||||
|
t.Fatal("expected memory-root node in graph")
|
||||||
|
}
|
||||||
|
if !foundSessionMessage {
|
||||||
|
t.Fatal("expected session content to appear in graph nodes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetPicoSubagents_UsesScopedGatewayStatus(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("PICOCLAW_HOME", home)
|
||||||
|
|
||||||
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/internal/subagents/status" {
|
||||||
|
t.Fatalf("path = %q, want %q", r.URL.Path, "/internal/subagents/status")
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("Authorization"); got != "Bearer gateway-auth-token" {
|
||||||
|
t.Fatalf("Authorization = %q, want %q", got, "Bearer gateway-auth-token")
|
||||||
|
}
|
||||||
|
if got := r.URL.Query().Get("channel"); got != "pico" {
|
||||||
|
t.Fatalf("channel query = %q, want %q", got, "pico")
|
||||||
|
}
|
||||||
|
if got := r.URL.Query().Get("chat_id"); got != "session-42" {
|
||||||
|
t.Fatalf("chat_id query = %q, want %q", got, "session-42")
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"channel": "pico",
|
||||||
|
"chat_id": "session-42",
|
||||||
|
"tasks": []map[string]any{
|
||||||
|
{
|
||||||
|
"id": "subagent-1",
|
||||||
|
"label": "Research",
|
||||||
|
"status": "running",
|
||||||
|
"created": int64(1710000000000),
|
||||||
|
"result": "This is a very long status summary that should still round-trip through the launcher API cleanly.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Gateway.Host = "127.0.0.1"
|
||||||
|
cfg.Gateway.Port = mustGatewayTestPort(t, server.URL)
|
||||||
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||||
|
t.Fatalf("SaveConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := startGatewayLikeProcess(t)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if cmd.Process != nil {
|
||||||
|
_ = cmd.Process.Kill()
|
||||||
|
}
|
||||||
|
_ = cmd.Wait()
|
||||||
|
})
|
||||||
|
|
||||||
|
origPidData := gateway.pidData
|
||||||
|
origCmd := gateway.cmd
|
||||||
|
t.Cleanup(func() {
|
||||||
|
gateway.mu.Lock()
|
||||||
|
gateway.pidData = origPidData
|
||||||
|
gateway.cmd = origCmd
|
||||||
|
gateway.mu.Unlock()
|
||||||
|
})
|
||||||
|
|
||||||
|
gateway.mu.Lock()
|
||||||
|
gateway.pidData = &ppid.PidFileData{PID: cmd.Process.Pid, Token: "gateway-auth-token"}
|
||||||
|
gateway.cmd = cmd
|
||||||
|
gateway.mu.Unlock()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://launcher.local/api/pico/subagents?session_id=session-42", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.handleGetPicoSubagents(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp picoSubagentStatusResponse
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if resp.SessionID != "session-42" || resp.ChatID != "session-42" || resp.Channel != "pico" {
|
||||||
|
t.Fatalf("response = %#v, want scoped session metadata", resp)
|
||||||
|
}
|
||||||
|
if len(resp.Tasks) != 1 || resp.Tasks[0].ID != "subagent-1" {
|
||||||
|
t.Fatalf("tasks = %#v, want one proxied task", resp.Tasks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandleWebSocketProxyRejectsStalePidDataAfterProcessExit(t *testing.T) {
|
func TestHandleWebSocketProxyRejectsStalePidDataAfterProcessExit(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
t.Setenv("HOME", tmpDir)
|
t.Setenv("HOME", tmpDir)
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,43 @@ interface PicoSetupResponse {
|
||||||
changed: boolean
|
changed: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PicoSubagentStatusItem {
|
||||||
|
id: string
|
||||||
|
label?: string
|
||||||
|
status: "running" | "completed" | "failed" | "canceled" | string
|
||||||
|
created: number
|
||||||
|
result?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PicoSubagentStatusResponse {
|
||||||
|
session_id: string
|
||||||
|
channel: string
|
||||||
|
chat_id: string
|
||||||
|
tasks: PicoSubagentStatusItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PicoMemoryGraphNode {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
kind: string
|
||||||
|
group: string
|
||||||
|
preview?: string
|
||||||
|
weight?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PicoMemoryGraphEdge {
|
||||||
|
source: string
|
||||||
|
target: string
|
||||||
|
kind: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PicoMemoryGraphResponse {
|
||||||
|
session_id: string
|
||||||
|
generated_at: string
|
||||||
|
nodes: PicoMemoryGraphNode[]
|
||||||
|
edges: PicoMemoryGraphEdge[]
|
||||||
|
}
|
||||||
|
|
||||||
const BASE_URL = ""
|
const BASE_URL = ""
|
||||||
|
|
||||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
|
|
@ -37,4 +74,22 @@ export async function setupPico(): Promise<PicoSetupResponse> {
|
||||||
return request<PicoSetupResponse>("/api/pico/setup", { method: "POST" })
|
return request<PicoSetupResponse>("/api/pico/setup", { method: "POST" })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getPicoSubagents(
|
||||||
|
sessionId: string,
|
||||||
|
): Promise<PicoSubagentStatusResponse> {
|
||||||
|
const params = new URLSearchParams({ session_id: sessionId })
|
||||||
|
return request<PicoSubagentStatusResponse>(
|
||||||
|
`/api/pico/subagents?${params.toString()}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPicoMemoryGraph(
|
||||||
|
sessionId: string,
|
||||||
|
): Promise<PicoMemoryGraphResponse> {
|
||||||
|
const params = new URLSearchParams({ session_id: sessionId })
|
||||||
|
return request<PicoMemoryGraphResponse>(
|
||||||
|
`/api/pico/memory-graph?${params.toString()}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export type { PicoInfoResponse, PicoSetupResponse }
|
export type { PicoInfoResponse, PicoSetupResponse }
|
||||||
|
|
|
||||||
651
web/frontend/src/components/agent/cockpit/cockpit-page.tsx
Normal file
651
web/frontend/src/components/agent/cockpit/cockpit-page.tsx
Normal file
|
|
@ -0,0 +1,651 @@
|
||||||
|
import {
|
||||||
|
IconArrowRight,
|
||||||
|
IconBrain,
|
||||||
|
IconMicrophone,
|
||||||
|
IconMicrophoneOff,
|
||||||
|
IconPhoto,
|
||||||
|
IconSearch,
|
||||||
|
IconSettings,
|
||||||
|
IconUpload,
|
||||||
|
} from "@tabler/icons-react"
|
||||||
|
import { Link } from "@tanstack/react-router"
|
||||||
|
import dayjs from "dayjs"
|
||||||
|
import { type ChangeEvent, useEffect, useMemo, useRef, useState } from "react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import type { ChatAttachment } from "@/store/chat"
|
||||||
|
import { usePicoChat } from "@/hooks/use-pico-chat"
|
||||||
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
import { MemoryGraph } from "./memory-graph"
|
||||||
|
import { useAgentCockpit } from "./use-agent-cockpit"
|
||||||
|
|
||||||
|
const MAX_IMAGE_SIZE_BYTES = 7 * 1024 * 1024
|
||||||
|
const ALLOWED_IMAGE_TYPES = new Set([
|
||||||
|
"image/jpeg",
|
||||||
|
"image/png",
|
||||||
|
"image/gif",
|
||||||
|
"image/webp",
|
||||||
|
"image/bmp",
|
||||||
|
])
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
SpeechRecognition?: new () => SpeechRecognitionLike
|
||||||
|
webkitSpeechRecognition?: new () => SpeechRecognitionLike
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpeechRecognitionLike {
|
||||||
|
continuous: boolean
|
||||||
|
interimResults: boolean
|
||||||
|
lang: string
|
||||||
|
onresult: ((event: SpeechRecognitionEventLike) => void) | null
|
||||||
|
onend: (() => void) | null
|
||||||
|
onerror: ((event: { error: string }) => void) | null
|
||||||
|
start(): void
|
||||||
|
stop(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpeechRecognitionEventLike {
|
||||||
|
results: ArrayLike<ArrayLike<{ transcript: string }>>
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBadgeVariant(status: string) {
|
||||||
|
switch (status) {
|
||||||
|
case "enabled":
|
||||||
|
case "completed":
|
||||||
|
return "default" as const
|
||||||
|
case "blocked":
|
||||||
|
case "failed":
|
||||||
|
return "destructive" as const
|
||||||
|
case "running":
|
||||||
|
return "secondary" as const
|
||||||
|
default:
|
||||||
|
return "outline" as const
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reasonLabel(reasonCode?: string) {
|
||||||
|
switch (reasonCode) {
|
||||||
|
case "requires_subagent":
|
||||||
|
return "Requires subagent runtime"
|
||||||
|
case "requires_skills":
|
||||||
|
return "Requires skills support"
|
||||||
|
case "requires_mcp_discovery":
|
||||||
|
return "Requires MCP discovery"
|
||||||
|
case "requires_linux":
|
||||||
|
return "Linux only"
|
||||||
|
case "requires_serial_platform":
|
||||||
|
return "Unsupported serial platform"
|
||||||
|
default:
|
||||||
|
return reasonCode ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFileAsDataUrl(file: File): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
if (typeof reader.result === "string") {
|
||||||
|
resolve(reader.result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reject(new Error("Failed to read file"))
|
||||||
|
}
|
||||||
|
reader.onerror = () =>
|
||||||
|
reject(reader.error || new Error("Failed to read file"))
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CockpitPage() {
|
||||||
|
const { activeSessionId, connectionState, sendMessage } = usePicoChat()
|
||||||
|
const {
|
||||||
|
categoryCounts,
|
||||||
|
groupedTools,
|
||||||
|
pendingToolName,
|
||||||
|
searchQuery,
|
||||||
|
sessionSubagents,
|
||||||
|
sessionMemoryGraph,
|
||||||
|
statusCounts,
|
||||||
|
statusFilter,
|
||||||
|
webSearchConfig,
|
||||||
|
hasMemoryGraphError,
|
||||||
|
hasSubagentsError,
|
||||||
|
hasToolsError,
|
||||||
|
isMemoryGraphLoading,
|
||||||
|
isSubagentsLoading,
|
||||||
|
isToolsLoading,
|
||||||
|
isWebSearchLoading,
|
||||||
|
setSearchQuery,
|
||||||
|
setStatusFilter,
|
||||||
|
toggleTool,
|
||||||
|
} = useAgentCockpit(activeSessionId)
|
||||||
|
|
||||||
|
const [prompt, setPrompt] = useState("")
|
||||||
|
const [attachments, setAttachments] = useState<ChatAttachment[]>([])
|
||||||
|
const [isListening, setIsListening] = useState(false)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
|
const recognitionRef = useRef<SpeechRecognitionLike | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const Recognition =
|
||||||
|
window.SpeechRecognition ?? window.webkitSpeechRecognition
|
||||||
|
if (!Recognition) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const recognition = new Recognition()
|
||||||
|
recognition.continuous = false
|
||||||
|
recognition.interimResults = false
|
||||||
|
recognition.lang = "en-US"
|
||||||
|
recognition.onresult = (event) => {
|
||||||
|
const transcript = event.results[0]?.[0]?.transcript?.trim() ?? ""
|
||||||
|
setPrompt(transcript)
|
||||||
|
if (!transcript) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const sent = sendMessage({ content: transcript })
|
||||||
|
if (!sent) {
|
||||||
|
toast.error("Voice capture worked, but chat is not ready to send.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
recognition.onend = () => setIsListening(false)
|
||||||
|
recognition.onerror = (event) => {
|
||||||
|
setIsListening(false)
|
||||||
|
toast.error(`Voice capture error: ${event.error}`)
|
||||||
|
}
|
||||||
|
recognitionRef.current = recognition
|
||||||
|
}, [sendMessage])
|
||||||
|
|
||||||
|
const filteredToolCount = useMemo(
|
||||||
|
() => groupedTools.reduce((total, [, items]) => total + items.length, 0),
|
||||||
|
[groupedTools],
|
||||||
|
)
|
||||||
|
|
||||||
|
const currentProviderLabel = useMemo(() => {
|
||||||
|
const current = webSearchConfig?.providers.find((provider) => provider.current)
|
||||||
|
return current?.label ?? webSearchConfig?.provider ?? "Auto"
|
||||||
|
}, [webSearchConfig])
|
||||||
|
|
||||||
|
const handleImageSelection = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = Array.from(event.target.files ?? [])
|
||||||
|
event.target.value = ""
|
||||||
|
if (files.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextAttachments: ChatAttachment[] = []
|
||||||
|
for (const file of files) {
|
||||||
|
if (!ALLOWED_IMAGE_TYPES.has(file.type)) {
|
||||||
|
toast.error(`Unsupported image type: ${file.name}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (file.size > MAX_IMAGE_SIZE_BYTES) {
|
||||||
|
toast.error(`${file.name} exceeds 7 MB.`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const url = await readFileAsDataUrl(file)
|
||||||
|
nextAttachments.push({
|
||||||
|
type: "image",
|
||||||
|
url,
|
||||||
|
filename: file.name,
|
||||||
|
contentType: file.type,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : `Failed to read ${file.name}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setAttachments((current) => [...current, ...nextAttachments])
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSendBridgeMessage = () => {
|
||||||
|
const sent = sendMessage({ content: prompt, attachments })
|
||||||
|
if (!sent) {
|
||||||
|
toast.error("Chat connection is not ready yet.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setPrompt("")
|
||||||
|
setAttachments([])
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleVoice = () => {
|
||||||
|
if (!recognitionRef.current) {
|
||||||
|
toast.error("Voice capture is not supported in this browser.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isListening) {
|
||||||
|
recognitionRef.current.stop()
|
||||||
|
setIsListening(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
recognitionRef.current.start()
|
||||||
|
setIsListening(true)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Unable to start voice capture.",
|
||||||
|
)
|
||||||
|
setIsListening(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col overflow-hidden bg-[#07110a] text-[#d7f9df]">
|
||||||
|
<PageHeader title="Agent Cockpit" />
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto px-4 py-4 sm:px-6 sm:py-6">
|
||||||
|
<div className="mx-auto grid w-full max-w-[1500px] gap-4 xl:grid-cols-[260px_minmax(0,1fr)_360px]">
|
||||||
|
<aside className="space-y-4">
|
||||||
|
<Card className="border-[#1f4f31] bg-[#09150c] text-[#d7f9df] shadow-none">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="font-mono text-sm uppercase tracking-[0.22em]">
|
||||||
|
Systems
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-[#8bb39a]">
|
||||||
|
Filter the active tool surface.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="relative">
|
||||||
|
<IconSearch className="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-[#6aa37b]" />
|
||||||
|
<Input
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(event) => setSearchQuery(event.target.value)}
|
||||||
|
placeholder="Search tools"
|
||||||
|
className="border-[#1f4f31] bg-[#050d08] pl-9 text-[#d7f9df] placeholder:text-[#5c8168]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(["all", "enabled", "disabled", "blocked"] as const).map(
|
||||||
|
(status) => (
|
||||||
|
<button
|
||||||
|
key={status}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStatusFilter(status)}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center justify-between rounded-lg border px-3 py-2 text-left text-sm transition-colors",
|
||||||
|
statusFilter === status
|
||||||
|
? "border-[#4fc267] bg-[#10351b] text-[#effff3]"
|
||||||
|
: "border-[#173621] bg-[#08100b] text-[#8bb39a] hover:border-[#2c6a3e] hover:text-[#d7f9df]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="capitalize">{status}</span>
|
||||||
|
<span className="font-mono text-xs">
|
||||||
|
{statusCounts[status]}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 border-t border-[#173621] pt-4">
|
||||||
|
<p className="font-mono text-[11px] uppercase tracking-[0.22em] text-[#7ca88a]">
|
||||||
|
Categories
|
||||||
|
</p>
|
||||||
|
{categoryCounts.map(([category, count]) => (
|
||||||
|
<div
|
||||||
|
key={category}
|
||||||
|
className="flex items-center justify-between text-sm text-[#9cc8a8]"
|
||||||
|
>
|
||||||
|
<span className="capitalize">{category}</span>
|
||||||
|
<span className="font-mono text-xs">{count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="space-y-4">
|
||||||
|
<Card className="border-[#215d36] bg-[linear-gradient(180deg,#0a150c_0%,#0a1c10_100%)] text-[#effff3] shadow-none">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="font-mono text-sm uppercase tracking-[0.24em]">
|
||||||
|
Tool Grid
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-[#98c7a5]">
|
||||||
|
Real launcher tools, live from PicoClaw.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="border-[#2a6b3d] text-[#9fd8ae]">
|
||||||
|
{filteredToolCount} visible
|
||||||
|
</Badge>
|
||||||
|
<Button asChild variant="outline" className="border-[#2a6b3d] bg-transparent text-[#c8f5d2] hover:bg-[#12301a]">
|
||||||
|
<Link to="/agent/tools">
|
||||||
|
<IconSettings className="size-4" />
|
||||||
|
Full Tools
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{hasToolsError ? (
|
||||||
|
<div className="rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-200">
|
||||||
|
Failed to load tools.
|
||||||
|
</div>
|
||||||
|
) : isToolsLoading ? (
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
{Array.from({ length: 6 }).map((_, index) => (
|
||||||
|
<Skeleton key={index} className="h-36 rounded-xl bg-[#112117]" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{groupedTools.map(([category, items]) => (
|
||||||
|
<div key={category} className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between border-b border-[#173621] pb-2">
|
||||||
|
<h2 className="font-mono text-xs uppercase tracking-[0.24em] text-[#8ec49c]">
|
||||||
|
{category}
|
||||||
|
</h2>
|
||||||
|
<span className="font-mono text-xs text-[#5f8f6f]">
|
||||||
|
{items.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
{items.map((tool) => (
|
||||||
|
<Card
|
||||||
|
key={tool.name}
|
||||||
|
size="sm"
|
||||||
|
className="border-[#173621] bg-[#08100b] text-[#d7f9df] shadow-none"
|
||||||
|
>
|
||||||
|
<CardHeader className="gap-2">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<CardTitle className="font-mono text-sm">
|
||||||
|
{tool.name}
|
||||||
|
</CardTitle>
|
||||||
|
<Badge
|
||||||
|
variant={statusBadgeVariant(tool.status)}
|
||||||
|
className="capitalize"
|
||||||
|
>
|
||||||
|
{tool.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={tool.status !== "disabled"}
|
||||||
|
disabled={pendingToolName === tool.name}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
toggleTool(tool.name, checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<CardDescription className="text-[#8fb59b]">
|
||||||
|
{tool.description}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between text-xs text-[#739981]">
|
||||||
|
<span className="capitalize">{tool.category}</span>
|
||||||
|
<span className="font-mono">{tool.config_key}</span>
|
||||||
|
</div>
|
||||||
|
{tool.reason_code ? (
|
||||||
|
<div className="rounded-lg border border-amber-400/20 bg-amber-400/10 px-3 py-2 text-xs text-amber-100">
|
||||||
|
{reasonLabel(tool.reason_code)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-[#215d36] bg-[linear-gradient(180deg,#09130b_0%,#071109_100%)] text-[#effff3] shadow-none">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="font-mono text-sm uppercase tracking-[0.24em]">
|
||||||
|
Memory Network
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-[#98c7a5]">
|
||||||
|
Obsidian-style graph built from PicoClaw workspace memory and the active session trail.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{hasMemoryGraphError ? (
|
||||||
|
<div className="rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-200">
|
||||||
|
Failed to load memory graph.
|
||||||
|
</div>
|
||||||
|
) : isMemoryGraphLoading ? (
|
||||||
|
<Skeleton className="h-[420px] rounded-xl bg-[#112117]" />
|
||||||
|
) : sessionMemoryGraph && sessionMemoryGraph.nodes.length > 0 ? (
|
||||||
|
<MemoryGraph
|
||||||
|
nodes={sessionMemoryGraph.nodes}
|
||||||
|
edges={sessionMemoryGraph.edges}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-[#173621] bg-[#050d08] px-4 py-3 text-sm text-[#8bb39a]">
|
||||||
|
Memory graph will appear when the session and workspace memory have visible context.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside className="space-y-4">
|
||||||
|
<Card className="border-[#1f4f31] bg-[#09150c] text-[#d7f9df] shadow-none">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="font-mono text-sm uppercase tracking-[0.22em]">
|
||||||
|
Session Bridge
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-[#8bb39a]">
|
||||||
|
Voice and images route into the active Pico session.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="rounded-lg border border-[#173621] bg-[#050d08] px-3 py-2 text-xs text-[#9fc6aa]">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="font-mono uppercase tracking-[0.18em]">
|
||||||
|
Session
|
||||||
|
</span>
|
||||||
|
<Badge variant="outline" className="border-[#2a6b3d] text-[#9fd8ae]">
|
||||||
|
{connectionState}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 break-all font-mono text-[11px] text-[#d7f9df]">
|
||||||
|
{activeSessionId}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
value={prompt}
|
||||||
|
onChange={(event) => setPrompt(event.target.value)}
|
||||||
|
placeholder="Send a message to the active agent session"
|
||||||
|
className="border-[#1f4f31] bg-[#050d08] text-[#d7f9df] placeholder:text-[#5c8168]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{attachments.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{attachments.map((attachment, index) => (
|
||||||
|
<div
|
||||||
|
key={`${attachment.filename ?? "attachment"}-${index}`}
|
||||||
|
className="flex items-center justify-between rounded-lg border border-[#173621] bg-[#050d08] px-3 py-2 text-xs text-[#bfe7c8]"
|
||||||
|
>
|
||||||
|
<span className="truncate">{attachment.filename ?? "Image"}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-[#7ca88a] hover:text-[#effff3]"
|
||||||
|
onClick={() =>
|
||||||
|
setAttachments((current) =>
|
||||||
|
current.filter((_, itemIndex) => itemIndex !== index),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="border-[#2a6b3d] bg-transparent text-[#d7f9df] hover:bg-[#12301a]"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<IconPhoto className="size-4" />
|
||||||
|
Add Image
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={isListening ? "destructive" : "outline"}
|
||||||
|
className={cn(
|
||||||
|
"border-[#2a6b3d] text-[#d7f9df] hover:bg-[#12301a]",
|
||||||
|
isListening && "border-red-400/40 bg-red-500/10 text-red-100",
|
||||||
|
)}
|
||||||
|
onClick={toggleVoice}
|
||||||
|
>
|
||||||
|
{isListening ? (
|
||||||
|
<IconMicrophoneOff className="size-4" />
|
||||||
|
) : (
|
||||||
|
<IconMicrophone className="size-4" />
|
||||||
|
)}
|
||||||
|
Voice
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="w-full bg-[#4fc267] text-[#08120b] hover:bg-[#77e58e]"
|
||||||
|
onClick={handleSendBridgeMessage}
|
||||||
|
>
|
||||||
|
<IconUpload className="size-4" />
|
||||||
|
Send To Active Session
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<Button asChild variant="outline" className="border-[#2a6b3d] bg-transparent text-[#d7f9df] hover:bg-[#12301a]">
|
||||||
|
<Link to="/">
|
||||||
|
Open Chat
|
||||||
|
<IconArrowRight className="size-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild variant="outline" className="border-[#2a6b3d] bg-transparent text-[#d7f9df] hover:bg-[#12301a]">
|
||||||
|
<Link to="/agent/hub">
|
||||||
|
Skills Hub
|
||||||
|
<IconArrowRight className="size-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/gif,image/webp,image/bmp"
|
||||||
|
className="hidden"
|
||||||
|
multiple
|
||||||
|
onChange={handleImageSelection}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-[#1f4f31] bg-[#09150c] text-[#d7f9df] shadow-none">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="font-mono text-sm uppercase tracking-[0.22em]">
|
||||||
|
Main Agent Subagents
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-[#8bb39a]">
|
||||||
|
Live status for the current Pico session only.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{hasSubagentsError ? (
|
||||||
|
<div className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
||||||
|
Failed to load subagent status.
|
||||||
|
</div>
|
||||||
|
) : isSubagentsLoading ? (
|
||||||
|
Array.from({ length: 3 }).map((_, index) => (
|
||||||
|
<Skeleton key={index} className="h-20 rounded-xl bg-[#112117]" />
|
||||||
|
))
|
||||||
|
) : sessionSubagents.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-[#173621] bg-[#050d08] px-3 py-3 text-sm text-[#8bb39a]">
|
||||||
|
No subagents have been created in this session yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
sessionSubagents.map((task) => (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
className="rounded-xl border border-[#173621] bg-[#050d08] px-3 py-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-mono text-sm text-[#effff3]">
|
||||||
|
{task.label || task.id}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-[#7ca88a]">
|
||||||
|
{dayjs(task.created).format("MMM D, HH:mm")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant={statusBadgeVariant(task.status)} className="capitalize">
|
||||||
|
{task.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{task.result ? (
|
||||||
|
<p className="mt-3 text-xs leading-relaxed text-[#9dc2a7]">
|
||||||
|
{task.result}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-[#1f4f31] bg-[#09150c] text-[#d7f9df] shadow-none">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="font-mono text-sm uppercase tracking-[0.22em]">
|
||||||
|
Tool Runtime
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-[#8bb39a]">
|
||||||
|
Quick view of the current search/tool setup.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 text-sm text-[#a8cfb3]">
|
||||||
|
<div className="flex items-center justify-between rounded-lg border border-[#173621] bg-[#050d08] px-3 py-3">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<IconBrain className="size-4 text-[#71d888]" />
|
||||||
|
Web search provider
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-xs uppercase text-[#effff3]">
|
||||||
|
{isWebSearchLoading ? "Loading" : currentProviderLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[#7ca88a]">
|
||||||
|
The cockpit reuses PicoClaw’s existing Pico session, tool
|
||||||
|
toggles, and media pipeline instead of the source app’s
|
||||||
|
Firebase and Gemini-specific wiring.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
231
web/frontend/src/components/agent/cockpit/memory-graph.tsx
Normal file
231
web/frontend/src/components/agent/cockpit/memory-graph.tsx
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
import { useMemo, useState } from "react"
|
||||||
|
|
||||||
|
import type {
|
||||||
|
PicoMemoryGraphEdge,
|
||||||
|
PicoMemoryGraphNode,
|
||||||
|
} from "@/api/pico"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const VIEWBOX_WIDTH = 1000
|
||||||
|
const VIEWBOX_HEIGHT = 560
|
||||||
|
|
||||||
|
type PositionedNode = PicoMemoryGraphNode & { x: number; y: number }
|
||||||
|
|
||||||
|
interface MemoryGraphProps {
|
||||||
|
nodes: PicoMemoryGraphNode[]
|
||||||
|
edges: PicoMemoryGraphEdge[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const GROUP_COLUMNS: Record<string, number> = {
|
||||||
|
memory: 170,
|
||||||
|
"daily-1": 295,
|
||||||
|
"daily-2": 295,
|
||||||
|
"daily-3": 295,
|
||||||
|
session: 700,
|
||||||
|
tool: 840,
|
||||||
|
media: 920,
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupColor(group: string) {
|
||||||
|
if (group === "memory") return "#72f0a0"
|
||||||
|
if (group.startsWith("daily-")) return "#4dd0e1"
|
||||||
|
if (group === "tool") return "#f8d66d"
|
||||||
|
if (group === "media") return "#f395d6"
|
||||||
|
return "#90e89f"
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeLayout(nodes: PicoMemoryGraphNode[]): PositionedNode[] {
|
||||||
|
const groups = new Map<string, PicoMemoryGraphNode[]>()
|
||||||
|
for (const node of nodes) {
|
||||||
|
const items = groups.get(node.group) ?? []
|
||||||
|
items.push(node)
|
||||||
|
groups.set(node.group, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
const layout: PositionedNode[] = []
|
||||||
|
for (const [group, items] of groups.entries()) {
|
||||||
|
const x = GROUP_COLUMNS[group] ?? 500
|
||||||
|
const count = items.length
|
||||||
|
const gap = VIEWBOX_HEIGHT / (count + 1)
|
||||||
|
items.forEach((node, index) => {
|
||||||
|
const offset =
|
||||||
|
group === "session" && node.kind === "root"
|
||||||
|
? 0
|
||||||
|
: Math.sin((index + 1) * 1.7) * 12
|
||||||
|
layout.push({
|
||||||
|
...node,
|
||||||
|
x,
|
||||||
|
y: Math.max(52, Math.min(VIEWBOX_HEIGHT - 52, gap * (index + 1) + offset)),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return layout.sort((left, right) => left.x - right.x || left.y - right.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MemoryGraph({ nodes, edges }: MemoryGraphProps) {
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(nodes[0]?.id ?? null)
|
||||||
|
|
||||||
|
const positionedNodes = useMemo(() => computeLayout(nodes), [nodes])
|
||||||
|
const nodeMap = useMemo(
|
||||||
|
() => new Map(positionedNodes.map((node) => [node.id, node])),
|
||||||
|
[positionedNodes],
|
||||||
|
)
|
||||||
|
const selectedNode =
|
||||||
|
positionedNodes.find((node) => node.id === selectedId) ?? positionedNodes[0]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_280px]">
|
||||||
|
<div className="overflow-hidden rounded-xl border border-[#173621] bg-[#041008]">
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${VIEWBOX_WIDTH} ${VIEWBOX_HEIGHT}`}
|
||||||
|
className="h-[420px] w-full"
|
||||||
|
role="img"
|
||||||
|
aria-label="PicoClaw memory graph"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="memoryGrid" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stopColor="#0c2014" />
|
||||||
|
<stop offset="100%" stopColor="#050d08" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width={VIEWBOX_WIDTH} height={VIEWBOX_HEIGHT} fill="url(#memoryGrid)" />
|
||||||
|
{Array.from({ length: 10 }).map((_, index) => (
|
||||||
|
<line
|
||||||
|
key={`v-${index}`}
|
||||||
|
x1={(VIEWBOX_WIDTH / 10) * index}
|
||||||
|
y1="0"
|
||||||
|
x2={(VIEWBOX_WIDTH / 10) * index}
|
||||||
|
y2={VIEWBOX_HEIGHT}
|
||||||
|
stroke="#0d2817"
|
||||||
|
strokeWidth="1"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{Array.from({ length: 8 }).map((_, index) => (
|
||||||
|
<line
|
||||||
|
key={`h-${index}`}
|
||||||
|
x1="0"
|
||||||
|
y1={(VIEWBOX_HEIGHT / 8) * index}
|
||||||
|
x2={VIEWBOX_WIDTH}
|
||||||
|
y2={(VIEWBOX_HEIGHT / 8) * index}
|
||||||
|
stroke="#0d2817"
|
||||||
|
strokeWidth="1"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{edges.map((edge) => {
|
||||||
|
const source = nodeMap.get(edge.source)
|
||||||
|
const target = nodeMap.get(edge.target)
|
||||||
|
if (!source || !target) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const active =
|
||||||
|
selectedId != null &&
|
||||||
|
(selectedId === edge.source || selectedId === edge.target)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<line
|
||||||
|
key={`${edge.source}-${edge.target}-${edge.kind}`}
|
||||||
|
x1={source.x}
|
||||||
|
y1={source.y}
|
||||||
|
x2={target.x}
|
||||||
|
y2={target.y}
|
||||||
|
stroke={active ? "#9dfdbb" : "#1f5c34"}
|
||||||
|
strokeOpacity={active ? 0.9 : 0.45}
|
||||||
|
strokeWidth={active ? 2.5 : 1.4}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{positionedNodes.map((node) => {
|
||||||
|
const active = node.id === selectedNode?.id
|
||||||
|
const color = groupColor(node.group)
|
||||||
|
const radius =
|
||||||
|
node.kind === "root" ? 24 : node.kind === "document" ? 18 : 14
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={node.id}
|
||||||
|
onMouseEnter={() => setSelectedId(node.id)}
|
||||||
|
onClick={() => setSelectedId(node.id)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx={node.x}
|
||||||
|
cy={node.y}
|
||||||
|
r={radius + 10}
|
||||||
|
fill={active ? `${color}22` : "transparent"}
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx={node.x}
|
||||||
|
cy={node.y}
|
||||||
|
r={radius}
|
||||||
|
fill="#07110a"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={active ? 3 : 2}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={node.x}
|
||||||
|
y={node.y + 4}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill={color}
|
||||||
|
fontSize={node.kind === "root" ? "12" : "10"}
|
||||||
|
fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
|
||||||
|
>
|
||||||
|
{node.kind === "root" ? node.label : node.label.slice(0, 18)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 rounded-xl border border-[#173621] bg-[#050d08] p-4">
|
||||||
|
{selectedNode ? (
|
||||||
|
<>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-mono text-sm uppercase tracking-[0.22em] text-[#95d7a5]">
|
||||||
|
Node Focus
|
||||||
|
</p>
|
||||||
|
<h3 className="mt-2 text-sm font-semibold text-[#effff3]">
|
||||||
|
{selectedNode.label}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"border-[#2a6b3d] text-[#9fd8ae]",
|
||||||
|
selectedNode.kind === "tool" && "border-[#8c7931] text-[#f8d66d]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selectedNode.kind}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm leading-relaxed text-[#9cc8a8]">
|
||||||
|
{selectedNode.preview || "No extra preview for this node yet."}
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-2 text-xs text-[#74a282]">
|
||||||
|
<div className="flex items-center justify-between rounded-lg border border-[#13301d] px-3 py-2">
|
||||||
|
<span>Cluster</span>
|
||||||
|
<span className="font-mono uppercase text-[#d7f9df]">
|
||||||
|
{selectedNode.group}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between rounded-lg border border-[#13301d] px-3 py-2">
|
||||||
|
<span>Weight</span>
|
||||||
|
<span className="font-mono text-[#d7f9df]">
|
||||||
|
{selectedNode.weight ?? 1}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-[#8bb39a]">No graph data available.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
136
web/frontend/src/components/agent/cockpit/use-agent-cockpit.ts
Normal file
136
web/frontend/src/components/agent/cockpit/use-agent-cockpit.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { useDeferredValue, useMemo, useState } from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { getPicoMemoryGraph, getPicoSubagents } from "@/api/pico"
|
||||||
|
import { getTools, getWebSearchConfig, setToolEnabled } from "@/api/tools"
|
||||||
|
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
|
||||||
|
import { refreshGatewayState } from "@/store/gateway"
|
||||||
|
|
||||||
|
type ToolStatusFilter = "all" | "enabled" | "disabled" | "blocked"
|
||||||
|
|
||||||
|
export function useAgentCockpit(sessionId: string) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [searchQuery, setSearchQuery] = useState("")
|
||||||
|
const [statusFilter, setStatusFilter] = useState<ToolStatusFilter>("all")
|
||||||
|
const deferredSearchQuery = useDeferredValue(searchQuery)
|
||||||
|
|
||||||
|
const toolsQuery = useQuery({
|
||||||
|
queryKey: ["tools"],
|
||||||
|
queryFn: getTools,
|
||||||
|
})
|
||||||
|
const webSearchQuery = useQuery({
|
||||||
|
queryKey: ["tools", "web-search-config"],
|
||||||
|
queryFn: getWebSearchConfig,
|
||||||
|
})
|
||||||
|
const subagentsQuery = useQuery({
|
||||||
|
queryKey: ["pico", "subagents", sessionId],
|
||||||
|
queryFn: () => getPicoSubagents(sessionId),
|
||||||
|
enabled: Boolean(sessionId),
|
||||||
|
refetchInterval: 3000,
|
||||||
|
})
|
||||||
|
const memoryGraphQuery = useQuery({
|
||||||
|
queryKey: ["pico", "memory-graph", sessionId],
|
||||||
|
queryFn: () => getPicoMemoryGraph(sessionId),
|
||||||
|
enabled: Boolean(sessionId),
|
||||||
|
refetchInterval: 10000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleToolMutation = useMutation({
|
||||||
|
mutationFn: async ({ name, enabled }: { name: string; enabled: boolean }) =>
|
||||||
|
setToolEnabled(name, enabled),
|
||||||
|
onSuccess: async (_, variables) => {
|
||||||
|
const gateway = await refreshGatewayState({ force: true })
|
||||||
|
showSaveSuccessOrRestartToast(
|
||||||
|
t,
|
||||||
|
variables.enabled
|
||||||
|
? t("pages.agent.tools.enable_success", "Tool enabled successfully")
|
||||||
|
: t(
|
||||||
|
"pages.agent.tools.disable_success",
|
||||||
|
"Tool disabled successfully",
|
||||||
|
),
|
||||||
|
"Agent Cockpit",
|
||||||
|
gateway?.restartRequired === true,
|
||||||
|
)
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["tools"] })
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: t("pages.agent.tools.toggle_error", "Failed to toggle tool"),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const tools = toolsQuery.data?.tools ?? []
|
||||||
|
const normalizedSearchQuery = deferredSearchQuery.trim().toLowerCase()
|
||||||
|
|
||||||
|
const groupedTools = useMemo(() => {
|
||||||
|
const groups = new Map<string, typeof tools>()
|
||||||
|
|
||||||
|
for (const tool of tools) {
|
||||||
|
if (statusFilter !== "all" && tool.status !== statusFilter) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedSearchQuery) {
|
||||||
|
const haystack = `${tool.name} ${tool.description}`.toLowerCase()
|
||||||
|
if (!haystack.includes(normalizedSearchQuery)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = groups.get(tool.category) ?? []
|
||||||
|
items.push(tool)
|
||||||
|
groups.set(tool.category, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(groups.entries())
|
||||||
|
}, [normalizedSearchQuery, statusFilter, tools])
|
||||||
|
|
||||||
|
const categoryCounts = useMemo(() => {
|
||||||
|
const counts = new Map<string, number>()
|
||||||
|
for (const tool of tools) {
|
||||||
|
counts.set(tool.category, (counts.get(tool.category) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
return Array.from(counts.entries())
|
||||||
|
}, [tools])
|
||||||
|
|
||||||
|
const statusCounts = useMemo(() => {
|
||||||
|
return {
|
||||||
|
all: tools.length,
|
||||||
|
enabled: tools.filter((tool) => tool.status === "enabled").length,
|
||||||
|
disabled: tools.filter((tool) => tool.status === "disabled").length,
|
||||||
|
blocked: tools.filter((tool) => tool.status === "blocked").length,
|
||||||
|
}
|
||||||
|
}, [tools])
|
||||||
|
|
||||||
|
return {
|
||||||
|
categoryCounts,
|
||||||
|
groupedTools,
|
||||||
|
pendingToolName: toggleToolMutation.isPending
|
||||||
|
? (toggleToolMutation.variables?.name ?? null)
|
||||||
|
: null,
|
||||||
|
searchQuery,
|
||||||
|
sessionMemoryGraph: memoryGraphQuery.data ?? null,
|
||||||
|
sessionSubagents: subagentsQuery.data?.tasks ?? [],
|
||||||
|
statusCounts,
|
||||||
|
statusFilter,
|
||||||
|
tools,
|
||||||
|
hasMemoryGraphError: memoryGraphQuery.error != null,
|
||||||
|
webSearchConfig: webSearchQuery.data ?? null,
|
||||||
|
hasSubagentsError: subagentsQuery.error != null,
|
||||||
|
hasToolsError: toolsQuery.error != null,
|
||||||
|
isMemoryGraphLoading: memoryGraphQuery.isLoading,
|
||||||
|
isSubagentsLoading: subagentsQuery.isLoading,
|
||||||
|
isToolsLoading: toolsQuery.isLoading,
|
||||||
|
isWebSearchLoading: webSearchQuery.isLoading,
|
||||||
|
setSearchQuery,
|
||||||
|
setStatusFilter,
|
||||||
|
toggleTool: (name: string, enabled: boolean) =>
|
||||||
|
toggleToolMutation.mutate({ name, enabled }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import {
|
||||||
IconAtom,
|
IconAtom,
|
||||||
IconChevronsDown,
|
IconChevronsDown,
|
||||||
IconChevronsUp,
|
IconChevronsUp,
|
||||||
|
IconCpu,
|
||||||
IconKey,
|
IconKey,
|
||||||
IconListDetails,
|
IconListDetails,
|
||||||
IconMessageCircle,
|
IconMessageCircle,
|
||||||
|
|
@ -132,6 +133,12 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
{
|
{
|
||||||
...baseNavGroups[2],
|
...baseNavGroups[2],
|
||||||
items: [
|
items: [
|
||||||
|
{
|
||||||
|
title: "Agent Cockpit",
|
||||||
|
url: "/agent/cockpit",
|
||||||
|
icon: IconCpu,
|
||||||
|
translateTitle: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "navigation.hub",
|
title: "navigation.hub",
|
||||||
url: "/agent/hub",
|
url: "/agent/hub",
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import { Route as ChannelsNameRouteImport } from './routes/channels/$name'
|
||||||
import { Route as AgentToolsRouteImport } from './routes/agent/tools'
|
import { Route as AgentToolsRouteImport } from './routes/agent/tools'
|
||||||
import { Route as AgentSkillsRouteImport } from './routes/agent/skills'
|
import { Route as AgentSkillsRouteImport } from './routes/agent/skills'
|
||||||
import { Route as AgentHubRouteImport } from './routes/agent/hub'
|
import { Route as AgentHubRouteImport } from './routes/agent/hub'
|
||||||
|
import { Route as AgentCockpitRouteImport } from './routes/agent/cockpit'
|
||||||
|
|
||||||
const ModelsRoute = ModelsRouteImport.update({
|
const ModelsRoute = ModelsRouteImport.update({
|
||||||
id: '/models',
|
id: '/models',
|
||||||
|
|
@ -94,6 +95,11 @@ const AgentHubRoute = AgentHubRouteImport.update({
|
||||||
path: '/hub',
|
path: '/hub',
|
||||||
getParentRoute: () => AgentRoute,
|
getParentRoute: () => AgentRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AgentCockpitRoute = AgentCockpitRouteImport.update({
|
||||||
|
id: '/cockpit',
|
||||||
|
path: '/cockpit',
|
||||||
|
getParentRoute: () => AgentRoute,
|
||||||
|
} as any)
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
|
@ -105,6 +111,7 @@ export interface FileRoutesByFullPath {
|
||||||
'/launcher-setup': typeof LauncherSetupRoute
|
'/launcher-setup': typeof LauncherSetupRoute
|
||||||
'/logs': typeof LogsRoute
|
'/logs': typeof LogsRoute
|
||||||
'/models': typeof ModelsRoute
|
'/models': typeof ModelsRoute
|
||||||
|
'/agent/cockpit': typeof AgentCockpitRoute
|
||||||
'/agent/hub': typeof AgentHubRoute
|
'/agent/hub': typeof AgentHubRoute
|
||||||
'/agent/skills': typeof AgentSkillsRoute
|
'/agent/skills': typeof AgentSkillsRoute
|
||||||
'/agent/tools': typeof AgentToolsRoute
|
'/agent/tools': typeof AgentToolsRoute
|
||||||
|
|
@ -121,6 +128,7 @@ export interface FileRoutesByTo {
|
||||||
'/launcher-setup': typeof LauncherSetupRoute
|
'/launcher-setup': typeof LauncherSetupRoute
|
||||||
'/logs': typeof LogsRoute
|
'/logs': typeof LogsRoute
|
||||||
'/models': typeof ModelsRoute
|
'/models': typeof ModelsRoute
|
||||||
|
'/agent/cockpit': typeof AgentCockpitRoute
|
||||||
'/agent/hub': typeof AgentHubRoute
|
'/agent/hub': typeof AgentHubRoute
|
||||||
'/agent/skills': typeof AgentSkillsRoute
|
'/agent/skills': typeof AgentSkillsRoute
|
||||||
'/agent/tools': typeof AgentToolsRoute
|
'/agent/tools': typeof AgentToolsRoute
|
||||||
|
|
@ -138,6 +146,7 @@ export interface FileRoutesById {
|
||||||
'/launcher-setup': typeof LauncherSetupRoute
|
'/launcher-setup': typeof LauncherSetupRoute
|
||||||
'/logs': typeof LogsRoute
|
'/logs': typeof LogsRoute
|
||||||
'/models': typeof ModelsRoute
|
'/models': typeof ModelsRoute
|
||||||
|
'/agent/cockpit': typeof AgentCockpitRoute
|
||||||
'/agent/hub': typeof AgentHubRoute
|
'/agent/hub': typeof AgentHubRoute
|
||||||
'/agent/skills': typeof AgentSkillsRoute
|
'/agent/skills': typeof AgentSkillsRoute
|
||||||
'/agent/tools': typeof AgentToolsRoute
|
'/agent/tools': typeof AgentToolsRoute
|
||||||
|
|
@ -156,6 +165,7 @@ export interface FileRouteTypes {
|
||||||
| '/launcher-setup'
|
| '/launcher-setup'
|
||||||
| '/logs'
|
| '/logs'
|
||||||
| '/models'
|
| '/models'
|
||||||
|
| '/agent/cockpit'
|
||||||
| '/agent/hub'
|
| '/agent/hub'
|
||||||
| '/agent/skills'
|
| '/agent/skills'
|
||||||
| '/agent/tools'
|
| '/agent/tools'
|
||||||
|
|
@ -172,6 +182,7 @@ export interface FileRouteTypes {
|
||||||
| '/launcher-setup'
|
| '/launcher-setup'
|
||||||
| '/logs'
|
| '/logs'
|
||||||
| '/models'
|
| '/models'
|
||||||
|
| '/agent/cockpit'
|
||||||
| '/agent/hub'
|
| '/agent/hub'
|
||||||
| '/agent/skills'
|
| '/agent/skills'
|
||||||
| '/agent/tools'
|
| '/agent/tools'
|
||||||
|
|
@ -188,6 +199,7 @@ export interface FileRouteTypes {
|
||||||
| '/launcher-setup'
|
| '/launcher-setup'
|
||||||
| '/logs'
|
| '/logs'
|
||||||
| '/models'
|
| '/models'
|
||||||
|
| '/agent/cockpit'
|
||||||
| '/agent/hub'
|
| '/agent/hub'
|
||||||
| '/agent/skills'
|
| '/agent/skills'
|
||||||
| '/agent/tools'
|
| '/agent/tools'
|
||||||
|
|
@ -307,6 +319,13 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof AgentHubRouteImport
|
preLoaderRoute: typeof AgentHubRouteImport
|
||||||
parentRoute: typeof AgentRoute
|
parentRoute: typeof AgentRoute
|
||||||
}
|
}
|
||||||
|
'/agent/cockpit': {
|
||||||
|
id: '/agent/cockpit'
|
||||||
|
path: '/cockpit'
|
||||||
|
fullPath: '/agent/cockpit'
|
||||||
|
preLoaderRoute: typeof AgentCockpitRouteImport
|
||||||
|
parentRoute: typeof AgentRoute
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -323,12 +342,14 @@ const ChannelsRouteRouteWithChildren = ChannelsRouteRoute._addFileChildren(
|
||||||
)
|
)
|
||||||
|
|
||||||
interface AgentRouteChildren {
|
interface AgentRouteChildren {
|
||||||
|
AgentCockpitRoute: typeof AgentCockpitRoute
|
||||||
AgentHubRoute: typeof AgentHubRoute
|
AgentHubRoute: typeof AgentHubRoute
|
||||||
AgentSkillsRoute: typeof AgentSkillsRoute
|
AgentSkillsRoute: typeof AgentSkillsRoute
|
||||||
AgentToolsRoute: typeof AgentToolsRoute
|
AgentToolsRoute: typeof AgentToolsRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const AgentRouteChildren: AgentRouteChildren = {
|
const AgentRouteChildren: AgentRouteChildren = {
|
||||||
|
AgentCockpitRoute: AgentCockpitRoute,
|
||||||
AgentHubRoute: AgentHubRoute,
|
AgentHubRoute: AgentHubRoute,
|
||||||
AgentSkillsRoute: AgentSkillsRoute,
|
AgentSkillsRoute: AgentSkillsRoute,
|
||||||
AgentToolsRoute: AgentToolsRoute,
|
AgentToolsRoute: AgentToolsRoute,
|
||||||
|
|
|
||||||
11
web/frontend/src/routes/agent/cockpit.tsx
Normal file
11
web/frontend/src/routes/agent/cockpit.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
|
|
||||||
|
import { CockpitPage } from "@/components/agent/cockpit/cockpit-page"
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/agent/cockpit")({
|
||||||
|
component: AgentCockpitRoute,
|
||||||
|
})
|
||||||
|
|
||||||
|
function AgentCockpitRoute() {
|
||||||
|
return <CockpitPage />
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue