Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions BULK_SOURCE_ACTION_FEATURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Bulk Source Action Feature

## Overview
This feature adds bulk action capabilities for including/excluding sources in a chat session for notebooks. When dealing with a high number of sources, this feature saves time by avoiding unnecessary scrolls multiple times per session.

## Implementation Details

### Backend Changes

1. **API Endpoint**: Added a new endpoint `/api/notebooks/{notebook_id}/sources/bulk` that supports bulk operations (add/remove) for sources in a notebook.

2. **Request Model**:
- `BulkSourceOperationRequest` with fields:
- `source_ids`: List of source IDs to operate on
- `operation`: Either "add" or "remove"

3. **Response Model**:
- `BulkSourceOperationResponse` with:
- `message`: Summary of the operation
- `results`: Detailed results for each source operation

### Frontend Changes

1. **API Client**: Updated the notebooks API client to include the new `bulkSourceOperation` method.

2. **Hooks**: Added `useBulkSourceOperation` hook in `use-sources.ts` to handle bulk operations with proper error handling and UI feedback.

3. **UI Components**:
- Created `BulkSourceActionDialog` component for selecting multiple sources
- Updated `SourcesColumn` component to include bulk action options in the dropdown menu

4. **User Experience**:
- Added "Bulk Add Sources" and "Bulk Remove Sources" options to the sources dropdown
- Implemented select-all functionality in the bulk action dialog
- Added visual feedback for selected sources count
- Integrated with existing toast notifications for operation results

## How to Use

1. Navigate to a notebook page
2. Click the "Add Source" dropdown button in the Sources column
3. Select either "Bulk Add Sources" or "Bulk Remove Sources"
4. In the dialog that appears:
- Use the checkboxes to select which sources to include/exclude
- Use the "Select All" checkbox to quickly select/deselect all sources
- Click the action button to perform the bulk operation
5. The sources will be added/removed from the notebook in a single operation

## Benefits

- **Time Savings**: Eliminates the need to individually add/remove many sources
- **Efficiency**: Reduces repetitive scrolling and clicking when managing large numbers of sources
- **User Experience**: Provides a more streamlined workflow for source management
- **Error Handling**: Gracefully handles partial failures with detailed feedback

## Technical Notes

- The implementation follows the existing code patterns and conventions
- All operations are atomic - if one source fails, others continue to process
- Proper error handling and user feedback is implemented
- The feature integrates seamlessly with existing source management workflows
5 changes: 5 additions & 0 deletions api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -422,3 +422,8 @@ class SourceStatusResponse(BaseModel):
class ErrorResponse(BaseModel):
error: str
message: str


class BulkSourceOperationRequest(BaseModel):
source_ids: List[str]
operation: Literal["add", "remove"]
101 changes: 100 additions & 1 deletion api/routers/notebooks.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from typing import List, Optional
from typing import List, Literal, Optional

from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from loguru import logger

from api.models import NotebookCreate, NotebookResponse, NotebookUpdate
Expand Down Expand Up @@ -180,6 +181,104 @@ async def update_notebook(notebook_id: str, notebook_update: NotebookUpdate):
)


class BulkSourceOperationRequest(BaseModel):
source_ids: List[str]
operation: Literal["add", "remove"]


@router.post("/notebooks/{notebook_id}/sources/bulk")
async def bulk_source_operation(
notebook_id: str, request: BulkSourceOperationRequest
):
"""Bulk add or remove sources from a notebook."""
try:
# Check if notebook exists
notebook = await Notebook.get(notebook_id)
if not notebook:
raise HTTPException(status_code=404, detail="Notebook not found")

results = []
for source_id in request.source_ids:
try:
if request.operation == "add":
# Check if source exists
source = await Source.get(source_id)
if not source:
results.append({
"source_id": source_id,
"success": False,
"error": "Source not found"
})
continue

# 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),
},
)

results.append({
"source_id": source_id,
"success": True,
"message": "Source added to notebook successfully"
})

elif request.operation == "remove":
# Delete the reference record linking source to notebook
await repo_query(
"DELETE FROM reference WHERE out = $notebook_id AND in = $source_id",
{
"notebook_id": ensure_record_id(notebook_id),
"source_id": ensure_record_id(source_id),
},
)

results.append({
"source_id": source_id,
"success": True,
"message": "Source removed from notebook successfully"
})

except Exception as e:
logger.error(
f"Error processing source {source_id} for notebook {notebook_id}: {str(e)}"
)
results.append({
"source_id": source_id,
"success": False,
"error": str(e)
})
Comment on lines +201 to +263

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 CRITICAL - N+1 database queries in bulk operation loop

Category: performance

Description:
Loop executes Source.get() and multiple repo_query() calls for each source_id, causing severe performance degradation at scale

Suggestion:
Batch fetch all sources with a single query: sources = await Source.get_many(request.source_ids), then process in-memory. For reference operations, consider bulk RELATE query or batched operations.

Confidence: 92%
Rule: perf_n_plus_one_queries

Comment on lines +201 to +263

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 HIGH - Missing transaction handling with rollback

Category: performance

Description:
Bulk operation with multiple database statements lacks transaction wrapping, risking partial failures and data inconsistency

Suggestion:
Wrap the loop in a database transaction with try/except to commit on success and rollback on failure. Use transaction context manager or begin/commit/rollback pattern.

Confidence: 87%
Rule: py_add_transaction_handling_with_rollback


success_count = sum(1 for r in results if r["success"])
return {
"message": f"Bulk operation completed. {success_count}/{len(results)} operations successful.",
"results": results
}

except HTTPException:
raise
except Exception as e:
logger.error(
f"Error performing bulk source operation for notebook {notebook_id}: {str(e)}"
)
raise HTTPException(
status_code=500, detail=f"Error performing bulk source operation: {str(e)}"
)


@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)."""
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,4 @@ services:
volumes:
- ./notebook_data:/app/data
restart: always


67 changes: 38 additions & 29 deletions frontend/src/app/(dashboard)/notebooks/components/SourcesColumn.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
'use client'

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 {
Expand All @@ -10,7 +9,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Plus, FileText, Link2, ChevronDown } from 'lucide-react'
import { Plus, FileText, Link2, ChevronDown, ListPlus } from 'lucide-react'
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
import { EmptyState } from '@/components/common/EmptyState'
import { AddSourceDialog } from '@/components/sources/AddSourceDialog'
Expand All @@ -19,40 +18,25 @@ import { SourceCard } from '@/components/sources/SourceCard'
import { useDeleteSource, useRetrySource, useRemoveSourceFromNotebook } from '@/lib/hooks/use-sources'
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
import { useModalManager } from '@/lib/hooks/use-modal-manager'
import { ContextMode } from '../[id]/page'

interface SourcesColumnProps {
sources?: SourceListResponse[]
isLoading: boolean
notebookId: string
notebookName?: string
onRefresh?: () => void
contextSelections?: Record<string, ContextMode>
onContextModeChange?: (sourceId: string, mode: ContextMode) => void
}

export function SourcesColumn({
sources,
isLoading,
notebookId,
onRefresh,
contextSelections,
onContextModeChange
}: SourcesColumnProps) {
import { BulkSourceActionDialog } from '@/components/notebooks/BulkSourceActionDialog'

export function SourcesColumn({ sources, isLoading, notebookId, onRefresh, contextSelections, onContextModeChange }) {
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 [sourceToDelete, setSourceToDelete] = useState(null)
const [removeDialogOpen, setRemoveDialogOpen] = useState(false)
const [sourceToRemove, setSourceToRemove] = useState<string | null>(null)
const [sourceToRemove, setSourceToRemove] = useState(null)
const [bulkAddDialogOpen, setBulkAddDialogOpen] = useState(false)
const [bulkRemoveDialogOpen, setBulkRemoveDialogOpen] = useState(false)

const { openModal } = useModalManager()
const deleteSource = useDeleteSource()
const retrySource = useRetrySource()
const removeFromNotebook = useRemoveSourceFromNotebook()

const handleDeleteClick = (sourceId: string) => {
const handleDeleteClick = (sourceId) => {
setSourceToDelete(sourceId)
setDeleteDialogOpen(true)
}
Expand All @@ -70,7 +54,7 @@ export function SourcesColumn({
}
}

const handleRemoveFromNotebook = (sourceId: string) => {
const handleRemoveFromNotebook = (sourceId) => {
setSourceToRemove(sourceId)
setRemoveDialogOpen(true)
}
Expand All @@ -91,17 +75,18 @@ export function SourcesColumn({
}
}

const handleRetry = async (sourceId: string) => {
const handleRetry = async (sourceId) => {
try {
await retrySource.mutateAsync(sourceId)
} catch (error) {
console.error('Failed to retry source:', error)
}
}

const handleSourceClick = (sourceId: string) => {
const handleSourceClick = (sourceId) => {
openModal('source', sourceId)
}

return (
<Card className="h-full flex flex-col flex-1 overflow-hidden">
<CardHeader className="pb-3 flex-shrink-0">
Expand All @@ -124,6 +109,14 @@ export function SourcesColumn({
<Link2 className="h-4 w-4 mr-2" />
Add Existing Source
</DropdownMenuItem>
<DropdownMenuItem onClick={() => { setDropdownOpen(false); setBulkAddDialogOpen(true); }}>
<ListPlus className="h-4 w-4 mr-2" />
Bulk Add Sources
</DropdownMenuItem>
<DropdownMenuItem onClick={() => { setDropdownOpen(false); setBulkRemoveDialogOpen(true); }}>
<ListPlus className="h-4 w-4 mr-2" />
Bulk Remove Sources
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
Expand Down Expand Up @@ -197,6 +190,22 @@ export function SourcesColumn({
isLoading={removeFromNotebook.isPending}
confirmVariant="default"
/>

<BulkSourceActionDialog
open={bulkAddDialogOpen}
onOpenChange={setBulkAddDialogOpen}
notebookId={notebookId}
operation="add"
onSuccess={onRefresh}
/>

<BulkSourceActionDialog
open={bulkRemoveDialogOpen}
onOpenChange={setBulkRemoveDialogOpen}
notebookId={notebookId}
operation="remove"
onSuccess={onRefresh}
/>
</Card>
)
}
}
Loading