feat(vault): optimize graph visualization and fix sidebar state bugs

- Sigma.js graph: restore doc_type coloring (revert Louvain community detection)
- Fix animation flash after FA2 layout finishes by removing post-processing
  camera reset and redundant noverlap/compactOrphans in stopLayout
- Fix vault tree "Load more" state bug when filtering by doc_type:
  add treeVersion counter to force re-mount and reset auto-expand state
- Fix meta map loss: loadRoot now merges instead of replacing, preserving
  subtree entries from previous loadSubtree calls
- Add compact graph DTO endpoints and hooks for KG and vault graphs
- Add semantic zoom tiers, adaptive FA2 settings, and node sizing
This commit is contained in:
viettranx committed 2026-04-13 10:59:27 +07:00
1 parent c06d9d9705
commit 5a86c18402
28 files changed
+1543 -157

No files matched your search

+10
View File
@@ -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
+13
View File
@@ -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) }
+13
View File
@@ -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) }
+6
View File
@@ -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)
+70 -2
View File
@@ -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 |
+5
View File
@@ -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) }
+198
View File
@@ -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
}
+112
View File
@@ -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
}
+188
View File
@@ -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
}
+105
View File
@@ -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
}
+177
View File
@@ -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
}
+59
View File
@@ -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)
}
+1
View File
@@ -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",
+27
View File
@@ -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
+35 -1
View File
@@ -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<string, number>();
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,
});
+29 -1
View File
@@ -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,
+88 -7
View File
@@ -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) */
@@ -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<string>;
}
/** 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<HTMLDivElement>(null);
const internalSigmaRef = useRef<Sigma | null>(null);
const layoutRef = useRef<FA2Layout | null>(null);
// Incremented when sigma instance changes — used to trigger event handler registration.
const [sigmaVersion, setSigmaVersion] = useState(0);
const [hoveredNode, setHoveredNode] = useState<string | null>(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<string, typeof EdgeArrowProgram> = {
@@ -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<typeof setTimeout> | 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 (
<div
ref={containerRef}
className="h-full w-full"
style={{ minHeight: compact ? 200 : 300 }}
/>
<div className="relative h-full w-full" style={{ minHeight: compact ? 200 : 300 }}>
<div ref={containerRef} className="h-full w-full" />
{layoutRunning && !compact && (
<div className="absolute top-2 left-2 z-10 flex items-center gap-2">
<div className="flex items-center gap-1.5 rounded-md bg-background/80 px-2 py-1 text-xs text-muted-foreground backdrop-blur-sm">
<span className="h-2 w-2 animate-pulse rounded-full bg-blue-500" />
Laying out…
</div>
<button
onClick={handleStopLayout}
className="rounded-md bg-background/80 px-2 py-1 text-xs text-muted-foreground backdrop-blur-sm hover:bg-muted cursor-pointer"
>
Stop
</button>
</div>
)}
</div>
);
}
@@ -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;
+35
View File
@@ -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<string, string> = { 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<KGGraphCompactResponse>(
`/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,
};
}
+39
View File
@@ -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<string, string> = { 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<VaultGraphResponse>("/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,
};
}
@@ -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[];
@@ -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 (
<div className="flex items-center justify-center py-8">
@@ -160,7 +162,7 @@ export function VaultTree({ tree, meta, loading, activePath, onSelect, onLoadMor
return (
<div className="flex-1 min-h-0">
{tree.map((node) => (
<VaultTreeNode key={node.path} node={node} depth={0}
<VaultTreeNode key={`${treeVersion}:${node.path}`} node={node} depth={0}
meta={meta} activePath={activePath} onSelect={onSelect} onLoadMore={onLoadMore} />
))}
</div>
+11 -4
View File
@@ -34,6 +34,8 @@ export function useVaultTree(filter: VaultTreeFilter = {}) {
const [tree, setTree] = useState<TreeNode[]>([]);
const [meta, setMeta] = useState<Map<string, VaultTreeEntry>>(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<string, string> => {
@@ -53,10 +55,15 @@ export function useVaultTree(filter: VaultTreeFilter = {}) {
try {
const res = await http.get<VaultTreeResponse>("/v1/vault/tree", buildParams());
const entries = res.entries ?? [];
const m = new Map<string, VaultTreeEntry>();
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 };
}
@@ -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({
)
) : (
<VaultTree tree={tree} meta={meta} loading={loading}
activePath={selectedPath} onSelect={onSelect} onLoadMore={onLoadMore} />
activePath={selectedPath} onSelect={onSelect} onLoadMore={onLoadMore} treeVersion={treeVersion} />
)}
</div>
</div>
+22 -26
View File
@@ -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<Set<string>>(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 (
<div
ref={containerRef}
tabIndex={0}
role="application"
aria-label={`Vault knowledge graph with ${totalCount} documents and ${links.length} links`}
aria-label={`Vault knowledge graph with ${totalNodes} documents and ${totalEdges} links`}
className="flex h-full flex-col overflow-hidden bg-background outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
>
{/* Top bar — responsive: stacks on narrow screens */}
@@ -107,7 +103,7 @@ export function VaultGraphView({ agentId, teamId, selectedDocId, onNodeSelect, o
{/* Graph canvas + minimap overlay */}
<div className="min-h-0 flex-1 relative">
{loading && allDocs.length === 0 ? (
{loading && nodes.length === 0 ? (
<div className="h-full animate-pulse rounded-md bg-muted" />
) : !hasData ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">No documents</div>
@@ -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,
}}
/>
+14 -7
View File
@@ -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<VaultDocument>(`/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<VaultDocument>(`/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}
/>
</div>
+48
View File
@@ -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;
}