Skip to content

Commit a624807

Browse files
authored
feat: add knowledge graph visualization (#108)
1 parent 52a65cd commit a624807

38 files changed

Lines changed: 5603 additions & 41 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"think-app": minor
3+
---
4+
5+
Add Knowledge Graph visualization with AI-powered link suggestions
6+
7+
- New GraphPage with force-directed graph visualization using Reagraph
8+
- Interactive filters for memory type, date range, and isolated nodes
9+
- Community detection with automatic coloring
10+
- AI-powered link suggestions panel with one-click accept
11+
- "View in graph" button in memory detail panel
12+
- Backend analytics endpoints for centrality, communities, and health metrics
13+
- Smart caching with TTL and invalidation for graph data

app/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@
7777
"@microsoft/fetch-event-source": "^2.0.1",
7878
"@radix-ui/react-dialog": "^1.1.15",
7979
"@radix-ui/react-popover": "^1.1.15",
80+
"@radix-ui/react-slot": "^1.2.4",
81+
"@radix-ui/react-tooltip": "^1.2.8",
8082
"@tiptap/extension-placeholder": "^3.13.0",
8183
"@tiptap/pm": "^3.13.0",
8284
"@tiptap/react": "^3.13.0",
@@ -92,6 +94,7 @@
9294
"react-markdown": "^10.1.0",
9395
"react-pdf": "^10.3.0",
9496
"react-router-dom": "^7.10.0",
97+
"reagraph": "^4.20.0",
9598
"sonner": "^2.0.7",
9699
"tailwind-merge": "^2.6.0"
97100
},

app/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import MainLayout from "./layouts/MainLayout";
66
import HomePage from "./pages/HomePage";
77
import ChatPage from "./pages/ChatPage";
88
import MemoriesPage from "./pages/MemoriesPage";
9+
import GraphPage from "./pages/GraphPage";
910
import SettingsPage from "./pages/SettingsPage";
1011
import RecordingPage from "./pages/RecordingPage";
1112
import { NamePromptDialog } from "./components/NamePromptDialog";
@@ -283,6 +284,7 @@ function App() {
283284
<Route path="/" element={<HomePage userName={userName} />} />
284285
<Route path="/memories" element={<MemoriesPage />} />
285286
<Route path="/chat" element={<ChatPage />} />
287+
<Route path="/graph" element={<GraphPage />} />
286288
<Route
287289
path="/settings"
288290
element={<SettingsPage onNameChange={setUserName} />}
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
import * as React from "react";
2+
import { Button } from "@/components/ui/button";
3+
import { Input } from "@/components/ui/input";
4+
import {
5+
Popover,
6+
PopoverContent,
7+
PopoverTrigger,
8+
} from "@/components/ui/popover";
9+
import { cn } from "@/lib/utils";
10+
import { glass, memoryTypeColors } from "@/lib/design-tokens";
11+
import {
12+
ChevronDown,
13+
Check,
14+
Globe,
15+
FileText,
16+
Mic,
17+
FileAudio,
18+
Video,
19+
LayoutGrid,
20+
Calendar,
21+
X,
22+
} from "lucide-react";
23+
import type { GraphFilters as GraphFiltersType } from "../lib/api";
24+
25+
const TYPE_FILTER_OPTIONS = [
26+
{ value: "all", label: "All", icon: LayoutGrid, dot: null },
27+
{ value: "web", label: "Web", icon: Globe, dot: memoryTypeColors.web.bg },
28+
{ value: "note", label: "Notes", icon: FileText, dot: memoryTypeColors.note.bg },
29+
{ value: "voice_memo", label: "Voice Memos", icon: Mic, dot: memoryTypeColors.voice_memo.bg },
30+
{ value: "audio", label: "Audio", icon: FileAudio, dot: memoryTypeColors.audio.bg },
31+
{ value: "video", label: "Video", icon: Video, dot: memoryTypeColors.video.bg },
32+
{ value: "document", label: "Documents", icon: FileText, dot: memoryTypeColors.document.bg },
33+
] as const;
34+
35+
const DATE_FILTER_OPTIONS = [
36+
{ value: "all", label: "All Time" },
37+
{ value: "today", label: "Today" },
38+
{ value: "week", label: "This Week" },
39+
{ value: "month", label: "This Month" },
40+
] as const;
41+
42+
interface GraphFiltersProps {
43+
filters: GraphFiltersType;
44+
onFiltersChange: (filters: GraphFiltersType) => void;
45+
searchQuery: string;
46+
onSearchChange: (query: string) => void;
47+
inline?: boolean;
48+
}
49+
50+
export default function GraphFilters({
51+
filters,
52+
onFiltersChange,
53+
searchQuery,
54+
onSearchChange,
55+
inline = false,
56+
}: GraphFiltersProps) {
57+
const typeFilter = filters.type || "all";
58+
const dateFilter = filters.date_range || "all";
59+
const showIsolated = filters.include_isolated !== false;
60+
61+
const [typeFilterOpen, setTypeFilterOpen] = React.useState(false);
62+
const [dateFilterOpen, setDateFilterOpen] = React.useState(false);
63+
64+
const isNonDefault = typeFilter !== "all" || dateFilter !== "all" || !showIsolated;
65+
66+
const selectedTypeOption =
67+
TYPE_FILTER_OPTIONS.find((opt) => opt.value === typeFilter) ||
68+
TYPE_FILTER_OPTIONS[0];
69+
const selectedDateOption =
70+
DATE_FILTER_OPTIONS.find((opt) => opt.value === dateFilter) ||
71+
DATE_FILTER_OPTIONS[0];
72+
73+
const TypeIcon = selectedTypeOption.icon;
74+
75+
const handleClear = () => {
76+
onFiltersChange({ type: "all", date_range: "all", include_isolated: true });
77+
};
78+
79+
const filterButtons = (
80+
<>
81+
{/* Segmented control pill */}
82+
<div className={cn(
83+
"flex items-center rounded-lg overflow-hidden",
84+
"border border-white/30 dark:border-white/[0.06]",
85+
"bg-white/20 dark:bg-white/[0.02]"
86+
)}>
87+
{/* Type Filter */}
88+
<Popover open={typeFilterOpen} onOpenChange={setTypeFilterOpen}>
89+
<PopoverTrigger asChild>
90+
<Button
91+
variant="ghost"
92+
size="sm"
93+
className={cn(
94+
"h-8 rounded-none border-r border-white/20 dark:border-white/[0.06]",
95+
typeFilter !== "all" && "bg-primary/10 text-primary"
96+
)}
97+
>
98+
{selectedTypeOption.dot ? (
99+
<div className={cn("mr-1.5 h-2.5 w-2.5 rounded-full", selectedTypeOption.dot)} />
100+
) : (
101+
<TypeIcon className="mr-1.5 h-3.5 w-3.5 text-muted-foreground" />
102+
)}
103+
{selectedTypeOption.label}
104+
<ChevronDown className="ml-1.5 h-3 w-3" />
105+
</Button>
106+
</PopoverTrigger>
107+
<PopoverContent className="w-48 p-2" align="start">
108+
{TYPE_FILTER_OPTIONS.map((option) => {
109+
const Icon = option.icon;
110+
return (
111+
<button
112+
key={option.value}
113+
onClick={() => {
114+
onFiltersChange({ ...filters, type: option.value });
115+
setTypeFilterOpen(false);
116+
}}
117+
className={cn(
118+
"flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent",
119+
typeFilter === option.value && "bg-accent"
120+
)}
121+
>
122+
{option.dot ? (
123+
<div className={cn("h-3 w-3 rounded-full", option.dot)} />
124+
) : (
125+
<Icon className="h-4 w-4 text-muted-foreground" />
126+
)}
127+
<span className="flex-1 text-left">{option.label}</span>
128+
{typeFilter === option.value && (
129+
<Check className="h-4 w-4 text-primary" />
130+
)}
131+
</button>
132+
);
133+
})}
134+
</PopoverContent>
135+
</Popover>
136+
137+
{/* Date Filter */}
138+
<Popover open={dateFilterOpen} onOpenChange={setDateFilterOpen}>
139+
<PopoverTrigger asChild>
140+
<Button
141+
variant="ghost"
142+
size="sm"
143+
className={cn(
144+
"h-8 rounded-none border-r border-white/20 dark:border-white/[0.06]",
145+
dateFilter !== "all" && "bg-primary/10 text-primary"
146+
)}
147+
>
148+
<Calendar className={cn("mr-1.5 h-3.5 w-3.5", dateFilter !== "all" && "text-primary")} />
149+
{selectedDateOption.label}
150+
<ChevronDown className="ml-1.5 h-3 w-3" />
151+
</Button>
152+
</PopoverTrigger>
153+
<PopoverContent className="w-48 p-2" align="start">
154+
{DATE_FILTER_OPTIONS.map((option) => (
155+
<button
156+
key={option.value}
157+
onClick={() => {
158+
onFiltersChange({ ...filters, date_range: option.value });
159+
setDateFilterOpen(false);
160+
}}
161+
className={cn(
162+
"flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent",
163+
dateFilter === option.value && "bg-accent"
164+
)}
165+
>
166+
<span className="flex-1 text-left">{option.label}</span>
167+
{dateFilter === option.value && (
168+
<Check className="h-4 w-4 text-primary" />
169+
)}
170+
</button>
171+
))}
172+
</PopoverContent>
173+
</Popover>
174+
175+
{/* Show Isolated Toggle */}
176+
<Button
177+
variant="ghost"
178+
size="sm"
179+
className={cn(
180+
"h-8 rounded-none",
181+
!showIsolated && "bg-primary/10 text-primary"
182+
)}
183+
onClick={() => {
184+
onFiltersChange({ ...filters, include_isolated: !showIsolated });
185+
}}
186+
>
187+
<Check
188+
className={cn("mr-1.5 h-3.5 w-3.5", !showIsolated && "opacity-0")}
189+
/>
190+
Isolated
191+
</Button>
192+
</div>
193+
194+
{/* Clear button - visible only when non-default */}
195+
{isNonDefault && (
196+
<Button
197+
variant="ghost"
198+
size="sm"
199+
className="h-8 px-2 text-muted-foreground hover:text-foreground"
200+
onClick={handleClear}
201+
>
202+
<X className="h-3.5 w-3.5 mr-1" />
203+
Clear
204+
</Button>
205+
)}
206+
</>
207+
);
208+
209+
// When inline, skip the outer glass wrapper - parent toolbar provides it
210+
if (inline) {
211+
return (
212+
<div className="flex items-center gap-2 flex-1 min-w-0">
213+
{filterButtons}
214+
<div className="ml-auto max-w-xs flex-1">
215+
<Input
216+
placeholder="Search memories..."
217+
value={searchQuery}
218+
onChange={(e) => onSearchChange(e.target.value)}
219+
className="h-8 text-sm"
220+
/>
221+
</div>
222+
</div>
223+
);
224+
}
225+
226+
return (
227+
<div className={cn(
228+
"flex items-center gap-3 px-4 py-3",
229+
glass.base,
230+
"rounded-none border-t-0 border-l-0 border-r-0"
231+
)}>
232+
{filterButtons}
233+
<div className="ml-auto max-w-md flex-1">
234+
<Input
235+
placeholder="Search memories..."
236+
value={searchQuery}
237+
onChange={(e) => onSearchChange(e.target.value)}
238+
className="h-9"
239+
/>
240+
</div>
241+
</div>
242+
);
243+
}

0 commit comments

Comments
 (0)