diff --git a/internal/http/vault_handlers.go b/internal/http/vault_handlers.go index 45e13b3b..06171984 100644 --- a/internal/http/vault_handlers.go +++ b/internal/http/vault_handlers.go @@ -116,6 +116,7 @@ func (h *VaultHandler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /v1/vault/documents/{docID}/links", h.auth(h.handleGetLinks)) mux.HandleFunc("POST /v1/vault/links", h.auth(h.handleCreateLink)) mux.HandleFunc("DELETE /v1/vault/links/{linkID}", h.auth(h.handleDeleteLink)) + mux.HandleFunc("POST /v1/vault/links/batch", h.auth(h.handleBatchGetLinks)) mux.HandleFunc("POST /v1/vault/rescan", h.auth(h.handleRescan)) mux.HandleFunc("POST /v1/vault/search", h.auth(h.handleSearchAll)) mux.HandleFunc("GET /v1/vault/enrichment/status", h.auth(h.handleEnrichmentStatus)) @@ -602,6 +603,37 @@ func (h *VaultHandler) handleCreateLink(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusCreated, link) } +// handleBatchGetLinks returns all outlinks for a batch of doc IDs in one query. +func (h *VaultHandler) handleBatchGetLinks(w http.ResponseWriter, r *http.Request) { + locale := extractLocale(r) + tenantID := store.TenantIDFromContext(r.Context()) + + var body struct { + DocIDs []string `json:"doc_ids"` + } + if !bindJSON(w, r, locale, &body) { + return + } + if len(body.DocIDs) == 0 { + writeJSON(w, http.StatusOK, []store.VaultLink{}) + return + } + if len(body.DocIDs) > 500 { + body.DocIDs = body.DocIDs[:500] + } + + links, err := h.store.GetOutLinksBatch(r.Context(), tenantID.String(), body.DocIDs) + if err != nil { + slog.Warn("vault.batch_links failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if links == nil { + links = []store.VaultLink{} + } + writeJSON(w, http.StatusOK, links) +} + // handleDeleteLink deletes a vault link. func (h *VaultHandler) handleDeleteLink(w http.ResponseWriter, r *http.Request) { tenantID := store.TenantIDFromContext(r.Context()) diff --git a/internal/store/pg/vault_links.go b/internal/store/pg/vault_links.go index 14df122c..66ef4fb8 100644 --- a/internal/store/pg/vault_links.go +++ b/internal/store/pg/vault_links.go @@ -88,6 +88,25 @@ func (s *PGVaultStore) GetOutLinks(ctx context.Context, tenantID, docID string) return scanVaultLinks(rows) } +// GetOutLinksBatch returns all outlinks for multiple doc IDs in a single query. +func (s *PGVaultStore) GetOutLinksBatch(ctx context.Context, tenantID string, docIDs []string) ([]store.VaultLink, error) { + if len(docIDs) == 0 { + return nil, nil + } + tid := mustParseUUID(tenantID) + rows, err := s.db.QueryContext(ctx, ` + SELECT vl.id, vl.from_doc_id, vl.to_doc_id, vl.link_type, vl.context, vl.created_at + FROM vault_links vl + JOIN vault_documents vd ON vl.from_doc_id = vd.id + WHERE vl.from_doc_id = ANY($1) AND vd.tenant_id = $2 + ORDER BY vl.created_at`, pqStringArray(docIDs), tid) + if err != nil { + return nil, err + } + defer rows.Close() + return scanVaultLinks(rows) +} + // GetBacklinks returns enriched backlinks pointing to a document (single JOIN, LIMIT 100). func (s *PGVaultStore) GetBacklinks(ctx context.Context, tenantID, docID string) ([]store.VaultBacklink, error) { did := mustParseUUID(docID) diff --git a/internal/store/sqlitestore/vault_links.go b/internal/store/sqlitestore/vault_links.go index 11abe711..65d3f186 100644 --- a/internal/store/sqlitestore/vault_links.go +++ b/internal/store/sqlitestore/vault_links.go @@ -78,6 +78,30 @@ func (s *SQLiteVaultStore) GetOutLinks(ctx context.Context, tenantID, docID stri return scanVaultLinkRows(rows) } +// GetOutLinksBatch returns all outlinks for multiple doc IDs in a single query. +func (s *SQLiteVaultStore) GetOutLinksBatch(ctx context.Context, tenantID string, docIDs []string) ([]store.VaultLink, error) { + if len(docIDs) == 0 { + return nil, nil + } + ph := strings.Repeat("?,", len(docIDs)-1) + "?" + args := make([]any, 0, len(docIDs)+1) + for _, id := range docIDs { + args = append(args, id) + } + args = append(args, tenantID) + rows, err := s.db.QueryContext(ctx, + `SELECT vl.id, vl.from_doc_id, vl.to_doc_id, vl.link_type, vl.context, vl.created_at + FROM vault_links vl + JOIN vault_documents d ON d.id = vl.from_doc_id + WHERE vl.from_doc_id IN (`+ph+`) AND d.tenant_id = ? + ORDER BY vl.created_at`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + return scanVaultLinkRows(rows) +} + // GetBacklinks returns enriched backlinks pointing to a document (single JOIN, LIMIT 100). func (s *SQLiteVaultStore) GetBacklinks(ctx context.Context, tenantID, docID string) ([]store.VaultBacklink, error) { rows, err := s.db.QueryContext(ctx, ` diff --git a/internal/store/vault_store.go b/internal/store/vault_store.go index cd9d8e33..4db4ba12 100644 --- a/internal/store/vault_store.go +++ b/internal/store/vault_store.go @@ -90,6 +90,7 @@ type VaultStore interface { CreateLink(ctx context.Context, link *VaultLink) error DeleteLink(ctx context.Context, tenantID, id string) error GetOutLinks(ctx context.Context, tenantID, docID string) ([]VaultLink, error) + GetOutLinksBatch(ctx context.Context, tenantID string, docIDs []string) ([]VaultLink, error) GetBacklinks(ctx context.Context, tenantID, docID string) ([]VaultBacklink, error) DeleteDocLinks(ctx context.Context, tenantID, docID string) error DeleteDocLinksByType(ctx context.Context, tenantID, docID, linkType string) error diff --git a/ui/web/src/components/graph/sigma-graph-container.tsx b/ui/web/src/components/graph/sigma-graph-container.tsx index 613eeb6d..7d80fe15 100644 --- a/ui/web/src/components/graph/sigma-graph-container.tsx +++ b/ui/web/src/components/graph/sigma-graph-container.tsx @@ -76,97 +76,87 @@ export function SigmaGraphContainer({ useEffect(() => { if (!containerRef.current || graph.order === 0) return; - // Step 1: Detect communities with Louvain, then seed nodes spatially per community. - // This breaks the "ring" pattern that FA2 + circular init produces on sparse graphs. + // Layout: community-seeded FA2 for dense graphs, random+FA2 for sparse. if (graph.order > 1) { - // Detect communities — Louvain returns { nodeId: communityId } - const communities = louvain(graph, { resolution: 1 }); + const edgeDensity = graph.size / graph.order; // edges per node - // Group nodes by community - const communityGroups = new Map(); - for (const node of graph.nodes()) { - const c = communities[node] ?? 0; - if (!communityGroups.has(c)) communityGroups.set(c, []); - communityGroups.get(c)!.push(node); + if (edgeDensity >= 0.8) { + // Dense graph: Louvain community detection → grid seed → FA2 refinement. + const communities = louvain(graph, { resolution: 1 }); + const communityGroups = new Map(); + for (const node of graph.nodes()) { + const c = communities[node] ?? 0; + if (!communityGroups.has(c)) communityGroups.set(c, []); + communityGroups.get(c)!.push(node); + } + const communityIds = Array.from(communityGroups.keys()) + .sort((a, b) => communityGroups.get(b)!.length - communityGroups.get(a)!.length); + const numCommunities = communityIds.length; + const maxCommunitySize = Math.max( + ...Array.from(communityGroups.values(), (nodes) => nodes.length), + ); + const cellSize = Math.max(Math.sqrt(maxCommunitySize) * 28, 140); + const cols = Math.ceil(Math.sqrt(numCommunities * 1.4)); + const gridWidth = cols * cellSize; + const gridHeight = Math.ceil(numCommunities / cols) * cellSize; + const jitter = (seed: number) => { + const x = Math.sin(seed * 9999) * 10000; + return (x - Math.floor(x)) - 0.5; + }; + communityIds.forEach((cId, idx) => { + const nodes = communityGroups.get(cId)!; + const col = idx % cols; + const row = Math.floor(idx / cols); + const cx = col * cellSize - gridWidth / 2 + cellSize / 2 + jitter(idx) * cellSize * 0.2; + const cy = row * cellSize - gridHeight / 2 + cellSize / 2 + jitter(idx + 1000) * cellSize * 0.2; + const localRadius = Math.max(Math.sqrt(nodes.length) * 12, 25); + nodes.forEach((nodeId, i) => { + const angle = (i / nodes.length) * Math.PI * 2; + const r = localRadius * (0.6 + Math.abs(jitter(i + idx * 100)) * 0.7); + graph.setNodeAttribute(nodeId, "x", cx + Math.cos(angle) * r); + graph.setNodeAttribute(nodeId, "y", cy + Math.sin(angle) * r); + }); + }); + } else { + // Sparse graph: random disc init with uniform area distribution. + const spread = Math.sqrt(graph.order) * 20; + const nodes = graph.nodes(); + for (let i = 0; i < nodes.length; i++) { + const angle = Math.random() * Math.PI * 2; + const r = Math.sqrt(Math.random()) * spread; + graph.setNodeAttribute(nodes[i], "x", Math.cos(angle) * r); + graph.setNodeAttribute(nodes[i], "y", Math.sin(angle) * r); + } } - // Sort communities by size (big ones placed first / more central) - const communityIds = Array.from(communityGroups.keys()) - .sort((a, b) => communityGroups.get(b)!.length - communityGroups.get(a)!.length); - const numCommunities = communityIds.length; - - // Cell size derived from the LARGEST community — smaller clusters get same cell - // but will pack tighter via noverlap post-processing. - const maxCommunitySize = Math.max( - ...Array.from(communityGroups.values(), (nodes) => nodes.length), - ); - // Each node contributes ~12 units of space (node size ~5-12 + margin) - const cellSize = Math.max(Math.sqrt(maxCommunitySize) * 28, 140); - - // Grid dimensions — aim for slightly wide aspect (4:3) - const cols = Math.ceil(Math.sqrt(numCommunities * 1.4)); - const rows = Math.ceil(numCommunities / cols); - const gridWidth = cols * cellSize; - const gridHeight = rows * cellSize; - - // Deterministic pseudo-random for jitter (breaks grid rigidity) - const jitter = (seed: number) => { - const x = Math.sin(seed * 9999) * 10000; - return (x - Math.floor(x)) - 0.5; - }; - - communityIds.forEach((cId, idx) => { - const nodesInCommunity = communityGroups.get(cId)!; - const col = idx % cols; - const row = Math.floor(idx / cols); - // Cell center with slight jitter - const cx = col * cellSize - gridWidth / 2 + cellSize / 2 + jitter(idx) * cellSize * 0.2; - const cy = row * cellSize - gridHeight / 2 + cellSize / 2 + jitter(idx + 1000) * cellSize * 0.2; - // Local radius: scales with community size — bigger community = bigger circle - // Uses sqrt so large clusters don't balloon out of proportion - const localRadius = Math.max(Math.sqrt(nodesInCommunity.length) * 12, 25); - - nodesInCommunity.forEach((nodeId, i) => { - const localAngle = (i / nodesInCommunity.length) * Math.PI * 2; - const jitterR = localRadius * (0.6 + Math.abs(jitter(i + idx * 100)) * 0.7); - const x = cx + Math.cos(localAngle) * jitterR; - const y = cy + Math.sin(localAngle) * jitterR; - graph.setNodeAttribute(nodeId, "x", x); - graph.setNodeAttribute(nodeId, "y", y); - }); - }); - - // Step 2: Run FA2 with community-aware settings. - // Since we have community seeds, FA2 just refines (doesn't completely re-layout). - const iterations = graph.order < 100 ? 250 : graph.order < 500 ? 180 : 100; + // FA2 layout — Gephi-like defaults tuned per density. + // Sparse: gravity ≈ repulsion so orphans form loose cloud (not ring/grid). + // Dense: lower gravity, community seeds provide structure. + const isSparse = edgeDensity < 0.8; + const iterations = graph.order < 100 ? 300 : graph.order < 500 ? 200 : 120; forceAtlas2.assign(graph, { iterations, settings: { - // Standard FA2 (NOT linLogMode — causes ring collapse) linLogMode: false, outboundAttractionDistribution: false, - // Moderate gravity — pulls clusters slightly closer (prevents drift) - gravity: 0.15, - // Moderate repulsion — less aggressive spacing - scalingRatio: 8, - adjustSizes: true, + gravity: isSparse ? 3.0 : 0.15, + scalingRatio: isSparse ? 5.0 : 8, + adjustSizes: false, strongGravityMode: false, - slowDown: 8, + slowDown: 6, barnesHutOptimize: graph.order > 300, edgeWeightInfluence: 0, }, }); - // Step 3: Noverlap post-process — push apart any remaining overlaps (tight) - noverlap.assign(graph, { - maxIterations: 50, - settings: { - margin: 3, - ratio: 1.02, - speed: 3, - gridSize: 20, - }, - }); + // Noverlap only for dense graphs — sparse graphs rely on FA2 repulsion + // to space nodes naturally. Noverlap on sparse data creates grid artifacts. + if (!isSparse) { + noverlap.assign(graph, { + maxIterations: 50, + settings: { margin: 3, ratio: 1.02, speed: 3, gridSize: 20 }, + }); + } } const edgePrograms: Record = { diff --git a/ui/web/src/pages/vault/hooks/use-vault.ts b/ui/web/src/pages/vault/hooks/use-vault.ts index 8cb70f2d..c25b2669 100644 --- a/ui/web/src/pages/vault/hooks/use-vault.ts +++ b/ui/web/src/pages/vault/hooks/use-vault.ts @@ -92,38 +92,14 @@ export function useVaultSearchAll() { return { search }; } -/** Fetch all links for a set of vault documents (for graph view). */ +/** Fetch all links for a set of vault documents (single batch query). */ export function useVaultAllLinks(agentId: string, documents: { id: string }[]) { const http = useHttp(); - const docIds = useMemo(() => documents.map((d) => d.id), [documents]); const { data, isLoading } = useQuery({ queryKey: [VAULT_KEY, "all-links", agentId, [...docIds].sort().join(",")], - queryFn: async () => { - if (docIds.length === 0) return []; - const allLinks: VaultLink[] = []; - const batchSize = 10; - for (let i = 0; i < docIds.length; i += batchSize) { - const batch = docIds.slice(i, i + batchSize); - const results = await Promise.all( - batch.map((id) => - http.get<{ outlinks: VaultLink[]; backlinks: VaultLink[] }>( - `/v1/vault/documents/${id}/links`, - ).catch(() => ({ outlinks: [], backlinks: [] })), - ), - ); - for (const r of results) { - allLinks.push(...r.outlinks); - } - } - const seen = new Set(); - return allLinks.filter((l) => { - if (seen.has(l.id)) return false; - seen.add(l.id); - return true; - }); - }, + queryFn: () => http.post("/v1/vault/links/batch", { doc_ids: docIds }), enabled: docIds.length > 0, staleTime: 60_000, }); @@ -157,32 +133,11 @@ export function useVaultGraphData(agentId: string, opts?: { teamId?: string }) { [documents], ); - // Fetch links for all docs via cross-agent endpoint (works for null agent_id too). + // Fetch all links in one batch query (single SQL, no N+1). + const docIds = useMemo(() => documents.map((d) => d.id), [documents]); const { data: linksData, isLoading: linksLoading } = useQuery({ queryKey: [VAULT_KEY, "graph-links", docIdKey], - queryFn: async () => { - if (documents.length === 0) return []; - const allLinks: VaultLink[] = []; - const batchSize = 10; - for (let i = 0; i < documents.length; i += batchSize) { - const batch = documents.slice(i, i + batchSize); - const results = await Promise.all( - batch.map((doc) => - http.get<{ outlinks: VaultLink[]; backlinks: VaultLink[] }>( - `/v1/vault/documents/${doc.id}/links`, - ).catch(() => ({ outlinks: [], backlinks: [] })), - ), - ); - for (const r of results) allLinks.push(...r.outlinks); - } - // Dedup - const seen = new Set(); - return allLinks.filter((l) => { - if (seen.has(l.id)) return false; - seen.add(l.id); - return true; - }); - }, + queryFn: () => http.post("/v1/vault/links/batch", { doc_ids: docIds }), enabled: documents.length > 0, staleTime: 60_000, }); diff --git a/ui/web/src/pages/vault/vault-detail-dialog.tsx b/ui/web/src/pages/vault/vault-detail-dialog.tsx index 8a73e042..ee8491e5 100644 --- a/ui/web/src/pages/vault/vault-detail-dialog.tsx +++ b/ui/web/src/pages/vault/vault-detail-dialog.tsx @@ -278,7 +278,7 @@ export function VaultDetailDialog({ doc, open, onOpenChange, onDeleted }: Props) {linkDialogOpen && ( {linkDialogOpen && ( - )} diff --git a/ui/web/src/pages/vault/vault-graph-view.tsx b/ui/web/src/pages/vault/vault-graph-view.tsx index 585aeebb..922c904a 100644 --- a/ui/web/src/pages/vault/vault-graph-view.tsx +++ b/ui/web/src/pages/vault/vault-graph-view.tsx @@ -37,7 +37,12 @@ export function VaultGraphView({ agentId, teamId, selectedDocId, onNodeSelect, o 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]); - const graph = useMemo(() => buildVaultGraph(documents, links), [documents, links]); + // Only build graph when ALL data loaded — prevents double-render + // (orphan-only layout → with-links layout) that causes visual chaos. + const graph = useMemo( + () => loading ? buildVaultGraph([], []) : buildVaultGraph(documents, links), + [documents, links, loading], + ); const handleNodeDoubleClick = useCallback((nodeId: string) => { const doc = docMap.get(nodeId);