mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-12 19:04:49 +00:00
5a86c18402
- 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
199 lines
5.4 KiB
Go
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
|
|
}
|