feat(frontend): add notebook selection and management UI
Some checks failed
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
CI / lint (push) Has been cancelled

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
This commit is contained in:
Luca Sacchi Ricciardi
2026-04-06 18:20:06 +02:00
parent 568489cae4
commit 13c9bd5029
8 changed files with 875 additions and 18 deletions

View File

@@ -4,6 +4,7 @@ import { Layout } from '@/components/layout/Layout';
import { Login } from '@/pages/Login'; import { Login } from '@/pages/Login';
import { Dashboard } from '@/pages/Dashboard'; import { Dashboard } from '@/pages/Dashboard';
import { Documents } from '@/pages/Documents'; import { Documents } from '@/pages/Documents';
import { Notebooks } from '@/pages/Notebooks';
import { Chat } from '@/pages/Chat'; import { Chat } from '@/pages/Chat';
import { Settings } from '@/pages/Settings'; import { Settings } from '@/pages/Settings';
import { useAuthStore } from '@/stores/authStore'; import { useAuthStore } from '@/stores/authStore';
@@ -81,6 +82,14 @@ function AppContent() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/notebooks"
element={
<ProtectedRoute>
<Notebooks />
</ProtectedRoute>
}
/>
<Route <Route
path="/chat" path="/chat"
element={ element={

View File

@@ -112,6 +112,69 @@ class ApiClient {
return this.put('/config/provider', { provider, model }); return this.put('/config/provider', { provider, model });
} }
// NotebookLM Integration API
async getNotebooks(): Promise<Notebook[]> {
return this.get<Notebook[]>('/notebooks');
}
async syncNotebook(notebookId: string): Promise<SyncResponse> {
return this.post<SyncResponse>(`/notebooklm/sync/${notebookId}`);
}
async getIndexedNotebooks(): Promise<{ notebooks: IndexedNotebook[]; total: number }> {
return this.get<{ notebooks: IndexedNotebook[]; total: number }>('/notebooklm/indexed');
}
async getSyncStatus(notebookId: string): Promise<SyncStatus> {
return this.get<SyncStatus>(`/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 // Health check
async healthCheck(): Promise<{ status: string; version: string }> { async healthCheck(): Promise<{ status: string; version: string }> {
const response = await axios.get(`${API_BASE_URL}/api/health`); const response = await axios.get(`${API_BASE_URL}/api/health`);
@@ -120,7 +183,18 @@ class ApiClient {
} }
// Import types // 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 const apiClient = new ApiClient();
export default apiClient; export default apiClient;

View File

@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
import { import {
LayoutDashboard, LayoutDashboard,
FileText, FileText,
BookOpen,
MessageSquare, MessageSquare,
Settings, Settings,
Menu, Menu,
@@ -23,6 +24,7 @@ interface LayoutProps {
const navigation = [ const navigation = [
{ name: 'Dashboard', href: '/', icon: LayoutDashboard }, { name: 'Dashboard', href: '/', icon: LayoutDashboard },
{ name: 'Documents', href: '/documents', icon: FileText }, { name: 'Documents', href: '/documents', icon: FileText },
{ name: 'Notebooks', href: '/notebooks', icon: BookOpen },
{ name: 'Chat', href: '/chat', icon: MessageSquare }, { name: 'Chat', href: '/chat', icon: MessageSquare },
{ name: 'Settings', href: '/settings', icon: Settings }, { name: 'Settings', href: '/settings', icon: Settings },
]; ];

View File

@@ -5,18 +5,26 @@ import { Input } from '@/components/ui/input';
import { apiClient } from '@/api/client'; import { apiClient } from '@/api/client';
import { useChatStore } from '@/stores/chatStore'; import { useChatStore } from '@/stores/chatStore';
import { useSettingsStore } from '@/stores/settingsStore'; import { useSettingsStore } from '@/stores/settingsStore';
import { useNotebookStore } from '@/stores/notebookStore';
import { import {
Send, Send,
Loader2, Loader2,
Bot, Bot,
User, User,
FileText, FileText,
Plus Plus,
BookOpen,
AlertCircle,
ChevronDown,
ChevronUp,
Database,
Layers
} from 'lucide-react'; } from 'lucide-react';
import type { Message, Source } from '@/types'; import type { Message, Source, NotebookLMSource } from '@/types';
export function Chat() { export function Chat() {
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [showNotebookSelector, setShowNotebookSelector] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const { const {
@@ -29,11 +37,28 @@ export function Chat() {
const { defaultProvider, defaultModel } = useSettingsStore(); const { defaultProvider, defaultModel } = useSettingsStore();
const {
indexedNotebooks,
selectedNotebooks,
includeDocuments,
isLoading: isNotebooksLoading,
fetchIndexedNotebooks,
toggleNotebookSelection,
selectAllIndexed,
deselectAll,
setIncludeDocuments,
} = useNotebookStore();
// Auto-scroll to bottom // Auto-scroll to bottom
useEffect(() => { useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]); }, [messages]);
// Fetch indexed notebooks on mount
useEffect(() => {
fetchIndexedNotebooks();
}, [fetchIndexedNotebooks]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!input.trim() || isLoading) return; if (!input.trim() || isLoading) return;
@@ -50,16 +75,33 @@ export function Chat() {
setLoading(true); setLoading(true);
try { try {
const response = await apiClient.query( let response;
userMessage.content,
defaultProvider, // If notebooks are selected, use the notebook-aware query
defaultModel 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 = { const assistantMessage: Message = {
id: (Date.now() + 1).toString(), id: (Date.now() + 1).toString(),
role: 'assistant', role: 'assistant',
content: response.response, content: response.answer || response.response,
sources: response.sources, sources: response.sources,
timestamp: new Date().toISOString(), 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 ( return (
<div className="flex flex-col h-[calc(100vh-8rem)]"> <div className="flex flex-col h-[calc(100vh-8rem)]">
{/* Header */} {/* Header */}
@@ -94,7 +139,7 @@ export function Chat() {
<div> <div>
<h1 className="text-3xl font-bold tracking-tight">Chat</h1> <h1 className="text-3xl font-bold tracking-tight">Chat</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Ask questions about your documents Ask questions about your documents and notebooks
</p> </p>
</div> </div>
<Button variant="outline" onClick={handleNewChat}> <Button variant="outline" onClick={handleNewChat}>
@@ -103,6 +148,123 @@ export function Chat() {
</Button> </Button>
</div> </div>
{/* Notebook Selector */}
{hasNotebooks && (
<Card className="mb-4">
<CardContent className="p-4">
<div
className="flex items-center justify-between cursor-pointer"
onClick={() => setShowNotebookSelector(!showNotebookSelector)}
>
<div className="flex items-center space-x-2">
<BookOpen className="h-5 w-5 text-primary" />
<span className="font-medium">
{selectedNotebooks.length > 0
? `${selectedNotebooks.length} notebook${selectedNotebooks.length > 1 ? 's' : ''} selected`
: 'No notebooks selected'
}
</span>
{includeDocuments && (
<span className="text-sm text-muted-foreground flex items-center">
<Layers className="h-3 w-3 mr-1" />
+ documents
</span>
)}
</div>
<div className="flex items-center space-x-2">
{isNotebooksLoading && <Loader2 className="h-4 w-4 animate-spin" />}
{showNotebookSelector ? (
<ChevronUp className="h-5 w-5" />
) : (
<ChevronDown className="h-5 w-5" />
)}
</div>
</div>
{showNotebookSelector && (
<div className="mt-4 space-y-4">
{/* Options */}
<div className="flex items-center justify-between pb-3 border-b">
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
checked={includeDocuments}
onChange={(e) => setIncludeDocuments(e.target.checked)}
className="rounded border-gray-300"
/>
<span className="text-sm">Include uploaded documents</span>
</label>
<div className="space-x-2">
<Button
variant="ghost"
size="sm"
onClick={selectAllIndexed}
disabled={allSelected}
>
Select All
</Button>
<Button
variant="ghost"
size="sm"
onClick={deselectAll}
disabled={selectedNotebooks.length === 0}
>
Deselect All
</Button>
</div>
</div>
{/* Notebook List */}
<div className="space-y-2 max-h-48 overflow-y-auto">
{indexedNotebooks.map((notebook) => (
<label
key={notebook.notebook_id}
className="flex items-start space-x-3 p-2 rounded-lg hover:bg-muted cursor-pointer"
>
<input
type="checkbox"
checked={selectedNotebooks.includes(notebook.notebook_id)}
onChange={() => toggleNotebookSelection(notebook.notebook_id)}
className="mt-1 rounded border-gray-300"
/>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">
{notebook.notebook_title || 'Untitled Notebook'}
</p>
<p className="text-xs text-muted-foreground">
{notebook.sources_count} sources {notebook.chunks_count} chunks
</p>
</div>
<Database className="h-4 w-4 text-muted-foreground flex-shrink-0" />
</label>
))}
</div>
</div>
)}
</CardContent>
</Card>
)}
{/* No Notebooks Warning */}
{!hasNotebooks && !isNotebooksLoading && (
<Card className="mb-4 border-yellow-200 bg-yellow-50 dark:bg-yellow-900/20">
<CardContent className="p-4">
<div className="flex items-start space-x-3">
<AlertCircle className="h-5 w-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-sm text-yellow-800 dark:text-yellow-200">
No notebooks indexed
</p>
<p className="text-sm text-yellow-700 dark:text-yellow-300 mt-1">
Sync your NotebookLM notebooks to search through them.
Use the API to sync: POST /api/v1/notebooklm/sync/{'{notebook_id}'}
</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* Chat Container */} {/* Chat Container */}
<Card className="flex-1 flex flex-col overflow-hidden"> <Card className="flex-1 flex flex-col overflow-hidden">
{/* Messages */} {/* Messages */}
@@ -111,12 +273,15 @@ export function Chat() {
<div className="flex flex-col items-center justify-center h-full text-muted-foreground"> <div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<Bot className="h-16 w-16 mb-4 opacity-50" /> <Bot className="h-16 w-16 mb-4 opacity-50" />
<p className="text-lg font-medium">Start a conversation</p> <p className="text-lg font-medium">Start a conversation</p>
<p className="text-sm">Ask questions about your uploaded documents</p> <p className="text-sm text-center max-w-md">
Ask questions about your uploaded documents
{hasNotebooks && ' and synced notebooks'}
</p>
<div className="mt-8 grid grid-cols-1 md:grid-cols-2 gap-2 w-full max-w-lg"> <div className="mt-8 grid grid-cols-1 md:grid-cols-2 gap-2 w-full max-w-lg">
{[ {[
'What are the main topics in my documents?', 'What are the main topics?',
'Summarize the key points', 'Summarize the key points',
'Find information about...', hasNotebooks ? 'What do my notebooks say about...' : 'Find information about...',
'Explain the concept of...', 'Explain the concept of...',
].map((suggestion) => ( ].map((suggestion) => (
<Button <Button
@@ -150,7 +315,10 @@ export function Chat() {
<div className="p-4 border-t"> <div className="p-4 border-t">
<form onSubmit={handleSubmit} className="flex space-x-2"> <form onSubmit={handleSubmit} className="flex space-x-2">
<Input <Input
placeholder="Type your message..." placeholder={hasNotebooks
? "Ask about your documents and notebooks..."
: "Ask about your documents..."
}
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
disabled={isLoading} disabled={isLoading}
@@ -166,6 +334,7 @@ export function Chat() {
</form> </form>
<p className="text-xs text-muted-foreground mt-2 text-center"> <p className="text-xs text-muted-foreground mt-2 text-center">
Using {defaultProvider} {defaultModel} Using {defaultProvider} {defaultModel}
{selectedNotebooks.length > 0 && `${selectedNotebooks.length} notebook${selectedNotebooks.length > 1 ? 's' : ''}`}
</p> </p>
</div> </div>
</Card> </Card>
@@ -224,12 +393,30 @@ function ChatMessage({ message }: { message: Message }) {
} }
// Source Badge Component // 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 (
<div className="inline-flex items-center space-x-1 px-2 py-1 rounded bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 text-xs">
<BookOpen className="h-3 w-3" />
<span className="truncate max-w-[150px]">{notebookSource.source_title}</span>
<span className="text-muted-foreground"></span>
<span className="truncate max-w-[100px]">{notebookSource.notebook_title}</span>
</div>
);
}
// Regular document source
return ( return (
<div className="inline-flex items-center space-x-1 px-2 py-1 rounded bg-accent text-accent-foreground text-xs"> <div className="inline-flex items-center space-x-1 px-2 py-1 rounded bg-accent text-accent-foreground text-xs">
<FileText className="h-3 w-3" /> <FileText className="h-3 w-3" />
<span>{source.document_name}</span> <span>{source.document_name}</span>
<span className="text-muted-foreground">({(source.score * 100).toFixed(0)}%)</span> {'score' in source && (
<span className="text-muted-foreground">({(source.score * 100).toFixed(0)}%)</span>
)}
</div> </div>
); );
} }

View File

@@ -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<Notebook[]>([]);
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 (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Notebooks</h1>
<p className="text-muted-foreground">
Manage your NotebookLM notebooks and sync them for search
</p>
</div>
<Button onClick={() => fetchIndexedNotebooks()} disabled={isLoading}>
{isLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
Refresh
</Button>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardContent className="p-6">
<div className="flex items-center space-x-4">
<div className="p-3 bg-primary/10 rounded-full">
<Database className="h-6 w-6 text-primary" />
</div>
<div>
<p className="text-2xl font-bold">{indexedNotebooks.length}</p>
<p className="text-sm text-muted-foreground">Indexed Notebooks</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center space-x-4">
<div className="p-3 bg-blue-100 dark:bg-blue-900/30 rounded-full">
<FileText className="h-6 w-6 text-blue-600 dark:text-blue-400" />
</div>
<div>
<p className="text-2xl font-bold">
{indexedNotebooks.reduce((acc, n) => acc + n.sources_count, 0)}
</p>
<p className="text-sm text-muted-foreground">Total Sources</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center space-x-4">
<div className="p-3 bg-green-100 dark:bg-green-900/30 rounded-full">
<Clock className="h-6 w-6 text-green-600 dark:text-green-400" />
</div>
<div>
<p className="text-2xl font-bold">
{indexedNotebooks.reduce((acc, n) => acc + n.chunks_count, 0)}
</p>
<p className="text-sm text-muted-foreground">Total Chunks</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Tabs */}
<div className="flex space-x-1 rounded-lg bg-muted p-1 w-fit">
<button
onClick={() => setActiveTab('indexed')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
activeTab === 'indexed'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
Indexed ({indexedNotebooks.length})
</button>
<button
onClick={() => setActiveTab('available')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
activeTab === 'available'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
Available
</button>
</div>
{/* Content */}
{activeTab === 'indexed' ? (
<IndexedNotebooksList
notebooks={indexedNotebooks}
isLoading={isLoading}
onDelete={handleDelete}
onRefresh={fetchIndexedNotebooks}
/>
) : (
<AvailableNotebooksList
notebooks={availableNotebooks}
isLoading={isLoadingAvailable}
indexedIds={new Set(indexedNotebooks.map(n => n.notebook_id))}
onSync={handleSync}
isSyncing={isSyncing}
/>
)}
</div>
);
}
// Indexed Notebooks List Component
function IndexedNotebooksList({
notebooks,
isLoading,
onDelete,
onRefresh
}: {
notebooks: IndexedNotebook[];
isLoading: boolean;
onDelete: (id: string) => void;
onRefresh: () => void;
}) {
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
if (notebooks.length === 0) {
return (
<Card className="border-dashed">
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Database className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">No notebooks indexed</h3>
<p className="text-sm text-muted-foreground max-w-md mb-4">
Sync your NotebookLM notebooks to search through them using RAG.
Switch to the "Available" tab to see notebooks you can sync.
</p>
<Button onClick={onRefresh} variant="outline">
<RefreshCw className="mr-2 h-4 w-4" />
Refresh
</Button>
</CardContent>
</Card>
);
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{notebooks.map((notebook) => (
<Card key={notebook.notebook_id} className="hover:shadow-md transition-shadow">
<CardContent className="p-6">
<div className="flex items-start justify-between">
<div className="flex items-start space-x-4">
<div className="p-2 bg-primary/10 rounded-lg">
<BookOpen className="h-6 w-6 text-primary" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold truncate">
{notebook.notebook_title || 'Untitled Notebook'}
</h3>
<div className="flex items-center space-x-4 mt-2 text-sm text-muted-foreground">
<span className="flex items-center">
<FileText className="h-3 w-3 mr-1" />
{notebook.sources_count} sources
</span>
<span className="flex items-center">
<Database className="h-3 w-3 mr-1" />
{notebook.chunks_count} chunks
</span>
</div>
{notebook.last_sync && (
<p className="text-xs text-muted-foreground mt-2">
Last synced: {new Date(notebook.last_sync).toLocaleString()}
</p>
)}
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => onDelete(notebook.notebook_id)}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
);
}
// Available Notebooks List Component
function AvailableNotebooksList({
notebooks,
isLoading,
indexedIds,
onSync,
isSyncing,
}: {
notebooks: Notebook[];
isLoading: boolean;
indexedIds: Set<string>;
onSync: (id: string) => void;
isSyncing: boolean;
}) {
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
if (notebooks.length === 0) {
return (
<Card className="border-dashed">
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">No notebooks found</h3>
<p className="text-sm text-muted-foreground max-w-md">
No notebooks found in your NotebookLM account.
Make sure you're authenticated with NotebookLM.
</p>
</CardContent>
</Card>
);
}
return (
<div className="space-y-4">
{notebooks.map((notebook) => {
const isIndexed = indexedIds.has(notebook.id);
return (
<Card key={notebook.id} className={isIndexed ? 'border-green-200 bg-green-50/50 dark:bg-green-900/10' : ''}>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="flex items-start space-x-4">
<div className={`p-2 rounded-lg ${isIndexed ? 'bg-green-100 dark:bg-green-900/30' : 'bg-muted'}`}>
{isIndexed ? (
<Check className="h-6 w-6 text-green-600 dark:text-green-400" />
) : (
<BookOpen className="h-6 w-6 text-muted-foreground" />
)}
</div>
<div>
<h3 className="font-semibold">{notebook.title}</h3>
<p className="text-sm text-muted-foreground">
{notebook.sources_count !== undefined && (
<>{notebook.sources_count} sources • </>
)}
Created {new Date(notebook.created_at).toLocaleDateString()}
</p>
</div>
</div>
<Button
onClick={() => onSync(notebook.id)}
disabled={isIndexed || isSyncing}
variant={isIndexed ? 'ghost' : 'default'}
>
{isIndexed ? (
<>
<Check className="mr-2 h-4 w-4" />
Synced
</>
) : isSyncing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Syncing...
</>
) : (
<>
<Plus className="mr-2 h-4 w-4" />
Sync
</>
)}
</Button>
</div>
</CardContent>
</Card>
);
})}
</div>
);
}

View File

@@ -1,3 +1,4 @@
export { useAuthStore } from './authStore'; export { useAuthStore } from './authStore';
export { useChatStore } from './chatStore'; export { useChatStore } from './chatStore';
export { useSettingsStore } from './settingsStore'; export { useSettingsStore } from './settingsStore';
export { useNotebookStore } from './notebookStore';

View File

@@ -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<string, SyncStatus>;
isLoading: boolean;
isSyncing: boolean;
error: string | null;
includeDocuments: boolean;
// Actions
fetchNotebooks: () => Promise<void>;
fetchIndexedNotebooks: () => Promise<void>;
syncNotebook: (notebookId: string) => Promise<void>;
deleteNotebookIndex: (notebookId: string) => Promise<void>;
checkSyncStatus: (notebookId: string) => Promise<void>;
toggleNotebookSelection: (notebookId: string) => void;
selectAllIndexed: () => void;
deselectAll: () => void;
setIncludeDocuments: (include: boolean) => void;
clearError: () => void;
}
export const useNotebookStore = create<NotebookState>((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 });
},
}));

View File

@@ -73,3 +73,60 @@ export interface SystemConfig {
qdrant_host: string; qdrant_host: string;
qdrant_port: number; 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;
}