mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-15 02:16:56 +00:00
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:
1 parent
c06d9d9705
commit
5a86c18402
28 files changed
+1543
-157
No files matched your search
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
@@ -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) }
|
||||
@@ -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)
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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) }
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Generated
+27
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user