Hide sources notes (#273)
* fix: add missing overflow wrapper to notebooks list page
Adds flex-1 overflow-y-auto wrapper to enable proper scrolling
when notebook list exceeds viewport height. Matches the layout
pattern used by all other dashboard pages.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: reorder transformation routes to prevent dynamic route interception
Moved static routes (/transformations/execute and /transformations/default-prompt)
before dynamic routes (/transformations/{transformation_id}) to ensure FastAPI
matches them correctly. Previously, requests to static routes were incorrectly
captured by the dynamic route handler.
Fixes #250
Co-Authored-By: Claude <noreply@anthropic.com>
* chore: bump to 1.2.1
* hide source and notes panel - fixes #193
* feat: improve layout for mobile views
* bump version to 1.2.2
* fix: address PR review feedback for collapsible columns
- Remove unused CollapseButton component from CollapsibleColumn.tsx
- Rename useCollapseButton to createCollapseButton (not a React hook)
- Move dialogs outside Card in SourcesColumn.tsx for consistency
- Add useMemo for collapseButton in both columns to prevent re-renders
* feat: support multiple sources
* fix: prevent ChatColumn double mounting on desktop
Add useIsDesktop hook to conditionally render mobile view only on
mobile screens. Previously, the mobile ChatColumn was hidden via CSS
on desktop but still mounted, causing duplicate hooks initialization
and redundant network requests.
---------
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
b42cc06e65
commit
45a99831a9
14 changed files with 1285 additions and 830 deletions
191
.github/README.md
vendored
191
.github/README.md
vendored
|
|
@ -1,191 +0,0 @@
|
|||
# GitHub Workflow & Templates
|
||||
|
||||
This folder contains GitHub-specific configuration for Open Notebook's contribution workflow.
|
||||
|
||||
## 📋 Issue Templates
|
||||
|
||||
We have three issue templates to guide contributors:
|
||||
|
||||
### 🐛 Bug Report (`ISSUE_TEMPLATE/bug_report.yml`)
|
||||
|
||||
For reporting bugs or unexpected behavior when the app is running but misbehaving.
|
||||
|
||||
**Key Features:**
|
||||
- Structured format for bug details
|
||||
- Environment and version information
|
||||
- Checkbox for contributors who want to fix the issue
|
||||
- Automatic `bug` and `needs-triage` labels
|
||||
|
||||
**When to Use:** App is installed and running, but something doesn't work as expected.
|
||||
|
||||
### ✨ Feature Request (`ISSUE_TEMPLATE/feature_request.yml`)
|
||||
|
||||
For suggesting new features or improvements.
|
||||
|
||||
**Key Features:**
|
||||
- Description of the feature
|
||||
- Explanation of why it would be helpful
|
||||
- Space for proposed solution
|
||||
- Checkbox for contributors who want to implement it
|
||||
- Automatic `enhancement` and `needs-triage` labels
|
||||
|
||||
**When to Use:** You have an idea for a new feature or improvement.
|
||||
|
||||
### 🔧 Installation Issue (`ISSUE_TEMPLATE/installation_issue.yml`)
|
||||
|
||||
For problems with installation or setup.
|
||||
|
||||
**Key Features:**
|
||||
- Deployment type selection
|
||||
- Environment details
|
||||
- Error message collection
|
||||
- Automatic `installation` label
|
||||
|
||||
**When to Use:** Having trouble getting Open Notebook running.
|
||||
|
||||
## 🔄 Pull Request Template
|
||||
|
||||
The PR template (`pull_request_template.md`) ensures contributors provide all necessary information:
|
||||
|
||||
**Sections:**
|
||||
- Description and related issue
|
||||
- Type of change
|
||||
- Testing details
|
||||
- Design alignment with project principles
|
||||
- Comprehensive checklist for code quality, testing, documentation
|
||||
- Screenshots for UI changes
|
||||
|
||||
**Key Checkpoints:**
|
||||
- References an approved issue
|
||||
- Aligns with [design principles](../DESIGN_PRINCIPLES.md)
|
||||
- Includes tests and documentation
|
||||
- Follows code style guidelines
|
||||
|
||||
## 🚀 GitHub Actions
|
||||
|
||||
Our CI/CD workflows in `.github/workflows/`:
|
||||
|
||||
### `build-and-release.yml`
|
||||
- Builds Docker images on releases
|
||||
- Publishes to Docker Hub and GitHub Container Registry
|
||||
- Supports multi-platform builds (amd64, arm64)
|
||||
|
||||
### `build-dev.yml`
|
||||
- Builds dev images on pushes to main
|
||||
- Tags with commit SHA for testing
|
||||
|
||||
### `claude-code-review.yml`
|
||||
- Automated code review using Claude Code
|
||||
- Runs on pull requests
|
||||
- Provides AI-powered suggestions
|
||||
|
||||
## 📖 How the Contribution Flow Works
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 1. Contributor identifies a bug or has a feature idea │
|
||||
└────────────────────┬────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 2. Creates an issue using appropriate template │
|
||||
│ - Describes the problem/feature │
|
||||
│ - Checks "I am a developer..." if willing to work │
|
||||
└────────────────────┬────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 3. Maintainer reviews issue (within 48 hours) │
|
||||
│ - Assesses alignment with design principles │
|
||||
│ - Labels appropriately │
|
||||
│ - Asks for clarification if needed │
|
||||
└────────────────────┬────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 4. If approved: Contributor proposes solution approach │
|
||||
│ - Discusses implementation strategy │
|
||||
│ - Maintainer provides feedback │
|
||||
└────────────────────┬────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 5. Issue is assigned to contributor │
|
||||
│ - Contributor forks repo │
|
||||
│ - Creates feature branch from main │
|
||||
└────────────────────┬────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 6. Contributor develops solution │
|
||||
│ - Reads DESIGN_PRINCIPLES.md │
|
||||
│ - Reads docs/development/architecture.md │
|
||||
│ - Writes code, tests, documentation │
|
||||
└────────────────────┬────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 7. Creates Pull Request │
|
||||
│ - Uses PR template │
|
||||
│ - References issue number │
|
||||
│ - Fills out all checklist items │
|
||||
└────────────────────┬────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 8. Maintainer reviews PR │
|
||||
│ - Checks code quality │
|
||||
│ - Verifies tests pass │
|
||||
│ - Ensures alignment with architecture │
|
||||
│ - Provides feedback or approves │
|
||||
└────────────────────┬────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 9. If approved: PR is merged! 🎉 │
|
||||
│ - Contributor is thanked │
|
||||
│ - Issue is closed │
|
||||
│ - Changes included in next release │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 🎯 Why This Process?
|
||||
|
||||
### Prevents Wasted Effort
|
||||
- Contributors don't spend time on code that won't be merged
|
||||
- Ensures alignment with project vision before coding starts
|
||||
|
||||
### Maintains Quality
|
||||
- All code reviewed against design principles
|
||||
- Consistent architecture across contributions
|
||||
- Proper testing and documentation
|
||||
|
||||
### Respects Everyone's Time
|
||||
- Clear expectations upfront
|
||||
- Structured feedback process
|
||||
- Efficient review process
|
||||
|
||||
### Protects Project Vision
|
||||
- Maintainers can guide direction
|
||||
- Features align with long-term goals
|
||||
- Technical debt is minimized
|
||||
|
||||
## 📚 Related Documentation
|
||||
|
||||
For contributors:
|
||||
- [CONTRIBUTING.md](../CONTRIBUTING.md) - Contribution guidelines
|
||||
- [DESIGN_PRINCIPLES.md](../DESIGN_PRINCIPLES.md) - Project vision and principles
|
||||
- [docs/development/architecture.md](../docs/development/architecture.md) - Technical architecture
|
||||
|
||||
For maintainers:
|
||||
- [MAINTAINER_GUIDE.md](../MAINTAINER_GUIDE.md) - How to review and manage contributions
|
||||
|
||||
## 🤝 Questions?
|
||||
|
||||
- Join our [Discord](https://discord.gg/37XJPXfz2w)
|
||||
- Open a [Discussion](https://github.com/lfnovo/open-notebook/discussions)
|
||||
- Read the [FAQ](../docs/troubleshooting/faq.md)
|
||||
|
||||
---
|
||||
|
||||
**Thank you for contributing to Open Notebook!** Your contributions help make this the best open-source research tool available.
|
||||
|
|
@ -44,7 +44,17 @@ class SourceProcessingOutput(CommandOutput):
|
|||
error_message: Optional[str] = None
|
||||
|
||||
|
||||
@command("process_source", app="open_notebook")
|
||||
@command(
|
||||
"process_source",
|
||||
app="open_notebook",
|
||||
retry={
|
||||
"max_attempts": 5,
|
||||
"wait_strategy": "exponential_jitter",
|
||||
"wait_min": 1,
|
||||
"wait_max": 30,
|
||||
"retry_on": [RuntimeError],
|
||||
},
|
||||
)
|
||||
async def process_source_command(
|
||||
input_data: SourceProcessingInput,
|
||||
) -> SourceProcessingOutput:
|
||||
|
|
@ -124,10 +134,15 @@ async def process_source_command(
|
|||
processing_time=processing_time,
|
||||
)
|
||||
|
||||
except RuntimeError as e:
|
||||
# Transaction conflicts should be retried by surreal-commands
|
||||
logger.warning(f"Transaction conflict, will retry: {e}")
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
# Other errors are permanent failures
|
||||
processing_time = time.time() - start_time
|
||||
logger.error(f"Source processing failed: {e}")
|
||||
logger.exception(e)
|
||||
|
||||
return SourceProcessingOutput(
|
||||
success=False,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,11 @@ import { useNotebook } from '@/lib/hooks/use-notebooks'
|
|||
import { useSources } from '@/lib/hooks/use-sources'
|
||||
import { useNotes } from '@/lib/hooks/use-notes'
|
||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
|
||||
import { useNotebookColumnsStore } from '@/lib/stores/notebook-columns-store'
|
||||
import { useIsDesktop } from '@/lib/hooks/use-media-query'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { FileText, StickyNote, MessageSquare } from 'lucide-react'
|
||||
|
||||
export type ContextMode = 'off' | 'insights' | 'full'
|
||||
|
||||
|
|
@ -29,6 +34,15 @@ export default function NotebookPage() {
|
|||
const { data: sources, isLoading: sourcesLoading, refetch: refetchSources } = useSources(notebookId)
|
||||
const { data: notes, isLoading: notesLoading } = useNotes(notebookId)
|
||||
|
||||
// Get collapse states for dynamic layout
|
||||
const { sourcesCollapsed, notesCollapsed } = useNotebookColumnsStore()
|
||||
|
||||
// Detect desktop to avoid double-mounting ChatColumn
|
||||
const isDesktop = useIsDesktop()
|
||||
|
||||
// Mobile tab state (Sources, Notes, or Chat)
|
||||
const [mobileActiveTab, setMobileActiveTab] = useState<'sources' | 'notes' | 'chat'>('chat')
|
||||
|
||||
// Context selection state
|
||||
const [contextSelections, setContextSelections] = useState<ContextSelections>({
|
||||
sources: {},
|
||||
|
|
@ -105,32 +119,98 @@ export default function NotebookPage() {
|
|||
<NotebookHeader notebook={notebook} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-6 pt-6 overflow-hidden">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 h-full min-h-0">
|
||||
<div className="lg:col-span-2 grid grid-cols-1 md:grid-cols-2 gap-6 h-full min-h-0">
|
||||
<div className="flex flex-col h-full min-h-0 overflow-hidden">
|
||||
<SourcesColumn
|
||||
sources={sources}
|
||||
isLoading={sourcesLoading}
|
||||
notebookId={notebookId}
|
||||
notebookName={notebook?.name}
|
||||
onRefresh={refetchSources}
|
||||
contextSelections={contextSelections.sources}
|
||||
onContextModeChange={(sourceId, mode) => handleContextModeChange(sourceId, mode, 'source')}
|
||||
/>
|
||||
<div className="flex-1 p-6 pt-6 overflow-hidden flex flex-col">
|
||||
{/* Mobile: Tabbed interface - only render on mobile to avoid double-mounting */}
|
||||
{!isDesktop && (
|
||||
<>
|
||||
<div className="lg:hidden mb-4">
|
||||
<Tabs value={mobileActiveTab} onValueChange={(value) => setMobileActiveTab(value as 'sources' | 'notes' | 'chat')}>
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="sources" className="gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
Sources
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="notes" className="gap-2">
|
||||
<StickyNote className="h-4 w-4" />
|
||||
Notes
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="chat" className="gap-2">
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
Chat
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="flex flex-col h-full min-h-0 overflow-hidden">
|
||||
<NotesColumn
|
||||
notes={notes}
|
||||
isLoading={notesLoading}
|
||||
notebookId={notebookId}
|
||||
contextSelections={contextSelections.notes}
|
||||
onContextModeChange={(noteId, mode) => handleContextModeChange(noteId, mode, 'note')}
|
||||
/>
|
||||
|
||||
{/* Mobile: Show only active tab */}
|
||||
<div className="flex-1 overflow-hidden lg:hidden">
|
||||
{mobileActiveTab === 'sources' && (
|
||||
<SourcesColumn
|
||||
sources={sources}
|
||||
isLoading={sourcesLoading}
|
||||
notebookId={notebookId}
|
||||
notebookName={notebook?.name}
|
||||
onRefresh={refetchSources}
|
||||
contextSelections={contextSelections.sources}
|
||||
onContextModeChange={(sourceId, mode) => handleContextModeChange(sourceId, mode, 'source')}
|
||||
/>
|
||||
)}
|
||||
{mobileActiveTab === 'notes' && (
|
||||
<NotesColumn
|
||||
notes={notes}
|
||||
isLoading={notesLoading}
|
||||
notebookId={notebookId}
|
||||
contextSelections={contextSelections.notes}
|
||||
onContextModeChange={(noteId, mode) => handleContextModeChange(noteId, mode, 'note')}
|
||||
/>
|
||||
)}
|
||||
{mobileActiveTab === 'chat' && (
|
||||
<ChatColumn
|
||||
notebookId={notebookId}
|
||||
contextSelections={contextSelections}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Desktop: Collapsible columns layout */}
|
||||
<div className={cn(
|
||||
'hidden lg:flex h-full min-h-0 gap-6 transition-all duration-150',
|
||||
'flex-row'
|
||||
)}>
|
||||
{/* Sources Column */}
|
||||
<div className={cn(
|
||||
'transition-all duration-150',
|
||||
sourcesCollapsed ? 'w-12 flex-shrink-0' : 'flex-none basis-1/3'
|
||||
)}>
|
||||
<SourcesColumn
|
||||
sources={sources}
|
||||
isLoading={sourcesLoading}
|
||||
notebookId={notebookId}
|
||||
notebookName={notebook?.name}
|
||||
onRefresh={refetchSources}
|
||||
contextSelections={contextSelections.sources}
|
||||
onContextModeChange={(sourceId, mode) => handleContextModeChange(sourceId, mode, 'source')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col h-full min-h-0 overflow-hidden">
|
||||
{/* Notes Column */}
|
||||
<div className={cn(
|
||||
'transition-all duration-150',
|
||||
notesCollapsed ? 'w-12 flex-shrink-0' : 'flex-none basis-1/3'
|
||||
)}>
|
||||
<NotesColumn
|
||||
notes={notes}
|
||||
isLoading={notesLoading}
|
||||
notebookId={notebookId}
|
||||
contextSelections={contextSelections.notes}
|
||||
onContextModeChange={(noteId, mode) => handleContextModeChange(noteId, mode, 'note')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Chat Column - always expanded, takes remaining space */}
|
||||
<div className="transition-all duration-150 flex-1">
|
||||
<ChatColumn
|
||||
notebookId={notebookId}
|
||||
contextSelections={contextSelections}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { NoteResponse } from '@/lib/types/api'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
|
@ -20,6 +20,8 @@ import { ContextToggle } from '@/components/common/ContextToggle'
|
|||
import { ContextMode } from '../[id]/page'
|
||||
import { useDeleteNote } from '@/lib/hooks/use-notes'
|
||||
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
|
||||
import { CollapsibleColumn, createCollapseButton } from '@/components/notebooks/CollapsibleColumn'
|
||||
import { useNotebookColumnsStore } from '@/lib/stores/notebook-columns-store'
|
||||
|
||||
interface NotesColumnProps {
|
||||
notes?: NoteResponse[]
|
||||
|
|
@ -43,6 +45,13 @@ export function NotesColumn({
|
|||
|
||||
const deleteNote = useDeleteNote()
|
||||
|
||||
// Collapsible column state
|
||||
const { notesCollapsed, toggleNotes } = useNotebookColumnsStore()
|
||||
const collapseButton = useMemo(
|
||||
() => createCollapseButton(toggleNotes, 'Notes'),
|
||||
[toggleNotes]
|
||||
)
|
||||
|
||||
const handleDeleteClick = (noteId: string) => {
|
||||
setNoteToDelete(noteId)
|
||||
setDeleteDialogOpen(true)
|
||||
|
|
@ -62,113 +71,123 @@ export function NotesColumn({
|
|||
|
||||
return (
|
||||
<>
|
||||
<Card className="h-full flex flex-col flex-1 overflow-hidden">
|
||||
<CardHeader className="pb-3 flex-shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg">Notes</CardTitle>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingNote(null)
|
||||
setShowAddDialog(true)
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Write Note
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 overflow-y-auto min-h-0">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
) : !notes || notes.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={StickyNote}
|
||||
title="No notes yet"
|
||||
description="Create your first note to capture insights and observations."
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{notes.map((note) => (
|
||||
<div
|
||||
key={note.id}
|
||||
className="p-3 border rounded-lg card-hover group relative cursor-pointer"
|
||||
onClick={() => setEditingNote(note)}
|
||||
<CollapsibleColumn
|
||||
isCollapsed={notesCollapsed}
|
||||
onToggle={toggleNotes}
|
||||
collapsedIcon={StickyNote}
|
||||
collapsedLabel="Notes"
|
||||
>
|
||||
<Card className="h-full flex flex-col flex-1 overflow-hidden">
|
||||
<CardHeader className="pb-3 flex-shrink-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<CardTitle className="text-lg">Notes</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingNote(null)
|
||||
setShowAddDialog(true)
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{note.note_type === 'ai' ? (
|
||||
<Bot className="h-4 w-4 text-primary" />
|
||||
) : (
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{note.note_type === 'ai' ? 'AI Generated' : 'Human'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(note.updated), { addSuffix: true })}
|
||||
</span>
|
||||
|
||||
{/* Context toggle - only show if handler provided */}
|
||||
{onContextModeChange && contextSelections?.[note.id] && (
|
||||
<div onClick={(event) => event.stopPropagation()}>
|
||||
<ContextToggle
|
||||
mode={contextSelections[note.id]}
|
||||
hasInsights={false}
|
||||
onChange={(mode) => onContextModeChange(note.id, mode)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ellipsis menu for delete action */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeleteClick(note.id)
|
||||
}}
|
||||
className="text-red-600 focus:text-red-600"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete Note
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{note.title && (
|
||||
<h4 className="text-sm font-medium mb-2">{note.title}</h4>
|
||||
)}
|
||||
|
||||
{note.content && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-3">
|
||||
{note.content}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Write Note
|
||||
</Button>
|
||||
{collapseButton}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 overflow-y-auto min-h-0">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
) : !notes || notes.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={StickyNote}
|
||||
title="No notes yet"
|
||||
description="Create your first note to capture insights and observations."
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{notes.map((note) => (
|
||||
<div
|
||||
key={note.id}
|
||||
className="p-3 border rounded-lg card-hover group relative cursor-pointer"
|
||||
onClick={() => setEditingNote(note)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{note.note_type === 'ai' ? (
|
||||
<Bot className="h-4 w-4 text-primary" />
|
||||
) : (
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{note.note_type === 'ai' ? 'AI Generated' : 'Human'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(note.updated), { addSuffix: true })}
|
||||
</span>
|
||||
|
||||
{/* Context toggle - only show if handler provided */}
|
||||
{onContextModeChange && contextSelections?.[note.id] && (
|
||||
<div onClick={(event) => event.stopPropagation()}>
|
||||
<ContextToggle
|
||||
mode={contextSelections[note.id]}
|
||||
hasInsights={false}
|
||||
onChange={(mode) => onContextModeChange(note.id, mode)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ellipsis menu for delete action */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeleteClick(note.id)
|
||||
}}
|
||||
className="text-red-600 focus:text-red-600"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete Note
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{note.title && (
|
||||
<h4 className="text-sm font-medium mb-2">{note.title}</h4>
|
||||
)}
|
||||
|
||||
{note.content && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-3">
|
||||
{note.content}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CollapsibleColumn>
|
||||
|
||||
<NoteEditorDialog
|
||||
open={showAddDialog || Boolean(editingNote)}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { SourceListResponse } from '@/lib/types/api'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
|
@ -20,6 +20,8 @@ import { useDeleteSource, useRetrySource, useRemoveSourceFromNotebook } from '@/
|
|||
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
|
||||
import { useModalManager } from '@/lib/hooks/use-modal-manager'
|
||||
import { ContextMode } from '../[id]/page'
|
||||
import { CollapsibleColumn, createCollapseButton } from '@/components/notebooks/CollapsibleColumn'
|
||||
import { useNotebookColumnsStore } from '@/lib/stores/notebook-columns-store'
|
||||
|
||||
interface SourcesColumnProps {
|
||||
sources?: SourceListResponse[]
|
||||
|
|
@ -51,6 +53,13 @@ export function SourcesColumn({
|
|||
const deleteSource = useDeleteSource()
|
||||
const retrySource = useRetrySource()
|
||||
const removeFromNotebook = useRemoveSourceFromNotebook()
|
||||
|
||||
// Collapsible column state
|
||||
const { sourcesCollapsed, toggleSources } = useNotebookColumnsStore()
|
||||
const collapseButton = useMemo(
|
||||
() => createCollapseButton(toggleSources, 'Sources'),
|
||||
[toggleSources]
|
||||
)
|
||||
|
||||
const handleDeleteClick = (sourceId: string) => {
|
||||
setSourceToDelete(sourceId)
|
||||
|
|
@ -102,67 +111,80 @@ export function SourcesColumn({
|
|||
const handleSourceClick = (sourceId: string) => {
|
||||
openModal('source', sourceId)
|
||||
}
|
||||
return (
|
||||
<Card className="h-full flex flex-col flex-1 overflow-hidden">
|
||||
<CardHeader className="pb-3 flex-shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg">Sources</CardTitle>
|
||||
<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>
|
||||
|
||||
<CardContent className="flex-1 overflow-y-auto min-h-0">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
) : !sources || sources.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
title="No sources yet"
|
||||
description="Add your first source to start building your knowledge base."
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{sources.map((source) => (
|
||||
<SourceCard
|
||||
key={source.id}
|
||||
source={source}
|
||||
onClick={handleSourceClick}
|
||||
onDelete={handleDeleteClick}
|
||||
onRetry={handleRetry}
|
||||
onRemoveFromNotebook={handleRemoveFromNotebook}
|
||||
onRefresh={onRefresh}
|
||||
showRemoveFromNotebook={true}
|
||||
contextMode={contextSelections?.[source.id]}
|
||||
onContextModeChange={onContextModeChange
|
||||
? (mode) => onContextModeChange(source.id, mode)
|
||||
: undefined
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<CollapsibleColumn
|
||||
isCollapsed={sourcesCollapsed}
|
||||
onToggle={toggleSources}
|
||||
collapsedIcon={FileText}
|
||||
collapsedLabel="Sources"
|
||||
>
|
||||
<Card className="h-full flex flex-col flex-1 overflow-hidden">
|
||||
<CardHeader className="pb-3 flex-shrink-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<CardTitle className="text-lg">Sources</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
{collapseButton}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 overflow-y-auto min-h-0">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
) : !sources || sources.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
title="No sources yet"
|
||||
description="Add your first source to start building your knowledge base."
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{sources.map((source) => (
|
||||
<SourceCard
|
||||
key={source.id}
|
||||
source={source}
|
||||
onClick={handleSourceClick}
|
||||
onDelete={handleDeleteClick}
|
||||
onRetry={handleRetry}
|
||||
onRemoveFromNotebook={handleRemoveFromNotebook}
|
||||
onRefresh={onRefresh}
|
||||
showRemoveFromNotebook={true}
|
||||
contextMode={contextSelections?.[source.id]}
|
||||
onContextModeChange={onContextModeChange
|
||||
? (mode) => onContextModeChange(source.id, mode)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CollapsibleColumn>
|
||||
|
||||
<AddSourceDialog
|
||||
open={addDialogOpen}
|
||||
onOpenChange={setAddDialogOpen}
|
||||
|
|
@ -197,6 +219,6 @@ export function SourcesColumn({
|
|||
isLoading={removeFromNotebook.isPending}
|
||||
confirmVariant="default"
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
93
frontend/src/components/notebooks/CollapsibleColumn.tsx
Normal file
93
frontend/src/components/notebooks/CollapsibleColumn.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
'use client'
|
||||
|
||||
import { ReactNode } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { ChevronLeft, LucideIcon } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface CollapsibleColumnProps {
|
||||
isCollapsed: boolean
|
||||
onToggle: () => void
|
||||
collapsedIcon: LucideIcon
|
||||
collapsedLabel: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function CollapsibleColumn({
|
||||
isCollapsed,
|
||||
onToggle,
|
||||
collapsedIcon: CollapsedIcon,
|
||||
collapsedLabel,
|
||||
children,
|
||||
}: CollapsibleColumnProps) {
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-3',
|
||||
'w-12 h-full min-h-0',
|
||||
'border rounded-lg',
|
||||
'bg-card hover:bg-accent/50',
|
||||
'transition-all duration-150',
|
||||
'cursor-pointer group',
|
||||
'py-6'
|
||||
)}
|
||||
aria-label={`Expand ${collapsedLabel}`}
|
||||
>
|
||||
<CollapsedIcon className="h-5 w-5 text-muted-foreground group-hover:text-foreground transition-colors flex-shrink-0" />
|
||||
<div
|
||||
className="text-xs font-medium text-muted-foreground group-hover:text-foreground transition-colors whitespace-nowrap"
|
||||
style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)', textOrientation: 'mixed' }}
|
||||
>
|
||||
{collapsedLabel}
|
||||
</div>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Expand {collapsedLabel}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0 transition-all duration-150">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Factory function to create a collapse button for card headers
|
||||
export function createCollapseButton(onToggle: () => void, label: string) {
|
||||
return (
|
||||
<div className="hidden lg:block">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggle()
|
||||
}}
|
||||
className="h-7 w-7 hover:bg-accent"
|
||||
aria-label={`Collapse ${label}`}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Collapse {label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useState, useRef, useEffect, useMemo } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { LoaderIcon } from 'lucide-react'
|
||||
import { LoaderIcon, CheckCircleIcon, XCircleIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -14,7 +15,7 @@ import {
|
|||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { WizardContainer, WizardStep } from '@/components/ui/wizard-container'
|
||||
import { SourceTypeStep } from './steps/SourceTypeStep'
|
||||
import { SourceTypeStep, parseAndValidateUrls } from './steps/SourceTypeStep'
|
||||
import { NotebooksStep } from './steps/NotebooksStep'
|
||||
import { ProcessingStep } from './steps/ProcessingStep'
|
||||
import { useNotebooks } from '@/lib/hooks/use-notebooks'
|
||||
|
|
@ -23,6 +24,8 @@ import { useCreateSource } from '@/lib/hooks/use-sources'
|
|||
import { useSettings } from '@/lib/hooks/use-settings'
|
||||
import { CreateSourceRequest } from '@/lib/types/api'
|
||||
|
||||
const MAX_BATCH_SIZE = 50
|
||||
|
||||
const createSourceSchema = z.object({
|
||||
type: z.enum(['link', 'upload', 'text']),
|
||||
title: z.string().optional(),
|
||||
|
|
@ -80,6 +83,13 @@ interface ProcessingState {
|
|||
progress?: number
|
||||
}
|
||||
|
||||
interface BatchProgress {
|
||||
total: number
|
||||
completed: number
|
||||
failed: number
|
||||
currentItem?: string
|
||||
}
|
||||
|
||||
export function AddSourceDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
|
|
@ -94,6 +104,10 @@ export function AddSourceDialog({
|
|||
)
|
||||
const [selectedTransformations, setSelectedTransformations] = useState<string[]>([])
|
||||
|
||||
// Batch-specific state
|
||||
const [urlValidationErrors, setUrlValidationErrors] = useState<{ url: string; line: number }[]>([])
|
||||
const [batchProgress, setBatchProgress] = useState<BatchProgress | null>(null)
|
||||
|
||||
// Cleanup timeouts to prevent memory leaks
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
|
|
@ -158,12 +172,51 @@ export function AddSourceDialog({
|
|||
const watchedFile = watch('file')
|
||||
const watchedTitle = watch('title')
|
||||
|
||||
// Batch mode detection
|
||||
const { isBatchMode, itemCount, parsedUrls, parsedFiles } = useMemo(() => {
|
||||
let urlCount = 0
|
||||
let fileCount = 0
|
||||
let parsedUrls: string[] = []
|
||||
let parsedFiles: File[] = []
|
||||
|
||||
if (selectedType === 'link' && watchedUrl) {
|
||||
const { valid } = parseAndValidateUrls(watchedUrl)
|
||||
parsedUrls = valid
|
||||
urlCount = valid.length
|
||||
}
|
||||
|
||||
if (selectedType === 'upload' && watchedFile) {
|
||||
const fileList = watchedFile as FileList
|
||||
if (fileList?.length) {
|
||||
parsedFiles = Array.from(fileList)
|
||||
fileCount = parsedFiles.length
|
||||
}
|
||||
}
|
||||
|
||||
const isBatchMode = urlCount > 1 || fileCount > 1
|
||||
const itemCount = selectedType === 'link' ? urlCount : fileCount
|
||||
|
||||
return { isBatchMode, itemCount, parsedUrls, parsedFiles }
|
||||
}, [selectedType, watchedUrl, watchedFile])
|
||||
|
||||
// Check for batch size limit
|
||||
const isOverLimit = itemCount > MAX_BATCH_SIZE
|
||||
|
||||
// Step validation - now reactive with watched values
|
||||
const isStepValid = (step: number): boolean => {
|
||||
switch (step) {
|
||||
case 1:
|
||||
if (!selectedType) return false
|
||||
// Check batch size limit
|
||||
if (isOverLimit) return false
|
||||
// Check for URL validation errors
|
||||
if (urlValidationErrors.length > 0) return false
|
||||
|
||||
if (selectedType === 'link') {
|
||||
// In batch mode, check that we have at least one valid URL
|
||||
if (isBatchMode) {
|
||||
return parsedUrls.length > 0
|
||||
}
|
||||
return !!watchedUrl && watchedUrl.trim() !== ''
|
||||
}
|
||||
if (selectedType === 'text') {
|
||||
|
|
@ -172,7 +225,7 @@ export function AddSourceDialog({
|
|||
}
|
||||
if (selectedType === 'upload') {
|
||||
if (watchedFile instanceof FileList) {
|
||||
return watchedFile.length > 0
|
||||
return watchedFile.length > 0 && watchedFile.length <= MAX_BATCH_SIZE
|
||||
}
|
||||
return !!watchedFile
|
||||
}
|
||||
|
|
@ -189,11 +242,27 @@ export function AddSourceDialog({
|
|||
const handleNextStep = (e?: React.MouseEvent) => {
|
||||
e?.preventDefault()
|
||||
e?.stopPropagation()
|
||||
|
||||
// Validate URLs when leaving step 1 in link mode
|
||||
if (currentStep === 1 && selectedType === 'link' && watchedUrl) {
|
||||
const { invalid } = parseAndValidateUrls(watchedUrl)
|
||||
if (invalid.length > 0) {
|
||||
setUrlValidationErrors(invalid)
|
||||
return
|
||||
}
|
||||
setUrlValidationErrors([])
|
||||
}
|
||||
|
||||
if (currentStep < 3 && isStepValid(currentStep)) {
|
||||
setCurrentStep(currentStep + 1)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear URL validation errors when user edits
|
||||
const handleClearUrlErrors = () => {
|
||||
setUrlValidationErrors([])
|
||||
}
|
||||
|
||||
const handlePrevStep = (e?: React.MouseEvent) => {
|
||||
e?.preventDefault()
|
||||
e?.stopPropagation()
|
||||
|
|
@ -223,43 +292,127 @@ export function AddSourceDialog({
|
|||
setSelectedTransformations(updated)
|
||||
}
|
||||
|
||||
// Single source submission
|
||||
const submitSingleSource = async (data: CreateSourceFormData): Promise<void> => {
|
||||
const createRequest: CreateSourceRequest = {
|
||||
type: data.type,
|
||||
notebooks: selectedNotebooks,
|
||||
url: data.type === 'link' ? data.url : undefined,
|
||||
content: data.type === 'text' ? data.content : undefined,
|
||||
title: data.title,
|
||||
transformations: selectedTransformations,
|
||||
embed: data.embed,
|
||||
delete_source: false,
|
||||
async_processing: true,
|
||||
}
|
||||
|
||||
if (data.type === 'upload' && data.file) {
|
||||
const file = data.file instanceof FileList ? data.file[0] : data.file
|
||||
const requestWithFile = createRequest as CreateSourceRequest & { file?: File }
|
||||
requestWithFile.file = file
|
||||
}
|
||||
|
||||
await createSource.mutateAsync(createRequest)
|
||||
}
|
||||
|
||||
// Batch submission
|
||||
const submitBatch = async (data: CreateSourceFormData): Promise<{ success: number; failed: number }> => {
|
||||
const results = { success: 0, failed: 0 }
|
||||
const items: { type: 'url' | 'file'; value: string | File }[] = []
|
||||
|
||||
// Collect items to process
|
||||
if (data.type === 'link' && parsedUrls.length > 0) {
|
||||
parsedUrls.forEach(url => items.push({ type: 'url', value: url }))
|
||||
} else if (data.type === 'upload' && parsedFiles.length > 0) {
|
||||
parsedFiles.forEach(file => items.push({ type: 'file', value: file }))
|
||||
}
|
||||
|
||||
setBatchProgress({
|
||||
total: items.length,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
})
|
||||
|
||||
// Process each item sequentially
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i]
|
||||
const itemLabel = item.type === 'url'
|
||||
? (item.value as string).substring(0, 50) + '...'
|
||||
: (item.value as File).name
|
||||
|
||||
setBatchProgress(prev => prev ? {
|
||||
...prev,
|
||||
currentItem: itemLabel,
|
||||
} : null)
|
||||
|
||||
try {
|
||||
const createRequest: CreateSourceRequest = {
|
||||
type: item.type === 'url' ? 'link' : 'upload',
|
||||
notebooks: selectedNotebooks,
|
||||
url: item.type === 'url' ? item.value as string : undefined,
|
||||
transformations: selectedTransformations,
|
||||
embed: data.embed,
|
||||
delete_source: false,
|
||||
async_processing: true,
|
||||
}
|
||||
|
||||
if (item.type === 'file') {
|
||||
const requestWithFile = createRequest as CreateSourceRequest & { file?: File }
|
||||
requestWithFile.file = item.value as File
|
||||
}
|
||||
|
||||
await createSource.mutateAsync(createRequest)
|
||||
results.success++
|
||||
} catch (error) {
|
||||
console.error(`Error creating source for ${itemLabel}:`, error)
|
||||
results.failed++
|
||||
}
|
||||
|
||||
setBatchProgress(prev => prev ? {
|
||||
...prev,
|
||||
completed: results.success,
|
||||
failed: results.failed,
|
||||
} : null)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// Form submission
|
||||
const onSubmit = async (data: CreateSourceFormData) => {
|
||||
try {
|
||||
setProcessing(true)
|
||||
setProcessingStatus({ message: 'Submitting source for processing...' })
|
||||
|
||||
const createRequest: CreateSourceRequest = {
|
||||
type: data.type,
|
||||
notebooks: selectedNotebooks,
|
||||
url: data.type === 'link' ? data.url : undefined,
|
||||
content: data.type === 'text' ? data.content : undefined,
|
||||
title: data.title,
|
||||
transformations: selectedTransformations,
|
||||
embed: data.embed,
|
||||
delete_source: false,
|
||||
async_processing: true, // Always use async processing for frontend submissions
|
||||
if (isBatchMode) {
|
||||
// Batch submission
|
||||
setProcessingStatus({ message: `Processing ${itemCount} sources...` })
|
||||
const results = await submitBatch(data)
|
||||
|
||||
// Show summary toast
|
||||
if (results.failed === 0) {
|
||||
toast.success(`${results.success} source${results.success !== 1 ? 's' : ''} created successfully`)
|
||||
} else if (results.success === 0) {
|
||||
toast.error(`Failed to create all ${results.failed} sources`)
|
||||
} else {
|
||||
toast.warning(`${results.success} succeeded, ${results.failed} failed`)
|
||||
}
|
||||
|
||||
handleClose()
|
||||
} else {
|
||||
// Single source submission
|
||||
setProcessingStatus({ message: 'Submitting source for processing...' })
|
||||
await submitSingleSource(data)
|
||||
handleClose()
|
||||
}
|
||||
|
||||
|
||||
if (data.type === 'upload' && data.file) {
|
||||
const file = data.file instanceof FileList ? data.file[0] : data.file
|
||||
const requestWithFile = createRequest as CreateSourceRequest & { file?: File }
|
||||
requestWithFile.file = file
|
||||
}
|
||||
|
||||
await createSource.mutateAsync(createRequest)
|
||||
|
||||
// Close immediately - the toast will show the success message
|
||||
handleClose()
|
||||
} catch (error) {
|
||||
console.error('Error creating source:', error)
|
||||
setProcessingStatus({
|
||||
setProcessingStatus({
|
||||
message: 'Error creating source. Please try again.',
|
||||
})
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setProcessing(false)
|
||||
setProcessingStatus(null)
|
||||
setBatchProgress(null)
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
|
|
@ -277,6 +430,8 @@ export function AddSourceDialog({
|
|||
setProcessing(false)
|
||||
setProcessingStatus(null)
|
||||
setSelectedNotebooks(defaultNotebookId ? [defaultNotebookId] : [])
|
||||
setUrlValidationErrors([])
|
||||
setBatchProgress(null)
|
||||
|
||||
// Reset to default transformations
|
||||
if (transformations.length > 0) {
|
||||
|
|
@ -293,16 +448,25 @@ export function AddSourceDialog({
|
|||
|
||||
// Processing view
|
||||
if (processing) {
|
||||
const progressPercent = batchProgress
|
||||
? Math.round(((batchProgress.completed + batchProgress.failed) / batchProgress.total) * 100)
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[500px]" showCloseButton={true}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Processing Source</DialogTitle>
|
||||
<DialogTitle>
|
||||
{batchProgress ? 'Processing Batch' : 'Processing Source'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Your source is being processed. This may take a few moments.
|
||||
{batchProgress
|
||||
? `Processing ${batchProgress.total} sources. This may take a few moments.`
|
||||
: 'Your source is being processed. This may take a few moments.'
|
||||
}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<LoaderIcon className="h-5 w-5 animate-spin text-primary" />
|
||||
|
|
@ -310,11 +474,48 @@ export function AddSourceDialog({
|
|||
{processingStatus?.message || 'Processing...'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{processingStatus?.progress && (
|
||||
|
||||
{/* Batch progress */}
|
||||
{batchProgress && (
|
||||
<>
|
||||
<div className="w-full bg-muted rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="flex items-center gap-1.5 text-green-600">
|
||||
<CheckCircleIcon className="h-4 w-4" />
|
||||
{batchProgress.completed} completed
|
||||
</span>
|
||||
{batchProgress.failed > 0 && (
|
||||
<span className="flex items-center gap-1.5 text-destructive">
|
||||
<XCircleIcon className="h-4 w-4" />
|
||||
{batchProgress.failed} failed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted-foreground">
|
||||
{batchProgress.completed + batchProgress.failed} / {batchProgress.total}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{batchProgress.currentItem && (
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
Current: {batchProgress.currentItem}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Single source progress */}
|
||||
{!batchProgress && processingStatus?.progress && (
|
||||
<div className="w-full bg-muted rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary h-2 rounded-full transition-all duration-300"
|
||||
<div
|
||||
className="bg-primary h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${processingStatus.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -351,6 +552,8 @@ export function AddSourceDialog({
|
|||
register={register}
|
||||
// @ts-expect-error - Type inference issue with zod schema
|
||||
errors={errors}
|
||||
urlValidationErrors={urlValidationErrors}
|
||||
onClearUrlErrors={handleClearUrlErrors}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { Control, FieldErrors, UseFormRegister, useWatch } from "react-hook-form"
|
||||
import { FileIcon, LinkIcon, FileTextIcon } from "lucide-react"
|
||||
import { FormSection } from "@/components/ui/form-section"
|
||||
|
|
@ -7,6 +8,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
|||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Controller } from "react-hook-form"
|
||||
|
||||
interface CreateSourceFormData {
|
||||
|
|
@ -21,6 +23,45 @@ interface CreateSourceFormData {
|
|||
async_processing: boolean
|
||||
}
|
||||
|
||||
// Helper functions for batch URL parsing
|
||||
function parseUrls(text: string): string[] {
|
||||
return text
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0)
|
||||
}
|
||||
|
||||
function validateUrl(url: string): boolean {
|
||||
try {
|
||||
new URL(url)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function parseAndValidateUrls(text: string): {
|
||||
valid: string[]
|
||||
invalid: { url: string; line: number }[]
|
||||
} {
|
||||
const lines = text.split('\n')
|
||||
const valid: string[] = []
|
||||
const invalid: { url: string; line: number }[] = []
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed.length === 0) return // skip empty lines
|
||||
|
||||
if (validateUrl(trimmed)) {
|
||||
valid.push(trimmed)
|
||||
} else {
|
||||
invalid.push({ url: trimmed, line: index + 1 })
|
||||
}
|
||||
})
|
||||
|
||||
return { valid, invalid }
|
||||
}
|
||||
|
||||
const SOURCE_TYPES = [
|
||||
{
|
||||
value: 'link' as const,
|
||||
|
|
@ -46,11 +87,41 @@ interface SourceTypeStepProps {
|
|||
control: Control<CreateSourceFormData>
|
||||
register: UseFormRegister<CreateSourceFormData>
|
||||
errors: FieldErrors<CreateSourceFormData>
|
||||
urlValidationErrors?: { url: string; line: number }[]
|
||||
onClearUrlErrors?: () => void
|
||||
}
|
||||
|
||||
export function SourceTypeStep({ control, register, errors }: SourceTypeStepProps) {
|
||||
// Watch the selected type to make title conditional
|
||||
const MAX_BATCH_SIZE = 50
|
||||
|
||||
export function SourceTypeStep({ control, register, errors, urlValidationErrors, onClearUrlErrors }: SourceTypeStepProps) {
|
||||
// Watch the selected type and inputs to detect batch mode
|
||||
const selectedType = useWatch({ control, name: 'type' })
|
||||
const urlInput = useWatch({ control, name: 'url' })
|
||||
const fileInput = useWatch({ control, name: 'file' })
|
||||
|
||||
// Batch mode detection
|
||||
const { isBatchMode, itemCount, urlCount, fileCount } = useMemo(() => {
|
||||
let urlCount = 0
|
||||
let fileCount = 0
|
||||
|
||||
if (selectedType === 'link' && urlInput) {
|
||||
const urls = parseUrls(urlInput)
|
||||
urlCount = urls.length
|
||||
}
|
||||
|
||||
if (selectedType === 'upload' && fileInput) {
|
||||
const fileList = fileInput as FileList
|
||||
fileCount = fileList?.length || 0
|
||||
}
|
||||
|
||||
const isBatchMode = urlCount > 1 || fileCount > 1
|
||||
const itemCount = selectedType === 'link' ? urlCount : fileCount
|
||||
|
||||
return { isBatchMode, itemCount, urlCount, fileCount }
|
||||
}, [selectedType, urlInput, fileInput])
|
||||
|
||||
// Check for batch size limit
|
||||
const isOverLimit = itemCount > MAX_BATCH_SIZE
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<FormSection
|
||||
|
|
@ -85,34 +156,98 @@ export function SourceTypeStep({ control, register, errors }: SourceTypeStepProp
|
|||
{/* Type-specific fields */}
|
||||
{type.value === 'link' && (
|
||||
<div>
|
||||
<Label htmlFor="url" className="mb-2 block">URL *</Label>
|
||||
<Input
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label htmlFor="url">URL(s) *</Label>
|
||||
{urlCount > 0 && (
|
||||
<Badge variant={isOverLimit ? "destructive" : "secondary"}>
|
||||
{urlCount} URL{urlCount !== 1 ? 's' : ''}
|
||||
{isOverLimit && ` (max ${MAX_BATCH_SIZE})`}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Textarea
|
||||
id="url"
|
||||
{...register('url')}
|
||||
placeholder="https://example.com/article"
|
||||
type="url"
|
||||
{...register('url', {
|
||||
onChange: () => onClearUrlErrors?.()
|
||||
})}
|
||||
placeholder="Enter URLs, one per line https://example.com/article1 https://example.com/article2"
|
||||
rows={urlCount > 1 ? 6 : 2}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Paste multiple URLs (one per line) to batch import
|
||||
</p>
|
||||
{errors.url && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.url.message}</p>
|
||||
)}
|
||||
{urlValidationErrors && urlValidationErrors.length > 0 && (
|
||||
<div className="mt-2 p-3 bg-destructive/10 rounded-md border border-destructive/20">
|
||||
<p className="text-sm font-medium text-destructive mb-2">
|
||||
Invalid URLs detected:
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{urlValidationErrors.map((error, idx) => (
|
||||
<li key={idx} className="text-xs text-destructive flex items-start gap-2">
|
||||
<span className="font-mono bg-destructive/20 px-1 rounded">
|
||||
Line {error.line}
|
||||
</span>
|
||||
<span className="truncate">{error.url}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Please fix or remove invalid URLs to continue
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{type.value === 'upload' && (
|
||||
<div>
|
||||
<Label htmlFor="file" className="mb-2 block">File *</Label>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label htmlFor="file">File(s) *</Label>
|
||||
{fileCount > 0 && (
|
||||
<Badge variant={isOverLimit ? "destructive" : "secondary"}>
|
||||
{fileCount} file{fileCount !== 1 ? 's' : ''}
|
||||
{isOverLimit && ` (max ${MAX_BATCH_SIZE})`}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
id="file"
|
||||
type="file"
|
||||
multiple
|
||||
{...register('file')}
|
||||
accept=".pdf,.doc,.docx,.pptx,.ppt,.xlsx,.xls,.txt,.md,.epub,.mp4,.avi,.mov,.wmv,.mp3,.wav,.m4a,.aac,.jpg,.jpeg,.png,.tiff,.zip,.tar,.gz,.html"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Supported: Documents (PDF, DOC, DOCX, PPT, XLS, EPUB, TXT, MD), Media (MP4, MP3, WAV, M4A), Images (JPG, PNG), Archives (ZIP)
|
||||
Select multiple files to batch import. Supported: Documents (PDF, DOC, DOCX, PPT, XLS, EPUB, TXT, MD), Media (MP4, MP3, WAV, M4A), Images (JPG, PNG), Archives (ZIP)
|
||||
</p>
|
||||
{fileCount > 1 && fileInput instanceof FileList && (
|
||||
<div className="mt-2 p-3 bg-muted rounded-md">
|
||||
<p className="text-xs font-medium mb-2">Selected files:</p>
|
||||
<ul className="space-y-1 max-h-32 overflow-y-auto">
|
||||
{Array.from(fileInput).map((file, idx) => (
|
||||
<li key={idx} className="text-xs text-muted-foreground flex items-center gap-2">
|
||||
<FileIcon className="h-3 w-3" />
|
||||
<span className="truncate">{file.name}</span>
|
||||
<span className="text-muted-foreground/50">
|
||||
({(file.size / 1024).toFixed(1)} KB)
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{errors.file && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.file.message}</p>
|
||||
)}
|
||||
{isOverLimit && selectedType === 'upload' && (
|
||||
<p className="text-sm text-destructive mt-1">
|
||||
Maximum {MAX_BATCH_SIZE} files allowed per batch
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -140,22 +275,41 @@ export function SourceTypeStep({ control, register, errors }: SourceTypeStepProp
|
|||
)}
|
||||
</FormSection>
|
||||
|
||||
<FormSection
|
||||
title={selectedType === 'text' ? "Title *" : "Title (optional)"}
|
||||
description={selectedType === 'text'
|
||||
? "A title is required for text content"
|
||||
: "If left empty, a title will be generated from the content"
|
||||
}
|
||||
>
|
||||
<Input
|
||||
id="title"
|
||||
{...register('title')}
|
||||
placeholder="Give your source a descriptive title"
|
||||
/>
|
||||
{errors.title && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.title.message}</p>
|
||||
)}
|
||||
</FormSection>
|
||||
{/* Hide title field in batch mode - titles will be auto-generated */}
|
||||
{!isBatchMode && (
|
||||
<FormSection
|
||||
title={selectedType === 'text' ? "Title *" : "Title (optional)"}
|
||||
description={selectedType === 'text'
|
||||
? "A title is required for text content"
|
||||
: "If left empty, a title will be generated from the content"
|
||||
}
|
||||
>
|
||||
<Input
|
||||
id="title"
|
||||
{...register('title')}
|
||||
placeholder="Give your source a descriptive title"
|
||||
/>
|
||||
{errors.title && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.title.message}</p>
|
||||
)}
|
||||
</FormSection>
|
||||
)}
|
||||
|
||||
{/* Batch mode indicator */}
|
||||
{isBatchMode && (
|
||||
<div className="p-4 bg-primary/5 border border-primary/20 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Badge variant="default">Batch Mode</Badge>
|
||||
<span className="text-sm font-medium">
|
||||
{itemCount} {selectedType === 'link' ? 'URLs' : 'files'} will be processed
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Titles will be automatically generated for each source.
|
||||
The same notebooks and transformations will be applied to all items.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
32
frontend/src/lib/hooks/use-media-query.ts
Normal file
32
frontend/src/lib/hooks/use-media-query.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
/**
|
||||
* Hook to detect if viewport matches a media query.
|
||||
* Returns false during SSR to avoid hydration mismatches.
|
||||
*/
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia(query)
|
||||
setMatches(mediaQuery.matches)
|
||||
|
||||
const handler = (event: MediaQueryListEvent) => {
|
||||
setMatches(event.matches)
|
||||
}
|
||||
|
||||
mediaQuery.addEventListener('change', handler)
|
||||
return () => mediaQuery.removeEventListener('change', handler)
|
||||
}, [query])
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if viewport is >= 1024px (Tailwind's 'lg' breakpoint)
|
||||
*/
|
||||
export function useIsDesktop(): boolean {
|
||||
return useMediaQuery('(min-width: 1024px)')
|
||||
}
|
||||
27
frontend/src/lib/stores/notebook-columns-store.ts
Normal file
27
frontend/src/lib/stores/notebook-columns-store.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
interface NotebookColumnsState {
|
||||
sourcesCollapsed: boolean
|
||||
notesCollapsed: boolean
|
||||
toggleSources: () => void
|
||||
toggleNotes: () => void
|
||||
setSources: (collapsed: boolean) => void
|
||||
setNotes: (collapsed: boolean) => void
|
||||
}
|
||||
|
||||
export const useNotebookColumnsStore = create<NotebookColumnsState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
sourcesCollapsed: false,
|
||||
notesCollapsed: false,
|
||||
toggleSources: () => set((state) => ({ sourcesCollapsed: !state.sourcesCollapsed })),
|
||||
toggleNotes: () => set((state) => ({ notesCollapsed: !state.notesCollapsed })),
|
||||
setSources: (collapsed) => set({ sourcesCollapsed: collapsed }),
|
||||
setNotes: (collapsed) => set({ notesCollapsed: collapsed }),
|
||||
}),
|
||||
{
|
||||
name: 'notebook-columns-storage',
|
||||
}
|
||||
)
|
||||
)
|
||||
|
|
@ -73,6 +73,10 @@ async def repo_query(
|
|||
if isinstance(result, str):
|
||||
raise RuntimeError(result)
|
||||
return result
|
||||
except RuntimeError as e:
|
||||
# RuntimeError is raised for retriable transaction conflicts - log without stack trace
|
||||
logger.error(str(e))
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
raise
|
||||
|
|
@ -87,6 +91,9 @@ async def repo_create(table: str, data: Dict[str, Any]) -> Dict[str, Any]:
|
|||
try:
|
||||
async with db_connection() as connection:
|
||||
return parse_record_ids(await connection.insert(table, data))
|
||||
except RuntimeError as e:
|
||||
logger.error(str(e))
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
raise RuntimeError("Failed to create record")
|
||||
|
|
@ -144,18 +151,6 @@ async def repo_update(
|
|||
raise RuntimeError(f"Failed to update record: {str(e)}")
|
||||
|
||||
|
||||
async def repo_get_news_by_jota_id(jota_id: str) -> Dict[str, Any]:
|
||||
try:
|
||||
results = await repo_query(
|
||||
"SELECT * omit embedding FROM news where jota_id=$jota_id",
|
||||
{"jota_id": jota_id},
|
||||
)
|
||||
return parse_record_ids(results)
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
raise RuntimeError(f"Failed to fetch record: {str(e)}")
|
||||
|
||||
|
||||
async def repo_delete(record_id: Union[str, RecordID]):
|
||||
"""Delete a record by record id"""
|
||||
|
||||
|
|
|
|||
|
|
@ -158,6 +158,9 @@ class ObjectModel(BaseModel):
|
|||
except ValidationError as e:
|
||||
logger.error(f"Validation failed: {e}")
|
||||
raise
|
||||
except RuntimeError:
|
||||
# Transaction conflicts should propagate for retry
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving record: {e}")
|
||||
raise DatabaseOperationError(e)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "open-notebook"
|
||||
version = "1.2.1"
|
||||
version = "1.2.2"
|
||||
description = "An open source implementation of a research assistant, inspired by Google Notebook LM"
|
||||
authors = [
|
||||
{name = "Luis Novo", email = "lfnovo@gmail.com"}
|
||||
|
|
|
|||
Loading…
Reference in a new issue