From 37da704006944384461e63da7f1e830cc7fa35b3 Mon Sep 17 00:00:00 2001 From: Antonio Date: Thu, 5 Feb 2026 12:55:57 +0100 Subject: [PATCH 1/2] feat(app,backend): add AI-powered link suggestions (Phase 2) Implement automatic link suggestions using semantic similarity to help users discover related memories that could be linked together. Backend Changes: - Add suggestion service using vector search to find similar memories - Filter out existing links and self-links automatically - Convert cosine distance to relevance scores (0-1) - Add GET /api/memories/{id}/suggestions endpoint with configurable limit and min_relevance parameters - Add helper function to efficiently get linked memory IDs - Graceful error handling (returns empty array on failure) Frontend Changes: - Add "Suggested Connections" section in memory detail panel - Display suggestions with relevance scores as percentages - One-click acceptance with "+" button to create links - Loading state with spinner during fetch - Remove suggestions from list after acceptance - Refresh suggestions when links are created/deleted (SSE integration) - Visual distinction with dashed borders and Sparkles icon - Auto-fetch suggestions when panel opens Features: - Uses existing embedding infrastructure (no new models needed) - Default threshold of 0.35 cosine distance (60% relevance minimum) - Returns top 5 suggestions by default (configurable 1-20) - Created links are marked as "auto" type with relevance score - Empty state handling (no suggestions if no embeddings or no matches) --- app/src/components/LinkMemoryDialog.tsx | 307 ++++++++++++++++++++++ app/src/components/MemoryDetailPanel.tsx | 286 ++++++++++++++++++-- app/src/lib/api.ts | 109 ++++++++ backend/app/db/crud/__init__.py | 13 + backend/app/db/crud/links.py | 251 ++++++++++++++++++ backend/app/db/migrations.py | 36 +++ backend/app/models.py | 14 + backend/app/routes/__init__.py | 2 + backend/app/routes/links.py | 177 +++++++++++++ backend/app/services/links/__init__.py | 1 + backend/app/services/links/suggestions.py | 130 +++++++++ 11 files changed, 1311 insertions(+), 15 deletions(-) create mode 100644 app/src/components/LinkMemoryDialog.tsx create mode 100644 backend/app/db/crud/links.py create mode 100644 backend/app/routes/links.py create mode 100644 backend/app/services/links/__init__.py create mode 100644 backend/app/services/links/suggestions.py 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..76ba43d 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,77 @@ 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) 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); + } 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) return; + try { + await deleteLink(memoryId, targetId); + toast.success("Link removed"); + // Refresh links + fetchLinks(memoryId); + } catch (err) { + console.error("Failed to delete link:", err); + toast.error("Failed to remove link"); + } + }; + + const handleLinkCreated = () => { + if (memoryId) { + fetchLinks(memoryId); + } + }; + const handleSave = async () => { if (!memory || !editedTitle.trim()) return; setIsSaving(true); @@ -1804,6 +1902,160 @@ 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 +2065,6 @@ export function MemoryDetailPanel({ Coming Soon
-
- - Similar Memories - - Coming Soon - -
) : ( @@ -1844,6 +2089,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/lib/api.ts b/app/src/lib/api.ts index 3ba318c..adaf17f 100644 --- a/app/src/lib/api.ts +++ b/app/src/lib/api.ts @@ -43,3 +43,112 @@ export async function apiFetch( headers, }); } + +// ============================================================================ +// Memory Links API +// ============================================================================ + +export interface MemoryLink { + id: number; + memory_id: number; + title: string | null; + type: string; + link_type: "manual" | "auto"; + relevance_score: number | null; + created_at: string; +} + +export interface CreateLinkRequest { + target_memory_id: number; + link_type?: "manual" | "auto"; + relevance_score?: number; +} + +/** + * Create a bidirectional link between two memories. + */ +export async function createLink( + memoryId: number, + targetId: number, + linkType: "manual" | "auto" = "manual", + relevanceScore?: number +): Promise { + const response = await apiFetch(`/api/memories/${memoryId}/links`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + target_memory_id: targetId, + link_type: linkType, + relevance_score: relevanceScore, + }), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: "Failed to create link" })); + throw new Error(error.detail || "Failed to create link"); + } + + return response.json(); +} + +/** + * Delete a bidirectional link between two memories. + */ +export async function deleteLink(memoryId: number, targetId: number): Promise { + const response = await apiFetch(`/api/memories/${memoryId}/links/${targetId}`, { + method: "DELETE", + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: "Failed to delete link" })); + throw new Error(error.detail || "Failed to delete link"); + } +} + +/** + * Get all links for a memory. + */ +export async function getMemoryLinks(memoryId: number): Promise { + const response = await apiFetch(`/api/memories/${memoryId}/links`); + + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: "Failed to fetch links" })); + throw new Error(error.detail || "Failed to fetch links"); + } + + return response.json(); +} + +// ============================================================================ +// Memory Link Suggestions API +// ============================================================================ + +export interface MemorySuggestion { + memory_id: number; + title: string | null; + summary: string | null; + type: string; + relevance: number; // 0.0-1.0 +} + +/** + * Get AI-suggested memories to link based on semantic similarity. + */ +export async function getMemorySuggestions( + memoryId: number, + limit: number = 5, + minRelevance: number = 0.6 +): Promise { + const response = await apiFetch( + `/api/memories/${memoryId}/suggestions?limit=${limit}&min_relevance=${minRelevance}` + ); + + if (!response.ok) { + throw new Error("Failed to fetch suggestions"); + } + + const data = await response.json(); + return data.suggestions; +} diff --git a/backend/app/db/crud/__init__.py b/backend/app/db/crud/__init__.py index b9a01d5..1189420 100644 --- a/backend/app/db/crud/__init__.py +++ b/backend/app/db/crud/__init__.py @@ -67,6 +67,14 @@ delete_document_memory, ) +# Link CRUD functions +from .links import ( + create_link, + delete_link, + get_memory_links, + memory_exists, +) + __all__ = [ # Memory functions "create_memory", @@ -122,4 +130,9 @@ "get_document_memory", "update_document_thumbnail", "delete_document_memory", + # Link functions + "create_link", + "delete_link", + "get_memory_links", + "memory_exists", ] diff --git a/backend/app/db/crud/links.py b/backend/app/db/crud/links.py new file mode 100644 index 0000000..f35ffef --- /dev/null +++ b/backend/app/db/crud/links.py @@ -0,0 +1,251 @@ +"""CRUD operations for memory links (knowledge graph).""" + +from datetime import datetime +from sqlalchemy import select, and_, or_ +from fastapi import HTTPException + +from ..core import get_session_maker, run_sync +from ...models import Memory, MemoryLink +from ...events import event_manager, MemoryEvent, EventType + + +async def create_link( + source_id: int, + target_id: int, + link_type: str = "manual", + relevance_score: float | None = None, +) -> dict: + """Create a bidirectional link between two memories. + + Creates two rows: source→target and target→source for efficient querying. + + Args: + source_id: Source memory ID + target_id: Target memory ID + link_type: "manual" or "auto" + relevance_score: Optional AI confidence score (0.0-1.0) + + Returns: + Link details dictionary + + Raises: + HTTPException: 404 if memory not found, 400 if self-link, 409 if duplicate + """ + def _create(): + with get_session_maker()() as session: + # Validate: memories exist + source = session.get(Memory, source_id) + target = session.get(Memory, target_id) + + if not source or not target: + raise HTTPException(status_code=404, detail="Memory not found") + + # Prevent self-links + if source_id == target_id: + raise HTTPException(status_code=400, detail="Cannot link memory to itself") + + # Check for existing link (either direction) + existing = session.execute( + select(MemoryLink).where( + or_( + and_( + MemoryLink.source_memory_id == source_id, + MemoryLink.target_memory_id == target_id + ), + and_( + MemoryLink.source_memory_id == target_id, + MemoryLink.target_memory_id == source_id + ) + ) + ) + ).first() + + if existing: + raise HTTPException(status_code=409, detail="Link already exists") + + # Validate relevance score if provided + if relevance_score is not None and not (0.0 <= relevance_score <= 1.0): + raise HTTPException(status_code=400, detail="Relevance score must be between 0.0 and 1.0") + + # Create bidirectional links + link_forward = MemoryLink( + source_memory_id=source_id, + target_memory_id=target_id, + link_type=link_type, + relevance_score=relevance_score, + ) + link_backward = MemoryLink( + source_memory_id=target_id, + target_memory_id=source_id, + link_type=link_type, + relevance_score=relevance_score, + ) + + session.add(link_forward) + session.add(link_backward) + session.commit() + session.refresh(link_forward) + + return { + "id": link_forward.id, + "source_memory_id": source_id, + "target_memory_id": target_id, + "link_type": link_type, + "relevance_score": relevance_score, + "created_at": link_forward.created_at.isoformat(), + } + + result = await run_sync(_create) + + # Emit SSE event after successful creation + await event_manager.publish(MemoryEvent( + type=EventType.MEMORY_UPDATED, + memory_id=source_id, + data={"action": "link_created", "target_id": target_id, "link_type": link_type} + )) + await event_manager.publish(MemoryEvent( + type=EventType.MEMORY_UPDATED, + memory_id=target_id, + data={"action": "link_created", "target_id": source_id, "link_type": link_type} + )) + + return result + + +async def delete_link(source_id: int, target_id: int) -> bool: + """Delete a bidirectional link between two memories. + + Deletes both rows: source→target and target→source. + + Args: + source_id: Source memory ID + target_id: Target memory ID + + Returns: + True if link was deleted + + Raises: + HTTPException: 404 if link not found + """ + def _delete(): + with get_session_maker()() as session: + # Find and delete both directions + links = session.execute( + select(MemoryLink).where( + or_( + and_( + MemoryLink.source_memory_id == source_id, + MemoryLink.target_memory_id == target_id + ), + and_( + MemoryLink.source_memory_id == target_id, + MemoryLink.target_memory_id == source_id + ) + ) + ) + ).scalars().all() + + if not links: + raise HTTPException(status_code=404, detail="Link not found") + + for link in links: + session.delete(link) + + session.commit() + return True + + result = await run_sync(_delete) + + # Emit SSE event after successful deletion + await event_manager.publish(MemoryEvent( + type=EventType.MEMORY_UPDATED, + memory_id=source_id, + data={"action": "link_deleted", "target_id": target_id} + )) + await event_manager.publish(MemoryEvent( + type=EventType.MEMORY_UPDATED, + memory_id=target_id, + data={"action": "link_deleted", "target_id": source_id} + )) + + return result + + +async def get_memory_links(memory_id: int) -> list[dict]: + """Get all links for a memory (unified bidirectional view). + + Returns links where memory is either source or target, showing the connected memory. + + Args: + memory_id: Memory ID to get links for + + Returns: + List of link dictionaries with connected memory details + """ + def _get(): + with get_session_maker()() as session: + # Get all links where this memory is the source + # (bidirectional storage means we only need to query source_memory_id) + links = session.execute( + select(MemoryLink, Memory) + .join(Memory, MemoryLink.target_memory_id == Memory.id) + .where(MemoryLink.source_memory_id == memory_id) + .order_by(MemoryLink.created_at.desc()) + ).all() + + result = [] + for link, target_memory in links: + result.append({ + "id": link.id, + "memory_id": target_memory.id, + "title": target_memory.title, + "type": target_memory.type, + "link_type": link.link_type, + "relevance_score": link.relevance_score, + "created_at": link.created_at.isoformat(), + }) + + return result + + return await run_sync(_get) + + +async def memory_exists(memory_id: int) -> bool: + """Check if a memory exists. + + Args: + memory_id: Memory ID to check + + Returns: + True if memory exists + """ + def _exists(): + with get_session_maker()() as session: + return session.get(Memory, memory_id) is not None + + return await run_sync(_exists) + + +async def get_linked_memory_ids(memory_id: int) -> list[int]: + """Get list of memory IDs that are linked to given memory. + + Used for filtering suggestions to exclude already-linked memories. + + Args: + memory_id: Memory ID to get links for + + Returns: + List of linked memory IDs + """ + def _get(): + with get_session_maker()() as session: + # Get all links where this memory is the source + # (bidirectional storage means we only need to query source_memory_id) + links = session.execute( + select(MemoryLink.target_memory_id) + .where(MemoryLink.source_memory_id == memory_id) + ).scalars().all() + + return list(links) + + return await run_sync(_get) diff --git a/backend/app/db/migrations.py b/backend/app/db/migrations.py index 0c07eac..ec305f6 100644 --- a/backend/app/db/migrations.py +++ b/backend/app/db/migrations.py @@ -573,6 +573,42 @@ def migration_018(conn: Connection) -> None: conn.execute(text("ALTER TABLE memories ADD COLUMN document_page_count INTEGER")) +@migration(19, "Create memory_links table for knowledge graph") +def migration_019(conn: Connection) -> None: + """Create memory_links table for bidirectional memory relationships. + + This table stores links between memories to build a knowledge graph. + Links are stored bidirectionally (A→B and B→A as separate rows) for efficient querying. + """ + result = conn.execute(text( + "SELECT name FROM sqlite_master WHERE type='table' AND name='memory_links'" + )).fetchone() + + if not result: + conn.execute(text(""" + CREATE TABLE memory_links ( + id INTEGER PRIMARY KEY, + source_memory_id INTEGER NOT NULL, + target_memory_id INTEGER NOT NULL, + link_type VARCHAR(20) DEFAULT 'manual', + relevance_score FLOAT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (source_memory_id) REFERENCES memories(id) ON DELETE CASCADE, + FOREIGN KEY (target_memory_id) REFERENCES memories(id) ON DELETE CASCADE, + CHECK (source_memory_id != target_memory_id), + UNIQUE(source_memory_id, target_memory_id) + ) + """)) + + # Create indexes for efficient querying + conn.execute(text( + "CREATE INDEX idx_memory_links_source ON memory_links(source_memory_id)" + )) + conn.execute(text( + "CREATE INDEX idx_memory_links_target ON memory_links(target_memory_id)" + )) + + # --- Migration runner --- def run_migrations(conn: Connection) -> list[tuple[int, str]]: diff --git a/backend/app/models.py b/backend/app/models.py index 89c887f..2fd4710 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -133,3 +133,17 @@ class Job(Base): created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + + +class MemoryLink(Base): + __tablename__ = "memory_links" + + id: Mapped[int] = mapped_column(primary_key=True) + source_memory_id: Mapped[int] = mapped_column(ForeignKey("memories.id", ondelete="CASCADE")) + target_memory_id: Mapped[int] = mapped_column(ForeignKey("memories.id", ondelete="CASCADE")) + link_type: Mapped[str] = mapped_column(String(20), default="manual") # "manual" or "auto" + relevance_score: Mapped[float | None] = mapped_column(Float, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + source_memory: Mapped["Memory"] = relationship(foreign_keys=[source_memory_id]) + target_memory: Mapped["Memory"] = relationship(foreign_keys=[target_memory_id]) diff --git a/backend/app/routes/__init__.py b/backend/app/routes/__init__.py index 4635ec9..81f2c61 100644 --- a/backend/app/routes/__init__.py +++ b/backend/app/routes/__init__.py @@ -9,6 +9,7 @@ from .media import router as media_router from .video import router as video_router from .document import router as document_router +from .links import router as links_router router = APIRouter() router.include_router(auth_router) @@ -20,3 +21,4 @@ router.include_router(media_router) router.include_router(video_router) router.include_router(document_router) +router.include_router(links_router) diff --git a/backend/app/routes/links.py b/backend/app/routes/links.py new file mode 100644 index 0000000..47d6ec3 --- /dev/null +++ b/backend/app/routes/links.py @@ -0,0 +1,177 @@ +"""API routes for memory links (knowledge graph).""" + +import logging +from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel, Field + +from ..db.crud.links import create_link, delete_link, get_memory_links +from ..services.links.suggestions import get_link_suggestions + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/memories", tags=["links"]) + + +class CreateLinkRequest(BaseModel): + """Request body for creating a memory link.""" + target_memory_id: int = Field(..., description="Target memory ID to link to") + link_type: str = Field(default="manual", description="Link type: 'manual' or 'auto'") + relevance_score: float | None = Field(default=None, description="AI confidence score (0.0-1.0) for auto links") + + +class LinkResponse(BaseModel): + """Response model for a memory link.""" + id: int + source_memory_id: int + target_memory_id: int + link_type: str + relevance_score: float | None + created_at: str + + +class MemoryLinkDetail(BaseModel): + """Detailed link information with connected memory details.""" + id: int + memory_id: int + title: str | None + type: str + link_type: str + relevance_score: float | None + created_at: str + + +@router.post("/{memory_id}/links", response_model=LinkResponse) +async def create_memory_link(memory_id: int, request: CreateLinkRequest): + """Create a bidirectional link between two memories. + + Creates links in both directions (A→B and B→A) for efficient querying. + + Args: + memory_id: Source memory ID + request: Link creation request with target_memory_id + + Returns: + Created link details + + Raises: + 400: Self-link or invalid relevance score + 404: Memory not found + 409: Link already exists + """ + try: + link = await create_link( + source_id=memory_id, + target_id=request.target_memory_id, + link_type=request.link_type, + relevance_score=request.relevance_score, + ) + return link + except HTTPException: + raise + except Exception as e: + logger.error(f"Error creating link: {e}") + raise HTTPException(status_code=500, detail="Failed to create link") + + +@router.delete("/{memory_id}/links/{target_id}") +async def delete_memory_link(memory_id: int, target_id: int): + """Delete a bidirectional link between two memories. + + Removes links in both directions (A→B and B→A). + + Args: + memory_id: Source memory ID + target_id: Target memory ID + + Returns: + Success status + + Raises: + 404: Link not found + """ + try: + await delete_link(source_id=memory_id, target_id=target_id) + return {"success": True} + except HTTPException: + raise + except Exception as e: + logger.error(f"Error deleting link: {e}") + raise HTTPException(status_code=500, detail="Failed to delete link") + + +@router.get("/{memory_id}/links", response_model=list[MemoryLinkDetail]) +async def get_links_for_memory(memory_id: int): + """Get all links for a memory. + + Returns a unified view of connections (bidirectional links shown as single connections). + + Args: + memory_id: Memory ID to get links for + + Returns: + List of connected memories with link details + """ + try: + links = await get_memory_links(memory_id) + return links + except Exception as e: + logger.error(f"Error fetching links: {e}") + raise HTTPException(status_code=500, detail="Failed to fetch links") + + +class MemorySuggestion(BaseModel): + """AI-suggested memory to link.""" + memory_id: int + title: str | None + summary: str | None + type: str + relevance: float + + +class SuggestionsResponse(BaseModel): + """Response model for link suggestions.""" + suggestions: list[MemorySuggestion] + + +@router.get("/{memory_id}/suggestions", response_model=SuggestionsResponse) +async def get_link_suggestions_endpoint( + memory_id: int, + limit: int = Query(5, ge=1, le=20, description="Maximum suggestions to return"), + min_relevance: float = Query(0.5, ge=0.0, le=1.0, description="Minimum relevance score (0.0-1.0)"), +): + """ + Get AI-suggested memories to link based on semantic similarity. + + Uses vector embeddings to find related memories that could be linked. + Automatically excludes already-linked memories and the source memory itself. + + Args: + memory_id: Source memory ID + limit: Maximum number of suggestions (1-20, default 5) + min_relevance: Minimum relevance score 0.0-1.0 (default 0.5) + + Returns: + List of suggested memories with relevance scores + + Notes: + - Returns empty array if memory has no embedding + - Relevance scores are 0.0-1.0 (display as percentage) + - Results are sorted by relevance (highest first) + """ + try: + # Convert min_relevance to similarity_threshold (inverted) + # min_relevance=0.5 means max_distance=0.5 + similarity_threshold = 1.0 - min_relevance + + suggestions = await get_link_suggestions( + memory_id=memory_id, + similarity_threshold=similarity_threshold, + limit=limit, + ) + + return {"suggestions": suggestions} + except Exception as e: + logger.error(f"Error fetching link suggestions: {e}") + # Fail gracefully - return empty suggestions instead of 500 error + # This allows the UI to degrade gracefully + return {"suggestions": []} diff --git a/backend/app/services/links/__init__.py b/backend/app/services/links/__init__.py new file mode 100644 index 0000000..d9c1936 --- /dev/null +++ b/backend/app/services/links/__init__.py @@ -0,0 +1 @@ +"""Services for memory links and knowledge graph.""" diff --git a/backend/app/services/links/suggestions.py b/backend/app/services/links/suggestions.py new file mode 100644 index 0000000..4f19a3c --- /dev/null +++ b/backend/app/services/links/suggestions.py @@ -0,0 +1,130 @@ +"""AI-powered link suggestion service using semantic similarity.""" + +import logging +from sqlalchemy import select + +from ...db.core import get_session_maker, run_sync +from ...db.search import search_similar_memories +from ...models import Memory +from ...db.crud.links import get_linked_memory_ids + +logger = logging.getLogger(__name__) + + +def deserialize_embedding(embedding_bytes: bytes) -> list[float]: + """Deserialize embedding bytes to list of floats.""" + import struct + num_floats = len(embedding_bytes) // 4 + return list(struct.unpack(f"{num_floats}f", embedding_bytes)) + + +async def get_link_suggestions( + memory_id: int, + similarity_threshold: float = 0.35, + limit: int = 5, +) -> list[dict]: + """ + Find related memories for linking based on semantic similarity. + + Algorithm: + 1. Get source memory's embedding + 2. Search for similar memories using vector search + 3. Exclude existing links (bidirectional check) + 4. Exclude self-links + 5. Filter by threshold and limit results + 6. Convert distance to relevance score (0-100%) + + Args: + memory_id: Source memory ID to find suggestions for + similarity_threshold: Maximum cosine distance to consider (0.35 = good match) + limit: Maximum number of suggestions to return + + Returns: + List of suggestion dictionaries with format: + [ + { + "memory_id": int, + "title": str, + "summary": str, + "type": str, + "relevance": float # 0.0-1.0 (display as percentage) + } + ] + """ + def _get_suggestions(): + with get_session_maker()() as session: + # Get source memory and its embedding + memory = session.get(Memory, memory_id) + if not memory: + logger.warning(f"Memory {memory_id} not found for suggestions") + return [] + + if not memory.embedding: + logger.warning(f"Memory {memory_id} has no embedding, cannot suggest links") + return [] + + # Deserialize embedding + try: + embedding = deserialize_embedding(memory.embedding) + except Exception as e: + logger.error(f"Failed to deserialize embedding for memory {memory_id}: {e}") + return [] + + return embedding + + # Get embedding in sync context + embedding = await run_sync(_get_suggestions) + if not embedding: + return [] + + # Search for similar memories (over-fetch to account for filtering) + try: + similar = await search_similar_memories( + query_embedding=embedding, + limit=limit * 2, # Over-fetch to account for filtering + keyword_query=None + ) + except Exception as e: + logger.error(f"Failed to search similar memories: {e}") + return [] + + # Get existing linked memory IDs + try: + linked_ids = await get_linked_memory_ids(memory_id) + except Exception as e: + logger.error(f"Failed to get linked memory IDs: {e}") + linked_ids = [] + + # Filter and format results + suggestions = [] + for result in similar: + # Skip self-links + if result["id"] == memory_id: + continue + + # Skip existing links + if result["id"] in linked_ids: + continue + + # Check threshold (distance < threshold means good match) + distance = result.get("distance", 1.0) + if distance >= similarity_threshold: + continue + + # Convert distance to relevance (0-1 score, where 1 = perfect match) + relevance = 1.0 - distance + + suggestions.append({ + "memory_id": result["id"], + "title": result["title"], + "summary": result.get("summary"), + "type": result["type"], + "relevance": relevance, + }) + + # Stop when we have enough suggestions + if len(suggestions) >= limit: + break + + logger.info(f"Found {len(suggestions)} suggestions for memory {memory_id}") + return suggestions From 5faa0a4d3c821a292dc171506dbaae76f1fd4b91 Mon Sep 17 00:00:00 2001 From: Antonio Date: Thu, 5 Feb 2026 12:48:53 +0100 Subject: [PATCH 2/2] feat(app,backend): add knowledge graph visualization Add interactive knowledge graph visualization with AI-powered insights: Frontend: - New GraphPage with force-directed graph using Reagraph - Filters for memory type, date range, and isolated nodes - Community detection with automatic coloring - AI link suggestions panel with one-click accept - "View in graph" button in memory detail panel - New tooltip, legend, and filter components Backend: - Graph data endpoint with filtering support - Analytics endpoints for centrality, communities, stats - Insights endpoints for link recommendations and health metrics - Batch link creation for auto-linking - TTL cache with invalidation for graph data - NetworkX-based graph analytics services --- .changeset/knowledge-graph-feature.md | 13 + app/package.json | 3 + app/src/App.tsx | 2 + app/src/components/GraphFilters.tsx | 243 ++++++ app/src/components/GraphInsightsPanel.tsx | 226 ++++++ app/src/components/GraphLegend.tsx | 112 +++ app/src/components/GraphVisualization.tsx | 136 ++++ app/src/components/MemoryDetailPanel.tsx | 45 +- app/src/components/Sidebar.tsx | 64 +- app/src/components/ui/tooltip.tsx | 30 + app/src/layouts/MainLayout.tsx | 15 +- app/src/lib/api.ts | 324 +++++++- app/src/lib/date-utils.ts | 10 + app/src/lib/design-tokens.ts | 41 + app/src/pages/GraphPage.tsx | 328 ++++++++ app/tailwind.config.js | 13 +- backend/app/db/crud/analytics.py | 17 + backend/app/db/crud/graph.py | 155 ++++ backend/app/db/crud/insights.py | 85 ++ backend/app/db/crud/links.py | 111 +++ backend/app/routes/__init__.py | 6 + backend/app/routes/analytics.py | 207 +++++ backend/app/routes/graph.py | 67 ++ backend/app/routes/insights.py | 199 +++++ backend/app/routes/links.py | 11 + backend/app/services/cache.py | 99 +++ backend/app/services/graph/__init__.py | 7 + backend/app/services/graph/analytics.py | 264 +++++++ backend/app/services/graph/builder.py | 53 ++ backend/app/services/graph/insights.py | 544 +++++++++++++ backend/pyproject.toml | 2 + pnpm-lock.yaml | 910 ++++++++++++++++++++++ 32 files changed, 4304 insertions(+), 38 deletions(-) create mode 100644 .changeset/knowledge-graph-feature.md create mode 100644 app/src/components/GraphFilters.tsx create mode 100644 app/src/components/GraphInsightsPanel.tsx create mode 100644 app/src/components/GraphLegend.tsx create mode 100644 app/src/components/GraphVisualization.tsx create mode 100644 app/src/components/ui/tooltip.tsx create mode 100644 app/src/lib/date-utils.ts create mode 100644 app/src/pages/GraphPage.tsx create mode 100644 backend/app/db/crud/analytics.py create mode 100644 backend/app/db/crud/graph.py create mode 100644 backend/app/db/crud/insights.py create mode 100644 backend/app/routes/analytics.py create mode 100644 backend/app/routes/graph.py create mode 100644 backend/app/routes/insights.py create mode 100644 backend/app/services/cache.py create mode 100644 backend/app/services/graph/__init__.py create mode 100644 backend/app/services/graph/analytics.py create mode 100644 backend/app/services/graph/builder.py create mode 100644 backend/app/services/graph/insights.py 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/MemoryDetailPanel.tsx b/app/src/components/MemoryDetailPanel.tsx index 76ba43d..923b679 100644 --- a/app/src/components/MemoryDetailPanel.tsx +++ b/app/src/components/MemoryDetailPanel.tsx @@ -414,7 +414,7 @@ export function MemoryDetailPanel({ }; const handleAcceptSuggestion = async (suggestion: MemorySuggestion) => { - if (!memoryId) return; + if (!memoryId || !memory) return; setAcceptingSuggestionId(suggestion.memory_id); try { @@ -432,6 +432,9 @@ export function MemoryDetailPanel({ // 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"); @@ -441,12 +444,14 @@ export function MemoryDetailPanel({ }; const handleDeleteLink = async (targetId: number) => { - if (!memoryId) return; + 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"); @@ -456,6 +461,10 @@ export function MemoryDetailPanel({ const handleLinkCreated = () => { if (memoryId) { fetchLinks(memoryId); + // Notify parent to refresh graph + if (memory) { + onMemoryUpdated(memory); + } } }; @@ -1914,15 +1923,29 @@ export function MemoryDetailPanel({ )}
- +
+ + +
{isLoadingLinks ? ( 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 ( -