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/
|
doc_exports/
|
||||||
|
|
||||||
specs/
|
specs/
|
||||||
.claude
|
.claude
|
||||||
|
|
||||||
|
.playwright-mcp/
|
||||||
|
|
@ -340,6 +340,8 @@ class SourceResponse(BaseModel):
|
||||||
command_id: Optional[str] = None
|
command_id: Optional[str] = None
|
||||||
status: Optional[str] = None
|
status: Optional[str] = None
|
||||||
processing_info: Optional[Dict] = None
|
processing_info: Optional[Dict] = None
|
||||||
|
# Notebook associations
|
||||||
|
notebooks: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
class SourceListResponse(BaseModel):
|
class SourceListResponse(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ from loguru import logger
|
||||||
|
|
||||||
from api.models import NotebookCreate, NotebookResponse, NotebookUpdate
|
from api.models import NotebookCreate, NotebookResponse, NotebookUpdate
|
||||||
from open_notebook.database.repository import ensure_record_id, repo_query
|
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
|
from open_notebook.exceptions import InvalidInputError
|
||||||
|
|
||||||
router = APIRouter()
|
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}")
|
@router.delete("/notebooks/{notebook_id}/sources/{source_id}")
|
||||||
async def remove_source_from_notebook(notebook_id: str, source_id: str):
|
async def remove_source_from_notebook(notebook_id: str, source_id: str):
|
||||||
"""Remove a source from a notebook (delete the reference)."""
|
"""Remove a source from a notebook (delete the reference)."""
|
||||||
|
|
|
||||||
|
|
@ -653,6 +653,14 @@ async def get_source(source_id: str):
|
||||||
status = "unknown"
|
status = "unknown"
|
||||||
|
|
||||||
embedded_chunks = await source.get_embedded_chunks()
|
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(
|
return SourceResponse(
|
||||||
id=source.id or "",
|
id=source.id or "",
|
||||||
title=source.title,
|
title=source.title,
|
||||||
|
|
@ -673,6 +681,8 @@ async def get_source(source_id: str):
|
||||||
command_id=str(source.command) if source.command else None,
|
command_id=str(source.command) if source.command else None,
|
||||||
status=status,
|
status=status,
|
||||||
processing_info=processing_info,
|
processing_info=processing_info,
|
||||||
|
# Notebook associations
|
||||||
|
notebooks=notebook_ids,
|
||||||
)
|
)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|
|
||||||
12
frontend/package-lock.json
generated
12
frontend/package-lock.json
generated
|
|
@ -43,6 +43,7 @@
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"sonner": "^2.0.6",
|
"sonner": "^2.0.6",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"use-debounce": "^10.0.6",
|
||||||
"zod": "^4.0.5",
|
"zod": "^4.0.5",
|
||||||
"zustand": "^5.0.6"
|
"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": {
|
"node_modules/use-sidecar": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"sonner": "^2.0.6",
|
"sonner": "^2.0.6",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"use-debounce": "^10.0.6",
|
||||||
"zod": "^4.0.5",
|
"zod": "^4.0.5",
|
||||||
"zustand": "^5.0.6"
|
"zustand": "^5.0.6"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,17 @@ import { useState } from 'react'
|
||||||
import { SourceListResponse } from '@/lib/types/api'
|
import { SourceListResponse } from '@/lib/types/api'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
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 { LoadingSpinner } from '@/components/common/LoadingSpinner'
|
||||||
import { EmptyState } from '@/components/common/EmptyState'
|
import { EmptyState } from '@/components/common/EmptyState'
|
||||||
import { AddSourceDialog } from '@/components/sources/AddSourceDialog'
|
import { AddSourceDialog } from '@/components/sources/AddSourceDialog'
|
||||||
|
import { AddExistingSourceDialog } from '@/components/sources/AddExistingSourceDialog'
|
||||||
import { SourceCard } from '@/components/sources/SourceCard'
|
import { SourceCard } from '@/components/sources/SourceCard'
|
||||||
import { useDeleteSource, useRetrySource, useRemoveSourceFromNotebook } from '@/lib/hooks/use-sources'
|
import { useDeleteSource, useRetrySource, useRemoveSourceFromNotebook } from '@/lib/hooks/use-sources'
|
||||||
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
|
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
|
||||||
|
|
@ -32,7 +39,9 @@ export function SourcesColumn({
|
||||||
contextSelections,
|
contextSelections,
|
||||||
onContextModeChange
|
onContextModeChange
|
||||||
}: SourcesColumnProps) {
|
}: SourcesColumnProps) {
|
||||||
|
const [dropdownOpen, setDropdownOpen] = useState(false)
|
||||||
const [addDialogOpen, setAddDialogOpen] = useState(false)
|
const [addDialogOpen, setAddDialogOpen] = useState(false)
|
||||||
|
const [addExistingDialogOpen, setAddExistingDialogOpen] = useState(false)
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||||
const [sourceToDelete, setSourceToDelete] = useState<string | null>(null)
|
const [sourceToDelete, setSourceToDelete] = useState<string | null>(null)
|
||||||
const [removeDialogOpen, setRemoveDialogOpen] = useState(false)
|
const [removeDialogOpen, setRemoveDialogOpen] = useState(false)
|
||||||
|
|
@ -98,10 +107,25 @@ export function SourcesColumn({
|
||||||
<CardHeader className="pb-3 flex-shrink-0">
|
<CardHeader className="pb-3 flex-shrink-0">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="text-lg">Sources</CardTitle>
|
<CardTitle className="text-lg">Sources</CardTitle>
|
||||||
<Button size="sm" onClick={() => setAddDialogOpen(true)}>
|
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<DropdownMenuTrigger asChild>
|
||||||
Add Source
|
<Button size="sm">
|
||||||
</Button>
|
<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>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
|
|
@ -144,7 +168,14 @@ export function SourcesColumn({
|
||||||
onOpenChange={setAddDialogOpen}
|
onOpenChange={setAddDialogOpen}
|
||||||
defaultNotebookId={notebookId}
|
defaultNotebookId={notebookId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AddExistingSourceDialog
|
||||||
|
open={addExistingDialogOpen}
|
||||||
|
onOpenChange={setAddExistingDialogOpen}
|
||||||
|
notebookId={notebookId}
|
||||||
|
onSuccess={onRefresh}
|
||||||
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={deleteDialogOpen}
|
open={deleteDialogOpen}
|
||||||
onOpenChange={setDeleteDialogOpen}
|
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 { formatDistanceToNow } from 'date-fns'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { SourceInsightDialog } from '@/components/source/SourceInsightDialog'
|
import { SourceInsightDialog } from '@/components/source/SourceInsightDialog'
|
||||||
|
import { NotebookAssociations } from '@/components/source/NotebookAssociations'
|
||||||
|
|
||||||
interface SourceDetailContentProps {
|
interface SourceDetailContentProps {
|
||||||
sourceId: string
|
sourceId: string
|
||||||
|
|
@ -726,6 +727,13 @@ export function SourceDetailContent({
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Notebook Associations */}
|
||||||
|
<NotebookAssociations
|
||||||
|
sourceId={sourceId}
|
||||||
|
currentNotebookIds={source.notebooks || []}
|
||||||
|
onSave={fetchSource}
|
||||||
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</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
|
<DialogPrimitive.Overlay
|
||||||
data-slot="dialog-overlay"
|
data-slot="dialog-overlay"
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -60,7 +60,7 @@ function DialogContent({
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,11 @@ export const notebooksApi = {
|
||||||
await apiClient.delete(`/notebooks/${id}`)
|
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) => {
|
removeSource: async (notebookId: string, sourceId: string) => {
|
||||||
const response = await apiClient.delete(`/notebooks/${notebookId}/sources/${sourceId}`)
|
const response = await apiClient.delete(`/notebooks/${notebookId}/sources/${sourceId}`)
|
||||||
return response.data
|
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() {
|
export function useRemoveSourceFromNotebook() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ export interface SourceListResponse {
|
||||||
|
|
||||||
export interface SourceDetailResponse extends SourceListResponse {
|
export interface SourceDetailResponse extends SourceListResponse {
|
||||||
full_text: string
|
full_text: string
|
||||||
|
notebooks?: string[] // List of notebook IDs this source is linked to
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SourceResponse = SourceDetailResponse
|
export type SourceResponse = SourceDetailResponse
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ export interface SearchRequest {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SearchResult {
|
export interface SearchResult {
|
||||||
|
id: string
|
||||||
title: string
|
title: string
|
||||||
parent_id: string
|
parent_id: string
|
||||||
final_score: number
|
final_score: number
|
||||||
|
|
@ -16,6 +17,10 @@ export interface SearchResult {
|
||||||
relevance?: number
|
relevance?: number
|
||||||
similarity?: number
|
similarity?: number
|
||||||
score?: number
|
score?: number
|
||||||
|
type?: string
|
||||||
|
source_type?: string
|
||||||
|
created: string
|
||||||
|
updated: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SearchResponse {
|
export interface SearchResponse {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue