Merge branch 'main' of github.com:lfnovo/open-notebook
This commit is contained in:
commit
2df45efd78
15 changed files with 723 additions and 10 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -131,4 +131,6 @@ docs/custom_gpt
|
|||
doc_exports/
|
||||
|
||||
specs/
|
||||
.claude
|
||||
.claude
|
||||
|
||||
.playwright-mcp/
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
12
frontend/package-lock.json
generated
12
frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
227
frontend/src/components/source/NotebookAssociations.tsx
Normal file
227
frontend/src/components/source/NotebookAssociations.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
305
frontend/src/components/sources/AddExistingSourceDialog.tsx
Normal file
305
frontend/src/components/sources/AddExistingSourceDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue