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) => ( +
+
+ {type.label} +
+ ))} +
+ )} + + {/* 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 */} +
+
Connections
+
+
+ Manual links +
+
+
+ AI-suggested links +
+
+ + {/* 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 ( -