diff --git a/.changeset/knowledge-graph-feature.md b/.changeset/knowledge-graph-feature.md
new file mode 100644
index 0000000..a971bb6
--- /dev/null
+++ b/.changeset/knowledge-graph-feature.md
@@ -0,0 +1,13 @@
+---
+"think-app": minor
+---
+
+Add Knowledge Graph visualization with AI-powered link suggestions
+
+- New GraphPage with force-directed graph visualization using Reagraph
+- Interactive filters for memory type, date range, and isolated nodes
+- Community detection with automatic coloring
+- AI-powered link suggestions panel with one-click accept
+- "View in graph" button in memory detail panel
+- Backend analytics endpoints for centrality, communities, and health metrics
+- Smart caching with TTL and invalidation for graph data
diff --git a/app/package.json b/app/package.json
index 43fafa3..fcd38b4 100644
--- a/app/package.json
+++ b/app/package.json
@@ -77,6 +77,8 @@
"@microsoft/fetch-event-source": "^2.0.1",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-popover": "^1.1.15",
+ "@radix-ui/react-slot": "^1.2.4",
+ "@radix-ui/react-tooltip": "^1.2.8",
"@tiptap/extension-placeholder": "^3.13.0",
"@tiptap/pm": "^3.13.0",
"@tiptap/react": "^3.13.0",
@@ -92,6 +94,7 @@
"react-markdown": "^10.1.0",
"react-pdf": "^10.3.0",
"react-router-dom": "^7.10.0",
+ "reagraph": "^4.20.0",
"sonner": "^2.0.7",
"tailwind-merge": "^2.6.0"
},
diff --git a/app/src/App.tsx b/app/src/App.tsx
index 90cb9e1..6731ea5 100644
--- a/app/src/App.tsx
+++ b/app/src/App.tsx
@@ -6,6 +6,7 @@ import MainLayout from "./layouts/MainLayout";
import HomePage from "./pages/HomePage";
import ChatPage from "./pages/ChatPage";
import MemoriesPage from "./pages/MemoriesPage";
+import GraphPage from "./pages/GraphPage";
import SettingsPage from "./pages/SettingsPage";
import RecordingPage from "./pages/RecordingPage";
import { NamePromptDialog } from "./components/NamePromptDialog";
@@ -283,6 +284,7 @@ function App() {
} />
} />
} />
+ } />
}
diff --git a/app/src/components/GraphFilters.tsx b/app/src/components/GraphFilters.tsx
new file mode 100644
index 0000000..891b0a0
--- /dev/null
+++ b/app/src/components/GraphFilters.tsx
@@ -0,0 +1,243 @@
+import * as React from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { cn } from "@/lib/utils";
+import { glass, memoryTypeColors } from "@/lib/design-tokens";
+import {
+ ChevronDown,
+ Check,
+ Globe,
+ FileText,
+ Mic,
+ FileAudio,
+ Video,
+ LayoutGrid,
+ Calendar,
+ X,
+} from "lucide-react";
+import type { GraphFilters as GraphFiltersType } from "../lib/api";
+
+const TYPE_FILTER_OPTIONS = [
+ { value: "all", label: "All", icon: LayoutGrid, dot: null },
+ { value: "web", label: "Web", icon: Globe, dot: memoryTypeColors.web.bg },
+ { value: "note", label: "Notes", icon: FileText, dot: memoryTypeColors.note.bg },
+ { value: "voice_memo", label: "Voice Memos", icon: Mic, dot: memoryTypeColors.voice_memo.bg },
+ { value: "audio", label: "Audio", icon: FileAudio, dot: memoryTypeColors.audio.bg },
+ { value: "video", label: "Video", icon: Video, dot: memoryTypeColors.video.bg },
+ { value: "document", label: "Documents", icon: FileText, dot: memoryTypeColors.document.bg },
+] as const;
+
+const DATE_FILTER_OPTIONS = [
+ { value: "all", label: "All Time" },
+ { value: "today", label: "Today" },
+ { value: "week", label: "This Week" },
+ { value: "month", label: "This Month" },
+] as const;
+
+interface GraphFiltersProps {
+ filters: GraphFiltersType;
+ onFiltersChange: (filters: GraphFiltersType) => void;
+ searchQuery: string;
+ onSearchChange: (query: string) => void;
+ inline?: boolean;
+}
+
+export default function GraphFilters({
+ filters,
+ onFiltersChange,
+ searchQuery,
+ onSearchChange,
+ inline = false,
+}: GraphFiltersProps) {
+ const typeFilter = filters.type || "all";
+ const dateFilter = filters.date_range || "all";
+ const showIsolated = filters.include_isolated !== false;
+
+ const [typeFilterOpen, setTypeFilterOpen] = React.useState(false);
+ const [dateFilterOpen, setDateFilterOpen] = React.useState(false);
+
+ const isNonDefault = typeFilter !== "all" || dateFilter !== "all" || !showIsolated;
+
+ const selectedTypeOption =
+ TYPE_FILTER_OPTIONS.find((opt) => opt.value === typeFilter) ||
+ TYPE_FILTER_OPTIONS[0];
+ const selectedDateOption =
+ DATE_FILTER_OPTIONS.find((opt) => opt.value === dateFilter) ||
+ DATE_FILTER_OPTIONS[0];
+
+ const TypeIcon = selectedTypeOption.icon;
+
+ const handleClear = () => {
+ onFiltersChange({ type: "all", date_range: "all", include_isolated: true });
+ };
+
+ const filterButtons = (
+ <>
+ {/* Segmented control pill */}
+
+ {/* Type Filter */}
+
+
+
+
+
+ {TYPE_FILTER_OPTIONS.map((option) => {
+ const Icon = option.icon;
+ return (
+
+ );
+ })}
+
+
+
+ {/* Date Filter */}
+
+
+
+
+
+ {DATE_FILTER_OPTIONS.map((option) => (
+
+ ))}
+
+
+
+ {/* Show Isolated Toggle */}
+
+
+
+ {/* Clear button - visible only when non-default */}
+ {isNonDefault && (
+
+ )}
+ >
+ );
+
+ // When inline, skip the outer glass wrapper - parent toolbar provides it
+ if (inline) {
+ return (
+
+ {filterButtons}
+
+ onSearchChange(e.target.value)}
+ className="h-8 text-sm"
+ />
+
+
+ );
+ }
+
+ return (
+
+ {filterButtons}
+
+ onSearchChange(e.target.value)}
+ className="h-9"
+ />
+
+
+ );
+}
diff --git a/app/src/components/GraphInsightsPanel.tsx b/app/src/components/GraphInsightsPanel.tsx
new file mode 100644
index 0000000..3ad90e7
--- /dev/null
+++ b/app/src/components/GraphInsightsPanel.tsx
@@ -0,0 +1,226 @@
+import * as React from "react";
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+import { toast } from "sonner";
+import {
+ Sparkles,
+ AlertCircle,
+ Loader2,
+ ArrowRight,
+ Plus,
+} from "lucide-react";
+import type { GraphFilters, GraphNode } from "../lib/api";
+import {
+ getLinkRecommendations,
+ createLink,
+ ApiError,
+ type LinkRecommendation,
+} from "../lib/api";
+import { glass, getMemoryTypeColor } from "@/lib/design-tokens";
+
+const MIN_CONFIDENCE = 0.7; // Fixed threshold
+
+interface GraphInsightsPanelProps {
+ filters: GraphFilters;
+ nodes: GraphNode[];
+ onRefreshGraph?: () => void;
+}
+
+export default function GraphInsightsPanel({
+ filters,
+ nodes,
+ onRefreshGraph,
+}: GraphInsightsPanelProps) {
+ const [expanded, setExpanded] = useState(true);
+ const [recommendations, setRecommendations] = React.useState([]);
+ const [loading, setLoading] = React.useState(true);
+ const [error, setError] = React.useState(null);
+ const [creatingLinkId, setCreatingLinkId] = React.useState(null);
+
+ // Build node map for quick lookups
+ const nodeMap = React.useMemo(() => {
+ return new Map(nodes.map(node => [node.id, node]));
+ }, [nodes]);
+
+ // Fetch insights data
+ const fetchInsights = React.useCallback(async () => {
+ setError(null);
+ try {
+ const recs = await getLinkRecommendations(20, MIN_CONFIDENCE, filters);
+ setRecommendations(recs);
+ setLoading(false);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to load insights");
+ setLoading(false);
+ }
+ }, [filters]);
+
+ React.useEffect(() => {
+ fetchInsights();
+ }, [fetchInsights]);
+
+ const handleCreateLink = async (rec: LinkRecommendation) => {
+ const linkId = `${rec.source_id}-${rec.target_id}`;
+ setCreatingLinkId(linkId);
+
+ try {
+ // Backend creates bidirectional links automatically
+ await createLink(rec.source_id, rec.target_id, "auto", rec.confidence);
+
+ toast.success("Link created", {
+ description: `Connected "${rec.source_title}" and "${rec.target_title}"`,
+ });
+
+ // Refresh graph and recommendations
+ onRefreshGraph?.();
+ await fetchInsights();
+ } catch (err) {
+ // 409 Conflict indicates link already exists
+ if (err instanceof ApiError && err.status === 409) {
+ toast.error("Link already exists", {
+ description: "This connection has already been created",
+ });
+ } else {
+ toast.error("Failed to create link", {
+ description: err instanceof Error ? err.message : "An error occurred",
+ });
+ }
+ } finally {
+ setCreatingLinkId(null);
+ }
+ };
+
+ return (
+
+ {!expanded ? (
+
+ ) : (
+
+ {/* Header */}
+
+
+
+ AI Link Suggestions
+
+
+
+
+ {/* Content */}
+ {loading ? (
+
+
+ Finding suggestions...
+
+ ) : error ? (
+
+
+
{error}
+
+
+ ) : recommendations.length === 0 ? (
+
+ No suggestions found
+
+ ) : (
+
+ {recommendations.map((rec) => {
+ const key = `${rec.source_id}-${rec.target_id}`;
+ const isCreating = creatingLinkId === key;
+ const confidencePercent = Math.round(rec.confidence * 100);
+
+ // Get node colors for preview
+ const sourceNode = nodeMap.get(rec.source_id);
+ const targetNode = nodeMap.get(rec.target_id);
+ const sourceColor = sourceNode ? getMemoryTypeColor(sourceNode.type).hex : '#94a3b8';
+ const targetColor = targetNode ? getMemoryTypeColor(targetNode.type).hex : '#94a3b8';
+
+ return (
+
+ {/* Compact preview layout */}
+
+
+
{rec.source_title}
+
+
+
{rec.target_title}
+
+
+ {/* Confidence bar + button */}
+
+
+
+ {confidencePercent}%
+
+
+
+
+ );
+ })}
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/app/src/components/GraphLegend.tsx b/app/src/components/GraphLegend.tsx
new file mode 100644
index 0000000..ebf21ac
--- /dev/null
+++ b/app/src/components/GraphLegend.tsx
@@ -0,0 +1,112 @@
+import { useState } from 'react';
+import { glass, memoryTypeColors } from '@/lib/design-tokens';
+import { cn } from '@/lib/utils';
+import { Info } from 'lucide-react';
+
+interface GraphLegendProps {
+ colorMode?: 'none' | 'community';
+ communityColors?: string[];
+ communityLabels?: string[];
+}
+
+const NODE_TYPES = [
+ { color: memoryTypeColors.web.bg, label: 'Web' },
+ { color: memoryTypeColors.note.bg, label: 'Note' },
+ { color: memoryTypeColors.voice_memo.bg, label: 'Voice Memo' },
+ { color: memoryTypeColors.audio.bg, label: 'Audio' },
+ { color: memoryTypeColors.video.bg, label: 'Video' },
+ { color: memoryTypeColors.document.bg, label: 'Document' },
+];
+
+export default function GraphLegend({
+ colorMode = 'none',
+ communityColors = [],
+ communityLabels = [],
+}: GraphLegendProps) {
+ const [expanded, setExpanded] = useState(false);
+
+ return (
+
+ {!expanded ? (
+
+ ) : (
+
+
+ Legend
+
+
+
+ {/* Node Types - show only when not in community mode */}
+ {colorMode === 'none' && (
+
+
Node Types
+ {NODE_TYPES.map((type) => (
+
+ ))}
+
+ )}
+
+ {/* Community Colors - show when in community mode */}
+ {colorMode === 'community' && communityColors.length > 0 && (
+
+
Communities
+ {communityColors.slice(0, 8).map((color, idx) => (
+
+
+
{communityLabels[idx] || `Community ${idx + 1}`}
+
+ ))}
+ {communityColors.length > 8 && (
+
+{communityColors.length - 8} more
+ )}
+
+ )}
+
+ {/* Link Types */}
+
+
+ {/* Size Legend */}
+
+ Node size = connection count
+
+
+ )}
+
+ );
+}
diff --git a/app/src/components/GraphVisualization.tsx b/app/src/components/GraphVisualization.tsx
new file mode 100644
index 0000000..7c1b09b
--- /dev/null
+++ b/app/src/components/GraphVisualization.tsx
@@ -0,0 +1,136 @@
+import { useState, useEffect } from "react";
+import { GraphCanvas, GraphNode as ReagraphNode, GraphEdge, darkTheme, lightTheme } from "reagraph";
+import { type GraphNode, type GraphLink } from "../lib/api";
+import { getMemoryTypeColor, getCommunityColor, communityColors, memoryTypeColors } from "@/lib/design-tokens";
+
+// Edge and highlight colors from design tokens
+const HIGHLIGHT_COLOR = communityColors[9]; // amber-300 (#fbbf24)
+const MANUAL_LINK_COLOR = memoryTypeColors.audio.hex; // blue-500 (#3b82f6)
+const AI_LINK_COLOR = "#64748b"; // slate-500 (fallback color from getMemoryTypeColor)
+
+interface GraphVisualizationProps {
+ nodes: GraphNode[];
+ links: GraphLink[];
+ selectedNodeId?: number | null;
+ highlightedNodeIds?: Set;
+ onNodeClick: (node: GraphNode) => void;
+ colorByMetric?: "none" | "community";
+ communityMap?: Record;
+}
+
+export default function GraphVisualization({
+ nodes,
+ links,
+ selectedNodeId,
+ highlightedNodeIds = new Set(),
+ onNodeClick,
+ colorByMetric = "none",
+ communityMap = {},
+}: GraphVisualizationProps) {
+ const [isDark, setIsDark] = useState(() =>
+ document.documentElement.classList.contains("dark")
+ );
+
+ useEffect(() => {
+ const observer = new MutationObserver(() => {
+ setIsDark(document.documentElement.classList.contains("dark"));
+ });
+ observer.observe(document.documentElement, {
+ attributes: true,
+ attributeFilter: ["class"],
+ });
+ return () => observer.disconnect();
+ }, []);
+
+ // Custom themes with white hover highlight
+ const customDarkTheme = {
+ ...darkTheme,
+ node: {
+ ...darkTheme.node,
+ activeFill: "#ffffff",
+ label: { ...darkTheme.node.label, activeColor: "#ffffff" },
+ },
+ };
+ const customLightTheme = {
+ ...lightTheme,
+ node: {
+ ...lightTheme.node,
+ activeFill: "#ffffff",
+ label: { ...lightTheme.node.label, activeColor: "#ffffff" },
+ },
+ };
+
+
+ // Count connections from links (both directions)
+ const connectionCounts = new Map();
+ links.forEach((link) => {
+ connectionCounts.set(link.source, (connectionCounts.get(link.source) ?? 0) + 1);
+ connectionCounts.set(link.target, (connectionCounts.get(link.target) ?? 0) + 1);
+ });
+
+ const getNodeColor = (node: GraphNode): string => {
+ // Highlight color takes priority
+ if (highlightedNodeIds.has(node.id)) {
+ return HIGHLIGHT_COLOR;
+ }
+
+ // Community coloring mode
+ if (colorByMetric === "community" && communityMap[node.id] !== undefined) {
+ return getCommunityColor(communityMap[node.id]);
+ }
+
+ // Default type-based coloring
+ return getMemoryTypeColor(node.type).hex;
+ };
+
+ const getNodeSize = (nodeId: number): number => {
+ const count = connectionCounts.get(nodeId) ?? 0;
+ // Log scale so highly-connected nodes grow noticeably but don't become blobs
+ return Math.max(3, Math.min(12, 3 + Math.log2(count + 1) * 2.5));
+ };
+
+ // Convert nodes to Reagraph format
+ const graphNodes: ReagraphNode[] = nodes.map((node) => ({
+ id: String(node.id),
+ label: node.title,
+ fill: getNodeColor(node),
+ data: { size: getNodeSize(node.id) },
+ labelVisible: true,
+ }));
+
+ // Convert links to Reagraph format
+ const graphEdges: GraphEdge[] = links.map((link) => ({
+ id: `${link.source}-${link.target}`,
+ source: String(link.source),
+ target: String(link.target),
+ size: link.relevance_score ? 0.5 + link.relevance_score * 1 : 0.5,
+ fill: link.link_type === "manual" ? MANUAL_LINK_COLOR : AI_LINK_COLOR,
+ }));
+
+ const handleNodeClick = (node: ReagraphNode) => {
+ const originalNode = nodes.find((n) => n.id === Number(node.id));
+ if (originalNode) {
+ onNodeClick(originalNode);
+ }
+ };
+
+ return (
+
+ null}
+ />
+
+ );
+}
diff --git a/app/src/components/LinkMemoryDialog.tsx b/app/src/components/LinkMemoryDialog.tsx
new file mode 100644
index 0000000..62e1a72
--- /dev/null
+++ b/app/src/components/LinkMemoryDialog.tsx
@@ -0,0 +1,307 @@
+import { useState, useEffect } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { X, Search, Loader2, Globe, FileText, Mic, FileAudio, Video, File, Check } from "lucide-react";
+import { createPortal } from "react-dom";
+import { apiFetch, deleteLink } from "@/lib/api";
+import { toast } from "sonner";
+
+interface Memory {
+ id: number;
+ type: string;
+ title: string;
+ summary: string | null;
+ created_at: string;
+}
+
+interface LinkMemoryDialogProps {
+ isOpen: boolean;
+ onClose: () => void;
+ currentMemoryId: number;
+ onLinkCreated: () => void;
+ existingLinks: number[];
+}
+
+const MEMORY_TYPE_ICONS = {
+ web: Globe,
+ note: FileText,
+ voice_memo: Mic,
+ voice: Mic,
+ audio: FileAudio,
+ video: Video,
+ document: File,
+};
+
+export function LinkMemoryDialog({
+ isOpen,
+ onClose,
+ currentMemoryId,
+ onLinkCreated,
+ existingLinks,
+}: LinkMemoryDialogProps) {
+ const [searchQuery, setSearchQuery] = useState("");
+ const [searchResults, setSearchResults] = useState([]);
+ const [selectedMemoryIds, setSelectedMemoryIds] = useState>(new Set());
+ const [isSearching, setIsSearching] = useState(false);
+ const [isLinking, setIsLinking] = useState(false);
+
+ // Initialize selected state with existing links when dialog opens
+ useEffect(() => {
+ if (isOpen) {
+ setSelectedMemoryIds(new Set(existingLinks));
+ }
+ }, [isOpen, existingLinks]);
+
+ // Debounced search
+ useEffect(() => {
+ if (!searchQuery.trim()) {
+ setSearchResults([]);
+ return;
+ }
+
+ const timeoutId = setTimeout(() => {
+ performSearch(searchQuery);
+ }, 300);
+
+ return () => clearTimeout(timeoutId);
+ }, [searchQuery]);
+
+ const performSearch = async (query: string) => {
+ setIsSearching(true);
+ try {
+ const response = await apiFetch(`/api/memories/search?q=${encodeURIComponent(query)}&limit=10`);
+ if (!response.ok) throw new Error("Search failed");
+
+ const data = await response.json();
+ // Filter out current memory and limit results
+ const filtered = data.memories
+ .filter((m: Memory) => m.id !== currentMemoryId)
+ .slice(0, 8);
+ setSearchResults(filtered);
+ } catch (error) {
+ console.error("Search error:", error);
+ toast.error("Failed to search memories");
+ } finally {
+ setIsSearching(false);
+ }
+ };
+
+ const handleLink = async () => {
+ setIsLinking(true);
+
+ try {
+ // Calculate which links to delete (in existingLinks but not in selectedMemoryIds)
+ const linksToDelete = existingLinks.filter(id => !selectedMemoryIds.has(id));
+
+ // Calculate which links to create (in selectedMemoryIds but not in existingLinks)
+ const linksToCreate = Array.from(selectedMemoryIds).filter(id => !existingLinks.includes(id));
+
+ // Create all operations in parallel
+ const operations: Promise<{ type: 'create' | 'delete', success: boolean }>[] = [];
+
+ // Add delete operations
+ linksToDelete.forEach(targetId => {
+ operations.push(
+ deleteLink(currentMemoryId, targetId)
+ .then(() => ({ type: 'delete' as const, success: true }))
+ .catch(() => ({ type: 'delete' as const, success: false }))
+ );
+ });
+
+ // Add create operations
+ linksToCreate.forEach(targetId => {
+ operations.push(
+ apiFetch(`/api/memories/${currentMemoryId}/links`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ target_memory_id: targetId,
+ link_type: "manual",
+ }),
+ })
+ .then(response => {
+ if (!response.ok) throw new Error();
+ return { type: 'create' as const, success: true };
+ })
+ .catch(() => ({ type: 'create' as const, success: false }))
+ );
+ });
+
+ // Execute all operations in parallel
+ if (operations.length > 0) {
+ const results = await Promise.all(operations);
+
+ const createResults = results.filter(r => r.type === 'create');
+ const deleteResults = results.filter(r => r.type === 'delete');
+
+ const createdCount = createResults.filter(r => r.success).length;
+ const deletedCount = deleteResults.filter(r => r.success).length;
+ const failedCount = results.filter(r => !r.success).length;
+
+ // Show appropriate success messages
+ const messages: string[] = [];
+ if (createdCount > 0) {
+ messages.push(`Linked ${createdCount} ${createdCount === 1 ? 'memory' : 'memories'}`);
+ }
+ if (deletedCount > 0) {
+ messages.push(`Unlinked ${deletedCount} ${deletedCount === 1 ? 'memory' : 'memories'}`);
+ }
+
+ if (messages.length > 0) {
+ toast.success(messages.join(', '));
+ }
+
+ if (failedCount > 0) {
+ toast.error(`Failed ${failedCount} ${failedCount === 1 ? 'operation' : 'operations'}`);
+ }
+ }
+
+ onLinkCreated();
+ onClose();
+ } catch (error) {
+ console.error("Link management error:", error);
+ toast.error("Failed to update links");
+ } finally {
+ setIsLinking(false);
+ }
+ };
+
+ const handleClose = () => {
+ setSearchQuery("");
+ setSearchResults([]);
+ setSelectedMemoryIds(new Set());
+ onClose();
+ };
+
+ if (!isOpen) return null;
+
+ return createPortal(
+
+
+ {/* Header */}
+
+
Link Memory
+
+
+
+ {/* Search Input */}
+
+
+
+ setSearchQuery(e.target.value)}
+ className="pl-10"
+ autoFocus
+ />
+
+
+
+ {/* Search Results */}
+
+ {isSearching ? (
+
+
+
+ ) : searchResults.length > 0 ? (
+
+ {searchResults.map((memory) => {
+ const Icon = MEMORY_TYPE_ICONS[memory.type as keyof typeof MEMORY_TYPE_ICONS] || FileText;
+ const isExistingLink = existingLinks.includes(memory.id);
+ const isSelected = selectedMemoryIds.has(memory.id);
+
+ return (
+
+ );
+ })}
+
+ ) : searchQuery ? (
+
+ No memories found
+
+ ) : (
+
+ Start typing to search for memories to link
+
+ )}
+
+
+ {/* Footer */}
+
+
+
+
+
+
,
+ document.body
+ );
+}
diff --git a/app/src/components/MemoryDetailPanel.tsx b/app/src/components/MemoryDetailPanel.tsx
index cea404d..923b679 100644
--- a/app/src/components/MemoryDetailPanel.tsx
+++ b/app/src/components/MemoryDetailPanel.tsx
@@ -34,6 +34,7 @@ import {
ExternalLink,
ChevronLeft,
ChevronRight,
+ Plus,
} from "lucide-react";
// Set up PDF.js worker - served from public folder (dev) or copied to dist (build)
@@ -44,10 +45,11 @@ pdfjs.GlobalWorkerOptions.workerSrc = new URL(
).href;
import { toast } from "sonner";
import { cn } from "@/lib/utils";
-import { apiFetch, getAppToken } from "@/lib/api";
+import { apiFetch, getAppToken, getMemoryLinks, deleteLink, createLink, getMemorySuggestions, type MemoryLink, type MemorySuggestion } from "@/lib/api";
import { API_BASE_URL } from "@/constants";
import { useMemoryEvents } from "../hooks/useMemoryEvents";
import { useConversation } from "../contexts/ConversationContext";
+import { LinkMemoryDialog } from "./LinkMemoryDialog";
import type { TranscriptionStatus, TranscriptSegment, VideoProcessingStatus } from "@/types/chat";
interface MemoryTag {
@@ -157,20 +159,42 @@ export function MemoryDetailPanel({
const [isPdfLoading, setIsPdfLoading] = useState(false);
const pdfBlobUrlRef = useRef(null);
+ // Links/connections state
+ const [links, setLinks] = useState([]);
+ const [isLoadingLinks, setIsLoadingLinks] = useState(false);
+ const [showLinkDialog, setShowLinkDialog] = useState(false);
+
+ // Suggestions state
+ const [suggestions, setSuggestions] = useState([]);
+ const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false);
+ const [acceptingSuggestionId, setAcceptingSuggestionId] = useState(null);
+
const titleInputRef = useRef(null);
const navigate = useNavigate();
const { startNewChat, addAttachedMemory } = useConversation();
- // Listen for SSE updates to refresh memory data (e.g., after summary regeneration)
+ // Listen for SSE updates to refresh memory data (e.g., after summary regeneration, link changes)
useMemoryEvents({
onMemoryUpdated: (updatedMemoryId, data) => {
if (updatedMemoryId === memoryId && data) {
- const updatedMemory = data as Memory;
- setMemory(updatedMemory);
- setIsRegenerating(false);
- if (!isEditing) {
- setEditedTitle(updatedMemory.title || "");
- setEditedContent(updatedMemory.content || "");
+ // Handle link events
+ const eventData = data as any;
+ if (eventData.action === "link_created" || eventData.action === "link_deleted") {
+ // Refresh links when a link is created or deleted
+ if (memoryId) {
+ fetchLinks(memoryId);
+ // Also refresh suggestions since the linked memory should be excluded
+ fetchSuggestions(memoryId);
+ }
+ } else {
+ // Handle regular memory updates
+ const updatedMemory = data as Memory;
+ setMemory(updatedMemory);
+ setIsRegenerating(false);
+ if (!isEditing) {
+ setEditedTitle(updatedMemory.title || "");
+ setEditedContent(updatedMemory.content || "");
+ }
}
}
},
@@ -181,6 +205,7 @@ export function MemoryDetailPanel({
useEffect(() => {
if (memoryId && isOpen) {
fetchMemory(memoryId);
+ fetchSuggestions(memoryId);
}
}, [memoryId, isOpen]);
@@ -353,6 +378,8 @@ export function MemoryDetailPanel({
setMemory(data);
setEditedTitle(data.title || "");
setEditedContent(data.content || "");
+ // Fetch links for this memory
+ fetchLinks(id);
}
} catch (err) {
console.error("Failed to fetch memory:", err);
@@ -361,6 +388,86 @@ export function MemoryDetailPanel({
}
};
+ const fetchLinks = async (id: number) => {
+ setIsLoadingLinks(true);
+ try {
+ const links = await getMemoryLinks(id);
+ setLinks(links);
+ } catch (err) {
+ console.error("Failed to fetch links:", err);
+ } finally {
+ setIsLoadingLinks(false);
+ }
+ };
+
+ const fetchSuggestions = async (id: number) => {
+ setIsLoadingSuggestions(true);
+ try {
+ const suggestions = await getMemorySuggestions(id, 5, 0.6);
+ setSuggestions(suggestions);
+ } catch (err) {
+ console.error("Failed to fetch suggestions:", err);
+ // Fail silently - don't block UI
+ } finally {
+ setIsLoadingSuggestions(false);
+ }
+ };
+
+ const handleAcceptSuggestion = async (suggestion: MemorySuggestion) => {
+ if (!memoryId || !memory) return;
+
+ setAcceptingSuggestionId(suggestion.memory_id);
+ try {
+ await createLink(
+ memoryId,
+ suggestion.memory_id,
+ "auto",
+ suggestion.relevance
+ );
+
+ toast.success(`Connected to ${suggestion.title || "memory"}`);
+
+ // Remove from suggestions
+ setSuggestions(prev => prev.filter(s => s.memory_id !== suggestion.memory_id));
+
+ // Refresh links
+ fetchLinks(memoryId);
+
+ // Notify parent to refresh graph
+ onMemoryUpdated(memory);
+ } catch (err) {
+ console.error("Failed to accept suggestion:", err);
+ toast.error("Failed to create link");
+ } finally {
+ setAcceptingSuggestionId(null);
+ }
+ };
+
+ const handleDeleteLink = async (targetId: number) => {
+ if (!memoryId || !memory) return;
+ try {
+ await deleteLink(memoryId, targetId);
+ toast.success("Link removed");
+ // Refresh links
+ fetchLinks(memoryId);
+ // Notify parent to refresh graph
+ onMemoryUpdated(memory);
+ } catch (err) {
+ console.error("Failed to delete link:", err);
+ toast.error("Failed to remove link");
+ }
+ };
+
+ const handleLinkCreated = () => {
+ if (memoryId) {
+ fetchLinks(memoryId);
+ // Notify parent to refresh graph
+ if (memory) {
+ onMemoryUpdated(memory);
+ }
+ }
+ };
+
const handleSave = async () => {
if (!memory || !editedTitle.trim()) return;
setIsSaving(true);
@@ -1804,6 +1911,174 @@ export function MemoryDetailPanel({
+ {/* Connections Section */}
+
+
+
+
+ Connections
+ {links.length > 0 && (
+
+ {links.length}
+
+ )}
+
+
+
+
+
+
+
+ {isLoadingLinks ? (
+
+
+
+ ) : links.length > 0 ? (
+
+ {links.map((link) => {
+ const Icon = link.type === "web" ? Globe :
+ link.type === "note" ? FileText :
+ link.type === "voice_memo" || link.type === "voice" ? Mic :
+ link.type === "audio" ? FileAudio :
+ link.type === "video" ? Video :
+ FileText;
+
+ return (
+
+
+
+
+
+
+ {link.link_type}
+
+ {link.relevance_score && (
+
+ {Math.round(link.relevance_score * 100)}% match
+
+ )}
+
+
+
+
+ );
+ })}
+
+ ) : (
+
+
+
No connections yet
+
Link related memories to build your knowledge graph
+
+ )}
+
+ {/* Suggested Connections */}
+ {(isLoadingSuggestions || suggestions.length > 0) && (
+
+
+
+ Suggested Connections
+
+
+ {isLoadingSuggestions ? (
+
+
+ Finding related memories...
+
+ ) : suggestions.length > 0 ? (
+
+ {suggestions.map((suggestion) => {
+ const Icon = suggestion.type === "web" ? Globe :
+ suggestion.type === "note" ? FileText :
+ suggestion.type === "voice_memo" || suggestion.type === "voice" ? Mic :
+ suggestion.type === "audio" ? FileAudio :
+ suggestion.type === "video" ? Video :
+ FileText;
+
+ const relevancePercent = Math.round(suggestion.relevance * 100);
+
+ return (
+
+
+
+
+ {suggestion.title || "Untitled"}
+
+ {suggestion.summary && (
+
+ {suggestion.summary}
+
+ )}
+
+ {relevancePercent}% match
+
+
+
+
+ );
+ })}
+
+ ) : null}
+
+ )}
+
+
{/* Future Sections (Placeholders) */}
@@ -1813,13 +2088,6 @@ export function MemoryDetailPanel({
Coming Soon
-
-
- Similar Memories
-
- Coming Soon
-
-
) : (
@@ -1844,6 +2112,17 @@ export function MemoryDetailPanel({
)}
+
+ {/* Link Memory Dialog */}
+ {memoryId && (
+ setShowLinkDialog(false)}
+ currentMemoryId={memoryId}
+ onLinkCreated={handleLinkCreated}
+ existingLinks={links.map(link => link.memory_id)}
+ />
+ )}
,
document.body
);
diff --git a/app/src/components/Sidebar.tsx b/app/src/components/Sidebar.tsx
index 035d419..49c69d6 100644
--- a/app/src/components/Sidebar.tsx
+++ b/app/src/components/Sidebar.tsx
@@ -1,12 +1,20 @@
import { NavLink } from "react-router-dom";
-import { Home, Brain, Settings, MessageSquare } from "lucide-react";
+import { Home, Brain, Settings, MessageSquare, Network } from "lucide-react";
import ProviderStatusIndicator from "./ProviderStatusIndicator";
import { useConversation } from "@/contexts/ConversationContext";
+import { sidebar } from "@/lib/design-tokens";
+import { cn } from "@/lib/utils";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
const navItems = [
{ to: "/", icon: Home, label: "Home" },
{ to: "/memories", icon: Brain, label: "Memories" },
{ to: "/chat", icon: MessageSquare, label: "Chats" },
+ { to: "/graph", icon: Network, label: "Graph" },
{ to: "/settings", icon: Settings, label: "Settings" },
];
@@ -20,8 +28,8 @@ export default function Sidebar() {
};
return (
-