diff --git a/cmd/gateway_http_wiring.go b/cmd/gateway_http_wiring.go index ae19bc58..4672946d 100644 --- a/cmd/gateway_http_wiring.go +++ b/cmd/gateway_http_wiring.go @@ -181,6 +181,16 @@ func (d *gatewayDeps) wireHTTPHandlersOnServer( vh.SetEnrichProgress(d.enrichProgress) vh.SetEnrichWorker(d.enrichWorker) d.server.SetVaultHandler(vh) + + // Lightweight graph visualization endpoints (vault + KG). + var kgGraph store.KGGraphStore + if d.pgStores.KnowledgeGraph != nil { + kgGraph = newKGGraphStore(d.pgStores.DB) + } + vgHandler := httpapi.NewVaultGraphHandler( + newVaultGraphStore(d.pgStores.DB), kgGraph, d.pgStores.Teams, + ) + d.server.SetVaultGraphHandler(vgHandler) } // V3: Episodic memory summaries API diff --git a/cmd/vault_graph_factory_pg.go b/cmd/vault_graph_factory_pg.go new file mode 100644 index 00000000..767dac02 --- /dev/null +++ b/cmd/vault_graph_factory_pg.go @@ -0,0 +1,13 @@ +//go:build !sqlite && !sqliteonly + +package cmd + +import ( + "database/sql" + + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/internal/store/pg" +) + +func newVaultGraphStore(db *sql.DB) store.VaultGraphStore { return pg.NewPGVaultGraphStore(db) } +func newKGGraphStore(db *sql.DB) store.KGGraphStore { return pg.NewPGKGGraphStore(db) } diff --git a/cmd/vault_graph_factory_sqlite.go b/cmd/vault_graph_factory_sqlite.go new file mode 100644 index 00000000..55bc146b --- /dev/null +++ b/cmd/vault_graph_factory_sqlite.go @@ -0,0 +1,13 @@ +//go:build sqlite || sqliteonly + +package cmd + +import ( + "database/sql" + + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/internal/store/sqlitestore" +) + +func newVaultGraphStore(db *sql.DB) store.VaultGraphStore { return sqlitestore.NewSQLiteVaultGraphStore(db) } +func newKGGraphStore(db *sql.DB) store.KGGraphStore { return sqlitestore.NewSQLiteKGGraphStore(db) } diff --git a/docs/17-changelog.md b/docs/17-changelog.md index f401f94f..577bed49 100644 --- a/docs/17-changelog.md +++ b/docs/17-changelog.md @@ -69,6 +69,12 @@ Tenant admins can override tool configuration without affecting other tenants. O ### Added +#### Vault & Knowledge Graph 10k Optimization (2026-04-12) +- **Graph visualization endpoints**: `GET /v1/vault/graph` (cross-tenant) + `GET /v1/agents/{agentID}/kg/graph/compact` for rendering document relationships and semantic entities with support for up to 10k nodes (up from 2k default limit) +- **FA2 layout optimization**: Graph layout computation moved to web worker (non-blocking frontend rendering) +- **Semantic zoom**: UI-level semantic zoom support for graph visualization +- **`DEFAULT_NODE_LIMIT` increase**: 200 → 2000 nodes per graph view to support larger knowledge bases + ### Testing #### Test Speed-Up + Coverage Ratchet Removal (2026-04-11) diff --git a/docs/22-v3-http-endpoints.md b/docs/22-v3-http-endpoints.md index 69776679..79a1a05d 100644 --- a/docs/22-v3-http-endpoints.md +++ b/docs/22-v3-http-endpoints.md @@ -371,7 +371,74 @@ Returns outgoing links and backlinks for a document. --- -## 4. Orchestration Mode +## 4. Graph Visualization + +Lightweight endpoints for rendering vault and knowledge graph relationships. + +### Get Vault Graph + +``` +GET /v1/vault/graph +``` + +**Query Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `agent_id` | string | Filter by agent (optional). | +| `team_id` | string | Scope by team (optional). | +| `limit` | integer | Max nodes to return (default: 500, max: 2000). | + +**Response:** + +```json +{ + "nodes": [ + { + "id": "uuid", + "label": "Document Title", + "type": "document", + "agent_id": "uuid", + "metadata": {} + } + ], + "edges": [ + { + "id": "link-uuid", + "source": "doc-uuid-1", + "target": "doc-uuid-2", + "type": "wikilink" + } + ], + "total_nodes": 150, + "total_edges": 120 +} +``` + +Used for vault document relationship visualization with FA2 layout (web worker rendering). + +### Get Knowledge Graph (Compact) + +``` +GET /v1/agents/{agentID}/kg/graph/compact +``` + +**Query Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `limit` | integer | Max nodes (default: 500, max: 2000). Optimized 10k node load. | + +**Response:** Same format as vault graph; nodes represent semantic entities, edges represent relationship types. + +**Features:** +- Semantic zoom support +- Optimized for 10k+ entity graphs +- FA2 layout computed via web worker + +--- + +## 5. Orchestration Mode Determines how an agent routes requests (standalone, delegation, team-based). @@ -420,7 +487,7 @@ Or in team mode: --- -## 5. V3 Feature Flags +## 6. V3 Feature Flags Per-agent feature flags control v3 system capabilities (evolution, episodic memory, vault, etc.). @@ -478,6 +545,7 @@ Accepts partial updates. Flag keys are validated against recognized v3 flags. | `internal/http/evolution_handlers.go` | Metrics + suggestions endpoints | | `internal/http/episodic_handlers.go` | Episodic memory list + search endpoints | | `internal/http/vault_handlers.go` | Knowledge vault document + link endpoints | +| `internal/http/vault_graph_handler.go` | Vault + KG graph visualization endpoints | | `internal/http/orchestration_handlers.go` | Orchestration mode info endpoint | | `internal/http/v3_flags_handlers.go` | V3 feature flag get/toggle endpoints | | `internal/store/evolution_store.go` | Evolution metrics/suggestions store interface | diff --git a/internal/gateway/server.go b/internal/gateway/server.go index 56c603ef..4cf37b60 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -512,6 +512,11 @@ func (s *Server) SetEvolutionHandler(h *httpapi.EvolutionHandler) { // SetVaultHandler sets the Knowledge Vault document handler. func (s *Server) SetVaultHandler(h *httpapi.VaultHandler) { s.handlers = append(s.handlers, h) } +// SetVaultGraphHandler sets the lightweight graph visualization handler. +func (s *Server) SetVaultGraphHandler(h *httpapi.VaultGraphHandler) { + s.handlers = append(s.handlers, h) +} + // SetEpisodicHandler sets the episodic memory handler. func (s *Server) SetEpisodicHandler(h *httpapi.EpisodicHandler) { s.handlers = append(s.handlers, h) } diff --git a/internal/http/vault_graph_handler.go b/internal/http/vault_graph_handler.go new file mode 100644 index 00000000..841614d5 --- /dev/null +++ b/internal/http/vault_graph_handler.go @@ -0,0 +1,198 @@ +package http + +import ( + "context" + "log/slog" + "net/http" + "strconv" + + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// VaultGraphHandler serves lightweight graph endpoints for visualization. +type VaultGraphHandler struct { + vaultGraph store.VaultGraphStore + kgGraph store.KGGraphStore + teamAccess store.TeamAccessStore // nil = skip team validation (lite edition) +} + +// NewVaultGraphHandler creates a new graph handler. +func NewVaultGraphHandler(vg store.VaultGraphStore, kg store.KGGraphStore, ta store.TeamAccessStore) *VaultGraphHandler { + return &VaultGraphHandler{vaultGraph: vg, kgGraph: kg, teamAccess: ta} +} + +// RegisterRoutes registers graph visualization routes. +func (h *VaultGraphHandler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /v1/vault/graph", h.auth(h.handleVaultGraph)) + if h.kgGraph != nil { + mux.HandleFunc("GET /v1/agents/{agentID}/kg/graph/compact", h.auth(h.handleKGGraphCompact)) + } +} + +func (h *VaultGraphHandler) auth(next http.HandlerFunc) http.HandlerFunc { + return requireAuth("", next) +} + +// --- vault graph --- + +type vaultGraphResponse struct { + Nodes []store.GraphNode `json:"nodes"` + Edges []store.GraphEdge `json:"edges"` + TotalNodes int `json:"total_nodes"` + TotalEdges int `json:"total_edges"` +} + +func (h *VaultGraphHandler) handleVaultGraph(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + tenantID := store.TenantIDFromContext(ctx).String() + + agentID := r.URL.Query().Get("agent_id") + teamID := r.URL.Query().Get("team_id") + + limit := parseGraphLimit(r.URL.Query().Get("limit")) + + opts := store.VaultGraphListOptions{Limit: limit} + + // Team scope validation. + if teamID != "" { + if !h.validateTeamMembership(ctx, w, teamID) { + return + } + opts.TeamID = &teamID + } else if !store.IsOwnerRole(ctx) { + h.applyNonOwnerTeamScope(ctx, &opts) + } + + nodes, totalNodes, err := h.vaultGraph.ListGraphNodes(ctx, tenantID, agentID, opts) + if err != nil { + slog.Warn("vault_graph.nodes failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"}) + return + } + if nodes == nil { + nodes = []store.GraphNode{} + } + + edges, totalEdges, err := h.vaultGraph.ListGraphEdges(ctx, tenantID, agentID, opts) + if err != nil { + slog.Warn("vault_graph.edges failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"}) + return + } + if edges == nil { + edges = []store.GraphEdge{} + } + + writeJSON(w, http.StatusOK, vaultGraphResponse{ + Nodes: nodes, + Edges: edges, + TotalNodes: totalNodes, + TotalEdges: totalEdges, + }) +} + +// --- KG graph compact --- + +type kgGraphCompactResponse struct { + Nodes []store.KGGraphNode `json:"nodes"` + Edges []store.KGGraphEdge `json:"edges"` + TotalNodes int `json:"total_nodes"` + TotalEdges int `json:"total_edges"` +} + +func (h *VaultGraphHandler) handleKGGraphCompact(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + agentID := r.PathValue("agentID") + userID := r.URL.Query().Get("user_id") + + limit := parseGraphLimit(r.URL.Query().Get("limit")) + + nodes, totalNodes, err := h.kgGraph.ListKGGraphNodes(ctx, agentID, userID, limit) + if err != nil { + slog.Warn("kg_graph_compact.nodes failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"}) + return + } + if nodes == nil { + nodes = []store.KGGraphNode{} + } + + edges, totalEdges, err := h.kgGraph.ListKGGraphEdges(ctx, agentID, userID, limit*3) + if err != nil { + slog.Warn("kg_graph_compact.edges failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"}) + return + } + if edges == nil { + edges = []store.KGGraphEdge{} + } + + writeJSON(w, http.StatusOK, kgGraphCompactResponse{ + Nodes: nodes, + Edges: edges, + TotalNodes: totalNodes, + TotalEdges: totalEdges, + }) +} + +// --- helpers --- + +func parseGraphLimit(s string) int { + limit, _ := strconv.Atoi(s) + if limit <= 0 { + limit = 2000 + } + if limit > 10000 { + limit = 10000 + } + return limit +} + +// validateTeamMembership checks team membership for non-owner users. +func (h *VaultGraphHandler) validateTeamMembership(ctx context.Context, w http.ResponseWriter, teamID string) bool { + if store.IsOwnerRole(ctx) { + return true + } + if h.teamAccess == nil { + return true + } + userID := store.UserIDFromContext(ctx) + if userID == "" { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "user identity required"}) + return false + } + tid, err := uuid.Parse(teamID) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid team_id"}) + return false + } + ok, err := h.teamAccess.HasTeamAccess(ctx, tid, userID) + if err != nil || !ok { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "not a member of the specified team"}) + return false + } + return true +} + +// applyNonOwnerTeamScope restricts a non-owner graph query to personal + user's teams. +func (h *VaultGraphHandler) applyNonOwnerTeamScope(ctx context.Context, opts *store.VaultGraphListOptions) { + if h.teamAccess == nil { + return + } + userID := store.UserIDFromContext(ctx) + if userID == "" { + return + } + teams, err := h.teamAccess.ListUserTeams(ctx, userID) + if err != nil || len(teams) == 0 { + empty := "" + opts.TeamID = &empty + return + } + ids := make([]string, len(teams)) + for i, t := range teams { + ids[i] = t.ID.String() + } + opts.TeamIDs = ids +} diff --git a/internal/store/pg/kg_graph.go b/internal/store/pg/kg_graph.go new file mode 100644 index 00000000..8c7c87bf --- /dev/null +++ b/internal/store/pg/kg_graph.go @@ -0,0 +1,112 @@ +package pg + +import ( + "context" + "database/sql" + "fmt" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// PGKGGraphStore implements store.KGGraphStore backed by PostgreSQL. +type PGKGGraphStore struct { + db *sql.DB +} + +// NewPGKGGraphStore creates a new PG-backed KG graph store. +func NewPGKGGraphStore(db *sql.DB) *PGKGGraphStore { + return &PGKGGraphStore{db: db} +} + +// ListKGGraphNodes returns lightweight KG entities for graph visualization. +func (s *PGKGGraphStore) ListKGGraphNodes(ctx context.Context, agentID, userID string, limit int) ([]store.KGGraphNode, int, error) { + aid := parseUUIDOrNil(agentID) + if limit <= 0 { + limit = 2000 + } + if limit > 10000 { + limit = 10000 + } + + tid := store.TenantIDFromContext(ctx) + + q := `SELECT id, name, entity_type, confidence + FROM kg_entities WHERE agent_id = $1 AND tenant_id = $2 AND valid_until IS NULL` + args := []any{aid, tid} + p := 3 + + if !store.IsSharedKG(ctx) && userID != "" { + q += fmt.Sprintf(" AND user_id = $%d", p) + args = append(args, userID) + p++ + } + + q += " ORDER BY updated_at DESC" + q += fmt.Sprintf(" LIMIT $%d", p) + args = append(args, limit) + + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, 0, fmt.Errorf("kg graph nodes: %w", err) + } + defer rows.Close() + + var nodes []store.KGGraphNode + for rows.Next() { + var n store.KGGraphNode + if err := rows.Scan(&n.ID, &n.Name, &n.EntityType, &n.Confidence); err != nil { + return nil, 0, fmt.Errorf("kg graph nodes scan: %w", err) + } + nodes = append(nodes, n) + } + if err := rows.Err(); err != nil { + return nil, 0, fmt.Errorf("kg graph nodes rows: %w", err) + } + return nodes, len(nodes), nil +} + +// ListKGGraphEdges returns lightweight KG relations for graph visualization. +func (s *PGKGGraphStore) ListKGGraphEdges(ctx context.Context, agentID, userID string, limit int) ([]store.KGGraphEdge, int, error) { + aid := parseUUIDOrNil(agentID) + if limit <= 0 { + limit = 6000 + } + if limit > 30000 { + limit = 30000 + } + + tid := store.TenantIDFromContext(ctx) + + q := `SELECT id, source_entity_id, target_entity_id, relation_type + FROM kg_relations WHERE agent_id = $1 AND tenant_id = $2 AND valid_until IS NULL` + args := []any{aid, tid} + p := 3 + + if !store.IsSharedKG(ctx) && userID != "" { + q += fmt.Sprintf(" AND user_id = $%d", p) + args = append(args, userID) + p++ + } + + q += fmt.Sprintf(" LIMIT $%d", p) + args = append(args, limit) + + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, 0, fmt.Errorf("kg graph edges: %w", err) + } + defer rows.Close() + + var edges []store.KGGraphEdge + for rows.Next() { + var e store.KGGraphEdge + if err := rows.Scan(&e.ID, &e.SourceID, &e.TargetID, &e.RelationType); err != nil { + return nil, 0, fmt.Errorf("kg graph edges scan: %w", err) + } + edges = append(edges, e) + } + if err := rows.Err(); err != nil { + return nil, 0, fmt.Errorf("kg graph edges rows: %w", err) + } + return edges, len(edges), nil +} diff --git a/internal/store/pg/vault_graph.go b/internal/store/pg/vault_graph.go new file mode 100644 index 00000000..ee9cacba --- /dev/null +++ b/internal/store/pg/vault_graph.go @@ -0,0 +1,188 @@ +package pg + +import ( + "context" + "database/sql" + "fmt" + "strings" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// PGVaultGraphStore implements store.VaultGraphStore backed by PostgreSQL. +type PGVaultGraphStore struct { + db *sql.DB +} + +// NewPGVaultGraphStore creates a new PG-backed vault graph store. +func NewPGVaultGraphStore(db *sql.DB) *PGVaultGraphStore { + return &PGVaultGraphStore{db: db} +} + +// ListGraphNodes returns lightweight vault nodes with pre-computed degree. +func (s *PGVaultGraphStore) ListGraphNodes(ctx context.Context, tenantID, agentID string, opts store.VaultGraphListOptions) ([]store.GraphNode, int, error) { + tid := parseUUIDOrNil(tenantID) + + q := `SELECT vd.id, vd.title, vd.path, vd.doc_type, + COALESCE(deg.cnt, 0) AS degree + FROM vault_documents vd + LEFT JOIN ( + SELECT doc_id, COUNT(*) AS cnt FROM ( + SELECT from_doc_id AS doc_id FROM vault_links vl + JOIN vault_documents d ON d.id = vl.from_doc_id AND d.tenant_id = $1 + UNION ALL + SELECT to_doc_id AS doc_id FROM vault_links vl + JOIN vault_documents d ON d.id = vl.to_doc_id AND d.tenant_id = $1 + ) sub GROUP BY doc_id + ) deg ON deg.doc_id = vd.id + WHERE vd.tenant_id = $1` + args := []any{tid} + p := 2 + + if agentID != "" { + aid := parseUUIDOrNil(agentID) + q += fmt.Sprintf(" AND vd.agent_id = $%d", p) + args = append(args, aid) + p++ + } + + q, args, p = appendGraphTeamFilter(q, args, p, "vd", opts.TeamID, opts.TeamIDs) + + q += " ORDER BY degree DESC, vd.updated_at DESC" + + limit := opts.Limit + if limit <= 0 { + limit = 2000 + } + if limit > 10000 { + limit = 10000 + } + q += fmt.Sprintf(" LIMIT $%d", p) + args = append(args, limit) + + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, 0, fmt.Errorf("vault graph nodes: %w", err) + } + defer rows.Close() + + var nodes []store.GraphNode + for rows.Next() { + var n store.GraphNode + if err := rows.Scan(&n.ID, &n.Title, &n.Path, &n.DocType, &n.Degree); err != nil { + return nil, 0, fmt.Errorf("vault graph nodes scan: %w", err) + } + nodes = append(nodes, n) + } + if err := rows.Err(); err != nil { + return nil, 0, fmt.Errorf("vault graph nodes rows: %w", err) + } + + // Count total nodes in scope (without LIMIT). + total, err := s.countGraphNodes(ctx, tid, agentID, opts) + if err != nil { + return nodes, len(nodes), nil // non-fatal: return what we have + } + return nodes, total, nil +} + +// ListGraphEdges returns lightweight vault edges for nodes in scope. +func (s *PGVaultGraphStore) ListGraphEdges(ctx context.Context, tenantID, agentID string, opts store.VaultGraphListOptions) ([]store.GraphEdge, int, error) { + tid := parseUUIDOrNil(tenantID) + + // Subquery selects doc IDs in scope (same filters as ListGraphNodes). + subQ := `SELECT id FROM vault_documents WHERE tenant_id = $1` + args := []any{tid} + p := 2 + + if agentID != "" { + aid := parseUUIDOrNil(agentID) + subQ += fmt.Sprintf(" AND agent_id = $%d", p) + args = append(args, aid) + p++ + } + + subQ, args, p = appendGraphTeamFilter(subQ, args, p, "", opts.TeamID, opts.TeamIDs) + + limit := opts.Limit + if limit <= 0 { + limit = 2000 + } + if limit > 10000 { + limit = 10000 + } + subQ += fmt.Sprintf(" LIMIT $%d", p) + args = append(args, limit) + p++ + + q := fmt.Sprintf(`SELECT vl.id, vl.from_doc_id, vl.to_doc_id, vl.link_type + FROM vault_links vl + WHERE vl.from_doc_id IN (%s) AND vl.to_doc_id IN (%s)`, subQ, subQ) + + // PG positional params ($1,$2..) are reused across both IN subqueries — no duplication needed. + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, 0, fmt.Errorf("vault graph edges: %w", err) + } + defer rows.Close() + + var edges []store.GraphEdge + for rows.Next() { + var e store.GraphEdge + if err := rows.Scan(&e.ID, &e.FromID, &e.ToID, &e.LinkType); err != nil { + return nil, 0, fmt.Errorf("vault graph edges scan: %w", err) + } + edges = append(edges, e) + } + if err := rows.Err(); err != nil { + return nil, 0, fmt.Errorf("vault graph edges rows: %w", err) + } + return edges, len(edges), nil +} + +func (s *PGVaultGraphStore) countGraphNodes(ctx context.Context, tid any, agentID string, opts store.VaultGraphListOptions) (int, error) { + q := `SELECT COUNT(*) FROM vault_documents WHERE tenant_id = $1` + args := []any{tid} + p := 2 + + if agentID != "" { + aid := parseUUIDOrNil(agentID) + q += fmt.Sprintf(" AND agent_id = $%d", p) + args = append(args, aid) + p++ + } + q, args, _ = appendGraphTeamFilter(q, args, p, "", opts.TeamID, opts.TeamIDs) + + var count int + if err := s.db.QueryRowContext(ctx, q, args...).Scan(&count); err != nil { + return 0, err + } + return count, nil +} + +// appendGraphTeamFilter appends team_id clause. Optional tableAlias prefix (e.g. "vd"). +func appendGraphTeamFilter(q string, args []any, p int, tableAlias string, teamID *string, teamIDs []string) (string, []any, int) { + col := "team_id" + if tableAlias != "" { + col = tableAlias + ".team_id" + } + + if len(teamIDs) > 0 { + ph := make([]string, len(teamIDs)) + for i, id := range teamIDs { + ph[i] = fmt.Sprintf("$%d", p) + args = append(args, parseUUIDOrNil(id)) + p++ + } + q += fmt.Sprintf(" AND (%s IS NULL OR %s IN (%s))", col, col, strings.Join(ph, ",")) + } else if teamID != nil { + if *teamID != "" { + q += fmt.Sprintf(" AND %s = $%d", col, p) + args = append(args, parseUUIDOrNil(*teamID)) + p++ + } else { + q += fmt.Sprintf(" AND %s IS NULL", col) + } + } + return q, args, p +} diff --git a/internal/store/sqlitestore/kg_graph.go b/internal/store/sqlitestore/kg_graph.go new file mode 100644 index 00000000..f1ccedae --- /dev/null +++ b/internal/store/sqlitestore/kg_graph.go @@ -0,0 +1,105 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "database/sql" + "fmt" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// SQLiteKGGraphStore implements store.KGGraphStore backed by SQLite. +type SQLiteKGGraphStore struct { + db *sql.DB +} + +// NewSQLiteKGGraphStore creates a new SQLite-backed KG graph store. +func NewSQLiteKGGraphStore(db *sql.DB) *SQLiteKGGraphStore { + return &SQLiteKGGraphStore{db: db} +} + +// ListKGGraphNodes returns lightweight KG entities for graph visualization. +func (s *SQLiteKGGraphStore) ListKGGraphNodes(ctx context.Context, agentID, userID string, limit int) ([]store.KGGraphNode, int, error) { + if limit <= 0 { + limit = 2000 + } + if limit > 10000 { + limit = 10000 + } + + q := `SELECT id, name, entity_type, confidence + FROM kg_entities WHERE agent_id = ? AND valid_until IS NULL` + args := []any{agentID} + + userClause, userArgs := kgUserClauseFor(ctx, userID) + if userClause != "" { + q += userClause + args = append(args, userArgs...) + } + + q += " ORDER BY updated_at DESC LIMIT ?" + args = append(args, limit) + + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, 0, fmt.Errorf("kg graph nodes: %w", err) + } + defer rows.Close() + + var nodes []store.KGGraphNode + for rows.Next() { + var n store.KGGraphNode + if err := rows.Scan(&n.ID, &n.Name, &n.EntityType, &n.Confidence); err != nil { + return nil, 0, fmt.Errorf("kg graph nodes scan: %w", err) + } + nodes = append(nodes, n) + } + if err := rows.Err(); err != nil { + return nil, 0, fmt.Errorf("kg graph nodes rows: %w", err) + } + return nodes, len(nodes), nil +} + +// ListKGGraphEdges returns lightweight KG relations for graph visualization. +func (s *SQLiteKGGraphStore) ListKGGraphEdges(ctx context.Context, agentID, userID string, limit int) ([]store.KGGraphEdge, int, error) { + if limit <= 0 { + limit = 6000 + } + if limit > 30000 { + limit = 30000 + } + + q := `SELECT id, source_entity_id, target_entity_id, relation_type + FROM kg_relations WHERE agent_id = ? AND valid_until IS NULL` + args := []any{agentID} + + userClause, userArgs := kgUserClauseFor(ctx, userID) + if userClause != "" { + q += userClause + args = append(args, userArgs...) + } + + q += " LIMIT ?" + args = append(args, limit) + + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, 0, fmt.Errorf("kg graph edges: %w", err) + } + defer rows.Close() + + var edges []store.KGGraphEdge + for rows.Next() { + var e store.KGGraphEdge + if err := rows.Scan(&e.ID, &e.SourceID, &e.TargetID, &e.RelationType); err != nil { + return nil, 0, fmt.Errorf("kg graph edges scan: %w", err) + } + edges = append(edges, e) + } + if err := rows.Err(); err != nil { + return nil, 0, fmt.Errorf("kg graph edges rows: %w", err) + } + return edges, len(edges), nil +} diff --git a/internal/store/sqlitestore/vault_graph.go b/internal/store/sqlitestore/vault_graph.go new file mode 100644 index 00000000..01c51d1a --- /dev/null +++ b/internal/store/sqlitestore/vault_graph.go @@ -0,0 +1,177 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "database/sql" + "fmt" + "strings" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// SQLiteVaultGraphStore implements store.VaultGraphStore backed by SQLite. +type SQLiteVaultGraphStore struct { + db *sql.DB +} + +// NewSQLiteVaultGraphStore creates a new SQLite-backed vault graph store. +func NewSQLiteVaultGraphStore(db *sql.DB) *SQLiteVaultGraphStore { + return &SQLiteVaultGraphStore{db: db} +} + +// ListGraphNodes returns lightweight vault nodes with pre-computed degree. +func (s *SQLiteVaultGraphStore) ListGraphNodes(ctx context.Context, tenantID, agentID string, opts store.VaultGraphListOptions) ([]store.GraphNode, int, error) { + q := `SELECT vd.id, vd.title, vd.path, vd.doc_type, + COALESCE(deg.cnt, 0) AS degree + FROM vault_documents vd + LEFT JOIN ( + SELECT doc_id, COUNT(*) AS cnt FROM ( + SELECT from_doc_id AS doc_id FROM vault_links vl + JOIN vault_documents d ON d.id = vl.from_doc_id AND d.tenant_id = ? + UNION ALL + SELECT to_doc_id AS doc_id FROM vault_links vl + JOIN vault_documents d ON d.id = vl.to_doc_id AND d.tenant_id = ? + ) sub GROUP BY doc_id + ) deg ON deg.doc_id = vd.id + WHERE vd.tenant_id = ?` + args := []any{tenantID, tenantID, tenantID} + + if agentID != "" { + q += " AND vd.agent_id = ?" + args = append(args, agentID) + } + + q, args = sqliteAppendGraphTeamFilter(q, args, "vd", opts.TeamID, opts.TeamIDs) + + q += " ORDER BY degree DESC, vd.updated_at DESC" + + limit := opts.Limit + if limit <= 0 { + limit = 2000 + } + if limit > 10000 { + limit = 10000 + } + q += " LIMIT ?" + args = append(args, limit) + + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, 0, fmt.Errorf("vault graph nodes: %w", err) + } + defer rows.Close() + + var nodes []store.GraphNode + for rows.Next() { + var n store.GraphNode + if err := rows.Scan(&n.ID, &n.Title, &n.Path, &n.DocType, &n.Degree); err != nil { + return nil, 0, fmt.Errorf("vault graph nodes scan: %w", err) + } + nodes = append(nodes, n) + } + if err := rows.Err(); err != nil { + return nil, 0, fmt.Errorf("vault graph nodes rows: %w", err) + } + + total, err := s.countGraphNodes(ctx, tenantID, agentID, opts) + if err != nil { + return nodes, len(nodes), nil + } + return nodes, total, nil +} + +// ListGraphEdges returns lightweight vault edges for nodes in scope. +func (s *SQLiteVaultGraphStore) ListGraphEdges(ctx context.Context, tenantID, agentID string, opts store.VaultGraphListOptions) ([]store.GraphEdge, int, error) { + // Build subquery for doc IDs in scope. + subQ := `SELECT id FROM vault_documents WHERE tenant_id = ?` + subArgs := []any{tenantID} + + if agentID != "" { + subQ += " AND agent_id = ?" + subArgs = append(subArgs, agentID) + } + + subQ, subArgs = sqliteAppendGraphTeamFilter(subQ, subArgs, "", opts.TeamID, opts.TeamIDs) + + limit := opts.Limit + if limit <= 0 { + limit = 2000 + } + if limit > 10000 { + limit = 10000 + } + subQ += " LIMIT ?" + subArgs = append(subArgs, limit) + + q := fmt.Sprintf(`SELECT vl.id, vl.from_doc_id, vl.to_doc_id, vl.link_type + FROM vault_links vl + WHERE vl.from_doc_id IN (%s) AND vl.to_doc_id IN (%s)`, subQ, subQ) + + // Duplicate args for second IN subquery. + args := make([]any, 0, len(subArgs)*2) + args = append(args, subArgs...) + args = append(args, subArgs...) + + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, 0, fmt.Errorf("vault graph edges: %w", err) + } + defer rows.Close() + + var edges []store.GraphEdge + for rows.Next() { + var e store.GraphEdge + if err := rows.Scan(&e.ID, &e.FromID, &e.ToID, &e.LinkType); err != nil { + return nil, 0, fmt.Errorf("vault graph edges scan: %w", err) + } + edges = append(edges, e) + } + if err := rows.Err(); err != nil { + return nil, 0, fmt.Errorf("vault graph edges rows: %w", err) + } + return edges, len(edges), nil +} + +func (s *SQLiteVaultGraphStore) countGraphNodes(ctx context.Context, tenantID, agentID string, opts store.VaultGraphListOptions) (int, error) { + q := `SELECT COUNT(*) FROM vault_documents WHERE tenant_id = ?` + args := []any{tenantID} + + if agentID != "" { + q += " AND agent_id = ?" + args = append(args, agentID) + } + q, args = sqliteAppendGraphTeamFilter(q, args, "", opts.TeamID, opts.TeamIDs) + + var count int + if err := s.db.QueryRowContext(ctx, q, args...).Scan(&count); err != nil { + return 0, err + } + return count, nil +} + +// sqliteAppendGraphTeamFilter appends team_id clause for SQLite. +func sqliteAppendGraphTeamFilter(q string, args []any, tableAlias string, teamID *string, teamIDs []string) (string, []any) { + col := "team_id" + if tableAlias != "" { + col = tableAlias + ".team_id" + } + + if len(teamIDs) > 0 { + ph := "?" + strings.Repeat(",?", len(teamIDs)-1) + q += fmt.Sprintf(" AND (%s IS NULL OR %s IN (%s))", col, col, ph) + for _, id := range teamIDs { + args = append(args, id) + } + } else if teamID != nil { + if *teamID != "" { + q += fmt.Sprintf(" AND %s = ?", col) + args = append(args, *teamID) + } else { + q += fmt.Sprintf(" AND %s IS NULL", col) + } + } + return q, args +} + diff --git a/internal/store/vault_graph_store.go b/internal/store/vault_graph_store.go new file mode 100644 index 00000000..a49dc760 --- /dev/null +++ b/internal/store/vault_graph_store.go @@ -0,0 +1,59 @@ +package store + +import "context" + +// GraphNode is a lightweight vault document for graph visualization. +type GraphNode struct { + ID string `json:"id"` + Title string `json:"t"` // short key for wire size + Path string `json:"p"` // workspace-relative path + DocType string `json:"dt"` // doc_type + Degree int `json:"deg"` +} + +// GraphEdge is a lightweight vault link for graph visualization. +type GraphEdge struct { + ID string `json:"id"` + FromID string `json:"from"` + ToID string `json:"to"` + LinkType string `json:"type"` +} + +// KGGraphNode is a lightweight KG entity for graph visualization. +type KGGraphNode struct { + ID string `json:"id"` + Name string `json:"n"` + EntityType string `json:"t"` + Confidence float64 `json:"c"` +} + +// KGGraphEdge is a lightweight KG relation for graph visualization. +type KGGraphEdge struct { + ID string `json:"id"` + SourceID string `json:"src"` + TargetID string `json:"tgt"` + RelationType string `json:"type"` +} + +// VaultGraphListOptions configures a vault graph query. +type VaultGraphListOptions struct { + TeamID *string + TeamIDs []string + Limit int // max 10000 +} + +// VaultGraphStore provides read-only graph data for visualization. +type VaultGraphStore interface { + // ListGraphNodes returns lightweight nodes with pre-computed degree. + ListGraphNodes(ctx context.Context, tenantID, agentID string, opts VaultGraphListOptions) ([]GraphNode, int, error) + // ListGraphEdges returns lightweight edges for nodes in scope. + ListGraphEdges(ctx context.Context, tenantID, agentID string, opts VaultGraphListOptions) ([]GraphEdge, int, error) +} + +// KGGraphStore provides read-only KG graph data for visualization. +type KGGraphStore interface { + // ListKGGraphNodes returns lightweight entities. + ListKGGraphNodes(ctx context.Context, agentID, userID string, limit int) ([]KGGraphNode, int, error) + // ListKGGraphEdges returns lightweight relations. + ListKGGraphEdges(ctx context.Context, agentID, userID string, limit int) ([]KGGraphEdge, int, error) +} diff --git a/ui/web/package.json b/ui/web/package.json index 591598ae..d742e19a 100644 --- a/ui/web/package.json +++ b/ui/web/package.json @@ -27,6 +27,7 @@ "date-fns": "^4.1.0", "framer-motion": "^12.38.0", "graphology": "^0.26.0", + "graphology-communities-louvain": "^2.0.2", "graphology-layout": "^0.6.1", "graphology-layout-forceatlas2": "^0.10.1", "graphology-layout-noverlap": "^0.4.2", diff --git a/ui/web/pnpm-lock.yaml b/ui/web/pnpm-lock.yaml index 3e8e14b8..4c9cced6 100644 --- a/ui/web/pnpm-lock.yaml +++ b/ui/web/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: graphology: specifier: ^0.26.0 version: 0.26.0(graphology-types@0.24.8) + graphology-communities-louvain: + specifier: ^2.0.2 + version: 2.0.2(graphology-types@0.24.8) graphology-layout: specifier: ^0.6.1 version: 0.6.1(graphology-types@0.24.8) @@ -2197,6 +2200,16 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + graphology-communities-louvain@2.0.2: + resolution: {integrity: sha512-zt+2hHVPYxjEquyecxWXoUoIuN/UvYzsvI7boDdMNz0rRvpESQ7+e+Ejv6wK7AThycbZXuQ6DkG8NPMCq6XwoA==} + peerDependencies: + graphology-types: '>=0.19.0' + + graphology-indices@0.17.0: + resolution: {integrity: sha512-A7RXuKQvdqSWOpn7ZVQo4S33O0vCfPBnUSf7FwE0zNCasqwZVUaCXePuWo5HBpWw68KJcwObZDHpFk6HKH6MYQ==} + peerDependencies: + graphology-types: '>=0.20.0' + graphology-layout-forceatlas2@0.10.1: resolution: {integrity: sha512-ogzBeF1FvWzjkikrIFwxhlZXvD2+wlY54lqhsrWprcdPjopM2J9HoMweUmIgwaTvY4bUYVimpSsOdvDv1gPRFQ==} peerDependencies: @@ -5475,6 +5488,20 @@ snapshots: graceful-fs@4.2.11: {} + graphology-communities-louvain@2.0.2(graphology-types@0.24.8): + dependencies: + graphology-indices: 0.17.0(graphology-types@0.24.8) + graphology-types: 0.24.8 + graphology-utils: 2.5.2(graphology-types@0.24.8) + mnemonist: 0.39.8 + pandemonium: 2.4.1 + + graphology-indices@0.17.0(graphology-types@0.24.8): + dependencies: + graphology-types: 0.24.8 + graphology-utils: 2.5.2(graphology-types@0.24.8) + mnemonist: 0.39.8 + graphology-layout-forceatlas2@0.10.1(graphology-types@0.24.8): dependencies: graphology-types: 0.24.8 diff --git a/ui/web/src/adapters/kg-graph.adapter.ts b/ui/web/src/adapters/kg-graph.adapter.ts index d33bd5bf..b87defe1 100644 --- a/ui/web/src/adapters/kg-graph.adapter.ts +++ b/ui/web/src/adapters/kg-graph.adapter.ts @@ -1,5 +1,6 @@ import Graph from "graphology"; import type { KGEntity, KGRelation } from "@/types/knowledge-graph"; +import type { KGGraphNode, KGGraphEdge } from "@/types/graph-dto"; import { getNodeSize, truncateMiddle } from "@/components/graph/graph-utils"; // Solid colors per entity type @@ -21,6 +22,39 @@ export function computeDegreeMap(entities: KGEntity[], relations: KGRelation[]): return deg; } +/** Build KG graph from compact DTOs. */ +export function buildKGGraphFromDTO(nodes: KGGraphNode[], edges: KGGraphEdge[]): Graph { + const graph = new Graph({ multi: false, type: "directed" }); + const nodeIds = new Set(nodes.map((n) => n.id)); + + // Pre-compute degree from edges (compact DTO doesn't include it) + const deg = new Map(); + for (const e of edges) { + if (nodeIds.has(e.src)) deg.set(e.src, (deg.get(e.src) ?? 0) + 1); + if (nodeIds.has(e.tgt)) deg.set(e.tgt, (deg.get(e.tgt) ?? 0) + 1); + } + + for (const n of nodes) { + graph.addNode(n.id, { + label: truncateMiddle(n.n, 28), + x: 0, y: 0, + size: getNodeSize(deg.get(n.id) ?? 0, nodes.length), + color: KG_TYPE_COLORS[n.t] ?? KG_DEFAULT_COLOR, + entityType: n.t, + }); + } + + for (const e of edges) { + if (nodeIds.has(e.src) && nodeIds.has(e.tgt) && !graph.hasEdge(e.src, e.tgt)) { + graph.addEdgeWithKey(e.id, e.src, e.tgt, { + label: e.type.replace(/_/g, " "), type: "curvedArrow", + }); + } + } + + return graph; +} + /** Build a Graphology graph from KG entities and relations. */ export function buildKGGraph(entities: KGEntity[], allRelations: KGRelation[]): Graph { const graph = new Graph({ multi: false, type: "directed" }); @@ -35,7 +69,7 @@ export function buildKGGraph(entities: KGEntity[], allRelations: KGRelation[]): label: truncateMiddle(e.name, 28), x: 0, y: 0, - size: getNodeSize(degree), + size: getNodeSize(degree, entities.length), color: KG_TYPE_COLORS[e.entity_type] ?? KG_DEFAULT_COLOR, entityType: e.entity_type, }); diff --git a/ui/web/src/adapters/vault-graph-adapter.ts b/ui/web/src/adapters/vault-graph-adapter.ts index 7347cda0..4ad89198 100644 --- a/ui/web/src/adapters/vault-graph-adapter.ts +++ b/ui/web/src/adapters/vault-graph-adapter.ts @@ -1,5 +1,6 @@ import Graph from "graphology"; import type { VaultDocument, VaultLink } from "@/types/vault"; +import type { VaultGraphNode, VaultGraphEdge } from "@/types/graph-dto"; import { getNodeSize, truncateMiddle } from "@/components/graph/graph-utils"; // Colors per vault document type — matches DOC_TYPE_ICONS in vault-tree.tsx @@ -49,6 +50,33 @@ export function limitVaultDocsByDegree( return [...docs].sort((a, b) => (deg.get(b.id) ?? 0) - (deg.get(a.id) ?? 0)).slice(0, nodeLimit); } +/** Build graph from lightweight DTOs (degree pre-computed, no client loop). */ +export function buildVaultGraphFromDTO(nodes: VaultGraphNode[], edges: VaultGraphEdge[]): Graph { + const graph = new Graph({ multi: false, type: "directed" }); + const nodeIds = new Set(nodes.map((n) => n.id)); + + for (const n of nodes) { + graph.addNode(n.id, { + label: truncateMiddle(n.t || n.p.split("/").pop() || n.id.slice(0, 8), 28), + x: 0, y: 0, + size: getNodeSize(n.deg, nodes.length), + color: VAULT_TYPE_COLORS_LIGHT[n.dt] ?? DEFAULT_COLOR_LIGHT, + docType: n.dt, + }); + } + + for (const e of edges) { + if (nodeIds.has(e.from) && nodeIds.has(e.to) && !graph.hasEdge(e.from, e.to)) { + graph.addEdgeWithKey(e.id, e.from, e.to, { + label: e.type, type: "curvedArrow", + color: "#a1a1aa", size: 0.4, + }); + } + } + + return graph; +} + /** Build a Graphology graph from vault documents and their links. */ export function buildVaultGraph( documents: VaultDocument[], @@ -74,7 +102,7 @@ export function buildVaultGraph( label: truncateMiddle(rawLabel, 28), x: 0, y: 0, - size: getNodeSize(degree), + size: getNodeSize(degree, documents.length), // Color set by nodeReducer based on theme; use light as initial fallback color: VAULT_TYPE_COLORS_LIGHT[doc.doc_type] ?? DEFAULT_COLOR_LIGHT, docType: doc.doc_type, diff --git a/ui/web/src/components/graph/graph-utils.ts b/ui/web/src/components/graph/graph-utils.ts index a562580c..b9785822 100644 --- a/ui/web/src/components/graph/graph-utils.ts +++ b/ui/web/src/components/graph/graph-utils.ts @@ -1,7 +1,38 @@ -/** Degree-based node sizing: 4-12px range. Logarithmic scale for dense hubs. */ -export function getNodeSize(degree: number): number { - if (degree === 0) return 4; - return 4 + Math.min(Math.log2(degree + 1) * 2, 8); +import louvain from "graphology-communities-louvain"; +import type Graph from "graphology"; + +/** 16-color palette for community detection — high contrast, distinct hues. */ +const COMMUNITY_PALETTE = [ + "#e6194B", "#3cb44b", "#4363d8", "#f58231", + "#42d4f4", "#f032e6", "#bfef45", "#fabed4", + "#469990", "#dcbeff", "#9A6324", "#ffe119", + "#800000", "#aaffc3", "#808000", "#000075", +] as const; + +/** Run Louvain community detection and assign `community` + `color` attrs. + * Call AFTER graph is built (nodes + edges added). */ +export function assignCommunityColors(graph: Graph): void { + if (graph.order === 0) return; + const communities = louvain(graph, { resolution: 1.0 }); + graph.forEachNode((node) => { + const c = communities[node] ?? 0; + graph.setNodeAttribute(node, "community", c); + graph.setNodeAttribute(node, "color", COMMUNITY_PALETTE[c % COMMUNITY_PALETTE.length]); + }); +} + +/** Get community palette color by index (for legend). */ +export function getCommunityColor(idx: number): string { + return COMMUNITY_PALETTE[idx % COMMUNITY_PALETTE.length]!; +} + +/** Degree-based node sizing, scaled by graph density. + * Larger graphs → smaller nodes to avoid overlap. */ +export function getNodeSize(degree: number, nodeCount = 200): number { + const s = nodeCount < 100 ? 1.0 : nodeCount < 500 ? 0.6 : nodeCount < 2000 ? 0.4 : 0.3; + const base = 3 * s; + if (degree === 0) return base; + return base + Math.min(Math.log2(degree + 1) * 1.2 * s, 5 * s); } /** @@ -15,14 +46,64 @@ export function truncateMiddle(str: string, maxLength = 28): string { return `${str.slice(0, keepStart)}…${str.slice(-keepEnd)}`; } +/** Adaptive FA2 worker config based on graph size and orphan ratio. + * Key fix: NO strongGravityMode, NO linLogMode — eliminates ring artifacts. */ +export function getFA2WorkerSettings(nodeCount: number, orphanRatio: number) { + // Scale up repulsion for larger graphs to prevent dense blobs + const baseScaling = nodeCount < 200 ? 5 : nodeCount < 1000 ? 10 : 20; + return { + settings: { + linLogMode: false, + outboundAttractionDistribution: true, + gravity: 0.5 + orphanRatio * 2.0, + scalingRatio: baseScaling + (1 - orphanRatio) * 5, + strongGravityMode: false, + slowDown: 5, + barnesHutOptimize: nodeCount > 50, + barnesHutTheta: 0.5, + edgeWeightInfluence: 0, + adjustSizes: true, + }, + durationMs: nodeCount < 200 ? 2000 : + nodeCount < 1000 ? 3500 : + nodeCount < 5000 ? 5000 : 8000, + }; +} + +/** Semantic zoom tiers — cameraRatio thresholds (lower = more zoomed in). + * Zoom % ≈ 100/ratio. ratio 1.0=100%, 0.5=200%, 0.25=400%. */ +export const ZOOM_TIERS = { + /** Fully zoomed out: nodes only, no edges, no labels */ + FAR: 0.6, + /** Intermediate: hub edges only */ + MID: 0.3, + /** Close: most edges visible */ + NEAR: 0.12, +} as const; + +/** Minimum degree for nodes to remain visible per tier */ +export const TIER_MIN_DEGREE = { + FAR: 2, + MID: 1, + NEAR: 0, +} as const; + +/** Minimum endpoint degree for edges per tier. + * Edges hidden when EITHER endpoint below threshold. */ +export const TIER_EDGE_DEGREE = { + FAR: Infinity, // no edges at default view + MID: 6, // only hub-to-hub + NEAR: 2, // most edges +} as const; + /** Sigma settings constants for consistent look across both graph views */ export const SIGMA_SETTINGS = { /** Labels hidden for nodes smaller than this on screen */ - labelRenderedSizeThreshold: 8, + labelRenderedSizeThreshold: 14, /** Lower = fewer labels shown (avoids overlap) */ - labelDensity: 0.12, + labelDensity: 0.04, /** Grid cell size for label collision avoidance */ - labelGridCellSize: 120, + labelGridCellSize: 200, /** Default edge color (overridden per-theme) */ defaultEdgeColor: "#334155", /** Minimum camera ratio (most zoomed in) */ diff --git a/ui/web/src/components/graph/sigma-graph-container.tsx b/ui/web/src/components/graph/sigma-graph-container.tsx index 069236d3..a3653ac4 100644 --- a/ui/web/src/components/graph/sigma-graph-container.tsx +++ b/ui/web/src/components/graph/sigma-graph-container.tsx @@ -2,11 +2,12 @@ import { useRef, useEffect, useCallback, useState } from "react"; import Sigma from "sigma"; import { EdgeCurvedArrowProgram } from "@sigma/edge-curve"; import { EdgeArrowProgram } from "sigma/rendering"; +import FA2Layout from "graphology-layout-forceatlas2/worker"; import forceAtlas2 from "graphology-layout-forceatlas2"; import noverlap from "graphology-layout-noverlap"; import type Graph from "graphology"; import { useUiStore } from "@/stores/use-ui-store"; -import { SIGMA_SETTINGS } from "./graph-utils"; +import { SIGMA_SETTINGS, getFA2WorkerSettings, ZOOM_TIERS, TIER_MIN_DEGREE, TIER_EDGE_DEGREE } from "./graph-utils"; import { getVaultNodeColor } from "@/adapters/vault-graph-adapter"; export type EdgeType = "curvedArrow" | "arrow"; @@ -19,28 +20,71 @@ export interface SigmaGraphContainerProps { onNodeDoubleClick?: (nodeId: string) => void; /** Called when Sigma instance is ready (or destroyed) */ onSigmaReady?: (sigma: Sigma | null) => void; + /** Called when FA2 layout starts/stops */ + onLayoutStateChange?: (running: boolean) => void; /** Compact mode for embedded mini-graphs (no layout, smaller labels) */ compact?: boolean; /** Node types to hide (from filter component) */ hiddenTypes?: Set; } -/** Theme-aware colors - uses DOM class check for reliability */ +/** Theme-aware colors — derived from store value, not DOM class. + * DOM class lags behind store update, causing stale reads on theme toggle. */ function useThemeColors() { - // Subscribe to store to trigger re-render when theme changes - useUiStore((s) => s.theme); - // Check DOM class for actual dark state (handles system mode correctly) - const isDark = typeof document !== "undefined" && document.documentElement.classList.contains("dark"); + const theme = useUiStore((s) => s.theme); + const systemDark = typeof window !== "undefined" + && window.matchMedia("(prefers-color-scheme: dark)").matches; + const isDark = theme === "dark" || (theme === "system" && systemDark); return { isDark, labelColor: isDark ? "#e2e8f0" : "#1e293b", - // Base edge color — subtle gray - edgeColor: isDark ? "#71717a" : "#d4d4d8", // zinc-500 dark, zinc-300 light - // Highlight for active neighborhood edges + edgeColor: isDark ? "#71717a" : "#d4d4d8", highlightEdgeColor: isDark ? "#d4d4d8" : "#3f3f46", }; } +/** Reposition orphan nodes (degree=0) into a compact ring around the connected cluster. + * Prevents FA2 from scattering them far away and forcing camera to zoom out. */ +function compactOrphans(graph: Graph) { + const connected: { x: number; y: number }[] = []; + const orphans: string[] = []; + + graph.forEachNode((node) => { + if (graph.degree(node) === 0) { + orphans.push(node); + } else { + connected.push({ + x: graph.getNodeAttribute(node, "x") as number, + y: graph.getNodeAttribute(node, "y") as number, + }); + } + }); + + if (orphans.length === 0 || connected.length === 0) return; + + // Compute bounding circle of connected nodes + let cx = 0, cy = 0; + for (const p of connected) { cx += p.x; cy += p.y; } + cx /= connected.length; + cy /= connected.length; + + let maxR = 0; + for (const p of connected) { + const d = Math.sqrt((p.x - cx) ** 2 + (p.y - cy) ** 2); + if (d > maxR) maxR = d; + } + + // Place orphans in a ring just outside the cluster (1.1-1.4x radius) + const innerR = maxR * 1.1; + const outerR = maxR * 1.4; + for (let i = 0; i < orphans.length; i++) { + const angle = (i / orphans.length) * Math.PI * 2 + Math.random() * 0.3; + const r = innerR + Math.random() * (outerR - innerR); + graph.setNodeAttribute(orphans[i], "x", cx + Math.cos(angle) * r); + graph.setNodeAttribute(orphans[i], "y", cy + Math.sin(angle) * r); + } +} + export function SigmaGraphContainer({ graph, edgeType = "arrow", @@ -48,11 +92,13 @@ export function SigmaGraphContainer({ onNodeSelect, onNodeDoubleClick, onSigmaReady, + onLayoutStateChange, compact = false, hiddenTypes, }: SigmaGraphContainerProps) { const containerRef = useRef(null); const internalSigmaRef = useRef(null); + const layoutRef = useRef(null); // Incremented when sigma instance changes — used to trigger event handler registration. const [sigmaVersion, setSigmaVersion] = useState(0); const [hoveredNode, setHoveredNode] = useState(null); @@ -60,6 +106,8 @@ export function SigmaGraphContainer({ const [pulsePhase, setPulsePhase] = useState(0); // Camera ratio for edge density control (lower = more zoomed out) const [cameraRatio, setCameraRatio] = useState(1); + // Layout running state + const [layoutRunning, setLayoutRunning] = useState(false); const { isDark, labelColor, edgeColor, highlightEdgeColor } = useThemeColors(); const setSigmaRef = useCallback( @@ -71,15 +119,30 @@ export function SigmaGraphContainer({ [onSigmaReady], ); - // --- Initialize Sigma with PRE-COMPUTED FA2 layout (no shake, no worker) --- + // Propagate layout state to parent + useEffect(() => { + onLayoutStateChange?.(layoutRunning); + }, [layoutRunning, onLayoutStateChange]); + + /** Stop running layout worker — no post-processing to avoid visible flash. + * Noverlap + compactOrphans already ran after the sync pass (phase 1). + * The worker only does subtle refinement, so positions are already good. */ + const stopLayout = useCallback((_sigma: Sigma, _orphanRatio: number) => { + const layout = layoutRef.current; + if (layout?.isRunning()) layout.stop(); + layoutRef.current = null; + setLayoutRunning(false); + }, []); + + // --- Initialize Sigma + FA2 worker layout (non-blocking) --- useEffect(() => { if (!containerRef.current || graph.order === 0) return; - // Unified adaptive FA2 layout — no density branching. - // Params scale with orphan ratio (disconnected nodes / total nodes). + // Random disc init — scale spread with canvas diagonal for responsive layout if (graph.order > 1) { - // Random disc init — uniform area distribution for all graph types. - const spread = Math.sqrt(graph.order) * 20; + const rect = containerRef.current.getBoundingClientRect(); + const canvasDiag = Math.sqrt(rect.width ** 2 + rect.height ** 2); + const spread = Math.sqrt(graph.order) * (canvasDiag / 25); const nodes = graph.nodes(); for (let i = 0; i < nodes.length; i++) { const angle = Math.random() * Math.PI * 2; @@ -87,42 +150,6 @@ export function SigmaGraphContainer({ graph.setNodeAttribute(nodes[i], "x", Math.cos(angle) * r); graph.setNodeAttribute(nodes[i], "y", Math.sin(angle) * r); } - - // Orphan ratio drives adaptive parameters: - // High orphan → stronger gravity (pull orphans to center), tighter layout - // Low orphan → link forces dominate, more spread for cluster readability - let orphanCount = 0; - for (const node of nodes) { - if (graph.degree(node) === 0) orphanCount++; - } - const orphanRatio = orphanCount / graph.order; - const gravity = 0.5 + orphanRatio * 2.0; - const scalingRatio = 4.0 - orphanRatio * 2.0; - const iterations = graph.order < 100 ? 500 : graph.order < 500 ? 300 : 200; - - forceAtlas2.assign(graph, { - iterations, - settings: { - linLogMode: true, - outboundAttractionDistribution: true, - gravity, - scalingRatio, - adjustSizes: true, - strongGravityMode: true, - slowDown: 8, - barnesHutOptimize: graph.order > 100, - edgeWeightInfluence: 0, - }, - }); - - // Noverlap only for connected-heavy graphs (prevents overlap in dense clusters). - // Skip for high-orphan graphs to avoid grid artifacts. - if (orphanRatio < 0.3) { - noverlap.assign(graph, { - maxIterations: 30, - settings: { margin: 2, ratio: 1.02, speed: 3, gridSize: 20 }, - }); - } } const edgePrograms: Record = { @@ -130,6 +157,7 @@ export function SigmaGraphContainer({ curvedArrow: EdgeCurvedArrowProgram as unknown as typeof EdgeArrowProgram, }; + // Create Sigma — shows nodes at random positions immediately const sigma = new Sigma(graph, containerRef.current, { allowInvalidContainer: true, renderLabels: true, @@ -145,28 +173,64 @@ export function SigmaGraphContainer({ labelFont: "Inter, system-ui, sans-serif", labelSize: compact ? 10 : 12, zoomingRatio: 1.3, - // Enable z-index sorting — required for edge/node `zIndex` attr to affect render order zIndex: true, - // Disable default hover label box (white background) — we use forceLabel instead defaultDrawNodeHover: () => undefined, }); setSigmaRef(sigma); - // Fit viewport to graph on next frame (after initial render) - requestAnimationFrame(() => { - sigma.getCamera().animatedReset({ duration: 300 }); - }); + // FA2 layout: sync rough pass first, then optional worker refinement. + // This gives a reasonable layout immediately — no random-scatter animation. + let timer: ReturnType | undefined; + if (graph.order > 1) { + let orphanCount = 0; + const nodes = graph.nodes(); + for (const node of nodes) { + if (graph.degree(node) === 0) orphanCount++; + } + const orphanRatio = orphanCount / graph.order; + const { settings } = getFA2WorkerSettings(graph.order, orphanRatio); + + // Phase 1: quick sync pass — rough but instant layout + const syncIters = graph.order < 200 ? 300 : graph.order < 1000 ? 100 : 50; + forceAtlas2.assign(graph, { iterations: syncIters, settings }); + compactOrphans(graph); + + const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + if (compact || prefersReducedMotion) { + // Compact/reduced-motion: sync-only, no worker + if (orphanRatio < 0.3) { + noverlap.assign(graph, { + maxIterations: 30, + settings: { margin: 2, ratio: 1.02, speed: 3, gridSize: 20 }, + }); + } + } else { + // Phase 2: worker refinement — subtle position tweaks, short duration + const refineDuration = graph.order < 500 ? 1500 : graph.order < 2000 ? 2500 : 4000; + const layout = new FA2Layout(graph, { settings }); + layoutRef.current = layout; + setLayoutRunning(true); + layout.start(); + + timer = setTimeout(() => { + stopLayout(sigma, orphanRatio); + }, refineDuration); + } + } return () => { + if (timer) clearTimeout(timer); + if (layoutRef.current?.isRunning()) layoutRef.current.stop(); + layoutRef.current = null; + setLayoutRunning(false); sigma.kill(); - // Only clear external ref if it still points to THIS sigma (concurrent mode safety) if (internalSigmaRef.current === sigma) { internalSigmaRef.current = null; onSigmaReady?.(null); } }; - }, [graph, edgeType, compact, setSigmaRef]); + }, [graph, edgeType, compact]); // --- Update theme colors without re-init --- useEffect(() => { @@ -216,7 +280,6 @@ export function SigmaGraphContainer({ (attrs.docType || attrs.entityType || "other") as string; sigma.setSetting("nodeReducer", (node, data) => { - // Apply theme-aware color based on docType const docType = getNodeType(data); const themedColor = getVaultNodeColor(docType, isDark); const themedData = { ...data, color: themedColor }; @@ -226,21 +289,30 @@ export function SigmaGraphContainer({ return { ...themedData, hidden: true }; } + // Active node neighborhood always visible regardless of zoom tier const activeNode = selectedNodeId || hoveredNode; - if (!activeNode) return themedData; - const hood = neighborhoodRef.current; - if (!hood) return themedData; - if (node === activeNode) { - // Active node: bring to top, show label - return { ...themedData, zIndex: 3, forceLabel: true }; + if (activeNode && hood) { + if (node === activeNode) { + return { ...themedData, zIndex: 3, forceLabel: true }; + } + if (hood.nodes.has(node)) { + return { ...themedData, zIndex: 2, forceLabel: true }; + } } - if (hood.nodes.has(node)) { - // Within 2-hop neighborhood: show label, slightly raised z-index - return { ...themedData, zIndex: 2, forceLabel: true }; + + // Semantic zoom: hide low-degree nodes when zoomed out + const degree = graph.degree(node); + if (cameraRatio > ZOOM_TIERS.FAR && degree < TIER_MIN_DEGREE.FAR) { + return { ...themedData, hidden: true }; } - // Outside neighborhood: keep color/size, no dimming — just lower z-index + if (cameraRatio > ZOOM_TIERS.MID && degree < TIER_MIN_DEGREE.MID) { + return { ...themedData, hidden: true }; + } + + if (activeNode && !hood) return themedData; + if (!activeNode) return themedData; return { ...themedData, zIndex: 0 }; }); @@ -256,40 +328,48 @@ export function SigmaGraphContainer({ const activeNode = selectedNodeId || hoveredNode; - // Zoom-based edge density: when zoomed out, hide edges from low-degree nodes - // cameraRatio < 1 means zoomed out; < 0.5 means very zoomed out - if (!activeNode && cameraRatio < 0.6) { - const srcDegree = graph.degree(graph.source(edge)); - const tgtDegree = graph.degree(graph.target(edge)); - // Progressive threshold: more zoomed out → hide more edges - const minDegree = cameraRatio < 0.3 ? 8 : cameraRatio < 0.45 ? 5 : 3; - if (srcDegree < minDegree && tgtDegree < minDegree) { + // Active node neighborhood: always show related edges + if (activeNode) { + const hood = neighborhoodRef.current; + if (!hood) return data; + if (hood.edges.has(edge)) { + const alpha = Math.round(200 + Math.sin(pulsePhase * Math.PI * 2) * 40); + const alphaHex = Math.max(0, Math.min(255, alpha)).toString(16).padStart(2, "0"); + return { ...data, color: `${highlightEdgeColor}${alphaHex}`, size: 1, zIndex: 2 }; + } + return { ...data, hidden: true }; + } + + // Semantic zoom edge culling (no active node). + // Default/overview: NO edges. Edges appear progressively as user zooms in. + const srcDegree = graph.degree(graph.source(edge)); + const tgtDegree = graph.degree(graph.target(edge)); + + if (cameraRatio > ZOOM_TIERS.FAR) { + // zoom ≤~170%: hide all edges + return { ...data, hidden: true }; + } + if (cameraRatio > ZOOM_TIERS.MID) { + // zoom ~170-330%: only hub-to-hub + if (srcDegree < TIER_EDGE_DEGREE.MID || tgtDegree < TIER_EDGE_DEGREE.MID) { return { ...data, hidden: true }; } + return { ...data, color: edgeColor, size: 0.3 }; } - - // No active node: use default edge color from graph attributes - if (!activeNode) { - return { ...data, color: edgeColor, size: 0.5 }; + if (cameraRatio > ZOOM_TIERS.NEAR) { + // zoom ~330-830%: most edges + if (srcDegree < TIER_EDGE_DEGREE.NEAR && tgtDegree < TIER_EDGE_DEGREE.NEAR) { + return { ...data, hidden: true }; + } + return { ...data, color: edgeColor, size: 0.4 }; } - - const hood = neighborhoodRef.current; - if (!hood) return data; - - if (hood.edges.has(edge)) { - // Edges within neighborhood: thin + subtle OPACITY pulse (not size). - const alpha = Math.round(200 + Math.sin(pulsePhase * Math.PI * 2) * 40); - const alphaHex = Math.max(0, Math.min(255, alpha)).toString(16).padStart(2, "0"); - const pulsedColor = `${highlightEdgeColor}${alphaHex}`; - return { ...data, color: pulsedColor, size: 1, zIndex: 2 }; - } - // Non-related edges: HIDE entirely so active edges are clearly visible - return { ...data, hidden: true }; + // zoom >830%: show all edges + return { ...data, color: edgeColor, size: 0.5 }; }); sigma.refresh(); // sigmaVersion ensures this runs after sigma is created - }, [selectedNodeId, hoveredNode, graph, highlightEdgeColor, edgeColor, hiddenTypes, isDark, cameraRatio, sigmaVersion]); + }, [selectedNodeId, hoveredNode, graph, highlightEdgeColor, edgeColor, hiddenTypes, cameraRatio, sigmaVersion, isDark]); // --- Pulse animation for highlighted edges (only runs when a node is active) --- // Respects prefers-reduced-motion — skips animation entirely for accessibility @@ -357,10 +437,32 @@ export function SigmaGraphContainer({ onNodeSelectRef.current?.(null); }; - // Track camera ratio for edge density control + // rAF-debounced camera handler with adaptive label density + let pendingRatio = 1; + let rafId = 0; const handleCameraUpdate = () => { - const ratio = sigma.getCamera().ratio; - setCameraRatio(ratio); + pendingRatio = sigma.getCamera().ratio; + if (!rafId) { + rafId = requestAnimationFrame(() => { + rafId = 0; + setCameraRatio(pendingRatio); + // Adaptive label density per zoom tier — fewer labels when zoomed out + if (pendingRatio > ZOOM_TIERS.FAR) { + // Default view: only biggest hubs get labels + sigma.setSetting("labelRenderedSizeThreshold", 18); + sigma.setSetting("labelDensity", 0.02); + } else if (pendingRatio > ZOOM_TIERS.MID) { + sigma.setSetting("labelRenderedSizeThreshold", 12); + sigma.setSetting("labelDensity", 0.06); + } else if (pendingRatio > ZOOM_TIERS.NEAR) { + sigma.setSetting("labelRenderedSizeThreshold", 8); + sigma.setSetting("labelDensity", 0.12); + } else { + sigma.setSetting("labelRenderedSizeThreshold", 5); + sigma.setSetting("labelDensity", 0.2); + } + }); + } }; sigma.on("enterNode", handleEnterNode); @@ -371,6 +473,7 @@ export function SigmaGraphContainer({ sigma.getCamera().on("updated", handleCameraUpdate); return () => { + if (rafId) cancelAnimationFrame(rafId); sigma.off("enterNode", handleEnterNode); sigma.off("leaveNode", handleLeaveNode); sigma.off("clickNode", handleClickNode); @@ -384,6 +487,17 @@ export function SigmaGraphContainer({ // Camera only animates for explicit user actions (search, fit-to-view, keyboard F). // This matches the old force-graph behavior where clicking just highlights. + const handleStopLayout = useCallback(() => { + const sigma = internalSigmaRef.current; + if (!sigma) return; + let orphanCount = 0; + const nodes = graph.nodes(); + for (const node of nodes) { + if (graph.degree(node) === 0) orphanCount++; + } + stopLayout(sigma, orphanCount / (graph.order || 1)); + }, [graph, stopLayout]); + // No-data state if (graph.order === 0) { return ( @@ -394,10 +508,22 @@ export function SigmaGraphContainer({ } return ( -
+
+
+ {layoutRunning && !compact && ( +
+
+ + Laying out… +
+ +
+ )} +
); } diff --git a/ui/web/src/components/graph/sigma-graph-controls.tsx b/ui/web/src/components/graph/sigma-graph-controls.tsx index 2276e51a..df293fcb 100644 --- a/ui/web/src/components/graph/sigma-graph-controls.tsx +++ b/ui/web/src/components/graph/sigma-graph-controls.tsx @@ -3,7 +3,7 @@ import type Sigma from "sigma"; import { ZoomIn, ZoomOut, Maximize2 } from "lucide-react"; import { Button } from "@/components/ui/button"; -const NODE_LIMIT_OPTIONS = [100, 200, 500, 1000, 5000] as const; +const NODE_LIMIT_OPTIONS = [100, 200, 500, 1000, 2000, 5000] as const; interface SigmaGraphControlsProps { sigma: Sigma | null; diff --git a/ui/web/src/hooks/use-kg-graph-compact.ts b/ui/web/src/hooks/use-kg-graph-compact.ts new file mode 100644 index 00000000..a9160b97 --- /dev/null +++ b/ui/web/src/hooks/use-kg-graph-compact.ts @@ -0,0 +1,35 @@ +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useHttp } from "@/hooks/use-ws"; +import type { KGGraphCompactResponse } from "@/types/graph-dto"; + +const KG_GRAPH_KEY = "kg-graph-compact"; + +/** Fetches KG graph from dedicated compact endpoint. */ +export function useKGGraphCompact(agentId: string, opts?: { userId?: string; limit?: number }) { + const http = useHttp(); + const limit = opts?.limit ?? 2000; + + const params = useMemo(() => { + const p: Record = { limit: String(limit) }; + if (opts?.userId) p.user_id = opts.userId; + return p; + }, [opts?.userId, limit]); + + const { data, isLoading } = useQuery({ + queryKey: [KG_GRAPH_KEY, agentId, params], + queryFn: () => http.get( + `/v1/agents/${agentId}/kg/graph/compact`, params, + ), + staleTime: 60_000, + enabled: !!agentId, + }); + + return { + nodes: data?.nodes ?? [], + edges: data?.edges ?? [], + totalNodes: data?.total_nodes ?? 0, + totalEdges: data?.total_edges ?? 0, + loading: isLoading, + }; +} diff --git a/ui/web/src/hooks/use-vault-graph-data.ts b/ui/web/src/hooks/use-vault-graph-data.ts new file mode 100644 index 00000000..229c876f --- /dev/null +++ b/ui/web/src/hooks/use-vault-graph-data.ts @@ -0,0 +1,39 @@ +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useHttp } from "@/hooks/use-ws"; +import type { VaultGraphResponse } from "@/types/graph-dto"; + +const VAULT_GRAPH_KEY = "vault-graph"; + +interface VaultGraphOpts { + teamId?: string; + limit?: number; +} + +/** Fetches vault graph data from dedicated lightweight endpoint. + * Single request returns nodes (with degree) + edges. */ +export function useVaultGraphData(agentId: string, opts?: VaultGraphOpts) { + const http = useHttp(); + const limit = opts?.limit ?? 2000; + + const params = useMemo(() => { + const p: Record = { limit: String(limit) }; + if (agentId) p.agent_id = agentId; + if (opts?.teamId) p.team_id = opts.teamId; + return p; + }, [agentId, opts?.teamId, limit]); + + const { data, isLoading } = useQuery({ + queryKey: [VAULT_GRAPH_KEY, params], + queryFn: () => http.get("/v1/vault/graph", params), + staleTime: 60_000, + }); + + return { + nodes: data?.nodes ?? [], + edges: data?.edges ?? [], + totalNodes: data?.total_nodes ?? 0, + totalEdges: data?.total_edges ?? 0, + loading: isLoading, + }; +} diff --git a/ui/web/src/pages/memory/knowledge-graph/kg-graph-view.tsx b/ui/web/src/pages/memory/knowledge-graph/kg-graph-view.tsx index 283725a7..dd4018c7 100644 --- a/ui/web/src/pages/memory/knowledge-graph/kg-graph-view.tsx +++ b/ui/web/src/pages/memory/knowledge-graph/kg-graph-view.tsx @@ -11,7 +11,7 @@ import { SigmaGraphMinimap } from "@/components/graph/sigma-graph-minimap"; import { SigmaGraphKeyboardHelp } from "@/components/graph/sigma-graph-keyboard-help"; import { useSigmaKeyboard } from "@/components/graph/use-sigma-keyboard"; -const DEFAULT_NODE_LIMIT = 200; +const DEFAULT_NODE_LIMIT = 2000; interface KGGraphViewProps { entities: KGEntity[]; diff --git a/ui/web/src/pages/vault/components/vault-tree.tsx b/ui/web/src/pages/vault/components/vault-tree.tsx index aa9b8d8d..65704195 100644 --- a/ui/web/src/pages/vault/components/vault-tree.tsx +++ b/ui/web/src/pages/vault/components/vault-tree.tsx @@ -40,6 +40,8 @@ export interface VaultTreeProps { activePath: string | null; onSelect: (path: string) => void; onLoadMore: (path: string) => void; + /** Incremented on each loadRoot — forces re-mount to reset auto-expand state */ + treeVersion: number; } function VaultTreeNode({ @@ -142,7 +144,7 @@ function VaultTreeNode({ ); } -export function VaultTree({ tree, meta, loading, activePath, onSelect, onLoadMore }: VaultTreeProps) { +export function VaultTree({ tree, meta, loading, activePath, onSelect, onLoadMore, treeVersion }: VaultTreeProps) { if (loading && tree.length === 0) { return (
@@ -160,7 +162,7 @@ export function VaultTree({ tree, meta, loading, activePath, onSelect, onLoadMor return (
{tree.map((node) => ( - ))}
diff --git a/ui/web/src/pages/vault/hooks/use-vault-tree.ts b/ui/web/src/pages/vault/hooks/use-vault-tree.ts index 7f1403b7..17c0131c 100644 --- a/ui/web/src/pages/vault/hooks/use-vault-tree.ts +++ b/ui/web/src/pages/vault/hooks/use-vault-tree.ts @@ -34,6 +34,8 @@ export function useVaultTree(filter: VaultTreeFilter = {}) { const [tree, setTree] = useState([]); const [meta, setMeta] = useState>(new Map()); const [loading, setLoading] = useState(false); + // Incremented on each loadRoot — forces VaultTreeNode re-mount to reset didAutoLoad + const [treeVersion, setTreeVersion] = useState(0); const buildParams = useCallback( (extraPath?: string): Record => { @@ -53,10 +55,15 @@ export function useVaultTree(filter: VaultTreeFilter = {}) { try { const res = await http.get("/v1/vault/tree", buildParams()); const entries = res.entries ?? []; - const m = new Map(); - for (const e of entries) m.set(e.path, e); - setMeta(m); + // Merge into existing meta so subtree entries from loadSubtree survive. + // Stale entries from old filters are harmless — no tree node exists to click them. + setMeta((prev) => { + const next = new Map(prev); + for (const e of entries) next.set(e.path, e); + return next; + }); setTree(buildTree(toTreeInputs(entries))); + setTreeVersion((v) => v + 1); } finally { setLoading(false); } @@ -78,5 +85,5 @@ export function useVaultTree(filter: VaultTreeFilter = {}) { } }, [http, buildParams]); - return { tree, meta, loading, loadRoot, loadSubtree }; + return { tree, meta, loading, loadRoot, loadSubtree, treeVersion }; } diff --git a/ui/web/src/pages/vault/vault-document-sidebar.tsx b/ui/web/src/pages/vault/vault-document-sidebar.tsx index 6cca2498..54b350b1 100644 --- a/ui/web/src/pages/vault/vault-document-sidebar.tsx +++ b/ui/web/src/pages/vault/vault-document-sidebar.tsx @@ -34,6 +34,7 @@ interface Props { onDocTypeChange: (type: string) => void; agentId: string; teamId: string; + treeVersion: number; } function SearchResultCard({ doc, onClick }: { doc: VaultDocument; onClick: () => void }) { @@ -57,7 +58,7 @@ function SearchResultCard({ doc, onClick }: { doc: VaultDocument; onClick: () => export function VaultDocumentSidebar({ tree, meta, selectedPath, onSelect, onLoadMore, - loading, docType, onDocTypeChange, agentId, teamId, + loading, docType, onDocTypeChange, agentId, teamId, treeVersion, }: Props) { const { t } = useTranslation("vault"); const [query, setQuery] = useState(""); @@ -153,7 +154,7 @@ export function VaultDocumentSidebar({ ) ) : ( + activePath={selectedPath} onSelect={onSelect} onLoadMore={onLoadMore} treeVersion={treeVersion} /> )}
diff --git a/ui/web/src/pages/vault/vault-graph-view.tsx b/ui/web/src/pages/vault/vault-graph-view.tsx index 3c2a7563..a840e008 100644 --- a/ui/web/src/pages/vault/vault-graph-view.tsx +++ b/ui/web/src/pages/vault/vault-graph-view.tsx @@ -1,7 +1,7 @@ import { useRef, useMemo, useState, useCallback } from "react"; import type Sigma from "sigma"; import { useTranslation } from "react-i18next"; -import { buildVaultGraph, limitVaultDocsByDegree, VAULT_TYPE_COLORS_LIGHT, VAULT_TYPE_COLORS_DARK } from "@/adapters/vault-graph-adapter"; +import { buildVaultGraphFromDTO, VAULT_TYPE_COLORS_LIGHT, VAULT_TYPE_COLORS_DARK } from "@/adapters/vault-graph-adapter"; import { useUiStore } from "@/stores/use-ui-store"; import { SigmaGraphContainer } from "@/components/graph/sigma-graph-container"; import { SigmaGraphControls } from "@/components/graph/sigma-graph-controls"; @@ -10,17 +10,16 @@ import { SigmaGraphFilters } from "@/components/graph/sigma-graph-filters"; import { SigmaGraphMinimap } from "@/components/graph/sigma-graph-minimap"; import { SigmaGraphKeyboardHelp } from "@/components/graph/sigma-graph-keyboard-help"; import { useSigmaKeyboard } from "@/components/graph/use-sigma-keyboard"; -import type { VaultDocument } from "@/types/vault"; -import { useVaultGraphData } from "./hooks/use-vault"; +import { useVaultGraphData } from "@/hooks/use-vault-graph-data"; -const DEFAULT_NODE_LIMIT = 200; +const DEFAULT_NODE_LIMIT = 2000; interface Props { agentId: string; teamId?: string; selectedDocId?: string | null; onNodeSelect?: (docId: string | null) => void; - onNodeDoubleClick?: (doc: VaultDocument) => void; + onNodeDoubleClick?: (nodeId: string) => void; } export function VaultGraphView({ agentId, teamId, selectedDocId, onNodeSelect, onNodeDoubleClick }: Props) { @@ -32,28 +31,25 @@ export function VaultGraphView({ agentId, teamId, selectedDocId, onNodeSelect, o const [hiddenTypes, setHiddenTypes] = useState>(new Set()); const [filtersOpen, setFiltersOpen] = useState(false); - // Theme-aware node colors - subscribe to store for re-render, check DOM for actual state - useUiStore((s) => s.theme); - const isDark = typeof document !== "undefined" && document.documentElement.classList.contains("dark"); + // Theme-aware node colors — derive from store, not DOM class (avoids stale reads) + const theme = useUiStore((s) => s.theme); + const isDark = theme === "dark" || (theme === "system" + && typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches); const typeColors = isDark ? VAULT_TYPE_COLORS_DARK : VAULT_TYPE_COLORS_LIGHT; - const { documents: allDocs, links, loading } = useVaultGraphData(agentId, { teamId }); + const { nodes, edges, totalNodes, totalEdges, loading } = useVaultGraphData(agentId, { teamId, limit: nodeLimit }); - const totalCount = allDocs.length; - const isLimited = totalCount > nodeLimit; - const documents = useMemo(() => limitVaultDocsByDegree(allDocs, links, nodeLimit), [allDocs, links, nodeLimit]); - const docMap = useMemo(() => new Map(documents.map((d) => [d.id, d])), [documents]); - // Only build graph when ALL data loaded — prevents double-render - // (orphan-only layout → with-links layout) that causes visual chaos. + const isLimited = totalNodes > nodeLimit; + const nodeIds = useMemo(() => new Set(nodes.map((n) => n.id)), [nodes]); + // Only build graph when data loaded — prevents double-render const graph = useMemo( - () => loading ? buildVaultGraph([], []) : buildVaultGraph(documents, links), - [documents, links, loading], + () => loading ? buildVaultGraphFromDTO([], []) : buildVaultGraphFromDTO(nodes, edges), + [nodes, edges, loading], ); const handleNodeDoubleClick = useCallback((nodeId: string) => { - const doc = docMap.get(nodeId); - if (doc) onNodeDoubleClick?.(doc); - }, [docMap, onNodeDoubleClick]); + if (nodeIds.has(nodeId)) onNodeDoubleClick?.(nodeId); + }, [nodeIds, onNodeDoubleClick]); useSigmaKeyboard({ sigma, @@ -64,14 +60,14 @@ export function VaultGraphView({ agentId, teamId, selectedDocId, onNodeSelect, o searchInputRef, }); - const hasData = allDocs.length > 0; + const hasData = nodes.length > 0; return (
{/* Top bar — responsive: stacks on narrow screens */} @@ -107,7 +103,7 @@ export function VaultGraphView({ agentId, teamId, selectedDocId, onNodeSelect, o {/* Graph canvas + minimap overlay */}
- {loading && allDocs.length === 0 ? ( + {loading && nodes.length === 0 ? (
) : !hasData ? (
No documents
@@ -136,10 +132,10 @@ export function VaultGraphView({ agentId, teamId, selectedDocId, onNodeSelect, o isLimited={isLimited} onNodeLimitChange={setNodeLimit} labels={{ - nodes: t("graphDocs", { count: totalCount, defaultValue: "{{count}} docs" }), - edges: t("graphLinks", { count: links.length, defaultValue: "{{count}} links" }), + nodes: t("graphDocs", { count: totalNodes, defaultValue: "{{count}} docs" }), + edges: t("graphLinks", { count: totalEdges, defaultValue: "{{count}} links" }), limitNote: isLimited - ? t("graphLimitNote", { limit: nodeLimit, total: totalCount, defaultValue: "showing {{limit}} of {{total}}" }) + ? t("graphLimitNote", { limit: nodeLimit, total: totalNodes, defaultValue: "showing {{limit}} of {{total}}" }) : undefined, }} /> diff --git a/ui/web/src/pages/vault/vault-page.tsx b/ui/web/src/pages/vault/vault-page.tsx index 88b3bb68..aa7a7560 100644 --- a/ui/web/src/pages/vault/vault-page.tsx +++ b/ui/web/src/pages/vault/vault-page.tsx @@ -67,7 +67,7 @@ export function VaultPage() { doc_type: docType || undefined, team_id: selectedTeam || undefined, }), [selectedAgent, docType, selectedTeam]); - const { tree, meta, loading, loadRoot, loadSubtree } = useVaultTree(treeFilter); + const { tree, meta, loading, loadRoot, loadSubtree, treeVersion } = useVaultTree(treeFilter); useEffect(() => { loadRoot(); }, [loadRoot]); @@ -79,7 +79,10 @@ export function VaultPage() { const handleTreeSelect = useCallback(async (path: string) => { setSelectedPath(path); const entry = meta.get(path); - if (!entry?.docId) return; + if (!entry?.docId) { + console.warn("[vault] meta missing docId for path:", path); + return; + } setSelectedDocId(entry.docId); try { const doc = await http.get(`/v1/vault/documents/${entry.docId}`); @@ -93,11 +96,14 @@ export function VaultPage() { setSelectedDocId(docId); }, []); - // Graph double-click → open detail modal + highlight - const handleNodeDoubleClick = useCallback((doc: VaultDocument) => { - setDetailDoc(doc); - setSelectedDocId(doc.id); - }, []); + // Graph double-click → fetch full doc for detail modal + const handleNodeDoubleClick = useCallback(async (nodeId: string) => { + setSelectedDocId(nodeId); + try { + const doc = await http.get(`/v1/vault/documents/${nodeId}`); + setDetailDoc(doc); + } catch { /* http layer handles toast */ } + }, [http]); const handleCloseDetail = () => { setDetailDoc(null); }; @@ -124,6 +130,7 @@ export function VaultPage() { onDocTypeChange={handleDocTypeChange} agentId={selectedAgent} teamId={selectedTeam} + treeVersion={treeVersion} />
diff --git a/ui/web/src/types/graph-dto.ts b/ui/web/src/types/graph-dto.ts new file mode 100644 index 00000000..71b8cc8e --- /dev/null +++ b/ui/web/src/types/graph-dto.ts @@ -0,0 +1,48 @@ +/** Lightweight vault node from GET /v1/vault/graph (short keys match backend JSON) */ +export interface VaultGraphNode { + id: string; + t: string; // title + p: string; // path + dt: string; // doc_type + deg: number; // degree (pre-computed server-side) +} + +/** Lightweight vault edge from GET /v1/vault/graph */ +export interface VaultGraphEdge { + id: string; + from: string; + to: string; + type: string; +} + +/** GET /v1/vault/graph response */ +export interface VaultGraphResponse { + nodes: VaultGraphNode[]; + edges: VaultGraphEdge[]; + total_nodes: number; + total_edges: number; +} + +/** Lightweight KG node from GET /v1/agents/{id}/kg/graph/compact */ +export interface KGGraphNode { + id: string; + n: string; // name + t: string; // entity_type + c: number; // confidence +} + +/** Lightweight KG edge from GET /v1/agents/{id}/kg/graph/compact */ +export interface KGGraphEdge { + id: string; + src: string; + tgt: string; + type: string; +} + +/** GET /v1/agents/{id}/kg/graph/compact response */ +export interface KGGraphCompactResponse { + nodes: KGGraphNode[]; + edges: KGGraphEdge[]; + total_nodes: number; + total_edges: number; +}