From 8f2a0e79e1d399fa97b853d4a638b2a89d2e0250 Mon Sep 17 00:00:00 2001 From: Vikranth Reddimasu Date: Wed, 15 Apr 2026 23:23:22 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20research=20trust=20layer=20?= =?UTF-8?q?=E2=80=94=20click-to-highlight=20and=20BibTeX=20export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Click any source in the panel to open the PDF with the matching passage highlighted in yellow. The viewer auto-navigates to the page containing the source text using PDF.js text search, then highlights matching words with a customTextRenderer overlay. Export BibTeX from the overflow menu: generates .bib entries from conversation sources with auto-extracted author, year, title from filenames. Each entry includes the source passage as a note. Source cards now show "Click to view in document" on hover. The document preview badge shows which page the source was found on. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/desktop/src/DocumentPreview.css | 18 +++++ apps/desktop/src/DocumentPreview.tsx | 72 +++++++++++++++++-- apps/desktop/src/components/chat/ChatView.tsx | 11 +++ .../src/components/layout/AppShell.tsx | 15 +++- .../src/components/layout/SourcePanel.tsx | 17 ++++- apps/desktop/src/components/layout/layout.css | 21 ++++++ apps/desktop/src/utils/bibtex.ts | 67 +++++++++++++++++ 7 files changed, 211 insertions(+), 10 deletions(-) create mode 100644 apps/desktop/src/utils/bibtex.ts diff --git a/apps/desktop/src/DocumentPreview.css b/apps/desktop/src/DocumentPreview.css index dd90b4c..de178a1 100644 --- a/apps/desktop/src/DocumentPreview.css +++ b/apps/desktop/src/DocumentPreview.css @@ -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; +} + diff --git a/apps/desktop/src/DocumentPreview.tsx b/apps/desktop/src/DocumentPreview.tsx index 0508477..672b663 100644 --- a/apps/desktop/src/DocumentPreview.tsx +++ b/apps/desktop/src/DocumentPreview.tsx @@ -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'; @@ -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(null); const [pageNumber, setPageNumber] = useState(1); const [loading, setLoading] = useState(true); const [error, setError] = useState(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}`); @@ -66,6 +100,28 @@ 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 `${textItem.str}`; + } + return textItem.str; + }, + [searchSnippet], + ); + if (!isOpen) return null; return ( @@ -73,6 +129,9 @@ function DocumentPreview({ isOpen, onClose, documentUrl, filename }: DocumentPre
e.stopPropagation()}>

{filename}

+ {highlightText && highlightPageFound && ( + Source found on page {pageNumber} + )}
{isPdf && numPages && ( <> @@ -152,6 +211,7 @@ function DocumentPreview({ isOpen, onClose, documentUrl, filename }: DocumentPre renderTextLayer={true} renderAnnotationLayer={true} className="preview-pdf-page" + customTextRenderer={searchSnippet ? customTextRenderer : undefined} /> ))}
@@ -163,6 +223,7 @@ function DocumentPreview({ isOpen, onClose, documentUrl, filename }: DocumentPre renderTextLayer={true} renderAnnotationLayer={true} className="preview-pdf-page" + customTextRenderer={searchSnippet ? customTextRenderer : undefined} />
)} @@ -183,4 +244,3 @@ function DocumentPreview({ isOpen, onClose, documentUrl, filename }: DocumentPre } export default DocumentPreview; - diff --git a/apps/desktop/src/components/chat/ChatView.tsx b/apps/desktop/src/components/chat/ChatView.tsx index 8060e49..771507e 100644 --- a/apps/desktop/src/components/chat/ChatView.tsx +++ b/apps/desktop/src/components/chat/ChatView.tsx @@ -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 = [ @@ -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 }, ]; diff --git a/apps/desktop/src/components/layout/AppShell.tsx b/apps/desktop/src/components/layout/AppShell.tsx index 50c0315..bc3aa38 100644 --- a/apps/desktop/src/components/layout/AppShell.tsx +++ b/apps/desktop/src/components/layout/AppShell.tsx @@ -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(null); // Global keyboard shortcuts useEffect(() => { @@ -214,15 +215,25 @@ export function AppShell() {
setPendingSuggest(null)} /> - + { + 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 {} + }} />
{previewDocument && activeNotebookId && resolvedPreviewUrl && ( setPreviewDocument(null)} + onClose={() => { setPreviewDocument(null); setHighlightText(null); }} documentUrl={resolvedPreviewUrl} filename={previewDocument.filename} + highlightText={highlightText} /> )} diff --git a/apps/desktop/src/components/layout/SourcePanel.tsx b/apps/desktop/src/components/layout/SourcePanel.tsx index 196a75c..1a49557 100644 --- a/apps/desktop/src/components/layout/SourcePanel.tsx +++ b/apps/desktop/src/components/layout/SourcePanel.tsx @@ -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); @@ -27,7 +31,13 @@ export function SourcePanel() { const nbName = nbId ? notebooks.find((nb) => nb.notebook_id === nbId)?.title : null; return ( -
+
onSourceClick?.(source.source_path, source.preview)} + role={onSourceClick ? 'button' : undefined} + tabIndex={onSourceClick ? 0 : undefined} + > {crossNotebookMode && nbName && ( {nbName} )} @@ -44,6 +54,9 @@ export function SourcePanel() {
)}

{source.preview}

+ {onSourceClick && ( + Click to view in document + )}
); })} diff --git a/apps/desktop/src/components/layout/layout.css b/apps/desktop/src/components/layout/layout.css index 6fbb124..5b7a1f5 100644 --- a/apps/desktop/src/components/layout/layout.css +++ b/apps/desktop/src/components/layout/layout.css @@ -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); diff --git a/apps/desktop/src/utils/bibtex.ts b/apps/desktop/src/utils/bibtex.ts new file mode 100644 index 0000000..f8ede5c --- /dev/null +++ b/apps/desktop/src/utils/bibtex.ts @@ -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(); + 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); +} From 6d325360fb9d685d5222988089df3b002e04c70e Mon Sep 17 00:00:00 2001 From: Vikranth Reddimasu Date: Wed, 15 Apr 2026 23:25:55 -0400 Subject: [PATCH 2/2] chore: bump version to 0.3.1.0 and update CHANGELOG Research trust layer: click-to-highlight sources and BibTeX export. Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 6 ++++++ VERSION | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75dac03..d2ffc46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/VERSION b/VERSION index 1da00ae..5294537 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.0.0 +0.3.1.0