diff --git a/docs/troubleshooting/quick-fixes.md b/docs/troubleshooting/quick-fixes.md
index a829292..f5ca571 100644
--- a/docs/troubleshooting/quick-fixes.md
+++ b/docs/troubleshooting/quick-fixes.md
@@ -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
```
+
+### 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
+```
+
+
+### 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
+```
+
+
+### 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.
+
---
diff --git a/frontend/src/app/(dashboard)/notebooks/components/ChatColumn.tsx b/frontend/src/app/(dashboard)/notebooks/components/ChatColumn.tsx
index b65d4a3..be8ce32 100644
--- a/frontend/src/app/(dashboard)/notebooks/components/ChatColumn.tsx
+++ b/frontend/src/app/(dashboard)/notebooks/components/ChatColumn.tsx
@@ -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)}
diff --git a/frontend/src/components/source/SessionManager.tsx b/frontend/src/components/source/SessionManager.tsx
index cec74fa..001a42b 100644
--- a/frontend/src/components/source/SessionManager.tsx
+++ b/frontend/src/components/source/SessionManager.tsx
@@ -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(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({
{formatDistanceToNow(new Date(session.created), { addSuffix: true })}
- {session.message_count && session.message_count > 0 && (
+ {session.message_count != null && session.message_count > 0 && (
{session.message_count} messages
)}
{session.model_override && (
- {session.model_override}
+ {getModelName(session.model_override)}
)}
>
diff --git a/frontend/src/components/source/SourceInsightDialog.tsx b/frontend/src/components/source/SourceInsightDialog.tsx
index d7ded43..1ff3ad5 100644
--- a/frontend/src/components/source/SourceInsightDialog.tsx
+++ b/frontend/src/components/source/SourceInsightDialog.tsx
@@ -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
}
-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}
)}
- {onDelete && insight?.id && !showDeleteConfirm && (
+ {sourceId && (
)}
diff --git a/frontend/src/lib/hooks/useNotebookChat.ts b/frontend/src/lib/hooks/useNotebookChat.ts
index c10249c..e97cd18 100644
--- a/frontend/src/lib/hooks/useNotebookChat.ts
+++ b/frontend/src/lib/hooks/useNotebookChat.ts
@@ -28,6 +28,8 @@ export function useNotebookChat({ notebookId, sources, notes, contextSelections
const [isSending, setIsSending] = useState(false)
const [tokenCount, setTokenCount] = useState(0)
const [charCount, setCharCount] = useState(0)
+ // Pending model override for when user changes model before a session exists
+ const [pendingModelOverride, setPendingModelOverride] = useState(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
}
}
diff --git a/open_notebook/domain/base.py b/open_notebook/domain/base.py
index 83c1b3d..f9992e2 100644
--- a/open_notebook/domain/base.py
+++ b/open_notebook/domain/base.py
@@ -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:
diff --git a/open_notebook/domain/notebook.py b/open_notebook/domain/notebook.py
index cf096bd..2f589a6 100644
--- a/open_notebook/domain/notebook.py
+++ b/open_notebook/domain/notebook.py
@@ -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