fix(ui/vault): adaptive graph layout, color palette, and link name resolution

- 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
This commit is contained in:
viettranx committed 2026-04-10 19:41:54 +07:00
1 parent 0b2bc7ce78
commit e394072d88
9 files changed
+107 -132

No files matched your search

+20
View File
@@ -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,
})
}
-1
View File
@@ -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",
-27
View File
@@ -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
+7 -6
View File
@@ -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<string, string> = {
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";
@@ -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<number, string[]>();
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 },
});
}
}
+15 -3
View File
@@ -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<string, string>;
}>(`/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,
};
}
+16 -14
View File
@@ -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)
<span>{t("detail.outlinks")} ({outlinks.length})</span>
</div>
{outlinks.length > 0 ? (
<div className="flex flex-wrap items-center gap-1 flex-1">
<div className="flex items-center gap-1 flex-1 min-w-0 overflow-x-auto scrollbar-thin">
{outlinks.map((l) => (
<LinkBadge key={l.id} link={l} t={t} />
<LinkBadge key={l.id} link={l} docNames={docNames} t={t} />
))}
</div>
) : (
<span className="text-xs text-muted-foreground">{t("detail.noLinks")}</span>
)}
<Button variant="ghost" size="xs" className="h-6 px-1.5 gap-1 shrink-0" onClick={() => setLinkDialogOpen(true)}>
<Plus className="h-3 w-3" />
<span>{t("addLink")}</span>
</Button>
</div>
)}
{!loading && backlinks.length > 0 && (
<div className="flex items-start gap-4 text-xs">
<div className="flex items-center gap-4 text-xs">
<span className="text-muted-foreground shrink-0">{t("detail.backlinks")} ({backlinks.length})</span>
<div className="flex flex-wrap gap-1">
<div className="flex items-center gap-1 min-w-0 overflow-x-auto scrollbar-thin">
{backlinks.map((l) => (
<Badge key={l.id} variant="secondary" className="text-xs">
{l.link_type}: {l.from_doc_id.slice(0, 8)}
<Badge key={l.from_doc_id} variant="secondary" className="text-xs shrink-0" title={l.path}>
{l.title || l.from_doc_id.slice(0, 8)}
</Badge>
))}
</div>
</div>
)}
{/* Hash + link count as subtle footer */}
{/* Bottom bar: hash + add link */}
<div className="flex items-center justify-between text-2xs text-muted-foreground border-t pt-2">
<span className="font-mono">SHA-256: {doc.content_hash.length > 16 ? `${doc.content_hash.slice(0, 8)}...${doc.content_hash.slice(-8)}` : doc.content_hash}</span>
{linkCount > 0 && <span>{linkCount} link{linkCount !== 1 ? "s" : ""}</span>}
<div className="flex items-center gap-3">
<span className="font-mono">SHA-256: {doc.content_hash.length > 16 ? `${doc.content_hash.slice(0, 8)}...${doc.content_hash.slice(-8)}` : doc.content_hash}</span>
{linkCount > 0 && <span>{linkCount} link{linkCount !== 1 ? "s" : ""}</span>}
</div>
<Button variant="ghost" size="xs" className="h-6 px-1.5 gap-1" onClick={() => setLinkDialogOpen(true)}>
<Plus className="h-3 w-3" />
<span className="text-xs">{t("addLink")}</span>
</Button>
</div>
{/* Metadata JSON (if present) */}
@@ -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<string, string>; 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 (
<Badge variant="secondary" className="text-xs group relative pr-1 gap-1">
<span>{link.link_type}: {link.to_doc_id.slice(0, 8)}</span>
<Badge variant="secondary" className="text-xs group relative pr-1 gap-1 shrink-0">
<span>{link.link_type}: {docNames?.[link.to_doc_id] || link.to_doc_id.slice(0, 8)}</span>
<button
onClick={() => setConfirmDelete(true)}
className="opacity-0 group-hover:opacity-100 transition-opacity"
@@ -19,7 +19,7 @@ interface Props {
export function VaultDetailPanel({ doc, open, onClose, onDeleted }: Props) {
const { t } = useTranslation("vault");
const { outlinks, backlinks } = useVaultLinks(doc?.id ?? null);
const { outlinks, backlinks, docNames } = useVaultLinks(doc?.id ?? null);
const { content, loading: contentLoading, error: contentError } = useVaultFileContent(open && doc ? doc.path : null);
const { update } = useUpdateDocument(doc?.id ?? "");
const { remove } = useDeleteDocument(doc?.id ?? "");
@@ -151,7 +151,7 @@ export function VaultDetailPanel({ doc, open, onClose, onDeleted }: Props) {
</div>
{outlinks.length > 0 && (
<div className="flex flex-wrap gap-1">
{outlinks.map((l) => <LinkBadge key={l.id} link={l} t={t} />)}
{outlinks.map((l) => <LinkBadge key={l.id} link={l} docNames={docNames} t={t} />)}
</div>
)}
</div>
@@ -162,7 +162,9 @@ export function VaultDetailPanel({ doc, open, onClose, onDeleted }: Props) {
<span className="text-muted-foreground">{t("detail.backlinks")} ({backlinks.length})</span>
<div className="flex flex-wrap gap-1">
{backlinks.map((l) => (
<Badge key={l.id} variant="secondary" className="text-2xs">{l.from_doc_id.slice(0, 8)}</Badge>
<Badge key={l.from_doc_id} variant="secondary" className="text-2xs" title={l.path}>
{l.title || l.from_doc_id.slice(0, 8)}
</Badge>
))}
</div>
</div>