mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-15 02:16:56 +00:00
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:
1 parent
0b2bc7ce78
commit
e394072d88
9 files changed
+107
-132
No files matched your search
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
-27
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user