From 13c9bd502907da02b31cb70f470dd4adc3900767 Mon Sep 17 00:00:00 2001 From: Luca Sacchi Ricciardi Date: Mon, 6 Apr 2026 18:20:06 +0200 Subject: [PATCH] feat(frontend): add notebook selection and management UI Implement complete frontend integration for NotebookLM + RAG: New Components: - Notebooks.tsx: Full notebook management page - View indexed notebooks with stats - Sync available notebooks from NotebookLM - Delete notebook indexes - Tab-based interface (Indexed/Available) Updated Components: - Chat.tsx: Enhanced with notebook selector - Collapsible notebook selection panel - Multi-notebook selection with checkboxes - 'Include documents' toggle - Visual indicators for notebook sources in chat - Shows selected notebook count in header New Store: - notebookStore.ts: Zustand store for notebook state - Manage selected notebooks - Track sync status - Fetch indexed/available notebooks - Handle sync/delete operations API Client Updates: - Added notebook-related endpoints: - getNotebooks(), getIndexedNotebooks() - syncNotebook(), deleteNotebookIndex() - getSyncStatus(), queryWithNotebooks() Type Definitions: - Notebook, IndexedNotebook, SyncStatus - SyncResponse, NotebookLMSource - Extended Source types for notebook metadata UI/UX: - Added Notebooks to navigation menu - Stats cards showing indexed/total sources/chunks - Color-coded source badges (blue for notebooks) - Warning when no notebooks indexed - Loading states for all operations Closes frontend integration requirement --- frontend/src/App.tsx | 9 + frontend/src/api/client.ts | 76 ++++- frontend/src/components/layout/Layout.tsx | 2 + frontend/src/pages/Chat.tsx | 219 ++++++++++++- frontend/src/pages/Notebooks.tsx | 367 ++++++++++++++++++++++ frontend/src/stores/index.ts | 3 +- frontend/src/stores/notebookStore.ts | 160 ++++++++++ frontend/src/types/index.ts | 57 ++++ 8 files changed, 875 insertions(+), 18 deletions(-) create mode 100644 frontend/src/pages/Notebooks.tsx create mode 100644 frontend/src/stores/notebookStore.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f96ebbf..1b454b5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ import { Layout } from '@/components/layout/Layout'; import { Login } from '@/pages/Login'; import { Dashboard } from '@/pages/Dashboard'; import { Documents } from '@/pages/Documents'; +import { Notebooks } from '@/pages/Notebooks'; import { Chat } from '@/pages/Chat'; import { Settings } from '@/pages/Settings'; import { useAuthStore } from '@/stores/authStore'; @@ -81,6 +82,14 @@ function AppContent() { } /> + + + + } + /> { + return this.get('/notebooks'); + } + + async syncNotebook(notebookId: string): Promise { + return this.post(`/notebooklm/sync/${notebookId}`); + } + + async getIndexedNotebooks(): Promise<{ notebooks: IndexedNotebook[]; total: number }> { + return this.get<{ notebooks: IndexedNotebook[]; total: number }>('/notebooklm/indexed'); + } + + async getSyncStatus(notebookId: string): Promise { + return this.get(`/notebooklm/sync/${notebookId}/status`); + } + + async deleteNotebookIndex(notebookId: string): Promise<{ notebook_id: string; deleted: boolean; message: string }> { + return this.delete<{ notebook_id: string; deleted: boolean; message: string }>(`/notebooklm/sync/${notebookId}`); + } + + async queryWithNotebooks( + question: string, + notebookIds: string[], + options?: { + includeDocuments?: boolean; + provider?: string; + model?: string; + k?: number; + } + ): Promise<{ + question: string; + answer: string; + sources: (Source | NotebookLMSource)[]; + provider: string; + model: string; + filters_applied?: { + notebook_ids: string[]; + include_documents: boolean; + }; + }> { + if (options?.includeDocuments !== false) { + // Query mista (documenti + notebook) + return this.post('/query', { + question, + notebook_ids: notebookIds, + include_documents: options?.includeDocuments ?? true, + provider: options?.provider, + model: options?.model, + k: options?.k ?? 5, + }); + } else { + // Query solo sui notebook + return this.post('/query/notebooks', { + question, + notebook_ids: notebookIds, + provider: options?.provider, + model: options?.model, + k: options?.k ?? 5, + }); + } + } + // Health check async healthCheck(): Promise<{ status: string; version: string }> { const response = await axios.get(`${API_BASE_URL}/api/health`); @@ -120,7 +183,18 @@ class ApiClient { } // Import types -import type { Document, Provider, Model, Source, SystemConfig } from '@/types'; +import type { + Document, + Provider, + Model, + Source, + SystemConfig, + Notebook, + IndexedNotebook, + SyncStatus, + SyncResponse, + NotebookLMSource +} from '@/types'; export const apiClient = new ApiClient(); export default apiClient; \ No newline at end of file diff --git a/frontend/src/components/layout/Layout.tsx b/frontend/src/components/layout/Layout.tsx index f1d37b7..5dfe830 100644 --- a/frontend/src/components/layout/Layout.tsx +++ b/frontend/src/components/layout/Layout.tsx @@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button'; import { LayoutDashboard, FileText, + BookOpen, MessageSquare, Settings, Menu, @@ -23,6 +24,7 @@ interface LayoutProps { const navigation = [ { name: 'Dashboard', href: '/', icon: LayoutDashboard }, { name: 'Documents', href: '/documents', icon: FileText }, + { name: 'Notebooks', href: '/notebooks', icon: BookOpen }, { name: 'Chat', href: '/chat', icon: MessageSquare }, { name: 'Settings', href: '/settings', icon: Settings }, ]; diff --git a/frontend/src/pages/Chat.tsx b/frontend/src/pages/Chat.tsx index e597fd3..49df44f 100644 --- a/frontend/src/pages/Chat.tsx +++ b/frontend/src/pages/Chat.tsx @@ -5,18 +5,26 @@ import { Input } from '@/components/ui/input'; import { apiClient } from '@/api/client'; import { useChatStore } from '@/stores/chatStore'; import { useSettingsStore } from '@/stores/settingsStore'; +import { useNotebookStore } from '@/stores/notebookStore'; import { Send, Loader2, Bot, User, FileText, - Plus + Plus, + BookOpen, + AlertCircle, + ChevronDown, + ChevronUp, + Database, + Layers } from 'lucide-react'; -import type { Message, Source } from '@/types'; +import type { Message, Source, NotebookLMSource } from '@/types'; export function Chat() { const [input, setInput] = useState(''); + const [showNotebookSelector, setShowNotebookSelector] = useState(false); const messagesEndRef = useRef(null); const { @@ -28,12 +36,29 @@ export function Chat() { } = useChatStore(); const { defaultProvider, defaultModel } = useSettingsStore(); + + const { + indexedNotebooks, + selectedNotebooks, + includeDocuments, + isLoading: isNotebooksLoading, + fetchIndexedNotebooks, + toggleNotebookSelection, + selectAllIndexed, + deselectAll, + setIncludeDocuments, + } = useNotebookStore(); // Auto-scroll to bottom useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); + // Fetch indexed notebooks on mount + useEffect(() => { + fetchIndexedNotebooks(); + }, [fetchIndexedNotebooks]); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!input.trim() || isLoading) return; @@ -50,16 +75,33 @@ export function Chat() { setLoading(true); try { - const response = await apiClient.query( - userMessage.content, - defaultProvider, - defaultModel - ); + let response; + + // If notebooks are selected, use the notebook-aware query + if (selectedNotebooks.length > 0) { + response = await apiClient.queryWithNotebooks( + userMessage.content, + selectedNotebooks, + { + includeDocuments, + provider: defaultProvider, + model: defaultModel, + k: 10, + } + ); + } else { + // Standard query without notebooks + response = await apiClient.query( + userMessage.content, + defaultProvider, + defaultModel + ); + } const assistantMessage: Message = { id: (Date.now() + 1).toString(), role: 'assistant', - content: response.response, + content: response.answer || response.response, sources: response.sources, timestamp: new Date().toISOString(), }; @@ -87,6 +129,9 @@ export function Chat() { } }; + const hasNotebooks = indexedNotebooks.length > 0; + const allSelected = selectedNotebooks.length === indexedNotebooks.length && indexedNotebooks.length > 0; + return (
{/* Header */} @@ -94,7 +139,7 @@ export function Chat() {

Chat

- Ask questions about your documents + Ask questions about your documents and notebooks

+ {/* Notebook Selector */} + {hasNotebooks && ( + + +
setShowNotebookSelector(!showNotebookSelector)} + > +
+ + + {selectedNotebooks.length > 0 + ? `${selectedNotebooks.length} notebook${selectedNotebooks.length > 1 ? 's' : ''} selected` + : 'No notebooks selected' + } + + {includeDocuments && ( + + + + documents + + )} +
+
+ {isNotebooksLoading && } + {showNotebookSelector ? ( + + ) : ( + + )} +
+
+ + {showNotebookSelector && ( +
+ {/* Options */} +
+ +
+ + +
+
+ + {/* Notebook List */} +
+ {indexedNotebooks.map((notebook) => ( + + ))} +
+
+ )} +
+
+ )} + + {/* No Notebooks Warning */} + {!hasNotebooks && !isNotebooksLoading && ( + + +
+ +
+

+ No notebooks indexed +

+

+ Sync your NotebookLM notebooks to search through them. + Use the API to sync: POST /api/v1/notebooklm/sync/{'{notebook_id}'} +

+
+
+
+
+ )} + {/* Chat Container */} {/* Messages */} @@ -111,12 +273,15 @@ export function Chat() {

Start a conversation

-

Ask questions about your uploaded documents

+

+ Ask questions about your uploaded documents + {hasNotebooks && ' and synced notebooks'} +

{[ - 'What are the main topics in my documents?', + 'What are the main topics?', 'Summarize the key points', - 'Find information about...', + hasNotebooks ? 'What do my notebooks say about...' : 'Find information about...', 'Explain the concept of...', ].map((suggestion) => (
@@ -224,12 +393,30 @@ function ChatMessage({ message }: { message: Message }) { } // Source Badge Component -function SourceBadge({ source }: { source: Source }) { +function SourceBadge({ source }: { source: Source | NotebookLMSource }) { + // Check if it's a notebook source + const isNotebookSource = 'source_type' in source && source.source_type === 'notebooklm'; + + if (isNotebookSource) { + const notebookSource = source as NotebookLMSource; + return ( +
+ + {notebookSource.source_title} + + {notebookSource.notebook_title} +
+ ); + } + + // Regular document source return (
{source.document_name} - ({(source.score * 100).toFixed(0)}%) + {'score' in source && ( + ({(source.score * 100).toFixed(0)}%) + )}
); -} \ No newline at end of file +} diff --git a/frontend/src/pages/Notebooks.tsx b/frontend/src/pages/Notebooks.tsx new file mode 100644 index 0000000..1fa877b --- /dev/null +++ b/frontend/src/pages/Notebooks.tsx @@ -0,0 +1,367 @@ +import { useEffect, useState } from 'react'; +import { Card, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { apiClient } from '@/api/client'; +import { useNotebookStore } from '@/stores/notebookStore'; +import { + BookOpen, + RefreshCw, + Trash2, + Check, + AlertCircle, + Loader2, + Database, + FileText, + Clock, + Plus +} from 'lucide-react'; +import type { Notebook, IndexedNotebook } from '@/types'; + +export function Notebooks() { + const [activeTab, setActiveTab] = useState<'available' | 'indexed'>('indexed'); + const [availableNotebooks, setAvailableNotebooks] = useState([]); + const [isLoadingAvailable, setIsLoadingAvailable] = useState(false); + + const { + indexedNotebooks, + isLoading, + isSyncing, + fetchIndexedNotebooks, + syncNotebook, + deleteNotebookIndex, + } = useNotebookStore(); + + // Load indexed notebooks on mount + useEffect(() => { + fetchIndexedNotebooks(); + }, [fetchIndexedNotebooks]); + + // Load available notebooks when tab changes + useEffect(() => { + if (activeTab === 'available') { + loadAvailableNotebooks(); + } + }, [activeTab]); + + const loadAvailableNotebooks = async () => { + setIsLoadingAvailable(true); + try { + const notebooks = await apiClient.getNotebooks(); + setAvailableNotebooks(notebooks); + } catch (error) { + console.error('Error loading available notebooks:', error); + } finally { + setIsLoadingAvailable(false); + } + }; + + const handleSync = async (notebookId: string) => { + await syncNotebook(notebookId); + // Refresh both lists + await fetchIndexedNotebooks(); + if (activeTab === 'available') { + await loadAvailableNotebooks(); + } + }; + + const handleDelete = async (notebookId: string) => { + if (confirm('Are you sure you want to remove this notebook from the search index?')) { + await deleteNotebookIndex(notebookId); + } + }; + + return ( +
+ {/* Header */} +
+
+

Notebooks

+

+ Manage your NotebookLM notebooks and sync them for search +

+
+ +
+ + {/* Stats */} +
+ + +
+
+ +
+
+

{indexedNotebooks.length}

+

Indexed Notebooks

+
+
+
+
+ + + +
+
+ +
+
+

+ {indexedNotebooks.reduce((acc, n) => acc + n.sources_count, 0)} +

+

Total Sources

+
+
+
+
+ + + +
+
+ +
+
+

+ {indexedNotebooks.reduce((acc, n) => acc + n.chunks_count, 0)} +

+

Total Chunks

+
+
+
+
+
+ + {/* Tabs */} +
+ + +
+ + {/* Content */} + {activeTab === 'indexed' ? ( + + ) : ( + n.notebook_id))} + onSync={handleSync} + isSyncing={isSyncing} + /> + )} +
+ ); +} + +// Indexed Notebooks List Component +function IndexedNotebooksList({ + notebooks, + isLoading, + onDelete, + onRefresh +}: { + notebooks: IndexedNotebook[]; + isLoading: boolean; + onDelete: (id: string) => void; + onRefresh: () => void; +}) { + if (isLoading) { + return ( +
+ +
+ ); + } + + if (notebooks.length === 0) { + return ( + + + +

No notebooks indexed

+

+ Sync your NotebookLM notebooks to search through them using RAG. + Switch to the "Available" tab to see notebooks you can sync. +

+ +
+
+ ); + } + + return ( +
+ {notebooks.map((notebook) => ( + + +
+
+
+ +
+
+

+ {notebook.notebook_title || 'Untitled Notebook'} +

+
+ + + {notebook.sources_count} sources + + + + {notebook.chunks_count} chunks + +
+ {notebook.last_sync && ( +

+ Last synced: {new Date(notebook.last_sync).toLocaleString()} +

+ )} +
+
+ +
+
+
+ ))} +
+ ); +} + +// Available Notebooks List Component +function AvailableNotebooksList({ + notebooks, + isLoading, + indexedIds, + onSync, + isSyncing, +}: { + notebooks: Notebook[]; + isLoading: boolean; + indexedIds: Set; + onSync: (id: string) => void; + isSyncing: boolean; +}) { + if (isLoading) { + return ( +
+ +
+ ); + } + + if (notebooks.length === 0) { + return ( + + + +

No notebooks found

+

+ No notebooks found in your NotebookLM account. + Make sure you're authenticated with NotebookLM. +

+
+
+ ); + } + + return ( +
+ {notebooks.map((notebook) => { + const isIndexed = indexedIds.has(notebook.id); + + return ( + + +
+
+
+ {isIndexed ? ( + + ) : ( + + )} +
+
+

{notebook.title}

+

+ {notebook.sources_count !== undefined && ( + <>{notebook.sources_count} sources • + )} + Created {new Date(notebook.created_at).toLocaleDateString()} +

+
+
+ + +
+
+
+ ); + })} +
+ ); +} diff --git a/frontend/src/stores/index.ts b/frontend/src/stores/index.ts index 15d30b7..ed217b1 100644 --- a/frontend/src/stores/index.ts +++ b/frontend/src/stores/index.ts @@ -1,3 +1,4 @@ export { useAuthStore } from './authStore'; export { useChatStore } from './chatStore'; -export { useSettingsStore } from './settingsStore'; \ No newline at end of file +export { useSettingsStore } from './settingsStore'; +export { useNotebookStore } from './notebookStore'; \ No newline at end of file diff --git a/frontend/src/stores/notebookStore.ts b/frontend/src/stores/notebookStore.ts new file mode 100644 index 0000000..2e394f2 --- /dev/null +++ b/frontend/src/stores/notebookStore.ts @@ -0,0 +1,160 @@ +import { create } from 'zustand'; +import { apiClient } from '@/api/client'; +import type { Notebook, IndexedNotebook, SyncStatus } from '@/types'; + +interface NotebookState { + // State + notebooks: Notebook[]; + indexedNotebooks: IndexedNotebook[]; + selectedNotebooks: string[]; + syncStatus: Map; + isLoading: boolean; + isSyncing: boolean; + error: string | null; + includeDocuments: boolean; + + // Actions + fetchNotebooks: () => Promise; + fetchIndexedNotebooks: () => Promise; + syncNotebook: (notebookId: string) => Promise; + deleteNotebookIndex: (notebookId: string) => Promise; + checkSyncStatus: (notebookId: string) => Promise; + toggleNotebookSelection: (notebookId: string) => void; + selectAllIndexed: () => void; + deselectAll: () => void; + setIncludeDocuments: (include: boolean) => void; + clearError: () => void; +} + +export const useNotebookStore = create((set, get) => ({ + // Initial state + notebooks: [], + indexedNotebooks: [], + selectedNotebooks: [], + syncStatus: new Map(), + isLoading: false, + isSyncing: false, + error: null, + includeDocuments: true, + + // Fetch all notebooks from NotebookLM + fetchNotebooks: async () => { + set({ isLoading: true, error: null }); + try { + const notebooks = await apiClient.getNotebooks(); + set({ notebooks }); + } catch (error) { + console.error('Error fetching notebooks:', error); + set({ error: 'Failed to fetch notebooks' }); + } finally { + set({ isLoading: false }); + } + }, + + // Fetch indexed notebooks + fetchIndexedNotebooks: async () => { + set({ isLoading: true, error: null }); + try { + const response = await apiClient.getIndexedNotebooks(); + set({ indexedNotebooks: response.notebooks }); + + // Automatically select all indexed notebooks by default + if (get().selectedNotebooks.length === 0 && response.notebooks.length > 0) { + set({ + selectedNotebooks: response.notebooks.map(n => n.notebook_id) + }); + } + } catch (error) { + console.error('Error fetching indexed notebooks:', error); + set({ error: 'Failed to fetch indexed notebooks' }); + } finally { + set({ isLoading: false }); + } + }, + + // Sync a notebook + syncNotebook: async (notebookId: string) => { + set({ isSyncing: true, error: null }); + try { + await apiClient.syncNotebook(notebookId); + // Refresh indexed notebooks after sync + await get().fetchIndexedNotebooks(); + // Check status + await get().checkSyncStatus(notebookId); + } catch (error) { + console.error('Error syncing notebook:', error); + set({ error: 'Failed to sync notebook' }); + } finally { + set({ isSyncing: false }); + } + }, + + // Delete notebook index + deleteNotebookIndex: async (notebookId: string) => { + set({ isLoading: true, error: null }); + try { + await apiClient.deleteNotebookIndex(notebookId); + // Remove from selected if present + set((state) => ({ + selectedNotebooks: state.selectedNotebooks.filter(id => id !== notebookId), + indexedNotebooks: state.indexedNotebooks.filter(n => n.notebook_id !== notebookId), + })); + } catch (error) { + console.error('Error deleting notebook index:', error); + set({ error: 'Failed to delete notebook index' }); + } finally { + set({ isLoading: false }); + } + }, + + // Check sync status + checkSyncStatus: async (notebookId: string) => { + try { + const status = await apiClient.getSyncStatus(notebookId); + set((state) => ({ + syncStatus: new Map(state.syncStatus).set(notebookId, status), + })); + } catch (error) { + console.error('Error checking sync status:', error); + } + }, + + // Toggle notebook selection + toggleNotebookSelection: (notebookId: string) => { + set((state) => { + const isSelected = state.selectedNotebooks.includes(notebookId); + if (isSelected) { + return { + selectedNotebooks: state.selectedNotebooks.filter(id => id !== notebookId), + }; + } else { + return { + selectedNotebooks: [...state.selectedNotebooks, notebookId], + }; + } + }); + }, + + // Select all indexed notebooks + selectAllIndexed: () => { + const { indexedNotebooks } = get(); + set({ + selectedNotebooks: indexedNotebooks.map(n => n.notebook_id), + }); + }, + + // Deselect all notebooks + deselectAll: () => { + set({ selectedNotebooks: [] }); + }, + + // Set include documents option + setIncludeDocuments: (include: boolean) => { + set({ includeDocuments: include }); + }, + + // Clear error + clearError: () => { + set({ error: null }); + }, +})); diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 7319ab2..4ca6cc9 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -72,4 +72,61 @@ export interface SystemConfig { configured_providers: string[]; qdrant_host: string; qdrant_port: number; +} + +// NotebookLM Integration types +export interface Notebook { + id: string; + title: string; + description?: string; + created_at: string; + updated_at: string; + sources_count?: number; + artifacts_count?: number; +} + +export interface NotebookSource { + id: string; + notebook_id: string; + type: 'url' | 'file' | 'youtube' | 'drive'; + title: string; + url?: string; + status: 'processing' | 'ready' | 'error'; + created_at: string; +} + +export interface IndexedNotebook { + notebook_id: string; + notebook_title: string | null; + sources_count: number; + chunks_count: number; + last_sync: string | null; +} + +export interface SyncStatus { + notebook_id: string; + status: 'indexed' | 'not_indexed' | 'syncing'; + sources_count?: number; + chunks_count?: number; + last_sync?: string; + message?: string; +} + +export interface SyncResponse { + sync_id: string; + notebook_id: string; + notebook_title: string | null; + status: string; + sources_indexed: number; + total_chunks: number; + message: string; +} + +// Extended Source type for notebook sources +export interface NotebookLMSource extends Source { + source_type: 'notebooklm'; + notebook_id: string; + notebook_title: string; + source_id: string; + source_title: string; } \ No newline at end of file