diff --git a/.gitignore b/.gitignore index 899477b..999338d 100644 --- a/.gitignore +++ b/.gitignore @@ -131,4 +131,6 @@ docs/custom_gpt doc_exports/ specs/ -.claude \ No newline at end of file +.claude + +.playwright-mcp/ \ No newline at end of file diff --git a/api/models.py b/api/models.py index 3626b25..79c4520 100644 --- a/api/models.py +++ b/api/models.py @@ -340,6 +340,8 @@ class SourceResponse(BaseModel): command_id: Optional[str] = None status: Optional[str] = None processing_info: Optional[Dict] = None + # Notebook associations + notebooks: Optional[List[str]] = None class SourceListResponse(BaseModel): diff --git a/api/routers/notebooks.py b/api/routers/notebooks.py index afde809..563ce5e 100644 --- a/api/routers/notebooks.py +++ b/api/routers/notebooks.py @@ -5,7 +5,7 @@ from loguru import logger from api.models import NotebookCreate, NotebookResponse, NotebookUpdate from open_notebook.database.repository import ensure_record_id, repo_query -from open_notebook.domain.notebook import Notebook +from open_notebook.domain.notebook import Notebook, Source from open_notebook.exceptions import InvalidInputError router = APIRouter() @@ -180,6 +180,51 @@ async def update_notebook(notebook_id: str, notebook_update: NotebookUpdate): ) +@router.post("/notebooks/{notebook_id}/sources/{source_id}") +async def add_source_to_notebook(notebook_id: str, source_id: str): + """Add an existing source to a notebook (create the reference).""" + try: + # Check if notebook exists + notebook = await Notebook.get(notebook_id) + if not notebook: + raise HTTPException(status_code=404, detail="Notebook not found") + + # Check if source exists + source = await Source.get(source_id) + if not source: + raise HTTPException(status_code=404, detail="Source not found") + + # Check if reference already exists (idempotency) + existing_ref = await repo_query( + "SELECT * FROM reference WHERE out = $source_id AND in = $notebook_id", + { + "notebook_id": ensure_record_id(notebook_id), + "source_id": ensure_record_id(source_id), + }, + ) + + # If reference doesn't exist, create it + if not existing_ref: + await repo_query( + "RELATE $source_id->reference->$notebook_id", + { + "notebook_id": ensure_record_id(notebook_id), + "source_id": ensure_record_id(source_id), + }, + ) + + return {"message": "Source linked to notebook successfully"} + except HTTPException: + raise + except Exception as e: + logger.error( + f"Error linking source {source_id} to notebook {notebook_id}: {str(e)}" + ) + raise HTTPException( + status_code=500, detail=f"Error linking source to notebook: {str(e)}" + ) + + @router.delete("/notebooks/{notebook_id}/sources/{source_id}") async def remove_source_from_notebook(notebook_id: str, source_id: str): """Remove a source from a notebook (delete the reference).""" diff --git a/api/routers/sources.py b/api/routers/sources.py index be9dcbb..2337ce6 100644 --- a/api/routers/sources.py +++ b/api/routers/sources.py @@ -653,6 +653,14 @@ async def get_source(source_id: str): status = "unknown" embedded_chunks = await source.get_embedded_chunks() + + # Get associated notebooks + notebooks_query = await repo_query( + "SELECT VALUE out FROM reference WHERE in = $source_id", + {"source_id": ensure_record_id(source.id or source_id)} + ) + notebook_ids = [str(nb_id) for nb_id in notebooks_query] if notebooks_query else [] + return SourceResponse( id=source.id or "", title=source.title, @@ -673,6 +681,8 @@ async def get_source(source_id: str): command_id=str(source.command) if source.command else None, status=status, processing_info=processing_info, + # Notebook associations + notebooks=notebook_ids, ) except HTTPException: raise diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9438175..3bc1c93 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -43,6 +43,7 @@ "react-markdown": "^10.1.0", "sonner": "^2.0.6", "tailwind-merge": "^3.3.1", + "use-debounce": "^10.0.6", "zod": "^4.0.5", "zustand": "^5.0.6" }, @@ -9397,6 +9398,17 @@ } } }, + "node_modules/use-debounce": { + "version": "10.0.6", + "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.0.6.tgz", + "integrity": "sha512-C5OtPyhAZgVoteO9heXMTdW7v/IbFI+8bSVKYCJrSmiWWCLsbUxiBSp4t9v0hNBTGY97bT72ydDIDyGSFWfwXg==", + "engines": { + "node": ">= 16.0.0" + }, + "peerDependencies": { + "react": "*" + } + }, "node_modules/use-sidecar": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7f30030..ddef734 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -44,6 +44,7 @@ "react-markdown": "^10.1.0", "sonner": "^2.0.6", "tailwind-merge": "^3.3.1", + "use-debounce": "^10.0.6", "zod": "^4.0.5", "zustand": "^5.0.6" }, diff --git a/frontend/src/app/(dashboard)/notebooks/components/SourcesColumn.tsx b/frontend/src/app/(dashboard)/notebooks/components/SourcesColumn.tsx index d95ccb3..2d9af91 100644 --- a/frontend/src/app/(dashboard)/notebooks/components/SourcesColumn.tsx +++ b/frontend/src/app/(dashboard)/notebooks/components/SourcesColumn.tsx @@ -4,10 +4,17 @@ import { useState } from 'react' import { SourceListResponse } from '@/lib/types/api' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { Plus, FileText } from 'lucide-react' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { Plus, FileText, Link2, ChevronDown } from 'lucide-react' import { LoadingSpinner } from '@/components/common/LoadingSpinner' import { EmptyState } from '@/components/common/EmptyState' import { AddSourceDialog } from '@/components/sources/AddSourceDialog' +import { AddExistingSourceDialog } from '@/components/sources/AddExistingSourceDialog' import { SourceCard } from '@/components/sources/SourceCard' import { useDeleteSource, useRetrySource, useRemoveSourceFromNotebook } from '@/lib/hooks/use-sources' import { ConfirmDialog } from '@/components/common/ConfirmDialog' @@ -32,7 +39,9 @@ export function SourcesColumn({ contextSelections, onContextModeChange }: SourcesColumnProps) { + const [dropdownOpen, setDropdownOpen] = useState(false) const [addDialogOpen, setAddDialogOpen] = useState(false) + const [addExistingDialogOpen, setAddExistingDialogOpen] = useState(false) const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) const [sourceToDelete, setSourceToDelete] = useState(null) const [removeDialogOpen, setRemoveDialogOpen] = useState(false) @@ -98,10 +107,25 @@ export function SourcesColumn({
Sources - + + + + + + { setDropdownOpen(false); setAddDialogOpen(true); }}> + + Add New Source + + { setDropdownOpen(false); setAddExistingDialogOpen(true); }}> + + Add Existing Source + + +
@@ -144,7 +168,14 @@ export function SourcesColumn({ onOpenChange={setAddDialogOpen} defaultNotebookId={notebookId} /> - + + + void +} + +export function NotebookAssociations({ + sourceId, + currentNotebookIds, + onSave, +}: NotebookAssociationsProps) { + const [selectedNotebookIds, setSelectedNotebookIds] = useState(currentNotebookIds) + const [isSaving, setIsSaving] = useState(false) + + const { data: notebooks, isLoading } = useNotebooks() + const addSources = useAddSourcesToNotebook() + const removeFromNotebook = useRemoveSourceFromNotebook() + + // Update selected notebooks when current changes (after save) + useEffect(() => { + setSelectedNotebookIds(currentNotebookIds) + }, [currentNotebookIds]) + + const hasChanges = useMemo(() => { + const current = new Set(currentNotebookIds) + const selected = new Set(selectedNotebookIds) + + if (current.size !== selected.size) return true + + for (const id of current) { + if (!selected.has(id)) return true + } + + return false + }, [currentNotebookIds, selectedNotebookIds]) + + const handleToggleNotebook = (notebookId: string) => { + setSelectedNotebookIds(prev => + prev.includes(notebookId) + ? prev.filter(id => id !== notebookId) + : [...prev, notebookId] + ) + } + + const handleSave = async () => { + if (!hasChanges) return + + try { + setIsSaving(true) + + const current = new Set(currentNotebookIds) + const selected = new Set(selectedNotebookIds) + + // Determine which notebooks to add and remove + const toAdd = selectedNotebookIds.filter(id => !current.has(id)) + const toRemove = currentNotebookIds.filter(id => !selected.has(id)) + + // Execute additions + if (toAdd.length > 0) { + await Promise.allSettled( + toAdd.map(notebookId => + addSources.mutateAsync({ + notebookId, + sourceIds: [sourceId], + }) + ) + ) + } + + // Execute removals + if (toRemove.length > 0) { + await Promise.allSettled( + toRemove.map(notebookId => + removeFromNotebook.mutateAsync({ + notebookId, + sourceId, + }) + ) + ) + } + + onSave?.() + } catch (error) { + console.error('Error saving notebook associations:', error) + } finally { + setIsSaving(false) + } + } + + const handleCancel = () => { + setSelectedNotebookIds(currentNotebookIds) + } + + if (isLoading) { + return ( + + + + + Notebooks + + + Manage which notebooks contain this source + + + +
+ +
+
+
+ ) + } + + if (!notebooks || notebooks.length === 0) { + return ( + + + + + Notebooks + + + Manage which notebooks contain this source + + + +

No notebooks available

+
+
+ ) + } + + return ( + + + + + Notebooks + + + Manage which notebooks contain this source + + + + +
+ {notebooks + .filter(nb => !nb.archived) + .map((notebook) => { + const isSelected = selectedNotebookIds.includes(notebook.id) + const isCurrentlyLinked = currentNotebookIds.includes(notebook.id) + + return ( +
+ handleToggleNotebook(notebook.id)} + className="mt-0.5" + /> +
+
+

+ {notebook.name} +

+ {isCurrentlyLinked && !hasChanges && ( + + )} +
+ {notebook.description && ( +

+ {notebook.description} +

+ )} +
+
+ ) + })} +
+
+ + {hasChanges && ( +
+ + +
+ )} +
+
+ ) +} diff --git a/frontend/src/components/source/SourceDetailContent.tsx b/frontend/src/components/source/SourceDetailContent.tsx index db3d094..e7d82ea 100644 --- a/frontend/src/components/source/SourceDetailContent.tsx +++ b/frontend/src/components/source/SourceDetailContent.tsx @@ -51,6 +51,7 @@ import { import { formatDistanceToNow } from 'date-fns' import { toast } from 'sonner' import { SourceInsightDialog } from '@/components/source/SourceInsightDialog' +import { NotebookAssociations } from '@/components/source/NotebookAssociations' interface SourceDetailContentProps { sourceId: string @@ -726,6 +727,13 @@ export function SourceDetailContent({ + + {/* Notebook Associations */} + diff --git a/frontend/src/components/sources/AddExistingSourceDialog.tsx b/frontend/src/components/sources/AddExistingSourceDialog.tsx new file mode 100644 index 0000000..0554d8b --- /dev/null +++ b/frontend/src/components/sources/AddExistingSourceDialog.tsx @@ -0,0 +1,305 @@ +'use client' + +import { useState, useEffect, useMemo } from 'react' +import { useDebounce } from 'use-debounce' +import { Search, Link2, LoaderIcon, FileText, Link as LinkIcon, Upload } from 'lucide-react' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Checkbox } from '@/components/ui/checkbox' +import { Badge } from '@/components/ui/badge' +import { ScrollArea } from '@/components/ui/scroll-area' +import { searchApi } from '@/lib/api/search' +import { sourcesApi } from '@/lib/api/sources' +import { useSources, useAddSourcesToNotebook } from '@/lib/hooks/use-sources' +import { SourceListResponse } from '@/lib/types/api' + +interface AddExistingSourceDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + notebookId: string + onSuccess?: () => void +} + +export function AddExistingSourceDialog({ + open, + onOpenChange, + notebookId, + onSuccess, +}: AddExistingSourceDialogProps) { + const [searchQuery, setSearchQuery] = useState('') + const [debouncedSearchQuery] = useDebounce(searchQuery, 300) + const [selectedSourceIds, setSelectedSourceIds] = useState([]) + const [allSources, setAllSources] = useState([]) + const [filteredSources, setFilteredSources] = useState([]) + const [isSearching, setIsSearching] = useState(false) + + // Get sources already in this notebook + const { data: currentNotebookSources } = useSources(notebookId) + const currentSourceIds = useMemo( + () => new Set(currentNotebookSources?.map(s => s.id) || []), + [currentNotebookSources] + ) + + const addSources = useAddSourcesToNotebook() + + // Load all sources initially + useEffect(() => { + if (open) { + loadAllSources() + } + }, [open]) + + // Filter sources when search query changes + useEffect(() => { + if (!debouncedSearchQuery) { + setFilteredSources(allSources) + setIsSearching(false) + return + } + + performSearch() + }, [debouncedSearchQuery, allSources]) + + const loadAllSources = async () => { + try { + setIsSearching(true) + // Use sources API directly to get all sources (max 100 per API limit) + const sources = await sourcesApi.list({ + limit: 100, + offset: 0, + sort_by: 'created', + sort_order: 'desc', + }) + + setAllSources(sources) + setFilteredSources(sources) + } catch (error) { + console.error('Error loading sources:', error) + } finally { + setIsSearching(false) + } + } + + const performSearch = async () => { + if (!debouncedSearchQuery.trim()) { + // Empty query - show all sources + setFilteredSources(allSources) + setIsSearching(false) + return + } + + try { + setIsSearching(true) + const response = await searchApi.search({ + query: debouncedSearchQuery, + type: 'text', + search_sources: true, + search_notes: false, + limit: 100, + minimum_score: 0.01, + }) + + // Since we set search_sources=true and search_notes=false, + // the API only returns sources, no need to filter + const sources = response.results.map(r => ({ + id: r.parent_id, + title: r.title || 'Untitled', + topics: [], + asset: null, + embedded: false, + embedded_chunks: 0, + insights_count: 0, + created: r.created, + updated: r.updated, + })) as SourceListResponse[] + + setFilteredSources(sources) + } catch (error) { + console.error('Error searching sources:', error) + // On error, fall back to showing all sources + setFilteredSources(allSources) + } finally { + setIsSearching(false) + } + } + + const handleToggleSource = (sourceId: string) => { + setSelectedSourceIds(prev => + prev.includes(sourceId) + ? prev.filter(id => id !== sourceId) + : [...prev, sourceId] + ) + } + + const handleAddSelected = async () => { + if (selectedSourceIds.length === 0) return + + try { + await addSources.mutateAsync({ + notebookId, + sourceIds: selectedSourceIds, + }) + + // Reset state + setSelectedSourceIds([]) + setSearchQuery('') + onOpenChange(false) + onSuccess?.() + } catch (error) { + // Error handled by the hook's onError + console.error('Error adding sources:', error) + } + } + + const getSourceIcon = (source: SourceListResponse) => { + // Derive type from asset + if (source.asset?.url) { + return + } + if (source.asset?.file_path) { + return + } + return + } + + const formatDate = (dateString: string) => { + try { + return new Date(dateString).toLocaleDateString() + } catch { + return '' + } + } + + return ( + + + + + + Add Existing Sources + + + Search and select existing sources to add to this notebook + + + +
+ {/* Search Input */} +
+ + setSearchQuery(e.target.value)} + className="pl-10" + /> + {isSearching && ( + + )} +
+ + {/* Source List */} + + {isSearching && filteredSources.length === 0 ? ( +
+ +

Loading sources...

+
+ ) : filteredSources.length === 0 ? ( +
+ +

No sources found

+
+ ) : ( +
+ {filteredSources.map((source) => { + const isAlreadyLinked = currentSourceIds.has(source.id) + const isSelected = selectedSourceIds.includes(source.id) + + return ( +
+ handleToggleSource(source.id)} + disabled={isAlreadyLinked} + className="mt-1" + /> +
+
+
+ {getSourceIcon(source)} +
+

+ {source.title} +

+ {isAlreadyLinked && ( + + Linked + + )} +
+

+ Added {formatDate(source.created)} +

+
+
+ ) + })} +
+ )} +
+ + {/* Truncation Warning */} + {allSources.length >= 100 && !debouncedSearchQuery && ( +
+ Showing first 100 sources. Use the Search feature to find specific sources. +
+ )} + + {/* Selection Summary */} + {selectedSourceIds.length > 0 && ( +
+ {selectedSourceIds.length} source{selectedSourceIds.length > 1 ? 's' : ''} selected +
+ )} +
+ + + + + +
+
+ ) +} diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx index 563d8b3..e5415f8 100644 --- a/frontend/src/components/ui/dialog.tsx +++ b/frontend/src/components/ui/dialog.tsx @@ -38,7 +38,7 @@ function DialogOverlay({ { + const response = await apiClient.post(`/notebooks/${notebookId}/sources/${sourceId}`) + return response.data + }, + removeSource: async (notebookId: string, sourceId: string) => { const response = await apiClient.delete(`/notebooks/${notebookId}/sources/${sourceId}`) return response.data diff --git a/frontend/src/lib/hooks/use-sources.ts b/frontend/src/lib/hooks/use-sources.ts index 8181d84..ab2ba42 100644 --- a/frontend/src/lib/hooks/use-sources.ts +++ b/frontend/src/lib/hooks/use-sources.ts @@ -215,6 +215,65 @@ export function useRetrySource() { }) } +export function useAddSourcesToNotebook() { + const queryClient = useQueryClient() + const { toast } = useToast() + + return useMutation({ + mutationFn: async ({ notebookId, sourceIds }: { notebookId: string; sourceIds: string[] }) => { + const { notebooksApi } = await import('@/lib/api/notebooks') + + // Use Promise.allSettled to handle partial failures gracefully + const results = await Promise.allSettled( + sourceIds.map(sourceId => notebooksApi.addSource(notebookId, sourceId)) + ) + + // Count successes and failures + const successes = results.filter(r => r.status === 'fulfilled').length + const failures = results.filter(r => r.status === 'rejected').length + + return { successes, failures, total: sourceIds.length } + }, + onSuccess: (result, { notebookId, sourceIds }) => { + // Invalidate ALL sources queries to refresh all lists + queryClient.invalidateQueries({ queryKey: ['sources'] }) + // Specifically invalidate the notebook's sources + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.sources(notebookId) }) + // Invalidate each affected source + sourceIds.forEach(sourceId => { + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.source(sourceId) }) + }) + + // Show appropriate toast based on results + if (result.failures === 0) { + toast({ + title: 'Success', + description: `${result.successes} source${result.successes > 1 ? 's' : ''} added to notebook`, + }) + } else if (result.successes === 0) { + toast({ + title: 'Error', + description: 'Failed to add sources to notebook', + variant: 'destructive', + }) + } else { + toast({ + title: 'Partial Success', + description: `${result.successes} source${result.successes > 1 ? 's' : ''} added, ${result.failures} failed`, + variant: 'default', + }) + } + }, + onError: () => { + toast({ + title: 'Error', + description: 'Failed to add sources to notebook', + variant: 'destructive', + }) + }, + }) +} + export function useRemoveSourceFromNotebook() { const queryClient = useQueryClient() const { toast } = useToast() diff --git a/frontend/src/lib/types/api.ts b/frontend/src/lib/types/api.ts index 8c0dcdc..2fa8883 100644 --- a/frontend/src/lib/types/api.ts +++ b/frontend/src/lib/types/api.ts @@ -40,6 +40,7 @@ export interface SourceListResponse { export interface SourceDetailResponse extends SourceListResponse { full_text: string + notebooks?: string[] // List of notebook IDs this source is linked to } export type SourceResponse = SourceDetailResponse diff --git a/frontend/src/lib/types/search.ts b/frontend/src/lib/types/search.ts index 4c49924..c9ae20a 100644 --- a/frontend/src/lib/types/search.ts +++ b/frontend/src/lib/types/search.ts @@ -9,6 +9,7 @@ export interface SearchRequest { } export interface SearchResult { + id: string title: string parent_id: string final_score: number @@ -16,6 +17,10 @@ export interface SearchResult { relevance?: number similarity?: number score?: number + type?: string + source_type?: string + created: string + updated: string } export interface SearchResponse {