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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

All notable changes to Notebook LM will be documented in this file.

## [0.2.0.0] - 2026-04-15

### Added
- Cross-document synthesis: click "All" in the chat header to query across every notebook at once. Ask "what do these papers disagree about?" and get a sourced answer that names which notebook and document each claim came from.
- Source panel now shows notebook labels when in cross-notebook mode, so you can trace every answer back to its origin.
- LLM prompt explicitly instructs the model to compare, contrast, and name disagreements across sources from different notebooks.

## [0.1.1.0] - 2026-04-15

### Added
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.1.0.0
0.2.0.0
25 changes: 22 additions & 3 deletions apps/desktop/src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export function ChatView({ pendingSuggest, onSuggestConsumed }: { pendingSuggest
const notebooks = useAppStore((s) => s.notebooks);
const toggleSourcePanel = useAppStore((s) => s.toggleSourcePanel);
const status = useAppStore((s) => s.status);
const crossNotebookMode = useAppStore((s) => s.crossNotebookMode);
const setCrossNotebookMode = useAppStore((s) => s.setCrossNotebookMode);

const activeNotebook = notebooks.find((nb) => nb.notebook_id === activeNotebookId) ?? null;

Expand Down Expand Up @@ -113,14 +115,31 @@ export function ChatView({ pendingSuggest, onSuggestConsumed }: { pendingSuggest
<div className="chat-view">
<div className="chat-header">
<div className="chat-header-title">
<h2>{activeNotebook ? activeNotebook.title : 'Notebook LM'}</h2>
{activeNotebook && (
<h2>{crossNotebookMode ? 'All Notebooks' : (activeNotebook ? activeNotebook.title : 'Notebook LM')}</h2>
{crossNotebookMode ? (
<span className="chat-header-meta">
{notebooks.length} {notebooks.length === 1 ? 'notebook' : 'notebooks'}
</span>
) : activeNotebook ? (
<span className="chat-header-meta">
{activeNotebook.source_count} {activeNotebook.source_count === 1 ? 'doc' : 'docs'}
</span>
)}
) : null}
</div>
<div className="chat-header-actions">
{notebooks.length > 1 && (
<button
type="button"
className={`chat-header-btn ${crossNotebookMode ? 'chat-header-btn-active' : ''}`}
onClick={() => {
setCrossNotebookMode(!crossNotebookMode);
clearChat();
}}
title={crossNotebookMode ? 'Switch to single notebook' : 'Query across all notebooks'}
>
{crossNotebookMode ? 'Single' : 'All'}
</button>
)}
{messages.length > 0 && (
<button type="button" className="chat-header-btn" onClick={clearChat}>
New chat
Expand Down
11 changes: 11 additions & 0 deletions apps/desktop/src/components/chat/chat.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
44 changes: 27 additions & 17 deletions apps/desktop/src/components/layout/SourcePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -20,23 +22,31 @@ export function SourcePanel() {
</div>

<div className="source-panel-list">
{activeSources.map((source, i) => (
<div key={`${source.source_path}-${i}`} className="source-card">
<span className="source-card-name">{source.document_name}</span>
{source.relevance_score != null && (
<div className="source-relevance-track">
<div
className="source-relevance-bar"
style={{
width: `${source.relevance_score}%`,
backgroundColor: relevanceColor(source.relevance_score),
}}
/>
</div>
)}
<p className="source-card-preview">{source.preview}</p>
</div>
))}
{activeSources.map((source, i) => {
const nbId = (source as Record<string, unknown>).notebook_id as string | undefined;
const nbName = nbId ? notebooks.find((nb) => nb.notebook_id === nbId)?.title : null;

return (
<div key={`${source.source_path}-${i}`} className="source-card">
{crossNotebookMode && nbName && (
<span className="source-card-notebook">{nbName}</span>
)}
<span className="source-card-name">{source.document_name}</span>
{source.relevance_score != null && (
<div className="source-relevance-track">
<div
className="source-relevance-bar"
style={{
width: `${source.relevance_score}%`,
backgroundColor: relevanceColor(source.relevance_score),
}}
/>
</div>
)}
<p className="source-card-preview">{source.preview}</p>
</div>
);
})}
</div>
</aside>
);
Expand Down
10 changes: 10 additions & 0 deletions apps/desktop/src/components/layout/layout.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src/hooks/useChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down
10 changes: 10 additions & 0 deletions apps/desktop/src/store/app-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ interface AppState {
conversations: Conversation[];
activeConversationId: string | null;

// Cross-notebook
crossNotebookMode: boolean;

// Documents
documents: DocumentInfo[];

Expand All @@ -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;

Expand All @@ -64,6 +70,7 @@ export const useAppStore = create<AppState>((set) => ({
isStreaming: false,
conversations: [],
activeConversationId: null,
crossNotebookMode: false,
documents: [],
sourcePanelOpen: true,
activeSources: [],
Expand Down Expand Up @@ -95,6 +102,9 @@ export const useAppStore = create<AppState>((set) => ({
setConversations: (convs) => set({ conversations: convs }),
setActiveConversationId: (id) => set({ activeConversationId: id }),

// Cross-notebook
setCrossNotebookMode: (val) => set({ crossNotebookMode: val }),

// Documents
setDocuments: (docs) => set({ documents: docs }),

Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export interface ChatRequest {
prompt: string;
history?: ChatMessage[];
notebook_id?: string | null;
notebook_ids?: string[] | null;
conversation_id?: string | null;
}

Expand Down
1 change: 1 addition & 0 deletions backend/notebooklm_backend/models/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")


Expand Down
1 change: 1 addition & 0 deletions backend/notebooklm_backend/routes/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
55 changes: 46 additions & 9 deletions backend/notebooklm_backend/services/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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(
Expand Down
80 changes: 80 additions & 0 deletions backend/notebooklm_backend/services/rag.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class SourceAttribution:
source_path: str
content: str
distance: float | None
notebook_id: str | None = None


@dataclass
Expand Down Expand Up @@ -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)
Loading
Loading