From 2c2e2cb5360cd963e46fa72cb4971950fbf97dcc Mon Sep 17 00:00:00 2001 From: viettranx Date: Sun, 12 Apr 2026 18:56:27 +0700 Subject: [PATCH] fix(vault): skip goclaw_gen media in enrichment, improve graph UX Backend: - Skip goclaw_gen_* files in Handle, EnqueueUnenriched, gatherCandidates - Skip in RescanWorkspace PendingEvents (progress count) - Reduces noisy auto-links by ~59% Frontend: - Theme-aware node colors (Tailwind -600/-400 matching sidebar) - Neutral gray edges (zinc-500/300), lighter inactive edges - Disable hover label white box (defaultDrawNodeHover) - Auto-expand + load first-level folders - Group hover text colors for tree items - Edge density based on camera zoom --- internal/vault/enrich_classify.go | 5 ++ internal/vault/enrich_worker.go | 17 ++++- internal/vault/rescan.go | 4 +- ui/web/src/adapters/vault-graph-adapter.ts | 45 ++++++++--- .../graph/sigma-graph-container.tsx | 74 +++++++++++++------ .../src/pages/vault/components/vault-tree.tsx | 30 +++++--- ui/web/src/pages/vault/vault-graph-view.tsx | 12 ++- 7 files changed, 138 insertions(+), 49 deletions(-) diff --git a/internal/vault/enrich_classify.go b/internal/vault/enrich_classify.go index 854517ed..3d1a7481 100644 --- a/internal/vault/enrich_classify.go +++ b/internal/vault/enrich_classify.go @@ -6,6 +6,7 @@ import ( "log/slog" "maps" "slices" + "strings" "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/store" @@ -153,6 +154,10 @@ func (w *EnrichWorker) gatherCandidates(ctx context.Context, tenantID, _ string, if n.Score < enrichSimilarityMin || n.Document.Summary == "" { continue } + // Skip auto-generated media files as link targets — they create noise. + if strings.HasPrefix(n.Document.PathBasename, "goclaw_gen_") { + continue + } // Bidirectional dedup: only process each pair once. a, b := src.DocID, n.Document.ID if a > b { diff --git a/internal/vault/enrich_worker.go b/internal/vault/enrich_worker.go index fa6c2b96..9d279042 100644 --- a/internal/vault/enrich_worker.go +++ b/internal/vault/enrich_worker.go @@ -125,7 +125,12 @@ func (w *EnrichWorker) EnqueueUnenriched(ctx context.Context, tenantID, workspac return 0, nil } + count := 0 for _, doc := range docs { + // Skip auto-generated media files — they create noise links. + if strings.HasPrefix(filepath.Base(doc.Path), "goclaw_gen_") { + continue + } agentID := "" if doc.AgentID != nil { agentID = *doc.AgentID @@ -147,10 +152,11 @@ func (w *EnrichWorker) EnqueueUnenriched(ctx context.Context, tenantID, workspac }, } bus.Publish(event) + count++ } - slog.Info("vault.enrich: enqueued unenriched", "tenant", tenantID, "count", len(docs)) - return len(docs), nil + slog.Info("vault.enrich: enqueued unenriched", "tenant", tenantID, "count", count) + return count, nil } // enrichTaskSiblingCap bounds the number of auto-linked siblings per @@ -172,6 +178,13 @@ func (w *EnrichWorker) Handle(ctx context.Context, event eventbus.DomainEvent) e return nil } + // Skip auto-generated media files (goclaw_gen_*) — they create excessive + // noise links due to similar embeddings. These are typically image outputs + // that don't benefit from semantic linking. + if basename := filepath.Base(payload.Path); strings.HasPrefix(basename, "goclaw_gen_") { + return nil + } + // Dedup: skip if same hash already processed. w.dedupMu.Lock() if prev, exists := w.dedup[payload.DocID]; exists && prev == payload.ContentHash { diff --git a/internal/vault/rescan.go b/internal/vault/rescan.go index 602507b2..b3e06760 100644 --- a/internal/vault/rescan.go +++ b/internal/vault/rescan.go @@ -115,7 +115,9 @@ func RescanWorkspace(ctx context.Context, params RescanParams, vs store.VaultSto // Collect enrichment events — published AFTER the loop so the caller // can call progress.Start(total) before workers receive events. - if bus != nil { + // Skip auto-generated media files (goclaw_gen_*) — they create excessive + // noise links and shouldn't be counted in progress tracking. + if bus != nil && !strings.HasPrefix(filepath.Base(relPath), "goclaw_gen_") { result.PendingEvents = append(result.PendingEvents, eventbus.DomainEvent{ ID: uuid.Must(uuid.NewV7()).String(), Type: eventbus.EventVaultDocUpserted, diff --git a/ui/web/src/adapters/vault-graph-adapter.ts b/ui/web/src/adapters/vault-graph-adapter.ts index 50504e31..7347cda0 100644 --- a/ui/web/src/adapters/vault-graph-adapter.ts +++ b/ui/web/src/adapters/vault-graph-adapter.ts @@ -2,18 +2,36 @@ import Graph from "graphology"; import type { VaultDocument, VaultLink } from "@/types/vault"; import { getNodeSize, truncateMiddle } from "@/components/graph/graph-utils"; -// Colors per vault document type — muted pastels that work well on both -// light and dark backgrounds. Avoid harsh neons on dark mode. -export const VAULT_TYPE_COLORS: Record = { - context: "#818cf8", // indigo-400 (softer) - memory: "#a78bfa", // violet-400 - note: "#5eead4", // teal-300 (soft, readable) - skill: "#86efac", // green-300 - episodic: "#fcd34d", // amber-300 - media: "#f9a8d4", // pink-300 (soft, not harsh) - document: "#67e8f9", // cyan-300 +// Colors per vault document type — matches DOC_TYPE_ICONS in vault-tree.tsx +// Light mode: Tailwind -600 variants for contrast on white/light backgrounds +// Dark mode: Tailwind -400 variants for visibility on dark backgrounds +export const VAULT_TYPE_COLORS_LIGHT: Record = { + context: "#2563eb", // blue-600 (matches text-blue-600) + memory: "#9333ea", // purple-600 (matches text-purple-600) + note: "#d97706", // amber-600 (matches text-amber-600) + skill: "#059669", // emerald-600 (matches text-emerald-600) + episodic: "#ea580c", // orange-600 (matches text-orange-600) + media: "#e11d48", // rose-600 (matches text-rose-600) + document: "#0891b2", // cyan-600 (matches text-cyan-600) }; -const DEFAULT_COLOR = "#a1a1aa"; +export const VAULT_TYPE_COLORS_DARK: Record = { + context: "#60a5fa", // blue-400 (matches dark:text-blue-400) + memory: "#c084fc", // purple-400 (matches dark:text-purple-400) + note: "#fbbf24", // amber-400 (matches dark:text-amber-400) + skill: "#34d399", // emerald-400 (matches dark:text-emerald-400) + episodic: "#fb923c", // orange-400 (matches dark:text-orange-400) + media: "#fb7185", // rose-400 (matches dark:text-rose-400) + document: "#22d3ee", // cyan-400 (matches dark:text-cyan-400) +}; +const DEFAULT_COLOR_LIGHT = "#475569"; // slate-600 +const DEFAULT_COLOR_DARK = "#94a3b8"; // slate-400 + +/** Get node color based on doc type and theme */ +export function getVaultNodeColor(docType: string, isDark: boolean): string { + const colors = isDark ? VAULT_TYPE_COLORS_DARK : VAULT_TYPE_COLORS_LIGHT; + const fallback = isDark ? DEFAULT_COLOR_DARK : DEFAULT_COLOR_LIGHT; + return colors[docType] ?? fallback; +} /** Limit documents by degree centrality (highest-connected first). */ export function limitVaultDocsByDegree( @@ -57,7 +75,8 @@ export function buildVaultGraph( x: 0, y: 0, size: getNodeSize(degree), - color: VAULT_TYPE_COLORS[doc.doc_type] ?? DEFAULT_COLOR, + // 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, }); } @@ -70,6 +89,8 @@ export function buildVaultGraph( graph.addEdgeWithKey(link.id, link.from_doc_id, link.to_doc_id, { label: link.link_type, type: "curvedArrow", + color: "#a1a1aa", // zinc-400, lighter gray + size: 0.4, }); } } diff --git a/ui/web/src/components/graph/sigma-graph-container.tsx b/ui/web/src/components/graph/sigma-graph-container.tsx index 0dd9d660..532a41a8 100644 --- a/ui/web/src/components/graph/sigma-graph-container.tsx +++ b/ui/web/src/components/graph/sigma-graph-container.tsx @@ -7,6 +7,7 @@ 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 { getVaultNodeColor } from "@/adapters/vault-graph-adapter"; export type EdgeType = "curvedArrow" | "arrow"; @@ -24,22 +25,19 @@ export interface SigmaGraphContainerProps { hiddenTypes?: Set; } -/** Theme-aware colors */ +/** Theme-aware colors - uses DOM class check for reliability */ function useThemeColors() { - const theme = useUiStore((s) => s.theme); - const isDark = - theme === "dark" || - (theme === "system" && - window.matchMedia("(prefers-color-scheme: dark)").matches); + // 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"); return { isDark, labelColor: isDark ? "#e2e8f0" : "#1e293b", - // Base edge color — subtle, not dominant - edgeColor: isDark ? "#47556944" : "#94a3b8aa", - // Dim color for non-active edges - dimEdgeColor: isDark ? "#47556922" : "#cbd5e155", + // Base edge color — subtle gray + edgeColor: isDark ? "#71717a" : "#d4d4d8", // zinc-500 dark, zinc-300 light // Highlight for active neighborhood edges - highlightEdgeColor: isDark ? "#a1a1aa" : "#64748b", + highlightEdgeColor: isDark ? "#d4d4d8" : "#3f3f46", }; } @@ -60,7 +58,9 @@ export function SigmaGraphContainer({ const [hoveredNode, setHoveredNode] = useState(null); // Pulse phase for animated highlighted edges (0..1, cycles) const [pulsePhase, setPulsePhase] = useState(0); - const { labelColor, edgeColor, dimEdgeColor, highlightEdgeColor } = useThemeColors(); + // Camera ratio for edge density control (lower = more zoomed out) + const [cameraRatio, setCameraRatio] = useState(1); + const { isDark, labelColor, edgeColor, highlightEdgeColor } = useThemeColors(); const setSigmaRef = useCallback( (instance: Sigma | null) => { @@ -147,6 +147,8 @@ export function SigmaGraphContainer({ 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); @@ -214,27 +216,32 @@ 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 }; + // Filter: hide nodes of hidden types - if (hiddenTypes?.size && hiddenTypes.has(getNodeType(data))) { - return { ...data, hidden: true }; + if (hiddenTypes?.size && hiddenTypes.has(docType)) { + return { ...themedData, hidden: true }; } const activeNode = selectedNodeId || hoveredNode; - if (!activeNode) return data; + if (!activeNode) return themedData; const hood = neighborhoodRef.current; - if (!hood) return data; + if (!hood) return themedData; if (node === activeNode) { // Active node: bring to top, show label - return { ...data, zIndex: 3, forceLabel: true }; + return { ...themedData, zIndex: 3, forceLabel: true }; } if (hood.nodes.has(node)) { // Within 2-hop neighborhood: show label, slightly raised z-index - return { ...data, zIndex: 2, forceLabel: true }; + return { ...themedData, zIndex: 2, forceLabel: true }; } // Outside neighborhood: keep color/size, no dimming — just lower z-index - return { ...data, zIndex: 0 }; + return { ...themedData, zIndex: 0 }; }); sigma.setSetting("edgeReducer", (edge, data) => { @@ -248,7 +255,23 @@ export function SigmaGraphContainer({ } const activeNode = selectedNodeId || hoveredNode; - if (!activeNode) return data; + + // 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) { + return { ...data, hidden: true }; + } + } + + // No active node: use default edge color from graph attributes + if (!activeNode) { + return { ...data, color: edgeColor, size: 0.5 }; + } const hood = neighborhoodRef.current; if (!hood) return data; @@ -265,7 +288,8 @@ export function SigmaGraphContainer({ }); sigma.refresh(); - }, [selectedNodeId, hoveredNode, graph, highlightEdgeColor, dimEdgeColor, hiddenTypes]); + // sigmaVersion ensures this runs after sigma is created + }, [selectedNodeId, hoveredNode, graph, highlightEdgeColor, edgeColor, hiddenTypes, isDark, cameraRatio, sigmaVersion]); // --- Pulse animation for highlighted edges (only runs when a node is active) --- // Respects prefers-reduced-motion — skips animation entirely for accessibility @@ -333,17 +357,25 @@ export function SigmaGraphContainer({ onNodeSelectRef.current?.(null); }; + // Track camera ratio for edge density control + const handleCameraUpdate = () => { + const ratio = sigma.getCamera().ratio; + setCameraRatio(ratio); + }; + sigma.on("enterNode", handleEnterNode); sigma.on("leaveNode", handleLeaveNode); sigma.on("clickNode", handleClickNode); sigma.on("doubleClickNode", handleDoubleClickNode); sigma.on("clickStage", handleClickStage); + sigma.getCamera().on("updated", handleCameraUpdate); return () => { sigma.off("enterNode", handleEnterNode); sigma.off("leaveNode", handleLeaveNode); sigma.off("clickNode", handleClickNode); sigma.off("doubleClickNode", handleDoubleClickNode); + sigma.getCamera().off("updated", handleCameraUpdate); sigma.off("clickStage", handleClickStage); }; }, [sigmaVersion]); // re-register only when sigma instance changes diff --git a/ui/web/src/pages/vault/components/vault-tree.tsx b/ui/web/src/pages/vault/components/vault-tree.tsx index 01833adf..aa9b8d8d 100644 --- a/ui/web/src/pages/vault/components/vault-tree.tsx +++ b/ui/web/src/pages/vault/components/vault-tree.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback } from "react"; +import { useState, useCallback, useEffect } from "react"; import { ChevronRight, Folder, FolderOpen, Loader2, FileText, Brain, StickyNote, Sparkles, Clock, Image, FileType, @@ -49,7 +49,17 @@ function VaultTreeNode({ activePath: string | null; onSelect: (path: string) => void; onLoadMore: (path: string) => void; }) { // Auto-expand first level folders (depth=0) by default - const [expanded, setExpanded] = useState(depth === 0 && node.isDir); + const shouldAutoExpand = depth === 0 && node.isDir; + const [expanded, setExpanded] = useState(shouldAutoExpand); + const [didAutoLoad, setDidAutoLoad] = useState(false); + + // Auto-load children when auto-expanded + useEffect(() => { + if (shouldAutoExpand && !didAutoLoad && node.hasChildren && node.children.length === 0 && !node.loading) { + setDidAutoLoad(true); + onLoadMore(node.path); + } + }, [shouldAutoExpand, didAutoLoad, node.hasChildren, node.children.length, node.loading, node.path, onLoadMore]); const entry = meta.get(node.path); const handleToggle = useCallback(() => { @@ -65,15 +75,15 @@ function VaultTreeNode({ return (
- + {expanded - ? - : } - {node.name} + ? + : } + {node.name} {node.loading && } {!node.loading && node.children.length > 0 && ( @@ -111,14 +121,14 @@ function VaultTreeNode({
onSelect(node.path)} > - {truncateMiddle(fileName)} + {truncateMiddle(fileName)} {scopeDot && }
diff --git a/ui/web/src/pages/vault/vault-graph-view.tsx b/ui/web/src/pages/vault/vault-graph-view.tsx index 922c904a..3c2a7563 100644 --- a/ui/web/src/pages/vault/vault-graph-view.tsx +++ b/ui/web/src/pages/vault/vault-graph-view.tsx @@ -1,7 +1,8 @@ import { useRef, useMemo, useState, useCallback } from "react"; import type Sigma from "sigma"; import { useTranslation } from "react-i18next"; -import { buildVaultGraph, limitVaultDocsByDegree, VAULT_TYPE_COLORS } from "@/adapters/vault-graph-adapter"; +import { buildVaultGraph, limitVaultDocsByDegree, 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"; import { SigmaGraphSearch } from "@/components/graph/sigma-graph-search"; @@ -31,6 +32,11 @@ export function VaultGraphView({ agentId, teamId, selectedDocId, onNodeSelect, o const [hiddenTypes, setHiddenTypes] = useState>(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"); + const typeColors = isDark ? VAULT_TYPE_COLORS_DARK : VAULT_TYPE_COLORS_LIGHT; + const { documents: allDocs, links, loading } = useVaultGraphData(agentId, { teamId }); const totalCount = allDocs.length; @@ -71,7 +77,7 @@ export function VaultGraphView({ agentId, teamId, selectedDocId, onNodeSelect, o {/* Top bar — responsive: stacks on narrow screens */}
- {Object.entries(VAULT_TYPE_COLORS).map(([type, color]) => ( + {Object.entries(typeColors).map(([type, color]) => ( {type} @@ -88,7 +94,7 @@ export function VaultGraphView({ agentId, teamId, selectedDocId, onNodeSelect, o />