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
This commit is contained in:
viettranx committed 2026-04-12 18:56:27 +07:00
1 parent 0546850df8
commit 2c2e2cb536
7 files changed
+138 -49

No files matched your search

+5
View File
@@ -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 {
+15 -2
View File
@@ -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 {
+3 -1
View File
@@ -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,
+33 -12
View File
@@ -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<string, string> = {
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<string, string> = {
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<string, string> = {
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,
});
}
}
@@ -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<string>;
}
/** 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<string | null>(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
@@ -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 (
<div>
<div
className="flex w-full items-center gap-1 rounded px-2 py-1 text-left text-sm cursor-pointer hover:bg-accent"
className="group flex w-full items-center gap-1 rounded px-2 py-1 text-left text-sm cursor-pointer hover:bg-accent"
style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={handleToggle}
>
<ChevronRight className={`h-3 w-3 shrink-0 transition-transform ${expanded ? "rotate-90" : ""}`} />
<ChevronRight className={`h-3 w-3 shrink-0 transition-transform text-muted-foreground group-hover:text-foreground ${expanded ? "rotate-90" : ""}`} />
{expanded
? <FolderOpen className="h-4 w-4 shrink-0 text-yellow-600" />
: <Folder className="h-4 w-4 shrink-0 text-yellow-600" />}
<span className="truncate text-xs">{node.name}</span>
? <FolderOpen className="h-4 w-4 shrink-0 text-yellow-600 dark:text-yellow-500" />
: <Folder className="h-4 w-4 shrink-0 text-yellow-600 dark:text-yellow-500" />}
<span className="truncate text-xs text-foreground/80 group-hover:text-foreground">{node.name}</span>
{node.loading && <Loader2 className="h-3 w-3 shrink-0 animate-spin text-muted-foreground ml-auto" />}
{!node.loading && node.children.length > 0 && (
<span className="ml-auto shrink-0 rounded-full bg-muted px-1 text-2xs tabular-nums text-muted-foreground">
@@ -111,14 +121,14 @@ function VaultTreeNode({
<Tooltip>
<TooltipTrigger asChild>
<div
className={`flex w-full items-center gap-1.5 rounded px-2 py-1 text-left cursor-pointer ${
isActive ? "bg-accent text-accent-foreground" : "hover:bg-accent/50"
className={`group flex w-full items-center gap-1.5 rounded px-2 py-1 text-left cursor-pointer ${
isActive ? "bg-accent" : "hover:bg-accent/50"
}`}
style={{ paddingLeft: `${depth * 16 + 20}px` }}
onClick={() => onSelect(node.path)}
>
<Icon className={`h-3.5 w-3.5 shrink-0 ${color}`} />
<span className="truncate text-xs">{truncateMiddle(fileName)}</span>
<span className={`truncate text-xs ${isActive ? "text-accent-foreground" : "text-foreground/80 group-hover:text-foreground"}`}>{truncateMiddle(fileName)}</span>
{scopeDot && <span className={`ml-auto h-1.5 w-1.5 rounded-full shrink-0 ${scopeDot}`} />}
</div>
</TooltipTrigger>
+9 -3
View File
@@ -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<Set<string>>(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 */}
<div className="flex flex-col sm:flex-row sm:items-center gap-2 px-3 py-1 border-b shrink-0">
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs text-muted-foreground flex-1 min-w-0">
{Object.entries(VAULT_TYPE_COLORS).map(([type, color]) => (
{Object.entries(typeColors).map(([type, color]) => (
<span key={type} className="flex items-center gap-1">
<span className="inline-block h-2.5 w-2.5 rounded-full" style={{ backgroundColor: color }} />
{type}
@@ -88,7 +94,7 @@ export function VaultGraphView({ agentId, teamId, selectedDocId, onNodeSelect, o
/>
<SigmaGraphFilters
graph={graph}
typeColors={VAULT_TYPE_COLORS}
typeColors={typeColors}
hiddenTypes={hiddenTypes}
onHiddenTypesChange={setHiddenTypes}
collapsed={!filtersOpen}