Merge branch 'main' into feat/delete-insight

This commit is contained in:
Luis Novo 2025-12-19 22:50:53 -03:00 committed by GitHub
commit c41cc074b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 198 additions and 46 deletions

View file

@ -8,9 +8,12 @@ Click your problem:
### 1. ["Unable to connect to server" or blank page](#fix-connection-error)
### 2. [Container won't start or crashes](#fix-container-crash)
### 3. [Works on server but not from my computer](#fix-remote-access)
### 4. [API or authentication errors](#fix-api-errors)
### 5. [Slow or timeout errors](#fix-performance)
### 3. [Quotes in environment variables](#fix-quotes-in-env)
### 4. [SurrealDB configuration issues](#fix-surrealdb-config)
### 5. [Network timeouts (slow connections / China)](#fix-network-timeouts)
### 6. [Works on server but not from my computer](#fix-remote-access)
### 7. [API or authentication errors](#fix-api-errors)
### 8. [Slow or timeout errors](#fix-performance)
---
@ -128,6 +131,9 @@ docker compose logs
| "Invalid API key" | Check OPENAI_API_KEY in environment variables |
| "Out of memory" | Increase Docker memory limit to 2GB+ in Docker Desktop settings |
| "No such file or directory" | Check volume paths exist and are accessible |
| "'' is not a valid UrlScheme" | [Remove quotes from environment variables](#fix-quotes-in-env) |
| "There was a problem with authentication" | [Check SurrealDB configuration](#fix-surrealdb-config) |
| Worker/API crashes on startup | [Check network timeouts](#fix-network-timeouts) |
**Quick reset:**
```bash
@ -135,6 +141,127 @@ docker compose down -v
docker compose up -d
```
<a name="fix-quotes-in-env"></a>
### Fix: Quotes in Environment Variables
**Symptom:** Error `'' is not a valid UrlScheme` or database connection fails with empty URL.
**Cause:** Docker Compose interprets quotes literally. If you have quotes around values in your `docker-compose.yml` or `.env` file, they become part of the value.
**Wrong** (quotes become part of the value):
```yaml
environment:
- SURREAL_URL="ws://localhost:8000/rpc"
- SURREAL_USER="root"
```
**Also wrong** in `.env` files:
```env
SURREAL_URL="ws://localhost:8000/rpc"
SURREAL_USER="root"
```
**Correct** (no quotes):
```yaml
environment:
- SURREAL_URL=ws://localhost:8000/rpc
- SURREAL_USER=root
- SURREAL_PASSWORD=root
- SURREAL_NAMESPACE=open_notebook
- SURREAL_DATABASE=production
```
**Correct** `.env` file:
```env
SURREAL_URL=ws://localhost:8000/rpc
SURREAL_USER=root
```
After fixing, restart:
```bash
docker compose down && docker compose up -d
```
<a name="fix-surrealdb-config"></a>
### Fix: SurrealDB Configuration Issues
#### Single Container Already Has SurrealDB
**Symptom:** Authentication errors or connection issues when using `v1-latest-single` with an external SurrealDB.
**Cause:** The `-single` image already includes SurrealDB. You don't need to run a separate SurrealDB container.
**Wrong** - running separate SurrealDB with single container:
```yaml
services:
surrealdb:
image: surrealdb/surrealdb:latest # Not needed!
open_notebook:
image: lfnovo/open_notebook:v1-latest-single
environment:
- SURREAL_URL=ws://surrealdb:8000/rpc # Wrong!
```
**Correct** - single container uses built-in SurrealDB:
```yaml
services:
open_notebook:
image: lfnovo/open_notebook:v1-latest-single
environment:
- SURREAL_URL=ws://localhost:8000/rpc # Uses internal DB
```
**If you want a separate SurrealDB**, use the `v1-latest` image (without `-single`) instead.
#### SurrealDB Version Compatibility
**Symptom:** Various database errors, authentication failures, or unexpected behavior.
**Cause:** Open Notebook currently supports **SurrealDB v2.x only**. SurrealDB v3 (alpha) is not yet supported.
✅ **Supported versions:**
```yaml
# Use v2.x
image: surrealdb/surrealdb:v2.1.4
image: surrealdb/surrealdb:v2 # Latest v2
```
❌ **Not supported yet:**
```yaml
# Don't use v3 alpha
image: surrealdb/surrealdb:v3.0.0-alpha.17
```
<a name="fix-network-timeouts"></a>
### Fix: Network Timeouts (Slow Connections / China)
**Symptom:** Container crashes on startup with `exit status 1`, worker enters FATAL state, or pip/uv dependency downloads fail.
**Cause:** The container downloads Python dependencies on first startup. Slow networks or restricted access (especially in China) can cause timeouts.
**Fix:** Add timeout and mirror configuration:
```yaml
services:
open_notebook:
image: lfnovo/open_notebook:v1-latest-single
environment:
# Increase download timeout to 10 minutes (default is 30s)
- UV_HTTP_TIMEOUT=600
# For users in China - use mirror
- UV_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
- PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
```
**Alternative mirrors for China:**
- Tsinghua: `https://pypi.tuna.tsinghua.edu.cn/simple`
- Aliyun: `https://mirrors.aliyun.com/pypi/simple/`
- Huawei: `https://repo.huaweicloud.com/repository/pypi/simple`
**Note:** First startup may take several minutes while dependencies are downloaded. Subsequent startups will be faster.
---
<a name="fix-remote-access"></a>

View file

@ -95,12 +95,8 @@ export function ChatColumn({ notebookId, contextSelections }: ChatColumnProps) {
isStreaming={chat.isSending}
contextIndicators={null}
onSendMessage={(message, modelOverride) => chat.sendMessage(message, modelOverride)}
modelOverride={chat.currentSession?.model_override ?? undefined}
onModelChange={(model) => {
if (chat.currentSessionId) {
chat.updateSession(chat.currentSessionId, { model_override: model ?? null })
}
}}
modelOverride={chat.currentSession?.model_override ?? chat.pendingModelOverride ?? undefined}
onModelChange={(model) => chat.setModelOverride(model ?? null)}
sessions={chat.sessions}
currentSessionId={chat.currentSessionId}
onCreateSession={(title) => chat.createSession(title)}

View file

@ -1,16 +1,16 @@
'use client'
import { useState } from 'react'
import { useState, useMemo } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Badge } from '@/components/ui/badge'
import {
MessageSquare,
Plus,
Trash2,
Edit2,
import {
MessageSquare,
Plus,
Trash2,
Edit2,
Check,
X,
Clock
@ -27,6 +27,7 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { BaseChatSession } from '@/lib/types/api'
import { useModels } from '@/lib/hooks/use-models'
interface SessionManagerProps {
sessions: BaseChatSession[]
@ -53,6 +54,16 @@ export function SessionManager({
const [editTitle, setEditTitle] = useState('')
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
const { data: models } = useModels()
// Helper to get model name from ID
const getModelName = useMemo(() => {
return (modelId: string) => {
const model = models?.find(m => m.id === modelId)
return model?.name || 'Custom Model'
}
}, [models])
const handleCreateSession = () => {
if (newSessionTitle.trim()) {
onCreateSession(newSessionTitle.trim())
@ -211,14 +222,14 @@ export function SessionManager({
<Clock className="h-3 w-3" />
{formatDistanceToNow(new Date(session.created), { addSuffix: true })}
</div>
{session.message_count && session.message_count > 0 && (
{session.message_count != null && session.message_count > 0 && (
<Badge variant="secondary" className="mt-2 text-xs">
{session.message_count} messages
</Badge>
)}
{session.model_override && (
<Badge variant="outline" className="mt-2 ml-2 text-xs">
{session.model_override}
{getModelName(session.model_override)}
</Badge>
)}
</>

View file

@ -4,10 +4,11 @@ import { useState, useEffect } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Trash2 } from 'lucide-react'
import { FileText } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { useInsight } from '@/lib/hooks/use-insights'
import { useModalManager } from '@/lib/hooks/use-modal-manager'
interface SourceInsightDialogProps {
open: boolean
@ -17,13 +18,13 @@ interface SourceInsightDialogProps {
insight_type?: string
content?: string
created?: string
source_id?: string
}
onDelete?: (insightId: string) => Promise<void>
}
export function SourceInsightDialog({ open, onOpenChange, insight, onDelete }: SourceInsightDialogProps) {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
export function SourceInsightDialog({ open, onOpenChange, insight }: SourceInsightDialogProps) {
const { openModal } = useModalManager()
// Ensure insight ID has 'source_insight:' prefix for API calls
const insightIdWithPrefix = insight?.id
@ -35,25 +36,12 @@ export function SourceInsightDialog({ open, onOpenChange, insight, onDelete }: S
// Use fetched data if available, otherwise fall back to passed-in insight
const displayInsight = fetchedInsight ?? insight
// Reset delete state when dialog closes
useEffect(() => {
if (!open) {
setShowDeleteConfirm(false)
setIsDeleting(false)
}
}, [open])
// Get source_id from fetched data (preferred) or passed-in insight
const sourceId = fetchedInsight?.source_id ?? insight?.source_id
const handleDelete = async () => {
if (!insight?.id || !onDelete) return
try {
setIsDeleting(true)
await onDelete(insight.id)
// Parent's onDelete callback handles closing the dialog via setSelectedInsight(null)
} catch {
// Only reset state if delete failed - dialog stays open
setShowDeleteConfirm(false)
setIsDeleting(false)
const handleViewSource = () => {
if (sourceId) {
openModal('source', sourceId)
}
}
@ -69,14 +57,15 @@ export function SourceInsightDialog({ open, onOpenChange, insight, onDelete }: S
{displayInsight.insight_type}
</Badge>
)}
{onDelete && insight?.id && !showDeleteConfirm && (
{sourceId && (
<Button
size="sm"
variant="outline"
onClick={() => setShowDeleteConfirm(true)}
className="text-destructive hover:text-destructive"
size="sm"
onClick={handleViewSource}
className="gap-1"
>
<Trash2 className="h-4 w-4" />
<FileText className="h-3 w-3" />
View Source
</Button>
)}
</div>

View file

@ -28,6 +28,8 @@ export function useNotebookChat({ notebookId, sources, notes, contextSelections
const [isSending, setIsSending] = useState(false)
const [tokenCount, setTokenCount] = useState<number>(0)
const [charCount, setCharCount] = useState<number>(0)
// Pending model override for when user changes model before a session exists
const [pendingModelOverride, setPendingModelOverride] = useState<string | null>(null)
// Fetch sessions for this notebook
const {
@ -176,10 +178,14 @@ export function useNotebookChat({ notebookId, sources, notes, contextSelections
: message
const newSession = await chatApi.createSession({
notebook_id: notebookId,
title: defaultTitle
title: defaultTitle,
// Include pending model override when creating session
model_override: pendingModelOverride ?? undefined
})
sessionId = newSession.id
setCurrentSessionId(sessionId)
// Clear pending model override now that it's applied to the session
setPendingModelOverride(null)
queryClient.invalidateQueries({
queryKey: QUERY_KEYS.notebookChatSessions(notebookId)
})
@ -226,6 +232,7 @@ export function useNotebookChat({ notebookId, sources, notes, contextSelections
notebookId,
currentSessionId,
currentSession,
pendingModelOverride,
buildContext,
refetchCurrentSession,
queryClient
@ -257,6 +264,20 @@ export function useNotebookChat({ notebookId, sources, notes, contextSelections
return deleteSessionMutation.mutate(sessionId)
}, [deleteSessionMutation])
// Set model override - handles both existing sessions and pending state
const setModelOverride = useCallback((model: string | null) => {
if (currentSessionId) {
// Session exists - update it directly
updateSessionMutation.mutate({
sessionId: currentSessionId,
data: { model_override: model }
})
} else {
// No session yet - store as pending
setPendingModelOverride(model)
}
}, [currentSessionId, updateSessionMutation])
// Update token/char counts when context selections change
useEffect(() => {
const updateContextCounts = async () => {
@ -279,6 +300,7 @@ export function useNotebookChat({ notebookId, sources, notes, contextSelections
loadingSessions,
tokenCount,
charCount,
pendingModelOverride,
// Actions
createSession,
@ -286,6 +308,7 @@ export function useNotebookChat({ notebookId, sources, notes, contextSelections
deleteSession,
switchSession,
sendMessage,
setModelOverride,
refetchSessions
}
}

View file

@ -25,6 +25,7 @@ T = TypeVar("T", bound="ObjectModel")
class ObjectModel(BaseModel):
id: Optional[str] = None
table_name: ClassVar[str] = ""
nullable_fields: ClassVar[set[str]] = set() # Fields that can be saved as None
created: Optional[datetime] = None
updated: Optional[datetime] = None
@ -167,7 +168,11 @@ class ObjectModel(BaseModel):
def _prepare_save_data(self) -> Dict[str, Any]:
data = self.model_dump()
return {key: value for key, value in data.items() if value is not None}
return {
key: value
for key, value in data.items()
if value is not None or key in self.__class__.nullable_fields
}
async def delete(self) -> bool:
if self.id is None:

View file

@ -389,6 +389,7 @@ class Note(ObjectModel):
class ChatSession(ObjectModel):
table_name: ClassVar[str] = "chat_session"
nullable_fields: ClassVar[set[str]] = {"model_override"}
title: Optional[str] = None
model_override: Optional[str] = None