Merge branch 'main' of github.com:lfnovo/open-notebook

This commit is contained in:
LUIS NOVO 2025-10-19 17:52:27 -03:00
commit 2df45efd78
15 changed files with 723 additions and 10 deletions

4
.gitignore vendored
View file

@ -131,4 +131,6 @@ docs/custom_gpt
doc_exports/
specs/
.claude
.claude
.playwright-mcp/

View file

@ -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):

View file

@ -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)."""

View file

@ -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

View file

@ -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",

View file

@ -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"
},

View file

@ -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<string | null>(null)
const [removeDialogOpen, setRemoveDialogOpen] = useState(false)
@ -98,10 +107,25 @@ export function SourcesColumn({
<CardHeader className="pb-3 flex-shrink-0">
<div className="flex items-center justify-between">
<CardTitle className="text-lg">Sources</CardTitle>
<Button size="sm" onClick={() => setAddDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
Add Source
</Button>
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button size="sm">
<Plus className="h-4 w-4 mr-2" />
Add Source
<ChevronDown className="h-4 w-4 ml-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => { setDropdownOpen(false); setAddDialogOpen(true); }}>
<Plus className="h-4 w-4 mr-2" />
Add New Source
</DropdownMenuItem>
<DropdownMenuItem onClick={() => { setDropdownOpen(false); setAddExistingDialogOpen(true); }}>
<Link2 className="h-4 w-4 mr-2" />
Add Existing Source
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
@ -144,7 +168,14 @@ export function SourcesColumn({
onOpenChange={setAddDialogOpen}
defaultNotebookId={notebookId}
/>
<AddExistingSourceDialog
open={addExistingDialogOpen}
onOpenChange={setAddExistingDialogOpen}
notebookId={notebookId}
onSuccess={onRefresh}
/>
<ConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}

View file

@ -0,0 +1,227 @@
'use client'
import { useState, useEffect, useMemo } from 'react'
import { LoaderIcon, BookOpen, Check } from 'lucide-react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { ScrollArea } from '@/components/ui/scroll-area'
import { useNotebooks } from '@/lib/hooks/use-notebooks'
import { useAddSourcesToNotebook, useRemoveSourceFromNotebook } from '@/lib/hooks/use-sources'
interface NotebookAssociationsProps {
sourceId: string
currentNotebookIds: string[]
onSave?: () => void
}
export function NotebookAssociations({
sourceId,
currentNotebookIds,
onSave,
}: NotebookAssociationsProps) {
const [selectedNotebookIds, setSelectedNotebookIds] = useState<string[]>(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 (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BookOpen className="h-5 w-5" />
Notebooks
</CardTitle>
<CardDescription>
Manage which notebooks contain this source
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center py-8">
<LoaderIcon className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
</CardContent>
</Card>
)
}
if (!notebooks || notebooks.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BookOpen className="h-5 w-5" />
Notebooks
</CardTitle>
<CardDescription>
Manage which notebooks contain this source
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">No notebooks available</p>
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BookOpen className="h-5 w-5" />
Notebooks
</CardTitle>
<CardDescription>
Manage which notebooks contain this source
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<ScrollArea className="h-[300px] border rounded-md p-4">
<div className="space-y-3">
{notebooks
.filter(nb => !nb.archived)
.map((notebook) => {
const isSelected = selectedNotebookIds.includes(notebook.id)
const isCurrentlyLinked = currentNotebookIds.includes(notebook.id)
return (
<div
key={notebook.id}
className={`flex items-start gap-3 p-3 rounded-lg border transition-colors ${
isSelected ? 'bg-accent border-accent-foreground/20' : 'hover:bg-accent/50'
}`}
>
<Checkbox
checked={isSelected}
onCheckedChange={() => handleToggleNotebook(notebook.id)}
className="mt-0.5"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h4 className="font-medium text-sm truncate">
{notebook.name}
</h4>
{isCurrentlyLinked && !hasChanges && (
<Check className="h-4 w-4 text-green-600" />
)}
</div>
{notebook.description && (
<p className="text-xs text-muted-foreground line-clamp-1">
{notebook.description}
</p>
)}
</div>
</div>
)
})}
</div>
</ScrollArea>
{hasChanges && (
<div className="flex items-center justify-end gap-2 pt-2 border-t">
<Button
variant="outline"
size="sm"
onClick={handleCancel}
disabled={isSaving}
>
Cancel
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={isSaving}
>
{isSaving ? (
<>
<LoaderIcon className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
'Save Changes'
)}
</Button>
</div>
)}
</CardContent>
</Card>
)
}

View file

@ -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({
</div>
</CardContent>
</Card>
{/* Notebook Associations */}
<NotebookAssociations
sourceId={sourceId}
currentNotebookIds={source.notebooks || []}
onSave={fetchSource}
/>
</TabsContent>
</Tabs>
</div>

View file

@ -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<string[]>([])
const [allSources, setAllSources] = useState<SourceListResponse[]>([])
const [filteredSources, setFilteredSources] = useState<SourceListResponse[]>([])
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 <LinkIcon className="h-4 w-4" />
}
if (source.asset?.file_path) {
return <Upload className="h-4 w-4" />
}
return <FileText className="h-4 w-4" />
}
const formatDate = (dateString: string) => {
try {
return new Date(dateString).toLocaleDateString()
} catch {
return ''
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl sm:max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Link2 className="h-5 w-5" />
Add Existing Sources
</DialogTitle>
<DialogDescription>
Search and select existing sources to add to this notebook
</DialogDescription>
</DialogHeader>
<div className="space-y-4 flex-1 overflow-hidden flex flex-col">
{/* Search Input */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search sources..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
{isSearching && (
<LoaderIcon className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground" />
)}
</div>
{/* Source List */}
<ScrollArea className="h-[400px] border rounded-md">
{isSearching && filteredSources.length === 0 ? (
<div className="flex flex-col items-center justify-center h-[200px] text-muted-foreground">
<LoaderIcon className="h-12 w-12 mb-2 animate-spin" />
<p>Loading sources...</p>
</div>
) : filteredSources.length === 0 ? (
<div className="flex flex-col items-center justify-center h-[200px] text-muted-foreground">
<FileText className="h-12 w-12 mb-2 opacity-50" />
<p>No sources found</p>
</div>
) : (
<div className="space-y-2 p-4">
{filteredSources.map((source) => {
const isAlreadyLinked = currentSourceIds.has(source.id)
const isSelected = selectedSourceIds.includes(source.id)
return (
<div
key={source.id}
className={`flex items-start gap-3 p-3 rounded-lg border transition-colors min-w-0 ${
isSelected ? 'bg-accent border-accent-foreground/20' : 'hover:bg-accent/50'
}`}
>
<Checkbox
checked={isSelected}
onCheckedChange={() => handleToggleSource(source.id)}
disabled={isAlreadyLinked}
className="mt-1"
/>
<div className="flex-1 min-w-0">
<div className="flex items-start gap-2 mb-1">
<div className="shrink-0 mt-0.5">
{getSourceIcon(source)}
</div>
<h4 className="font-medium text-sm break-words line-clamp-2 flex-1 min-w-0">
{source.title}
</h4>
{isAlreadyLinked && (
<Badge variant="secondary" className="text-xs shrink-0">
Linked
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground truncate">
Added {formatDate(source.created)}
</p>
</div>
</div>
)
})}
</div>
)}
</ScrollArea>
{/* Truncation Warning */}
{allSources.length >= 100 && !debouncedSearchQuery && (
<div className="text-xs text-muted-foreground bg-muted/50 p-2 rounded-md">
Showing first 100 sources. Use the Search feature to find specific sources.
</div>
)}
{/* Selection Summary */}
{selectedSourceIds.length > 0 && (
<div className="text-sm text-muted-foreground">
{selectedSourceIds.length} source{selectedSourceIds.length > 1 ? 's' : ''} selected
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={addSources.isPending}
>
Cancel
</Button>
<Button
onClick={handleAddSelected}
disabled={selectedSourceIds.length === 0 || addSources.isPending}
>
{addSources.isPending ? (
<>
<LoaderIcon className="mr-2 h-4 w-4 animate-spin" />
Adding...
</>
) : (
<>Add Selected</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View file

@ -38,7 +38,7 @@ function DialogOverlay({
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:pointer-events-none fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
@ -60,7 +60,7 @@ function DialogContent({
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-[calc(100%-2rem)]",
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:pointer-events-none fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-[calc(100%-2rem)]",
className
)}
{...props}

View file

@ -26,6 +26,11 @@ export const notebooksApi = {
await apiClient.delete(`/notebooks/${id}`)
},
addSource: async (notebookId: string, sourceId: string) => {
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

View file

@ -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()

View file

@ -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

View file

@ -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 {