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
This commit is contained in:
@@ -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() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/notebooks"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Notebooks />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/chat"
|
||||
element={
|
||||
|
||||
@@ -112,6 +112,69 @@ class ApiClient {
|
||||
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
|
||||
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;
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
|
||||
const {
|
||||
@@ -29,11 +37,28 @@ export function Chat() {
|
||||
|
||||
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(
|
||||
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 (
|
||||
<div className="flex flex-col h-[calc(100vh-8rem)]">
|
||||
{/* Header */}
|
||||
@@ -94,7 +139,7 @@ export function Chat() {
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Chat</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Ask questions about your documents
|
||||
Ask questions about your documents and notebooks
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={handleNewChat}>
|
||||
@@ -103,6 +148,123 @@ export function Chat() {
|
||||
</Button>
|
||||
</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 */}
|
||||
<Card className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Messages */}
|
||||
@@ -111,12 +273,15 @@ export function Chat() {
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||
<Bot className="h-16 w-16 mb-4 opacity-50" />
|
||||
<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">
|
||||
{[
|
||||
'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) => (
|
||||
<Button
|
||||
@@ -150,7 +315,10 @@ export function Chat() {
|
||||
<div className="p-4 border-t">
|
||||
<form onSubmit={handleSubmit} className="flex space-x-2">
|
||||
<Input
|
||||
placeholder="Type your message..."
|
||||
placeholder={hasNotebooks
|
||||
? "Ask about your documents and notebooks..."
|
||||
: "Ask about your documents..."
|
||||
}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
disabled={isLoading}
|
||||
@@ -166,6 +334,7 @@ export function Chat() {
|
||||
</form>
|
||||
<p className="text-xs text-muted-foreground mt-2 text-center">
|
||||
Using {defaultProvider} • {defaultModel}
|
||||
{selectedNotebooks.length > 0 && ` • ${selectedNotebooks.length} notebook${selectedNotebooks.length > 1 ? 's' : ''}`}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -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 (
|
||||
<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 (
|
||||
<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" />
|
||||
<span>{source.document_name}</span>
|
||||
{'score' in source && (
|
||||
<span className="text-muted-foreground">({(source.score * 100).toFixed(0)}%)</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
367
frontend/src/pages/Notebooks.tsx
Normal file
367
frontend/src/pages/Notebooks.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export { useAuthStore } from './authStore';
|
||||
export { useChatStore } from './chatStore';
|
||||
export { useSettingsStore } from './settingsStore';
|
||||
export { useNotebookStore } from './notebookStore';
|
||||
160
frontend/src/stores/notebookStore.ts
Normal file
160
frontend/src/stores/notebookStore.ts
Normal 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 });
|
||||
},
|
||||
}));
|
||||
@@ -73,3 +73,60 @@ export interface SystemConfig {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user