Files
goclaw/internal/http/vault_graph_handler.go
T
viettranx 5a86c18402 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
2026-04-13 10:59:27 +07:00

199 lines
5.4 KiB
Go

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
}