Skip to content
Merged
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
98 changes: 73 additions & 25 deletions apps/desktop/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,22 +50,55 @@ async function getApiBase(): Promise<string> {
return resolvedApiBase;
}

async function request<T>(path: string, init?: RequestInit): Promise<T> {
/** Default timeout for lookup-style requests. Uploads use their own (longer)
* timeout further down. 30s is generous for local ops but still bounded so a
* hung backend doesn't lock the UI indefinitely. */
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;

async function request<T>(path: string, init?: RequestInit & { timeoutMs?: number }): Promise<T> {
const apiBase = await getApiBase();
const response = await fetch(`${apiBase}${path}`, {
headers: {
'Content-Type': 'application/json',
...init?.headers,
},
...init,
});
const timeoutMs = init?.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;

// Compose caller-provided signal with our timeout — if either fires, abort.
const timeoutController = new AbortController();
const timer = setTimeout(() => timeoutController.abort(), timeoutMs);
const composed = composeSignals(init?.signal, timeoutController.signal);

try {
const response = await fetch(`${apiBase}${path}`, {
headers: {
'Content-Type': 'application/json',
...init?.headers,
},
...init,
signal: composed,
});

if (!response.ok) {
const detail = await response.text();
throw new Error(detail || `Request failed with status ${response.status}`);
}

if (!response.ok) {
const detail = await response.text();
throw new Error(detail || `Request failed with status ${response.status}`);
return (await response.json()) as T;
} catch (err) {
if (timeoutController.signal.aborted && !(init?.signal?.aborted)) {
throw new Error('Request timed out');
}
throw err;
} finally {
clearTimeout(timer);
}
}

return response.json() as Promise<T>;
function composeSignals(a: AbortSignal | null | undefined, b: AbortSignal): AbortSignal {
if (!a) return b;
const controller = new AbortController();
const onAbort = () => controller.abort();
if (a.aborted) controller.abort();
else a.addEventListener('abort', onAbort, { once: true });
if (b.aborted) controller.abort();
else b.addEventListener('abort', onAbort, { once: true });
return controller.signal;
}

export function fetchConfig(): Promise<BackendConfig> {
Expand All @@ -87,21 +120,36 @@ export async function uploadDocument(file: File, notebookId?: string): Promise<I
const apiBase = await getApiBase();
const formData = new FormData();
formData.append('file', file);
if (notebookId) {
formData.append('notebook_id', notebookId);
}

const response = await fetch(`${apiBase}/documents/ingest`, {
method: 'POST',
body: formData,
});
if (notebookId) formData.append('notebook_id', notebookId);

// Uploads include embedding generation and can take minutes on a large
// PDF. 5 minutes is a conservative ceiling; anything over this is more
// likely a backend hang than a legitimate long job.
const UPLOAD_TIMEOUT_MS = 5 * 60_000;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), UPLOAD_TIMEOUT_MS);

try {
const response = await fetch(`${apiBase}/documents/ingest`, {
method: 'POST',
body: formData,
signal: controller.signal,
});

if (!response.ok) {
const detail = await response.text();
throw new Error(detail || `Upload failed with status ${response.status}`);
}

if (!response.ok) {
const detail = await response.text();
throw new Error(detail || `Upload failed with status ${response.status}`);
return (await response.json()) as IngestionResponse;
} catch (err) {
if (controller.signal.aborted) {
throw new Error('Upload timed out after 5 minutes');
}
throw err;
} finally {
clearTimeout(timer);
}

return response.json() as Promise<IngestionResponse>;
}

export function listDocuments(notebookId: string): Promise<DocumentsListResponse> {
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/hooks/useChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,11 @@ export function useChat() {
break;
case 'done':
if (id) s.updateMessage(id, { content: event.reply, streaming: false });
// Defensive: if meta was lost, the done event also carries the
// conversation_id so we don't fork the thread on the next send.
if (event.conversation_id && !s.activeConversationId) {
s.setActiveConversationId(event.conversation_id);
}
s.setIsStreaming(false);
assistantIdRef.current = null;
if (s.activeNotebookId) {
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export type ChatStreamEvent =
conversation_id?: string;
}
| { type: 'token'; delta: string }
| { type: 'done'; reply: string; metrics?: Record<string, number> }
| { type: 'done'; reply: string; metrics?: Record<string, number>; conversation_id?: string }
| { type: 'error'; message: string }
| { type: 'warning'; message: string };

Expand Down
51 changes: 42 additions & 9 deletions backend/notebooklm_backend/app.py
Original file line number Diff line number Diff line change
@@ -1,37 +1,70 @@
from __future__ import annotations

import asyncio
import contextlib
import logging
from typing import AsyncIterator

from fastapi import FastAPI
from starlette.middleware.cors import CORSMiddleware

from .config import AppConfig, get_settings
from .routes import health, chat, documents, rag, notebooks, metrics, speech, export, agent, conversations, zotero
from .routes import (
agent,
chat,
conversations,
documents,
export,
health,
metrics,
notebooks,
rag,
speech,
zotero,
)
from .services.agent import AgentService
from .services.chat import ChatService
from .services.conversation_store import ConversationStore
from .services.embeddings import create_embedding_backend
from .services.ingestion import IngestionService
from .services.llm import create_llm_backend
from .services.metrics_store import MetricsStore
from .services.model_profiles import resolve_ollama_model
from .services.notebook_store import NotebookStore
from .services.rag import RAGService
from .services.rag_llamaindex import LlamaIndexRAGService
from .services.vector_store import create_vector_store
from .services.notebook_store import NotebookStore
from .services.conversation_store import ConversationStore
from .services.metrics_store import MetricsStore
from .services.speech import SpeechService
from .services.agent import AgentService
from .services.model_profiles import resolve_ollama_model
from .services.vector_store import create_vector_store

logger = logging.getLogger(__name__)

def create_app() -> FastAPI:
"""Create the FastAPI application for the chat-first Notebook LM backend."""
settings: AppConfig = get_settings()
settings.ensure_directories()
if settings.llm_provider == "ollama":
resolve_ollama_model(settings)

@contextlib.asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
# Resolve the preferred Ollama model in the background so a slow
# `/api/tags` round-trip doesn't delay startup and cause Electron's
# waitForBackend timeout to fire.
if settings.llm_provider == "ollama":
def _resolve() -> None:
try:
resolve_ollama_model(settings)
except Exception:
logger.exception("Background Ollama model resolution failed")

loop = asyncio.get_running_loop()
loop.run_in_executor(None, _resolve)
yield

app = FastAPI(
title="Offline Notebook LM",
version="0.1.0",
description="Local-first API for chatting with local models (Ollama) with RAG support.",
contact={"name": "Offline Notebook LM Team"},
lifespan=lifespan,
)

# Bootstrap services
Expand Down
10 changes: 8 additions & 2 deletions backend/notebooklm_backend/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,14 @@ class AppConfig(BaseSettings):
llm_model_path: Path | None = None # Used by llama-cpp
onnx_model_path: Path | None = None
onnx_execution_provider: Literal["cpu", "cuda", "metal"] = "cpu"
llm_context_window: int = 2048
llm_max_tokens: int = 2048
# llm_context_window: how much prompt the model can hold (llama-cpp n_ctx).
# llm_max_tokens: output length cap (passed as num_predict to Ollama,
# max_tokens to llama-cpp/ONNX). Previously both were
# 2048 — the same value was used for both, which
# silently truncated long RAG prompts to a 2048-token
# CONTEXT and also capped replies at 2048 tokens.
llm_context_window: int = 4096
llm_max_tokens: int = 1024

# Framework integration toggles
use_langchain_splitter: bool = True
Expand Down
7 changes: 5 additions & 2 deletions backend/notebooklm_backend/routes/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,11 @@ async def event_generator():
elif event.get("type") == "done":
accumulated_reply = event.get("reply", accumulated_reply)

# Inject conversation_id into meta events so frontend knows which conversation
if event.get("type") == "meta" and conversation_id:
# Inject conversation_id into meta AND done events. If the
# meta event is dropped (parse error, network hiccup), the
# frontend falls back to done, rather than silently creating
# a second conversation on the next send.
if event.get("type") in ("meta", "done") and conversation_id:
event["conversation_id"] = conversation_id

yield f"data: {json.dumps(event)}\n\n"
Expand Down
1 change: 1 addition & 0 deletions backend/notebooklm_backend/services/conversation_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ def __init__(self, settings: AppConfig) -> None:
def _connect(self) -> Iterable[sqlite3.Connection]:
conn = sqlite3.connect(str(self.db_path))
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode = WAL")
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("PRAGMA busy_timeout = 5000")
try:
Expand Down
25 changes: 22 additions & 3 deletions backend/notebooklm_backend/services/document_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,19 +81,38 @@ def _load_pdf(file_path: Path) -> LoadedDocument:
"""Load PDF file using pypdf."""
try:
from pypdf import PdfReader

reader = PdfReader(str(file_path))
text_parts = []

page_count = 0

for page in reader.pages:
page_count += 1
page_text = page.extract_text()
if page_text:
text_parts.append(page_text)

text = "\n\n".join(part for part in text_parts if part)

# Scanned PDFs (image-only, no embedded text) produce near-empty
# output. Previously we silently "succeeded" with 0 chunks and the
# user saw "no relevant documents found" on every query. Raise a
# clear error instead; the API turns this into a 400 with an
# actionable message.
MIN_CHARS_PER_PAGE = 40
expected_min = MIN_CHARS_PER_PAGE * max(1, page_count)
if len(text.strip()) < min(100, expected_min):
raise DocumentLoaderError(
"This PDF appears to be a scan (no embedded text). OCR isn't "
"supported yet — run the file through a tool like Adobe or "
"Preview's OCR first, then re-upload."
)

return LoadedDocument(path=file_path, text=text)
except ImportError:
raise DocumentLoaderError("pypdf is required for PDF support")
except DocumentLoaderError:
raise
except Exception as e:
raise DocumentLoaderError(f"Failed to read PDF {file_path}: {e}")

Expand Down
26 changes: 22 additions & 4 deletions backend/notebooklm_backend/services/embeddings.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import asyncio
from typing import Protocol

from ..config import AppConfig
Expand All @@ -10,6 +11,11 @@ def embed(self, texts: list[str]) -> list[list[float]]:
"""Generate embeddings for a list of texts."""
...

async def aembed(self, texts: list[str]) -> list[list[float]]:
"""Async variant that must not block the event loop. Default
implementation offloads to the default executor."""
...


class SentenceTransformersBackend:
"""Embedding backend using sentence-transformers."""
Expand All @@ -34,32 +40,44 @@ def embed(self, texts: list[str]) -> list[list[float]]:
embeddings = model.encode(texts, show_progress_bar=False)
return embeddings.tolist()

async def aembed(self, texts: list[str]) -> list[list[float]]:
# model.encode is CPU-bound and can take 100-500ms; awaiting it
# synchronously freezes the event loop, making the backend
# unresponsive to health checks and concurrent fetches during
# ingestion. Push it to the default threadpool.
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, self.embed, texts)


class HashEmbeddingBackend:
"""Simple hash-based embedding for testing (not semantic)."""

def embed(self, texts: list[str]) -> list[list[float]]:
"""Generate pseudo-embeddings using hashing."""
import hashlib

embeddings = []
for text in texts:
# Create a 768-dim vector from hash
hash_obj = hashlib.sha256(text.encode())
hash_bytes = hash_obj.digest()

# Repeat hash to get 768 dimensions
vector = []
for i in range(768):
byte_idx = i % len(hash_bytes)
# Normalize to [-1, 1]
val = (hash_bytes[byte_idx] / 255.0) * 2 - 1
vector.append(val)

embeddings.append(vector)

return embeddings

async def aembed(self, texts: list[str]) -> list[list[float]]:
# Cheap enough to run inline — no executor hop needed.
return self.embed(texts)


def create_embedding_backend(settings: AppConfig) -> EmbeddingBackend:
"""Create an embedding backend based on settings."""
Expand Down
Loading
Loading