-
{activeNotebook ? activeNotebook.title : 'Notebook LM'}
- {activeNotebook && (
+ {crossNotebookMode ? 'All Notebooks' : (activeNotebook ? activeNotebook.title : 'Notebook LM')}
+ {crossNotebookMode ? (
+
+ {notebooks.length} {notebooks.length === 1 ? 'notebook' : 'notebooks'}
+
+ ) : activeNotebook ? (
{activeNotebook.source_count} {activeNotebook.source_count === 1 ? 'doc' : 'docs'}
- )}
+ ) : null}
+ {notebooks.length > 1 && (
+ {
+ setCrossNotebookMode(!crossNotebookMode);
+ clearChat();
+ }}
+ title={crossNotebookMode ? 'Switch to single notebook' : 'Query across all notebooks'}
+ >
+ {crossNotebookMode ? 'Single' : 'All'}
+
+ )}
{messages.length > 0 && (
New chat
diff --git a/apps/desktop/src/components/chat/chat.css b/apps/desktop/src/components/chat/chat.css
index a50d911..59b3c44 100644
--- a/apps/desktop/src/components/chat/chat.css
+++ b/apps/desktop/src/components/chat/chat.css
@@ -78,6 +78,17 @@
transform: scale(0.97);
}
+.chat-header-btn-active {
+ background: var(--color-accent);
+ color: #0c0a09;
+ border-color: var(--color-accent);
+}
+
+.chat-header-btn-active:hover {
+ background: var(--color-accent-hover);
+ color: #0c0a09;
+}
+
/* ---- Message area ---- */
.chat-messages {
diff --git a/apps/desktop/src/components/layout/SourcePanel.tsx b/apps/desktop/src/components/layout/SourcePanel.tsx
index 984bba7..196a75c 100644
--- a/apps/desktop/src/components/layout/SourcePanel.tsx
+++ b/apps/desktop/src/components/layout/SourcePanel.tsx
@@ -9,6 +9,8 @@ function relevanceColor(score: number): string {
export function SourcePanel() {
const activeSources = useAppStore((s) => s.activeSources);
+ const crossNotebookMode = useAppStore((s) => s.crossNotebookMode);
+ const notebooks = useAppStore((s) => s.notebooks);
if (activeSources.length === 0) return null;
@@ -20,23 +22,31 @@ export function SourcePanel() {
- {activeSources.map((source, i) => (
-
-
{source.document_name}
- {source.relevance_score != null && (
-
- )}
-
{source.preview}
-
- ))}
+ {activeSources.map((source, i) => {
+ const nbId = (source as Record
).notebook_id as string | undefined;
+ const nbName = nbId ? notebooks.find((nb) => nb.notebook_id === nbId)?.title : null;
+
+ return (
+
+ {crossNotebookMode && nbName && (
+
{nbName}
+ )}
+
{source.document_name}
+ {source.relevance_score != null && (
+
+ )}
+
{source.preview}
+
+ );
+ })}
);
diff --git a/apps/desktop/src/components/layout/layout.css b/apps/desktop/src/components/layout/layout.css
index 488d4b0..6fbb124 100644
--- a/apps/desktop/src/components/layout/layout.css
+++ b/apps/desktop/src/components/layout/layout.css
@@ -441,6 +441,16 @@
box-shadow: var(--shadow-md);
}
+.source-card-notebook {
+ display: block;
+ font-size: 10px;
+ font-weight: 500;
+ color: var(--color-accent);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ margin-bottom: 2px;
+}
+
.source-card-name {
font-size: var(--text-sm);
font-weight: 550;
diff --git a/apps/desktop/src/hooks/useChat.ts b/apps/desktop/src/hooks/useChat.ts
index 55ae550..ed56838 100644
--- a/apps/desktop/src/hooks/useChat.ts
+++ b/apps/desktop/src/hooks/useChat.ts
@@ -30,10 +30,13 @@ export function useChat() {
store.setIsStreaming(true);
store.setActiveSources([]);
+ // Cross-notebook mode: send all notebook IDs instead of just one
+ const crossMode = store.crossNotebookMode;
const body = {
prompt,
history,
- notebook_id: store.activeNotebookId,
+ notebook_id: crossMode ? null : store.activeNotebookId,
+ notebook_ids: crossMode ? store.notebooks.map((nb) => nb.notebook_id) : null,
conversation_id: store.activeConversationId,
};
diff --git a/apps/desktop/src/store/app-store.ts b/apps/desktop/src/store/app-store.ts
index 59dca4b..a6782a9 100644
--- a/apps/desktop/src/store/app-store.ts
+++ b/apps/desktop/src/store/app-store.ts
@@ -19,6 +19,9 @@ interface AppState {
conversations: Conversation[];
activeConversationId: string | null;
+ // Cross-notebook
+ crossNotebookMode: boolean;
+
// Documents
documents: DocumentInfo[];
@@ -45,6 +48,9 @@ interface AppState {
setConversations: (convs: Conversation[]) => void;
setActiveConversationId: (id: string | null) => void;
+ // Actions — cross-notebook
+ setCrossNotebookMode: (val: boolean) => void;
+
// Actions — documents
setDocuments: (docs: DocumentInfo[]) => void;
@@ -64,6 +70,7 @@ export const useAppStore = create
((set) => ({
isStreaming: false,
conversations: [],
activeConversationId: null,
+ crossNotebookMode: false,
documents: [],
sourcePanelOpen: true,
activeSources: [],
@@ -95,6 +102,9 @@ export const useAppStore = create((set) => ({
setConversations: (convs) => set({ conversations: convs }),
setActiveConversationId: (id) => set({ activeConversationId: id }),
+ // Cross-notebook
+ setCrossNotebookMode: (val) => set({ crossNotebookMode: val }),
+
// Documents
setDocuments: (docs) => set({ documents: docs }),
diff --git a/apps/desktop/src/types.ts b/apps/desktop/src/types.ts
index b2dde0c..9c48dc7 100644
--- a/apps/desktop/src/types.ts
+++ b/apps/desktop/src/types.ts
@@ -7,6 +7,7 @@ export interface ChatRequest {
prompt: string;
history?: ChatMessage[];
notebook_id?: string | null;
+ notebook_ids?: string[] | null;
conversation_id?: string | null;
}
diff --git a/backend/notebooklm_backend/models/chat.py b/backend/notebooklm_backend/models/chat.py
index 28db0a4..76c2d30 100644
--- a/backend/notebooklm_backend/models/chat.py
+++ b/backend/notebooklm_backend/models/chat.py
@@ -14,6 +14,7 @@ class ChatRequest(BaseModel):
prompt: str
history: List[ChatMessage] | None = None
notebook_id: str | None = Field(None, description="Optional notebook ID for RAG-enabled chat")
+ notebook_ids: List[str] | None = Field(None, description="Optional list of notebook IDs for cross-notebook synthesis")
conversation_id: str | None = Field(None, description="Optional conversation ID for persistence")
diff --git a/backend/notebooklm_backend/routes/chat.py b/backend/notebooklm_backend/routes/chat.py
index e564044..aadddb5 100644
--- a/backend/notebooklm_backend/routes/chat.py
+++ b/backend/notebooklm_backend/routes/chat.py
@@ -75,6 +75,7 @@ async def event_generator():
prompt=payload.prompt,
history=history,
notebook_id=payload.notebook_id,
+ notebook_ids=payload.notebook_ids,
):
if event.get("type") == "meta":
sources_data = event.get("sources")
diff --git a/backend/notebooklm_backend/services/chat.py b/backend/notebooklm_backend/services/chat.py
index 38b3571..4e71a4e 100644
--- a/backend/notebooklm_backend/services/chat.py
+++ b/backend/notebooklm_backend/services/chat.py
@@ -89,6 +89,7 @@ async def stream_reply(
prompt: str,
history: Iterable[ChatMessage] | None = None,
notebook_id: str | None = None,
+ notebook_ids: list[str] | None = None,
) -> AsyncIterator[dict[str, Any]]:
"""
Stream tokens using SSE-friendly event payloads:
@@ -101,7 +102,42 @@ async def stream_reply(
sources: list[dict[str, Any]] = []
rag_context: RAGContext | None = None
- if notebook_id and self._rag_service:
+ # Cross-notebook synthesis mode
+ if notebook_ids and len(notebook_ids) > 1 and self._rag_service:
+ try:
+ full_question = prompt
+ if history:
+ recent_context = "\n".join([f"{msg.role}: {msg.content}" for msg in list(history)[-3:]])
+ full_question = f"Previous conversation:\n{recent_context}\n\nCurrent question: {prompt}"
+ context = await self._rag_service.prepare_prompt_cross_notebook(
+ notebook_ids=notebook_ids,
+ question=full_question,
+ top_k=20,
+ )
+ metrics.update(context.metrics)
+ prompt_text = context.prompt or None
+ sources = self._format_sources(context.sources)
+ rag_context = context
+ if not prompt_text:
+ yield {
+ "type": "meta",
+ "provider": self.provider,
+ "sources": sources,
+ "metrics": metrics or None,
+ }
+ yield {
+ "type": "done",
+ "reply": "No relevant documents found across the selected notebooks.",
+ "metrics": metrics or None,
+ }
+ return
+ except Exception as e:
+ import logging
+ logger = logging.getLogger(__name__)
+ logger.error(f"Cross-notebook RAG failed: {e}", exc_info=True)
+ prompt_text = None
+ metrics = {}
+ elif notebook_id and self._rag_service:
try:
full_question = prompt
if history:
@@ -202,14 +238,15 @@ def _format_sources(self, sources: List[SourceAttribution]) -> list[dict[str, An
if src.distance is not None:
# L2 distance normalization: 0 -> 100%, unbounded -> approaches 0%
relevance = round(100 / (1 + src.distance))
- formatted.append(
- {
- "source_path": src.source_path,
- "preview": preview,
- "distance": src.distance,
- "relevance_score": relevance,
- }
- )
+ entry: dict[str, Any] = {
+ "source_path": src.source_path,
+ "preview": preview,
+ "distance": src.distance,
+ "relevance_score": relevance,
+ }
+ if src.notebook_id:
+ entry["notebook_id"] = src.notebook_id
+ formatted.append(entry)
return formatted
def _record_metrics(
diff --git a/backend/notebooklm_backend/services/rag.py b/backend/notebooklm_backend/services/rag.py
index 5bc17c3..98808d1 100644
--- a/backend/notebooklm_backend/services/rag.py
+++ b/backend/notebooklm_backend/services/rag.py
@@ -15,6 +15,7 @@ class SourceAttribution:
source_path: str
content: str
distance: float | None
+ notebook_id: str | None = None
@dataclass
@@ -201,3 +202,82 @@ async def query(self, notebook_id: str, question: str, top_k: int = 5) -> RAGRes
metrics["total_ms"] = metrics.get("total_ms", 0.0) + metrics["llm_ms"]
return RAGResponse(answer=answer, sources=context.sources, metrics=metrics)
+
+ async def prepare_prompt_cross_notebook(
+ self,
+ notebook_ids: list[str],
+ question: str,
+ top_k: int = 20,
+ ) -> RAGContext:
+ """Prepare a prompt that retrieves and synthesizes across multiple notebooks."""
+ total_start = time.perf_counter()
+ metrics: dict[str, float] = {}
+
+ retrieval_start = time.perf_counter()
+ query_results = self.vector_store.query_across_notebooks(
+ notebook_ids=notebook_ids,
+ query=question,
+ top_k=top_k,
+ )
+ metrics["retrieval_ms"] = (time.perf_counter() - retrieval_start) * 1000
+
+ documents = query_results.get("documents", [[]])[0]
+ metadatas = query_results.get("metadatas", [[]])[0]
+ distances = query_results.get("distances", [[]])[0]
+
+ if not documents:
+ return RAGContext(prompt="", sources=[], metrics=metrics)
+
+ # Group chunks by notebook + source file
+ source_groups: dict[str, list[tuple[int, str, str]]] = {}
+ for idx, (doc, metadata) in enumerate(zip(documents, metadatas)):
+ nb_id = metadata.get("notebook_id", "unknown") if isinstance(metadata, dict) else "unknown"
+ source_path = metadata.get("source_path", "unknown") if isinstance(metadata, dict) else "unknown"
+ key = f"{nb_id}::{source_path}"
+ if key not in source_groups:
+ source_groups[key] = []
+ source_groups[key].append((idx, doc, nb_id))
+
+ # Build prompt with notebook labels
+ prompt_parts = []
+ for key, chunks in source_groups.items():
+ nb_id, source_path = key.split("::", 1)
+ source_name = Path(source_path).name if source_path != "unknown" else "Document"
+ prompt_parts.append(f"From notebook '{nb_id}', document '{source_name}':")
+ for idx, doc, _ in chunks:
+ prompt_parts.append(f" [Source {idx+1}]: {doc}")
+ prompt_parts.append("")
+
+ prompt_context = "\n".join(prompt_parts)
+ notebook_count = len(set(
+ m.get("notebook_id", "?") for m in metadatas if isinstance(m, dict)
+ ))
+
+ prompt = (
+ "You are answering a question by synthesizing information ACROSS MULTIPLE NOTEBOOKS.\n"
+ f"You have access to {notebook_count} notebooks with excerpts from various documents.\n"
+ "Rules:\n"
+ "- Compare and contrast information from different notebooks and documents.\n"
+ "- When sources from different notebooks disagree, name the disagreement explicitly.\n"
+ "- When citing, reference which notebook and document the information came from.\n"
+ "- If the answer is not present in the excerpts, "
+ "reply: 'I could not find this across the provided notebooks.'\n\n"
+ f"Excerpts grouped by notebook and document:\n{prompt_context}\n"
+ f"Question: {question}\n\n"
+ "Answer (synthesize across all sources):"
+ )
+
+ sources = [
+ SourceAttribution(
+ source_path=metadata.get("source_path", "unknown") if isinstance(metadata, dict) else "unknown",
+ content=document,
+ distance=distances[idx] if idx < len(distances) else None,
+ notebook_id=metadata.get("notebook_id") if isinstance(metadata, dict) else None,
+ )
+ for idx, (document, metadata) in enumerate(zip(documents, metadatas))
+ ]
+
+ metrics["prep_ms"] = (time.perf_counter() - total_start) * 1000
+ metrics["total_ms"] = metrics["prep_ms"]
+ metrics["notebooks_queried"] = float(len(notebook_ids))
+ return RAGContext(prompt=prompt, sources=sources, metrics=metrics)
diff --git a/backend/notebooklm_backend/services/vector_store.py b/backend/notebooklm_backend/services/vector_store.py
index a68ce21..e57e535 100644
--- a/backend/notebooklm_backend/services/vector_store.py
+++ b/backend/notebooklm_backend/services/vector_store.py
@@ -74,6 +74,62 @@ def query(
pass
return collection.query(**query_kwargs)
+ def query_across_notebooks(
+ self,
+ notebook_ids: list[str],
+ query: str,
+ top_k: int = 5,
+ ) -> dict:
+ """
+ Query multiple notebook collections and merge results ranked by distance.
+ Returns the same format as a single query() call but with an extra
+ 'notebook_id' field in each metadata entry.
+ """
+ query_emb = self.embedding_backend.embed([query])
+
+ all_documents: list[str] = []
+ all_metadatas: list[dict] = []
+ all_distances: list[float] = []
+
+ for nb_id in notebook_ids:
+ try:
+ collection = self.client.get_collection(name=self._collection_name(nb_id))
+ except Exception:
+ continue # notebook collection doesn't exist yet
+
+ if collection.count() == 0:
+ continue
+
+ result = collection.query(
+ query_embeddings=query_emb,
+ n_results=min(top_k, collection.count()),
+ )
+
+ docs = result.get("documents", [[]])[0]
+ metas = result.get("metadatas", [[]])[0]
+ dists = result.get("distances", [[]])[0] if result.get("distances") else []
+
+ for i, (doc, meta) in enumerate(zip(docs, metas)):
+ meta_with_nb = {**(meta if isinstance(meta, dict) else {}), "notebook_id": nb_id}
+ all_documents.append(doc)
+ all_metadatas.append(meta_with_nb)
+ all_distances.append(dists[i] if i < len(dists) else float("inf"))
+
+ # Sort by distance (best matches first) and take top_k
+ if not all_documents:
+ return {"documents": [[]], "metadatas": [[]], "distances": [[]]}
+
+ ranked = sorted(
+ zip(all_documents, all_metadatas, all_distances),
+ key=lambda x: x[2],
+ )[:top_k]
+
+ return {
+ "documents": [[r[0] for r in ranked]],
+ "metadatas": [[r[1] for r in ranked]],
+ "distances": [[r[2] for r in ranked]],
+ }
+
def _doc_summaries_collection_name(self, notebook_id: str) -> str:
"""Get the name of the document summaries collection."""
return f"notebook_{notebook_id}_summaries"
diff --git a/backend/tests/test_cross_notebook.py b/backend/tests/test_cross_notebook.py
new file mode 100644
index 0000000..595b0e8
--- /dev/null
+++ b/backend/tests/test_cross_notebook.py
@@ -0,0 +1,132 @@
+from __future__ import annotations
+
+import pytest
+from notebooklm_backend.config import AppConfig
+from notebooklm_backend.services.vector_store import VectorStoreManager, create_vector_store
+from notebooklm_backend.services.chunking import TextChunk
+
+
+@pytest.fixture
+def settings(tmp_path):
+ return AppConfig(
+ workspace_root=tmp_path,
+ data_dir=tmp_path / "data",
+ models_dir=tmp_path / "models",
+ index_dir=tmp_path / "indexes",
+ cache_dir=tmp_path / "cache",
+ embedding_backend="hash",
+ )
+
+
+@pytest.fixture
+def store(settings):
+ from notebooklm_backend.services.embeddings import create_embedding_backend
+ embedding = create_embedding_backend(settings)
+ return create_vector_store(settings, embedding)
+
+
+def _make_chunks(source_path: str, texts: list[str]) -> list[TextChunk]:
+ return [
+ TextChunk(chunk_id=f"{source_path}-{i}", text=t, source_path=source_path, order=i)
+ for i, t in enumerate(texts)
+ ]
+
+
+def test_query_across_notebooks_merges_results(store):
+ """Results from multiple notebooks are merged and ranked by distance."""
+ store.add_chunks("nb1", _make_chunks("paper_a.pdf", [
+ "Machine learning improves accuracy",
+ "Deep learning requires large datasets",
+ ]))
+ store.add_chunks("nb2", _make_chunks("paper_b.pdf", [
+ "Neural networks are powerful models",
+ "Transfer learning reduces training time",
+ ]))
+
+ result = store.query_across_notebooks(
+ notebook_ids=["nb1", "nb2"],
+ query="machine learning models",
+ top_k=4,
+ )
+
+ docs = result["documents"][0]
+ metas = result["metadatas"][0]
+
+ assert len(docs) == 4
+ # Each result should have notebook_id in metadata
+ nb_ids = [m["notebook_id"] for m in metas]
+ assert "nb1" in nb_ids
+ assert "nb2" in nb_ids
+
+
+def test_query_across_notebooks_respects_top_k(store):
+ """Only top_k results returned even when more exist across notebooks."""
+ store.add_chunks("nb1", _make_chunks("doc1.txt", ["text one", "text two", "text three"]))
+ store.add_chunks("nb2", _make_chunks("doc2.txt", ["text four", "text five"]))
+
+ result = store.query_across_notebooks(
+ notebook_ids=["nb1", "nb2"],
+ query="text",
+ top_k=3,
+ )
+
+ assert len(result["documents"][0]) == 3
+
+
+def test_query_across_notebooks_empty_notebook_skipped(store):
+ """Empty or nonexistent notebooks don't cause errors."""
+ store.add_chunks("nb1", _make_chunks("doc.txt", ["some content here"]))
+
+ result = store.query_across_notebooks(
+ notebook_ids=["nb1", "nb_nonexistent"],
+ query="content",
+ top_k=5,
+ )
+
+ docs = result["documents"][0]
+ assert len(docs) == 1
+ assert result["metadatas"][0][0]["notebook_id"] == "nb1"
+
+
+def test_query_across_notebooks_all_empty(store):
+ """Returns empty results when all notebooks are empty/missing."""
+ result = store.query_across_notebooks(
+ notebook_ids=["nb_missing_1", "nb_missing_2"],
+ query="anything",
+ top_k=5,
+ )
+
+ assert result["documents"] == [[]]
+
+
+def test_query_across_notebooks_single_notebook_fallback(store):
+ """Works correctly with just one notebook (degrades gracefully)."""
+ store.add_chunks("nb1", _make_chunks("paper.pdf", [
+ "Important finding about climate",
+ "Temperature data shows warming",
+ ]))
+
+ result = store.query_across_notebooks(
+ notebook_ids=["nb1"],
+ query="climate temperature",
+ top_k=2,
+ )
+
+ docs = result["documents"][0]
+ assert len(docs) == 2
+ assert all(m["notebook_id"] == "nb1" for m in result["metadatas"][0])
+
+
+def test_query_across_notebooks_results_sorted_by_distance(store):
+ """Results are sorted by distance (best first)."""
+ store.add_chunks("nb1", _make_chunks("a.txt", ["alpha text"]))
+ store.add_chunks("nb2", _make_chunks("b.txt", ["beta text"]))
+
+ result = store.query_across_notebooks(
+ notebook_ids=["nb1", "nb2"],
+ query="alpha text",
+ top_k=2,
+ )
+
+ distances = result["distances"][0]
+ assert distances == sorted(distances), "Results should be sorted by ascending distance"