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

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

## [0.3.1.0] - 2026-04-15

### Added
- Click any source in the panel to open the original PDF with the matching passage highlighted in yellow. The viewer auto-navigates to the correct page.
- Export BibTeX from the overflow menu: generates .bib entries from conversation sources with auto-extracted author, year, and title. Ready for LaTeX.

## [0.3.0.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.3.0.0
0.3.1.0
18 changes: 18 additions & 0 deletions apps/desktop/src/DocumentPreview.css
Original file line number Diff line number Diff line change
Expand Up @@ -217,3 +217,21 @@
background: white;
}

/* PDF text highlight */
.pdf-highlight {
background: rgba(251, 191, 36, 0.35);
border-radius: 2px;
padding: 1px 0;
}

.preview-highlight-badge {
font-size: 12px;
color: #fbbf24;
font-weight: 500;
padding: 3px 10px;
background: rgba(251, 191, 36, 0.1);
border: 1px solid rgba(251, 191, 36, 0.2);
border-radius: 12px;
white-space: nowrap;
}

72 changes: 66 additions & 6 deletions apps/desktop/src/DocumentPreview.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { Document, Page, pdfjs } from 'react-pdf';
import 'react-pdf/dist/Page/AnnotationLayer.css';
import 'react-pdf/dist/Page/TextLayer.css';
Expand All @@ -13,31 +13,65 @@ interface DocumentPreviewProps {
onClose: () => void;
documentUrl: string;
filename: string;
highlightText?: string | null;
}

function DocumentPreview({ isOpen, onClose, documentUrl, filename }: DocumentPreviewProps) {
function normalizeText(text: string): string {
return text.toLowerCase().replace(/\s+/g, ' ').trim();
}

function DocumentPreview({ isOpen, onClose, documentUrl, filename, highlightText }: DocumentPreviewProps) {
const [numPages, setNumPages] = useState<number | null>(null);
const [pageNumber, setPageNumber] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [scale, setScale] = useState(1.2);
const [showAllPages, setShowAllPages] = useState(false);
const [highlightPageFound, setHighlightPageFound] = useState(false);

const isPdf = filename.toLowerCase().endsWith('.pdf');

// Search snippet: take first 60 chars of highlight text for matching
const searchSnippet = highlightText
? normalizeText(highlightText).slice(0, 60)
: null;

useEffect(() => {
if (isOpen) {
setPageNumber(1);
setLoading(true);
setError(null);
setHighlightPageFound(false);
}
}, [isOpen, documentUrl]);

const onDocumentLoadSuccess = ({ numPages }: { numPages: number }) => {
setNumPages(numPages);
const onDocumentLoadSuccess = useCallback(async ({ numPages: np }: { numPages: number }) => {
setNumPages(np);
setLoading(false);
setError(null);
};

// If we have highlight text, find which page contains it
if (searchSnippet && isPdf) {
try {
const pdf = await pdfjs.getDocument(documentUrl).promise;
for (let i = 1; i <= np; i++) {
const page = await pdf.getPage(i);
const textContent = await page.getTextContent();
const pageText = normalizeText(
textContent.items.map((item: { str?: string }) => item.str || '').join(' ')
);
if (pageText.includes(searchSnippet)) {
setPageNumber(i);
setHighlightPageFound(true);
setShowAllPages(false);
break;
}
}
} catch {
// Failed to search, just show page 1
}
}
}, [searchSnippet, documentUrl, isPdf]);

const onDocumentLoadError = (error: Error) => {
setError(`Failed to load document: ${error.message}`);
Expand Down Expand Up @@ -66,13 +100,38 @@ function DocumentPreview({ isOpen, onClose, documentUrl, filename }: DocumentPre
}
};

// Custom text renderer that highlights matching text
const customTextRenderer = useCallback(
(textItem: { str: string }) => {
if (!searchSnippet) return textItem.str;

const normalizedItem = normalizeText(textItem.str);
// Check if this text item contains part of the search snippet
// Use a simpler word-overlap approach for partial matching
const words = searchSnippet.split(' ').filter((w) => w.length > 3);
if (words.length === 0) return textItem.str;

const matchCount = words.filter((w) => normalizedItem.includes(w)).length;
const matchRatio = matchCount / words.length;

if (matchRatio >= 0.3) {
return `<mark class="pdf-highlight">${textItem.str}</mark>`;
}
return textItem.str;
},
[searchSnippet],
);

if (!isOpen) return null;

return (
<div className="document-preview-overlay" onClick={onClose} onKeyDown={handleKeyDown} tabIndex={-1}>
<div className="document-preview-container" onClick={(e) => e.stopPropagation()}>
<div className="document-preview-header">
<h3 className="document-preview-title">{filename}</h3>
{highlightText && highlightPageFound && (
<span className="preview-highlight-badge">Source found on page {pageNumber}</span>
)}
<div className="document-preview-controls">
{isPdf && numPages && (
<>
Expand Down Expand Up @@ -152,6 +211,7 @@ function DocumentPreview({ isOpen, onClose, documentUrl, filename }: DocumentPre
renderTextLayer={true}
renderAnnotationLayer={true}
className="preview-pdf-page"
customTextRenderer={searchSnippet ? customTextRenderer : undefined}
/>
))}
</div>
Expand All @@ -163,6 +223,7 @@ function DocumentPreview({ isOpen, onClose, documentUrl, filename }: DocumentPre
renderTextLayer={true}
renderAnnotationLayer={true}
className="preview-pdf-page"
customTextRenderer={searchSnippet ? customTextRenderer : undefined}
/>
</div>
)}
Expand All @@ -183,4 +244,3 @@ function DocumentPreview({ isOpen, onClose, documentUrl, filename }: DocumentPre
}

export default DocumentPreview;

11 changes: 11 additions & 0 deletions apps/desktop/src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { exportConversation } from '../../api';
import { MessageBubble } from './MessageBubble';
import { QuickChips } from './QuickChips';
import { OverflowMenu } from '../ui/OverflowMenu';
import { downloadBibtex } from '../../utils/bibtex';
import './chat.css';

const EMPTY_CHIPS = [
Expand Down Expand Up @@ -103,8 +104,18 @@ export function ChatView({ pendingSuggest, onSuggestConsumed }: { pendingSuggest
}
};

const activeSources = useAppStore((s) => s.activeSources);

const handleExportBibtex = () => {
if (activeSources.length > 0) {
const title = activeNotebook?.title || 'Notebook LM';
downloadBibtex(activeSources, title);
}
};

const overflowItems = [
{ label: 'Export conversation', onClick: handleExport, disabled: messages.length === 0 },
{ label: 'Export BibTeX', onClick: handleExportBibtex, disabled: activeSources.length === 0 },
{ label: 'Toggle sources', onClick: toggleSourcePanel },
{ label: 'Clear chat', onClick: clearChat, disabled: messages.length === 0 },
];
Expand Down
15 changes: 13 additions & 2 deletions apps/desktop/src/components/layout/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export function AppShell() {
const [paletteOpen, setPaletteOpen] = useState(false);
const [shortcutsOpen, setShortcutsOpen] = useState(false);
const [zoteroOpen, setZoteroOpen] = useState(false);
const [highlightText, setHighlightText] = useState<string | null>(null);

// Global keyboard shortcuts
useEffect(() => {
Expand Down Expand Up @@ -214,15 +215,25 @@ export function AppShell() {
<div className="app-shell">
<Sidebar />
<ChatView pendingSuggest={pendingSuggest} onSuggestConsumed={() => setPendingSuggest(null)} />
<SourcePanel />
<SourcePanel onSourceClick={async (sourcePath, preview) => {
if (!activeNotebookId) return;
try {
const url = await getDocumentPreviewUrl(activeNotebookId, sourcePath);
const filename = sourcePath.split(/[/\\]/).pop() ?? sourcePath;
setResolvedPreviewUrl(url);
setHighlightText(preview);
setPreviewDocument({ filename, source_path: sourcePath, chunk_count: 0, preview: '' });
} catch {}
}} />
</div>

{previewDocument && activeNotebookId && resolvedPreviewUrl && (
<DocumentPreview
isOpen={true}
onClose={() => setPreviewDocument(null)}
onClose={() => { setPreviewDocument(null); setHighlightText(null); }}
documentUrl={resolvedPreviewUrl}
filename={previewDocument.filename}
highlightText={highlightText}
/>
)}

Expand Down
17 changes: 15 additions & 2 deletions apps/desktop/src/components/layout/SourcePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ function relevanceColor(score: number): string {
return '#a8a29e'; // muted gray
}

export function SourcePanel() {
interface SourcePanelProps {
onSourceClick?: (sourcePath: string, highlightText: string) => void;
}

export function SourcePanel({ onSourceClick }: SourcePanelProps) {
const activeSources = useAppStore((s) => s.activeSources);
const crossNotebookMode = useAppStore((s) => s.crossNotebookMode);
const notebooks = useAppStore((s) => s.notebooks);
Expand All @@ -27,7 +31,13 @@ export function SourcePanel() {
const nbName = nbId ? notebooks.find((nb) => nb.notebook_id === nbId)?.title : null;

return (
<div key={`${source.source_path}-${i}`} className="source-card">
<div
key={`${source.source_path}-${i}`}
className={`source-card ${onSourceClick ? 'source-card-clickable' : ''}`}
onClick={() => onSourceClick?.(source.source_path, source.preview)}
role={onSourceClick ? 'button' : undefined}
tabIndex={onSourceClick ? 0 : undefined}
>
{crossNotebookMode && nbName && (
<span className="source-card-notebook">{nbName}</span>
)}
Expand All @@ -44,6 +54,9 @@ export function SourcePanel() {
</div>
)}
<p className="source-card-preview">{source.preview}</p>
{onSourceClick && (
<span className="source-card-view-hint">Click to view in document</span>
)}
</div>
);
})}
Expand Down
21 changes: 21 additions & 0 deletions apps/desktop/src/components/layout/layout.css
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,27 @@
to { transform: scaleX(1); }
}

.source-card-clickable {
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease;
}

.source-card-clickable:hover {
background: var(--color-bg-hover);
border-color: var(--color-border-hover);
}

.source-card-view-hint {
display: none;
font-size: 10px;
color: var(--color-accent);
margin-top: var(--space-1);
}

.source-card-clickable:hover .source-card-view-hint {
display: block;
}

.source-card-preview {
font-size: var(--text-xs);
color: var(--color-text-muted);
Expand Down
67 changes: 67 additions & 0 deletions apps/desktop/src/utils/bibtex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { SourceChunk } from '../types';

function sanitizeKey(str: string): string {
return str
.replace(/[^a-zA-Z0-9]/g, '')
.slice(0, 20)
.toLowerCase() || 'source';
}

function extractYear(path: string): string {
const match = path.match(/(19|20)\d{2}/);
return match ? match[0] : new Date().getFullYear().toString();
}

function extractAuthor(filename: string): string {
// Try to extract author from common patterns: "Author - Title.pdf", "Author_Title.pdf"
const cleaned = filename.replace(/\.[^.]+$/, ''); // remove extension
const parts = cleaned.split(/\s*[-_]\s*/);
if (parts.length >= 2) {
return parts[0].trim();
}
return cleaned.trim();
}

export function sourcesToBibtex(sources: SourceChunk[]): string {
// Deduplicate by source_path
const seen = new Set<string>();
const unique = sources.filter((s) => {
if (seen.has(s.source_path)) return false;
seen.add(s.source_path);
return true;
});

const entries = unique.map((source, i) => {
const filename = source.document_name || source.source_path.split(/[/\\]/).pop() || 'document';
const title = filename.replace(/\.[^.]+$/, '').replace(/[_-]/g, ' ');
const author = extractAuthor(filename);
const year = extractYear(source.source_path);
const key = `${sanitizeKey(author)}${year}_${i + 1}`;

return [
`@misc{${key},`,
` title = {${title}},`,
` author = {${author}},`,
` year = {${year}},`,
` note = {Retrieved via Notebook LM. Passage: "${source.preview.slice(0, 100).replace(/"/g, "'")}..."},`,
` howpublished = {Local file: ${source.source_path}}`,
`}`,
].join('\n');
});

return entries.join('\n\n') + '\n';
}

export function downloadBibtex(sources: SourceChunk[], conversationTitle?: string): void {
const bibtex = sourcesToBibtex(sources);
const blob = new Blob([bibtex], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
const name = (conversationTitle || 'notebook-lm-sources').replace(/\s+/g, '_');
link.download = `${name}.bib`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
Loading