From e394072d88538396ac29dd58e9fe35bd5fe29048 Mon Sep 17 00:00:00 2001 From: viettranx Date: Fri, 10 Apr 2026 19:41:54 +0700 Subject: [PATCH] fix(ui/vault): adaptive graph layout, color palette, and link name resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace density-branched FA2 layout with unified adaptive algorithm based on orphan ratio instead of misleading edge density threshold - Remove Louvain community grid seeding (caused ring/rectangle artifacts) - Enable strongGravity + linLog + outboundAttractionDistribution for organic cluster layout on both vault and KG graphs - Update vault type colors: note→teal, media→pink (softer for dominant types) - Increase light-mode edge visibility (slate-400 @ 80% vs gray-300 @ 60%) - Enrich vault links API with doc_names map for outlink target titles - Show document names instead of truncated UUIDs in link badges - Use existing backlink title field from backend JOIN - Horizontal scroll for link badges to preserve content viewport - Move "Add Link" button to bottom bar in detail dialog - Remove unused graphology-communities-louvain dependency --- internal/http/vault_handlers.go | 20 +++ ui/web/package.json | 1 - ui/web/pnpm-lock.yaml | 27 ---- ui/web/src/adapters/vault-graph-adapter.ts | 13 +- .../graph/sigma-graph-container.tsx | 116 +++++++----------- ui/web/src/pages/vault/hooks/use-vault.ts | 18 ++- .../src/pages/vault/vault-detail-dialog.tsx | 30 ++--- .../pages/vault/vault-detail-edit-section.tsx | 6 +- ui/web/src/pages/vault/vault-detail-panel.tsx | 8 +- 9 files changed, 107 insertions(+), 132 deletions(-) diff --git a/internal/http/vault_handlers.go b/internal/http/vault_handlers.go index 06171984..543a19dd 100644 --- a/internal/http/vault_handlers.go +++ b/internal/http/vault_handlers.go @@ -369,9 +369,29 @@ func (h *VaultHandler) handleGetLinks(w http.ResponseWriter, r *http.Request) { backlinks = filtered } + // Resolve target doc titles for outlinks so the UI can display names instead of IDs. + docNames := make(map[string]string, len(outLinks)) + for _, l := range outLinks { + if _, seen := docNames[l.ToDocID]; seen { + continue + } + if d, err := h.store.GetDocumentByID(r.Context(), tenantID.String(), l.ToDocID); err == nil && d != nil { + name := d.Title + if name == "" { + if idx := strings.LastIndex(d.Path, "/"); idx >= 0 { + name = d.Path[idx+1:] + } else { + name = d.Path + } + } + docNames[l.ToDocID] = name + } + } + writeJSON(w, http.StatusOK, map[string]any{ "outlinks": outLinks, "backlinks": backlinks, + "doc_names": docNames, }) } diff --git a/ui/web/package.json b/ui/web/package.json index e77ea01a..0b287681 100644 --- a/ui/web/package.json +++ b/ui/web/package.json @@ -23,7 +23,6 @@ "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", diff --git a/ui/web/pnpm-lock.yaml b/ui/web/pnpm-lock.yaml index 1ae17066..aceed6cf 100644 --- a/ui/web/pnpm-lock.yaml +++ b/ui/web/pnpm-lock.yaml @@ -47,9 +47,6 @@ 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) @@ -1709,16 +1706,6 @@ 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: @@ -4139,20 +4126,6 @@ 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 diff --git a/ui/web/src/adapters/vault-graph-adapter.ts b/ui/web/src/adapters/vault-graph-adapter.ts index 334e25a2..6c021e83 100644 --- a/ui/web/src/adapters/vault-graph-adapter.ts +++ b/ui/web/src/adapters/vault-graph-adapter.ts @@ -2,14 +2,15 @@ import Graph from "graphology"; import type { VaultDocument, VaultLink } from "@/types/vault"; import { getNodeSize, truncateMiddle } from "@/components/graph/graph-utils"; -// Colors per vault document type +// Colors per vault document type — dominant types (note 44%, media 54%) use +// softer tones; rare types use vivid accents for visibility. export const VAULT_TYPE_COLORS: Record = { - context: "#3b82f6", // blue - memory: "#8b5cf6", // purple - note: "#eab308", // yellow + context: "#6366f1", // indigo (rare, vivid) + memory: "#8b5cf6", // violet + note: "#14b8a6", // teal (dominant, soft) skill: "#22c55e", // green - episodic: "#f97316", // orange - media: "#ef4444", // red + episodic: "#f59e0b", // amber + media: "#ec4899", // pink (dominant, soft) }; const DEFAULT_COLOR = "#9ca3af"; diff --git a/ui/web/src/components/graph/sigma-graph-container.tsx b/ui/web/src/components/graph/sigma-graph-container.tsx index 7d80fe15..1731a217 100644 --- a/ui/web/src/components/graph/sigma-graph-container.tsx +++ b/ui/web/src/components/graph/sigma-graph-container.tsx @@ -4,7 +4,6 @@ import { EdgeCurvedArrowProgram } from "@sigma/edge-curve"; import { EdgeArrowProgram } from "sigma/rendering"; import forceAtlas2 from "graphology-layout-forceatlas2"; import noverlap from "graphology-layout-noverlap"; -import louvain from "graphology-communities-louvain"; import type Graph from "graphology"; import { useUiStore } from "@/stores/use-ui-store"; import { SIGMA_SETTINGS } from "./graph-utils"; @@ -35,12 +34,12 @@ function useThemeColors() { return { isDark, labelColor: isDark ? "#e2e8f0" : "#1e293b", - // Soft base edge color — lighter than borders - edgeColor: isDark ? "#47556966" : "#d1d5db99", - // Even lighter dim color - dimEdgeColor: isDark ? "#47556933" : "#e5e7eb66", - // Softer highlight (not too dark) - highlightEdgeColor: isDark ? "#a1a1aa" : "#9ca3af", + // Base edge color — visible but not dominant + edgeColor: isDark ? "#47556966" : "#94a3b8cc", + // Dim color for non-active edges + dimEdgeColor: isDark ? "#47556933" : "#cbd5e166", + // Highlight for active neighborhood edges + highlightEdgeColor: isDark ? "#a1a1aa" : "#64748b", }; } @@ -76,85 +75,52 @@ export function SigmaGraphContainer({ useEffect(() => { if (!containerRef.current || graph.order === 0) return; - // Layout: community-seeded FA2 for dense graphs, random+FA2 for sparse. + // Unified adaptive FA2 layout — no density branching. + // Params scale with orphan ratio (disconnected nodes / total nodes). if (graph.order > 1) { - const edgeDensity = graph.size / graph.order; // edges per 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); - } + // Random disc init — uniform area distribution for all graph types. + 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); } - // 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; + // 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: false, - outboundAttractionDistribution: false, - gravity: isSparse ? 3.0 : 0.15, - scalingRatio: isSparse ? 5.0 : 8, - adjustSizes: false, - strongGravityMode: false, - slowDown: 6, - barnesHutOptimize: graph.order > 300, + linLogMode: true, + outboundAttractionDistribution: true, + gravity, + scalingRatio, + adjustSizes: true, + strongGravityMode: true, + slowDown: 8, + barnesHutOptimize: graph.order > 100, edgeWeightInfluence: 0, }, }); - // 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 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: 50, - settings: { margin: 3, ratio: 1.02, speed: 3, gridSize: 20 }, + maxIterations: 30, + settings: { margin: 2, ratio: 1.02, speed: 3, gridSize: 20 }, }); } } diff --git a/ui/web/src/pages/vault/hooks/use-vault.ts b/ui/web/src/pages/vault/hooks/use-vault.ts index c25b2669..7b72c323 100644 --- a/ui/web/src/pages/vault/hooks/use-vault.ts +++ b/ui/web/src/pages/vault/hooks/use-vault.ts @@ -145,15 +145,26 @@ export function useVaultGraphData(agentId: string, opts?: { teamId?: string }) { return { documents, links: linksData ?? [], loading: docsLoading || linksLoading }; } +/** Enriched backlink with source doc metadata (from backend JOIN). */ +export interface VaultBacklink { + from_doc_id: string; + context: string; + title: string; + path: string; + team_id?: string; +} + /** Get links (outlinks + backlinks) for a vault document. */ export function useVaultLinks(docId: string | null) { const http = useHttp(); const { data, isLoading } = useQuery({ queryKey: [VAULT_KEY, "links", docId], - queryFn: () => http.get<{ outlinks: VaultLink[]; backlinks: VaultLink[] }>( - `/v1/vault/documents/${docId}/links`, - ), + queryFn: () => http.get<{ + outlinks: VaultLink[]; + backlinks: VaultBacklink[]; + doc_names: Record; + }>(`/v1/vault/documents/${docId}/links`), enabled: !!docId, placeholderData: (prev) => prev, }); @@ -161,6 +172,7 @@ export function useVaultLinks(docId: string | null) { return { outlinks: data?.outlinks ?? [], backlinks: data?.backlinks ?? [], + docNames: data?.doc_names ?? {}, loading: isLoading, }; } diff --git a/ui/web/src/pages/vault/vault-detail-dialog.tsx b/ui/web/src/pages/vault/vault-detail-dialog.tsx index ee8491e5..a5adfbcf 100644 --- a/ui/web/src/pages/vault/vault-detail-dialog.tsx +++ b/ui/web/src/pages/vault/vault-detail-dialog.tsx @@ -24,7 +24,7 @@ interface Props { export function VaultDetailDialog({ doc, open, onOpenChange, onDeleted }: Props) { const { t } = useTranslation("vault"); - const { outlinks, backlinks, loading } = useVaultLinks(doc?.id ?? null); + const { outlinks, backlinks, docNames, loading } = useVaultLinks(doc?.id ?? null); const [editMode, setEditMode] = useState(false); const [editTitle, setEditTitle] = useState(""); @@ -227,38 +227,40 @@ export function VaultDetailDialog({ doc, open, onOpenChange, onDeleted }: Props) {t("detail.outlinks")} ({outlinks.length}) {outlinks.length > 0 ? ( -
+
{outlinks.map((l) => ( - + ))}
) : ( {t("detail.noLinks")} )} -
)} {!loading && backlinks.length > 0 && ( -
+
{t("detail.backlinks")} ({backlinks.length}) -
+
{backlinks.map((l) => ( - - {l.link_type}: {l.from_doc_id.slice(0, 8)} + + {l.title || l.from_doc_id.slice(0, 8)} ))}
)} - {/* Hash + link count as subtle footer */} + {/* Bottom bar: hash + add link */}
- SHA-256: {doc.content_hash.length > 16 ? `${doc.content_hash.slice(0, 8)}...${doc.content_hash.slice(-8)}` : doc.content_hash} - {linkCount > 0 && {linkCount} link{linkCount !== 1 ? "s" : ""}} +
+ SHA-256: {doc.content_hash.length > 16 ? `${doc.content_hash.slice(0, 8)}...${doc.content_hash.slice(-8)}` : doc.content_hash} + {linkCount > 0 && {linkCount} link{linkCount !== 1 ? "s" : ""}} +
+
{/* Metadata JSON (if present) */} diff --git a/ui/web/src/pages/vault/vault-detail-edit-section.tsx b/ui/web/src/pages/vault/vault-detail-edit-section.tsx index d77488d4..cba90727 100644 --- a/ui/web/src/pages/vault/vault-detail-edit-section.tsx +++ b/ui/web/src/pages/vault/vault-detail-edit-section.tsx @@ -99,7 +99,7 @@ export function ScopeSelect({ value, onChange, t }: ScopeSelectProps) { } /** Outlink badge with inline delete confirmation. */ -export function LinkBadge({ link, t }: { link: VaultLink; t: (k: string) => string }) { +export function LinkBadge({ link, docNames, t }: { link: VaultLink; docNames?: Record; t: (k: string) => string }) { const [confirmDelete, setConfirmDelete] = useState(false); const { remove } = useDeleteLink(link.id); @@ -123,8 +123,8 @@ export function LinkBadge({ link, t }: { link: VaultLink; t: (k: string) => stri } return ( - - {link.link_type}: {link.to_doc_id.slice(0, 8)} + + {link.link_type}: {docNames?.[link.to_doc_id] || link.to_doc_id.slice(0, 8)}
{outlinks.length > 0 && (
- {outlinks.map((l) => )} + {outlinks.map((l) => )}
)}
@@ -162,7 +162,9 @@ export function VaultDetailPanel({ doc, open, onClose, onDeleted }: Props) { {t("detail.backlinks")} ({backlinks.length})
{backlinks.map((l) => ( - {l.from_doc_id.slice(0, 8)} + + {l.title || l.from_doc_id.slice(0, 8)} + ))}