import React, { useEffect, useMemo, useRef, useState, Fragment } from 'react';
import { Dialog, Transition } from '@headlessui/react';
import {
Plus, Upload, Link as LinkIcon, FileText, Files, Search, Trash2, MoreVertical,
CheckCircle2, Circle, ChevronRight, ChevronDown, Send, Copy, ThumbsUp, ThumbsDown,
RefreshCw, Notebook, Bold, Italic, Underline, List, ListOrdered, Sparkles, X, Edit3
} from 'lucide-react';
function NotebookLMFrontend() {
// -----------------------------
// Types (JSDoc for clarity)
// -----------------------------
/**
* @typedef {{ id: string; title: string; type: 'text'|'url'|'pdf'|'gdoc'; content: string; selected: boolean; }} Source
* @typedef {{ num: number; sourceId: string; quote: string; }} Citation
* @typedef {{ id: string; role: 'user'|'ai'; content: string; citations?: Citation[]; suggestions?: string[]; liked?: boolean; disliked?: boolean; }} ChatMessage
*/
// -----------------------------
// Initial mock data from public post (paraphrased)
// -----------------------------
const initialSources = /** @type {Source[]} */ ([
{
id: 's1',
title: 'Introducing NotebookLM (Google Keyword, 2023)',
type: 'url',
content:
"NotebookLM is an experimental, AI-first notebook by Google Labs (formerly Project Tailwind). It grounds a language model in your chosen sources to help summarize, explain, and brainstorm. It supports adding Google Docs now and will add more formats. Each response includes citations back to your sources, and your files/conversations are private and not used to train new models.",
selected: true,
},
{
id: 's2',
title: 'Core interactions & safety',
type: 'text',
content:
"When you add a source, NotebookLM can auto-summarize and propose key topics and questions. You can ask deeper questions, create glossaries, and generate creative ideas like scripts or investor Q&A. Source-grounding reduces hallucinations, and answers include citations for easy fact checking. Built by Google Labs with user feedback and strict safety criteria aligned with AI Principles.",
selected: true,
},
{
id: 's3',
title: 'Use cases & workflow',
type: 'text',
content:
"Example use cases: A medical student creates a dopamine glossary from a neuroscience article. An author summarizes Houdini and Conan Doyle interactions. A creator drafts a short video script. An entrepreneur asks for expected investor questions. The system is rolling out as an experiment in the U.S. with a waitlist.",
selected: true,
},
]);
// -----------------------------
// App State
// -----------------------------
const [notebookTitle, setNotebookTitle] = useState('My Research Notebook');
const [sources, setSources] = useState(initialSources);
const [messages, setMessages] = useState(/** @type {ChatMessage[]} */([]));
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const [openAddSource, setOpenAddSource] = useState(false);
const [addMode, setAddMode] = useState('text'); // 'text'|'url'|'file'
const [newSourceTitle, setNewSourceTitle] = useState('');
const [newSourceText, setNewSourceText] = useState('');
const [newSourceUrl, setNewSourceUrl] = useState('');
const [fileError, setFileError] = useState('');
const [renamingId, setRenamingId] = useState(null);
const [renameText, setRenameText] = useState('');
const [activeSourceViewId, setActiveSourceViewId] = useState(null);
const [highlightedSourceId, setHighlightedSourceId] = useState(null);
const [toolbarStatus, setToolbarStatus] = useState({ copying: false });
const [caretAtEnd, setCaretAtEnd] = useState(true);
// Notepad state (autosave in memory)
const [notepadHtml, setNotepadHtml] = useState('Ghi chú của bạn sẽ xuất hiện ở đây…
');
const notepadRef = useRef(null);
// Refs to source cards for scrolling/highlight on citation click
const sourceRefs = useRef({}); // id -> element
const setSourceRef = (id) => (el) => {
if (el) sourceRefs.current[id] = el;
};
// -----------------------------
// Derived
// -----------------------------
const selectedSources = sources.filter((s) => s.selected);
const selectedCount = selectedSources.length;
const totalWordCount = useMemo(() => {
return selectedSources.reduce((acc, s) => acc + (s.content.trim().split(/\s+/).filter(Boolean).length || 0), 0);
}, [selectedSources]);
// -----------------------------
// Utility: simplistic tokenizer & scorer for mock retrieval
// -----------------------------
const stopWords = new Set(['the','a','an','and','or','to','of','in','on','for','with','is','are','was','were','be','as','by','it','that','this','at','from','your','you']);
const tokenize = (text) =>
text
.toLowerCase()
.replace(/[^a-z0-9\s]/g, ' ')
.split(/\s+/)
.filter((t) => t && !stopWords.has(t));
const scoreSourceRelevance = (question, source) => {
const qTokens = new Set(tokenize(question));
const sTokens = tokenize(source.content);
let matches = 0;
for (const t of sTokens) if (qTokens.has(t)) matches++;
return matches / (sTokens.length + 1e-6);
};
const makeQuote = (source, question) => {
// Return a short matching quote snippet
const qTokens = new Set(tokenize(question));
const sentences = source.content.split(/(?<=[.!?])\s+/);
let best = sentences[0] || source.content.slice(0, 140);
let bestScore = -1;
for (const s of sentences) {
const st = tokenize(s);
const m = st.reduce((acc, t) => acc + (qTokens.has(t) ? 1 : 0), 0);
if (m > bestScore) {
bestScore = m;
best = s;
}
}
return best.length > 240 ? best.slice(0, 240) + '…' : best;
};
// -----------------------------
// Fake RAG answer generator
// -----------------------------
const generateAIAnswer = async (question, activeSources) => {
// Rank sources by relevance
const ranked = activeSources
.map((s) => ({ s, score: scoreSourceRelevance(question, s) }))
.sort((a, b) => b.score - a.score);
// Pick top K sources (2-3)
const top = ranked.slice(0, Math.min(3, ranked.length)).map((r) => r.s);
const citations = top.map((s, i) => ({
num: i + 1,
sourceId: s.id,
quote: makeQuote(s, question),
}));
// Drafted answer referencing [1], [2], ...
const bullets = [
`NotebookLM là một sổ tay ưu tiên AI, “nối đất” vào nguồn bạn chọn để tóm tắt, giải thích, và gợi ý ý tưởng [1].`,
`Mỗi câu trả lời kèm trích dẫn để bạn kiểm tra lại nguồn, giúp giảm “ảo giác” và tăng độ tin cậy [1][2].`,
`Luồng sử dụng: thêm nguồn (Google Docs/tài liệu), đọc tóm tắt & chủ đề gợi ý, đặt câu hỏi sâu, và trích dẫn tự động [2][3].`,
];
// Mix question keywords into an opening line
const opening =
question.trim().length > 0
? `Dựa trên nguồn đang chọn, đây là phần trả lời cho: “${question}”.`
: `Dưới đây là tóm tắt nhanh theo nguồn đang chọn.`;
const content =
`${opening}\n\n` +
bullets.map((b) => `• ${b}`).join('\n') +
`\n\nGợi ý: Bạn có thể yêu cầu “tạo glossary”, “viết script ngắn”, hoặc “liệt kê câu hỏi nhà đầu tư thường hỏi” [2][3].`;
// Suggested follow-ups
const suggestions = [
'Tạo danh mục thuật ngữ (glossary) cho chủ đề chính từ các nguồn đang chọn',
'Liệt kê 5 câu hỏi tiếp theo để đào sâu “source-grounding” và trích dẫn',
'Tóm tắt 3 ý chính và đề xuất 3 câu hỏi nghiên cứu mới',
'Viết dàn ý (outline) cho một bài viết ngắn dựa trên nguồn',
];
// Simulate latency
await new Promise((r) => setTimeout(r, 600));
return { content, citations, suggestions };
};
// -----------------------------
// Chat handlers
// -----------------------------
const sendMessage = async (text) => {
if (!text.trim() || sending) return;
setSending(true);
const q = text.trim();
const userMsg = /** @type {ChatMessage} */ ({
id: `m_${Date.now()}_u`,
role: 'user',
content: q,
});
setMessages((prev) => [...prev, userMsg]);
try {
const { content, citations, suggestions } = await generateAIAnswer(q, selectedSources);
const aiMsg = /** @type {ChatMessage} */ ({
id: `m_${Date.now()}_a`,
role: 'ai',
content,
citations,
suggestions,
});
setMessages((prev) => [...prev, aiMsg]);
} catch (e) {
const aiMsg = /** @type {ChatMessage} */ ({
id: `m_${Date.now()}_a`,
role: 'ai',
content: 'Xin lỗi, đã có lỗi khi tạo câu trả lời. Vui lòng thử lại.',
});
setMessages((prev) => [...prev, aiMsg]);
} finally {
setSending(false);
setInput('');
}
};
const regenerateAnswer = async (aiMsg) => {
if (sending) return;
setSending(true);
try {
const lastUser = [...messages].reverse().find((m) => m.role === 'user');
const q = lastUser ? lastUser.content : '';
const { content, citations, suggestions } = await generateAIAnswer(q, selectedSources);
const newAiMsg = { ...aiMsg, id: `m_${Date.now()}_a2`, content, citations, suggestions };
setMessages((prev) => prev.map((m) => (m.id === aiMsg.id ? newAiMsg : m)));
} finally {
setSending(false);
}
};
// -----------------------------
// Citations: click to scroll/highlight
// -----------------------------
const onClickCitation = (citation) => {
const id = citation.sourceId;
setHighlightedSourceId(id);
const el = sourceRefs.current[id];
if (el && el.scrollIntoView) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
// Remove highlight after a moment
setTimeout(() => setHighlightedSourceId(null), 1800);
};
// -----------------------------
// Rendering helpers
// -----------------------------
const renderWithCitations = (msg) => {
const parts = msg.content.split(/(\[\d+\])/g);
return parts.map((p, idx) => {
const match = p.match(/^\[(\d+)\]$/);
if (!match) return {p};
const num = Number(match[1]);
const c = msg.citations?.find((ci) => ci.num === num);
if (!c) return [{num}];
const source = sources.find((s) => s.id === c.sourceId);
return (
);
});
};
// -----------------------------
// Notepad: Rich text controls
// -----------------------------
const exec = (cmd) => {
document.execCommand(cmd, false, null);
// sync state from DOM
if (notepadRef.current) {
setNotepadHtml(notepadRef.current.innerHTML);
}
};
const insertHtmlAtCursor = (html) => {
// place at caret or append to end
const sel = window.getSelection();
if (sel && sel.rangeCount > 0 && notepadRef.current && notepadRef.current.contains(sel.anchorNode)) {
const range = sel.getRangeAt(0);
range.deleteContents();
const el = document.createElement('div');
el.innerHTML = html;
const frag = document.createDocumentFragment();
let node, lastNode;
while ((node = el.firstChild)) {
lastNode = frag.appendChild(node);
}
range.insertNode(frag);
// move caret after inserted content
if (lastNode) {
range.setStartAfter(lastNode);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
}
} else {
// append
if (notepadRef.current) {
notepadRef.current.insertAdjacentHTML('beforeend', html);
}
}
if (notepadRef.current) setNotepadHtml(notepadRef.current.innerHTML);
};
const addMessageToNotepad = (msg) => {
const citationHtml =
(msg.citations && msg.citations.length > 0)
? `${msg.citations.map(c => {
const s = sources.find(ss => ss.id === c.sourceId);
return `[${c.num}] ${s ? s.title : c.sourceId}${c.quote ? ` — “${escapeHtml(c.quote)}”` : ''}`;
}).join(' ')}`
: '';
const block =
`AI Answer${escapeHtml(msg.content).replace(/\n/g,'
')}${citationHtml}`;
insertHtmlAtCursor(block);
};
const escapeHtml = (s) =>
s.replace(/&/g, '&')
.replace(//g, '>');
// -----------------------------
// Add Source modal
// -----------------------------
const resetAddForm = () => {
setAddMode('text');
setNewSourceTitle('');
setNewSourceText('');
setNewSourceUrl('');
setFileError('');
};
const addSource = (src) => {
setSources((prev) => [src, ...prev]);
};
const handleAddSource = () => {
if (addMode === 'text') {
const title = newSourceTitle.trim() || 'Untitled Text';
const content = newSourceText.trim();
if (!content) return;
addSource({
id: `s_${Date.now()}`,
title,
type: 'text',
content,
selected: true,
});
setOpenAddSource(false);
resetAddForm();
} else if (addMode === 'url') {
const url = newSourceUrl.trim();
if (!url) return;
addSource({
id: `s_${Date.now()}`,
title: newSourceTitle.trim() || url,
type: 'url',
content: `URL: ${url}\n(Placeholder) Dán tóm tắt hoặc nội dung chính của trang này vào đây để “grounding”.`,
selected: true,
});
setOpenAddSource(false);
resetAddForm();
}
};
const handleFile = async (file) => {
setFileError('');
if (!file) return;
const ext = file.name.toLowerCase().split('.').pop();
if (ext === 'txt') {
const text = await file.text();
addSource({
id: `s_${Date.now()}`,
title: newSourceTitle.trim() || file.name,
type: 'text',
content: text.slice(0, 20000),
selected: true,
});
setOpenAddSource(false);
resetAddForm();
} else if (ext === 'pdf') {
// No PDF parsing in frontend-only demo
addSource({
id: `s_${Date.now()}`,
title: newSourceTitle.trim() || file.name,
type: 'pdf',
content: '(PDF) Demo frontend chưa parse nội dung PDF. Hãy dùng TXT/URL hoặc dán văn bản.',
selected: true,
});
setOpenAddSource(false);
resetAddForm();
} else {
setFileError('Định dạng không hỗ trợ trong demo. Vui lòng dùng .txt hoặc .pdf.');
}
};
// -----------------------------
// Source actions
// -----------------------------
const toggleSource = (id) => {
setSources((prev) => prev.map((s) => (s.id === id ? { ...s, selected: !s.selected } : s)));
};
const removeSource = (id) => {
setSources((prev) => prev.filter((s) => s.id !== id));
if (activeSourceViewId === id) setActiveSourceViewId(null);
};
const startRename = (id, current) => {
setRenamingId(id);
setRenameText(current);
};
const commitRename = () => {
setSources((prev) => prev.map((s) => (s.id === renamingId ? { ...s, title: renameText.trim() || s.title } : s)));
setRenamingId(null);
setRenameText('');
};
// -----------------------------
// Source Preview (middle panel when clicking a source)
// -----------------------------
const getSourceSummary = (s) => {
const words = s.content.split(/\s+/).filter(Boolean);
const preview = words.slice(0, 70).join(' ');
return preview + (words.length > 70 ? '…' : '');
};
const getSourceTopics = (s) => {
const tokens = tokenize(s.content);
const freq = {};
tokens.forEach((t) => (freq[t] = (freq[t] || 0) + 1));
const top = Object.entries(freq)
.sort((a, b) => b[1] - a[1])
.slice(0, 6)
.map(([w]) => w);
return top;
};
// -----------------------------
// Misc
// -----------------------------
const copyAnswer = async (msg) => {
try {
setToolbarStatus({ copying: true });
await navigator.clipboard.writeText(msg.content);
} finally {
setTimeout(() => setToolbarStatus({ copying: false }), 600);
}
};
const likeToggle = (msg) => {
setMessages((prev) =>
prev.map((m) => (m.id === msg.id ? { ...m, liked: !m.liked, disliked: false } : m))
);
};
const dislikeToggle = (msg) => {
setMessages((prev) =>
prev.map((m) => (m.id === msg.id ? { ...m, disliked: !m.disliked, liked: false } : m))
);
};
const onSuggestionClick = (text) => {
setInput(text);
};
const handleSubmit = (e) => {
e.preventDefault();
sendMessage(input);
};
useEffect(() => {
// place caret at end if requested
if (notepadRef.current && caretAtEnd) {
const range = document.createRange();
range.selectNodeContents(notepadRef.current);
range.collapse(false);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}
}, [notepadRef, caretAtEnd]);
// -----------------------------
// UI
// -----------------------------
return (
{/* Header */}
setNotebookTitle(e.target.value)}
aria-label="Notebook title"
/>
Demo Frontend — mô phỏng NotebookLM (không kết nối backend)
{/* Body: 3 columns */}
{/* Left: Sources */}
Sources
{selectedCount}/25 sources selected • {totalWordCount} words
{sources.length === 0 ? (
Chưa có nguồn. Bấm “Add” để thêm.
) : (
{sources.map((s) => {
const isHighlighted = highlightedSourceId === s.id;
return (
-
{renamingId === s.id ? (
setRenameText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') commitRename();
if (e.key === 'Escape') {
setRenamingId(null);
setRenameText('');
}
}}
autoFocus
/>
) : (
)}
{s.content.slice(0, 100)}
{s.content.length > 100 ? '…' : ''}
);
})}
)}
{/* Middle: Chat or Source Preview */}
{activeSourceViewId ? (
Source preview
— {sources.find((s) => s.id === activeSourceViewId)?.title}
) : (
Answering from {selectedCount} source{selectedCount !== 1 ? 's' : ''}
)}
Inline citations • Suggested follow-ups • Toolbar
{activeSourceViewId ? (
s.id === activeSourceViewId)}
onClose={() => setActiveSourceViewId(null)}
getSummary={getSourceSummary}
getTopics={getSourceTopics}
/>
) : (
{messages.length === 0 ? (
) : (
messages.map((m) => (
{m.role === 'user' ? 'U' : 'AI'}
{m.role === 'ai' ? (
{renderWithCitations(m)}
{m.citations?.map((c) => {
const s = sources.find((ss) => ss.id === c.sourceId);
return (
);
})}
{m.suggestions && m.suggestions.length > 0 && (
Suggested questions
{m.suggestions.map((sug, i) => (
))}
)}
) : (
{m.content}
)}
))
)}
)}
{/* Composer */}
{!activeSourceViewId && (
)}
{/* Right: Notepad */}
Notepad
Autosave
{/* Toolbar */}
{/* Editor */}
setNotepadHtml(e.currentTarget.innerHTML)}
onFocus={() => setCaretAtEnd(true)}
dangerouslySetInnerHTML={{ __html: notepadHtml }}
/>
{/* Add Source Modal */}
);
}
// -----------------------------
// Subcomponents
// -----------------------------
function EmptyState({ selectedCount }) {
return (
Chào mừng đến với NotebookLM (Demo)
Thêm nguồn ở cột trái và đặt câu hỏi ở đây. Mỗi câu trả lời sẽ có trích dẫn nội tuyến để bạn kiểm chứng.
Đang dùng {selectedCount} nguồn cho câu trả lời.
);
}
function SourcePreview({ source, onClose, getSummary, getTopics }) {
if (!source) return null;
const summary = getSummary(source);
const topics = getTopics(source);
return (
{source.title}
Tóm tắt tự động
{summary}
Chủ đề gợi ý
{topics.map((t, i) => (
#{t}
))}
Nội dung nguồn (rút gọn)
{source.content.slice(0, 1200)}{source.content.length > 1200 ? '…' : ''}
);
}
export default NotebookLMFrontend;