(.*?)<\/blockquote>/g, '> $1\n')
+ .replace(/(.*?)<\/pre>/gs, '```\n$1\n```\n')
+ .replace(/(.*?)<\/code>/g, '`$1`')
+ .replace(/(.*?)<\/li>/g, '- $1\n')
+ .replace(/|<\/ul>||<\/ol>/g, '')
+ .replace(/(.*?)<\/p>/g, '$1\n\n')
+ .replace(/ /g, '\n')
+ .replace(/<[^>]+>/g, '');
+ return md.trim();
+ };
+
+ const handleSave = async () => {
+ if (!editorRef.current || !notebook?.id) return;
+ setSaving(true);
+ try {
+ const content = htmlToMarkdown(editorRef.current.innerHTML);
+ const mdContent = coverImage ? `\n\n# ${title}\n\n${content}` : `# ${title}\n\n${content}`;
+ const blob = new Blob([mdContent], { type: 'text/markdown' });
+ const file = new File([blob], `${title}.md`, { type: 'text/markdown' });
+
+ const formData = new FormData();
+ formData.append('file', file);
+ formData.append('email', user?.email || user?.id || 'default');
+ formData.append('user_id', user?.id || 'default');
+ formData.append('notebook_id', notebook.id);
+ formData.append('notebook_title', notebook?.title || notebook?.name || '');
+
+ const res = await apiFetch('/api/v1/kb/upload', { method: 'POST', body: formData });
+ if (!res.ok) throw new Error('Save failed');
+
+ alert('Note saved to knowledge base!');
+ onSaved?.();
+ onClose();
+ } catch (err) {
+ alert('Failed to save note');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleExport = () => {
+ if (!editorRef.current) return;
+ const content = htmlToMarkdown(editorRef.current.innerHTML);
+ const mdContent = coverImage ? `\n\n# ${title}\n\n${content}` : `# ${title}\n\n${content}`;
+ const blob = new Blob([mdContent], { type: 'text/markdown' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `${title}.md`;
+ a.click();
+ URL.revokeObjectURL(url);
+ };
+
+ return (
+
+
+
setTitle(e.target.value)}
+ className="text-2xl font-bold outline-none flex-1"
+ placeholder="Untitled"
+ />
+
+
+ Export
+
+
+ {saving ? 'Saving...' : 'Save'}
+
+
+ Done
+
+
+
+
+
+
coverInputRef.current?.click()} className="p-2 hover:bg-gray-200 rounded" title="Add Cover">
+
+
+
mdInputRef.current?.click()} className="p-2 hover:bg-gray-200 rounded" title="Embed Markdown">
+
+
+
+
execCommand('bold')} className="p-2 hover:bg-gray-200 rounded" title="Bold">
+
+
+
execCommand('italic')} className="p-2 hover:bg-gray-200 rounded" title="Italic">
+
+
+
execCommand('formatBlock', 'h1')} className="p-2 hover:bg-gray-200 rounded" title="Heading 1">
+
+
+
execCommand('formatBlock', 'h2')} className="p-2 hover:bg-gray-200 rounded" title="Heading 2">
+
+
+
execCommand('insertUnorderedList')} className="p-2 hover:bg-gray-200 rounded" title="Bullet List">
+
+
+
execCommand('insertOrderedList')} className="p-2 hover:bg-gray-200 rounded" title="Numbered List">
+
+
+
execCommand('formatBlock', 'blockquote')} className="p-2 hover:bg-gray-200 rounded" title="Quote">
+
+
+
execCommand('formatBlock', 'pre')} className="p-2 hover:bg-gray-200 rounded" title="Code Block">
+
+
+
+
+
+
+
+ {coverImage && (
+
+
+
setCoverImage(null)} className="absolute top-2 right-2 bg-red-500 text-white px-2 py-1 rounded text-xs">
+ Remove
+
+
+ )}
+
+
+
+ );
+};
diff --git a/frontend_en/src/components/notes/NotionEditor.tsx b/frontend_en/src/components/notes/NotionEditor.tsx
new file mode 100644
index 0000000..a23e0a6
--- /dev/null
+++ b/frontend_en/src/components/notes/NotionEditor.tsx
@@ -0,0 +1,606 @@
+import React, { useState, useCallback } from 'react';
+import { Block, BlockType } from './types';
+import { BlockEditor } from './BlockEditor';
+import { SlashMenu } from './SlashMenu';
+import { AIPanel } from './AIPanel';
+import { TextSelectionToolbar } from './TextSelectionToolbar';
+import { DiffPreviewPanel } from './DiffPreviewPanel';
+import { Image, Download, Save, X, Maximize2, Minimize2 } from 'lucide-react';
+import { apiFetch } from '../../config/api';
+import type { KnowledgeFile } from '../../types';
+
+interface NotionEditorProps {
+ onClose: () => void;
+ notebook: any;
+ user: any;
+ files?: KnowledgeFile[];
+ onSaved?: (noteInfo: { title: string; fileName: string }) => void;
+ initialTitle?: string;
+ initialBlocks?: Block[];
+}
+
+export const NotionEditor: React.FC = ({
+ onClose,
+ notebook,
+ user,
+ files = [],
+ onSaved,
+ initialTitle = 'Untitled',
+ initialBlocks = [{ id: '1', type: 'text', content: '' }],
+}) => {
+ const [title, setTitle] = useState(initialTitle);
+ const [coverImage, setCoverImage] = useState(null);
+ const [blocks, setBlocks] = useState(initialBlocks);
+ const [slashMenuBlock, setSlashMenuBlock] = useState(null);
+ const [aiPanelBlock, setAiPanelBlock] = useState(null);
+ const [saving, setSaving] = useState(false);
+ const [isFullScreen, setIsFullScreen] = useState(false);
+ const [draggedBlock, setDraggedBlock] = useState(null);
+
+ // Memory for AI context
+ const [noteMemory, setNoteMemory] = useState('');
+
+ // Text selection toolbar
+ const [textSelection, setTextSelection] = useState<{
+ blockId: string;
+ text: string;
+ position: { x: number; y: number };
+ } | null>(null);
+
+ // Diff preview panel (right side)
+ const [diffPreview, setDiffPreview] = useState<{
+ blockId: string;
+ originalText: string;
+ revisedText: string;
+ } | null>(null);
+
+ const coverInputRef = React.useRef(null);
+
+ const generateId = () => Math.random().toString(36).slice(2, 11);
+
+ const updateBlock = (id: string, content: string, url?: string, scale?: number) => {
+ setBlocks(prev =>
+ prev.map(b => (b.id === id ? { ...b, content, url: url ?? b.url, scale: scale ?? b.scale } : b))
+ );
+ if (content !== '/' && content !== '/') {
+ setSlashMenuBlock(null);
+ }
+ };
+
+ const toggleTodo = (id: string) => {
+ setBlocks(prev => prev.map(b => (b.id === id ? { ...b, checked: !b.checked } : b)));
+ };
+
+ const changeBlockType = (id: string, type: BlockType, newContent?: string) => {
+ setBlocks(prev =>
+ prev.map(b =>
+ b.id === id
+ ? {
+ ...b,
+ type,
+ content: newContent !== undefined
+ ? newContent
+ : b.content.endsWith('/') ? b.content.slice(0, -1) : b.content,
+ }
+ : b
+ )
+ );
+ setSlashMenuBlock(null);
+ };
+
+ const addBlock = (afterId: string, remainingContent?: string) => {
+ const index = blocks.findIndex(b => b.id === afterId);
+ const currentType = blocks[index]?.type;
+ const newType: BlockType = (currentType === 'bulletList' || currentType === 'numberedList' || currentType === 'todo')
+ ? currentType
+ : 'text';
+ const newBlock: Block = { id: generateId(), type: newType, content: remainingContent || '' };
+ setBlocks(prev => [...prev.slice(0, index + 1), newBlock, ...prev.slice(index + 1)]);
+ };
+
+ const deleteBlock = (id: string) => {
+ if (blocks.length === 1) {
+ setBlocks([{ id: blocks[0].id, type: 'text', content: '' }]);
+ return;
+ }
+ setBlocks(prev => prev.filter(b => b.id !== id));
+ };
+
+ const handleDragStart = (id: string) => setDraggedBlock(id);
+ const handleDragOver = (_id: string) => {};
+ const handleDrop = (targetId: string) => {
+ if (!draggedBlock || draggedBlock === targetId) return;
+ const dragIdx = blocks.findIndex(b => b.id === draggedBlock);
+ const targetIdx = blocks.findIndex(b => b.id === targetId);
+ const newBlocks = [...blocks];
+ const [removed] = newBlocks.splice(dragIdx, 1);
+ newBlocks.splice(targetIdx, 0, removed);
+ setBlocks(newBlocks);
+ setDraggedBlock(null);
+ };
+
+ const handleCoverUpload = (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (file) {
+ const reader = new FileReader();
+ reader.onload = () => setCoverImage(reader.result as string);
+ reader.readAsDataURL(file);
+ }
+ };
+
+ // Handle space key in empty block → show AI panel
+ const handleSpaceKeyEmpty = (blockId: string) => {
+ if (aiPanelBlock === blockId) {
+ // AI panel already open, close it and insert space
+ setAiPanelBlock(null);
+ updateBlock(blockId, ' ');
+ } else {
+ // Open AI panel
+ setSlashMenuBlock(null);
+ setAiPanelBlock(blockId);
+ }
+ };
+
+ // Container-level mouseup: catches selections released anywhere (inside or outside a textarea)
+ const handleEditorMouseUp = useCallback((e: React.MouseEvent) => {
+ // First try to get window selection (supports cross-block selection)
+ const selection = window.getSelection();
+ const selectedText = selection?.toString().trim();
+
+ if (selectedText && selection && selection.rangeCount > 0) {
+ const range = selection.getRangeAt(0);
+ const rect = range.getBoundingClientRect();
+
+ // Check if we have a valid position
+ if (rect.width > 0 && rect.height > 0) {
+ let blockEl = range.startContainer.parentElement?.closest('[data-block-id]');
+ const blockId = blockEl?.getAttribute('data-block-id') ?? '';
+
+ setTextSelection({
+ blockId,
+ text: selectedText,
+ position: {
+ x: Math.max(rect.left, 10),
+ y: Math.max(rect.top - 10, 10)
+ },
+ });
+ return;
+ }
+ }
+
+ // Fallback: check textarea/input selection
+ const active = document.activeElement;
+ if (active instanceof HTMLTextAreaElement || active instanceof HTMLInputElement) {
+ const start = active.selectionStart ?? 0;
+ const end = active.selectionEnd ?? 0;
+ if (end > start) {
+ const text = active.value.substring(start, end).trim();
+ if (text) {
+ const blockEl = active.closest('[data-block-id]');
+ const blockId = blockEl?.getAttribute('data-block-id') ?? '';
+ if (blockId) {
+ setTextSelection({
+ blockId,
+ text,
+ position: {
+ x: Math.max(e.clientX, 10),
+ y: Math.max(e.clientY - 60, 10)
+ },
+ });
+ }
+ }
+ }
+ }
+ }, []);
+
+ // Handle text selection in any block
+ const handleTextSelection = useCallback(
+ (blockId: string, text: string, rect: DOMRect) => {
+ setTextSelection({
+ blockId,
+ text,
+ position: { x: rect.left, y: rect.top },
+ });
+ },
+ []
+ );
+
+ // Close text selection toolbar
+ const handleCloseTextSelection = useCallback(() => {
+ setTextSelection(null);
+ }, []);
+
+ // Show diff preview on the right
+ const handleDiffResult = (originalText: string, revisedText: string) => {
+ if (!textSelection) return;
+ setDiffPreview({ blockId: textSelection.blockId, originalText, revisedText });
+ setTextSelection(null);
+ };
+
+ // Accept the revised text: replace the matching content in the block
+ const handleAcceptDiff = (blockId: string, revisedText: string) => {
+ if (!diffPreview) return;
+ setBlocks(prev =>
+ prev.map(b => {
+ if (b.id !== blockId) return b;
+ // Replace original text with revised text
+ const replaced = b.content.replace(diffPreview.originalText, revisedText);
+ return { ...b, content: replaced !== b.content ? replaced : revisedText };
+ })
+ );
+ setDiffPreview(null);
+ };
+
+ const handleRejectDiff = () => {
+ setDiffPreview(null);
+ };
+
+ // Insert AI text below target block, parsing markdown
+ const handleInsertText = (text: string, blockId: string) => {
+ const newBlocks = parseMarkdownToBlocks(text);
+ const index = blocks.findIndex(b => b.id === blockId);
+ // If the target block is empty, replace it; otherwise insert after it
+ const targetBlock = blocks[index];
+ if (targetBlock && targetBlock.content === '') {
+ setBlocks(prev => [
+ ...prev.slice(0, index),
+ ...newBlocks,
+ ...prev.slice(index + 1),
+ ]);
+ } else {
+ setBlocks(prev => [
+ ...prev.slice(0, index + 1),
+ ...newBlocks,
+ ...prev.slice(index + 1),
+ ]);
+ }
+ setSlashMenuBlock(null);
+ setAiPanelBlock(null);
+ };
+
+ // Insert text below the block containing selected text
+ const handleInsertBelow = (text: string) => {
+ if (!textSelection) {
+ // fallback: append at end
+ const newBlocks = parseMarkdownToBlocks(text);
+ setBlocks(prev => [...prev, ...newBlocks]);
+ return;
+ }
+ handleInsertText(text, textSelection.blockId);
+ setTextSelection(null);
+ };
+
+ /**
+ * Parse markdown text into Block[] — handles:
+ * - headings (# ## ###)
+ * - bullet lists (- * + )
+ * - numbered lists (1. 2. etc.) ← preserves correct ordering
+ * - code blocks (``` ```)
+ * - quotes (>)
+ * - plain text (wraps long lines correctly since we use textarea)
+ */
+ const parseMarkdownToBlocks = (text: string): Block[] => {
+ const lines = text.split('\n');
+ const newBlocks: Block[] = [];
+ let inCodeBlock = false;
+ let codeContent = '';
+ let tableLines: string[] = [];
+
+ const cleanMarkdown = (s: string) => s
+ .replace(/\*\*(.*?)\*\*/g, '$1')
+ .replace(/\*(.*?)\*/g, '$1')
+ .replace(/__(.*?)__/g, '$1')
+ .replace(/_(.*?)_/g, '$1');
+
+ const flushTable = () => {
+ if (tableLines.length > 0) {
+ newBlocks.push({ id: generateId(), type: 'table', content: tableLines.join('\n') });
+ tableLines = [];
+ }
+ };
+
+ lines.forEach(line => {
+ if (line.startsWith('```')) {
+ flushTable();
+ if (inCodeBlock) {
+ newBlocks.push({ id: generateId(), type: 'code', content: codeContent.trim() });
+ codeContent = '';
+ inCodeBlock = false;
+ } else {
+ inCodeBlock = true;
+ }
+ } else if (inCodeBlock) {
+ codeContent += line + '\n';
+ } else if (line.trim().startsWith('|') && line.trim().endsWith('|')) {
+ tableLines.push(line);
+ } else {
+ flushTable();
+ if (line.startsWith('###### ')) {
+ newBlocks.push({ id: generateId(), type: 'heading6', content: cleanMarkdown(line.slice(7)) });
+ } else if (line.startsWith('##### ')) {
+ newBlocks.push({ id: generateId(), type: 'heading5', content: cleanMarkdown(line.slice(6)) });
+ } else if (line.startsWith('#### ')) {
+ newBlocks.push({ id: generateId(), type: 'heading4', content: cleanMarkdown(line.slice(5)) });
+ } else if (line.startsWith('### ')) {
+ newBlocks.push({ id: generateId(), type: 'heading3', content: cleanMarkdown(line.slice(4)) });
+ } else if (line.startsWith('## ')) {
+ newBlocks.push({ id: generateId(), type: 'heading2', content: cleanMarkdown(line.slice(3)) });
+ } else if (line.startsWith('# ')) {
+ newBlocks.push({ id: generateId(), type: 'heading1', content: cleanMarkdown(line.slice(2)) });
+ } else if (line.match(/^\s*[-*+]\s/)) {
+ const trimmed = line.trimStart();
+ newBlocks.push({ id: generateId(), type: 'bulletList', content: cleanMarkdown(trimmed.slice(2)) });
+ } else if (line.match(/^\d+\.\s/)) {
+ const match = line.match(/^(\d+)\.\s+(.*)$/);
+ if (match) {
+ newBlocks.push({ id: generateId(), type: 'numberedList', content: cleanMarkdown(match[2]), number: parseInt(match[1]) });
+ }
+ } else if (line.startsWith('> ')) {
+ newBlocks.push({ id: generateId(), type: 'quote', content: cleanMarkdown(line.slice(2)) });
+ } else if (line.trim() === '---' || line.trim() === '***' || line.trim() === '___') {
+ newBlocks.push({ id: generateId(), type: 'divider', content: '' });
+ } else if (line.trim() === '') {
+ // skip empty lines
+ } else {
+ newBlocks.push({ id: generateId(), type: 'text', content: cleanMarkdown(line) });
+ }
+ }
+ });
+
+ flushTable();
+ if (inCodeBlock && codeContent.trim()) {
+ newBlocks.push({ id: generateId(), type: 'code', content: codeContent.trim() });
+ }
+
+ return newBlocks.length > 0 ? newBlocks : [{ id: generateId(), type: 'text', content: text }];
+ };
+
+ const getNoteContext = () => `Title: ${title}\n\n${blocksToMarkdown()}`;
+
+ const blocksToMarkdown = (): string => {
+ // Calculate numbered list indices
+ let numIdx = 0;
+ return blocks
+ .map(block => {
+ if (block.type !== 'numberedList') numIdx = 0;
+ switch (block.type) {
+ case 'heading1': return `# ${block.content}\n`;
+ case 'heading2': return `## ${block.content}\n`;
+ case 'heading3': return `### ${block.content}\n`;
+ case 'heading4': return `#### ${block.content}\n`;
+ case 'heading5': return `##### ${block.content}\n`;
+ case 'heading6': return `###### ${block.content}\n`;
+ case 'bulletList': return `- ${block.content}\n`;
+ case 'numberedList': {
+ numIdx++;
+ return `${numIdx}. ${block.content}\n`;
+ }
+ case 'todo': return `- [${block.checked ? 'x' : ' '}] ${block.content}\n`;
+ case 'quote': return `> ${block.content}\n`;
+ case 'code': return `\`\`\`\n${block.content}\n\`\`\`\n`;
+ case 'divider': return `---\n`;
+ default: return `${block.content}\n\n`;
+ }
+ })
+ .join('');
+ };
+
+ // Calculate numbered list index, only counting consecutive numberedList blocks
+ const getNumberedIndex = (blockIndex: number): number => {
+ let count = 0;
+ for (let i = blockIndex; i >= 0; i--) {
+ const t = blocks[i].type;
+ if (t === 'numberedList') {
+ count++;
+ } else {
+ // Stop at any non-numberedList block
+ break;
+ }
+ }
+ return count;
+ };
+
+ const handleSave = async () => {
+ if (!notebook?.id) return;
+ setSaving(true);
+ try {
+ const content = blocksToMarkdown();
+ const mdContent = coverImage
+ ? `\n\n# ${title}\n\n${content}`
+ : `# ${title}\n\n${content}`;
+ const blob = new Blob([mdContent], { type: 'text/markdown' });
+ const fileName = `${title}_${Date.now()}.md`;
+ const file = new File([blob], fileName, { type: 'text/markdown' });
+
+ const formData = new FormData();
+ formData.append('file', file);
+ formData.append('email', user?.email || user?.id || 'default');
+ formData.append('user_id', user?.id || 'default');
+ formData.append('notebook_id', notebook.id);
+ formData.append('notebook_title', notebook?.title || notebook?.name || '');
+
+ const res = await apiFetch('/api/v1/kb/upload', { method: 'POST', body: formData });
+ if (!res.ok) throw new Error('Save failed');
+
+ alert('Note saved to knowledge base!');
+ onSaved?.({ title, fileName });
+ onClose();
+ } catch {
+ alert('Failed to save note');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleExport = () => {
+ const content = blocksToMarkdown();
+ const mdContent = coverImage
+ ? `\n\n# ${title}\n\n${content}`
+ : `# ${title}\n\n${content}`;
+ const blob = new Blob([mdContent], { type: 'text/markdown' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `${title}.md`;
+ a.click();
+ URL.revokeObjectURL(url);
+ };
+
+ return (
+
+ {/* Toolbar */}
+
+
+ coverInputRef.current?.click()}
+ className="p-2 hover:bg-gray-100 rounded group relative"
+ title="Add Cover"
+ >
+
+
+ Click to add cover image
+
+
+
+
+ setIsFullScreen(!isFullScreen)}
+ className="p-2 hover:bg-gray-100 rounded"
+ title={isFullScreen ? 'Exit Full Screen' : 'Full Screen'}
+ >
+ {isFullScreen ? : }
+
+
+ Export
+
+
+ {saving ? 'Saving...' : 'Save'}
+
+
+
+
+
+
+
+
+
+ {/* Cover Image */}
+ {coverImage && (
+
+
+
setCoverImage(null)}
+ className="absolute top-2 right-2 bg-red-500 text-white px-2 py-1 rounded text-xs"
+ >
+ Remove
+
+
+ )}
+
+ {/* Main body: editing area + optional diff panel */}
+
+ {/* Left: editing area */}
+
+
setTitle(e.target.value)}
+ className="text-4xl font-bold outline-none w-full mb-4 bg-transparent"
+ placeholder="Untitled"
+ />
+
+ {blocks.map((block, blockIndex) => (
+
+
{
+ setAiPanelBlock(null);
+ setSlashMenuBlock(id);
+ }}
+ onSpaceKeyEmpty={handleSpaceKeyEmpty}
+ onTextSelection={handleTextSelection}
+ showSlashMenu={slashMenuBlock === block.id}
+ onDragStart={handleDragStart}
+ onDragOver={handleDragOver}
+ onDrop={handleDrop}
+ onToggleTodo={toggleTodo}
+ numberedIndex={
+ block.type === 'numberedList' ? getNumberedIndex(blockIndex) : undefined
+ }
+ />
+
+ {/* Slash menu */}
+ {slashMenuBlock === block.id && (
+ changeBlockType(block.id, type)}
+ onClose={() => setSlashMenuBlock(null)}
+ onInsertText={text => handleInsertText(text, block.id)}
+ noteContext={getNoteContext()}
+ user={user}
+ />
+ )}
+
+ {/* AI panel (space triggered) */}
+ {aiPanelBlock === block.id && (
+ setAiPanelBlock(null)}
+ onUpdateMemory={setNoteMemory}
+ />
+ )}
+
+ ))}
+
+
+ {/* Right: diff preview panel */}
+ {diffPreview && (
+
+ )}
+
+
+ {/* Floating text selection toolbar (fixed positioning) */}
+ {textSelection && (
+
+ )}
+
+ );
+};
diff --git a/frontend_en/src/components/notes/SlashMenu.tsx b/frontend_en/src/components/notes/SlashMenu.tsx
new file mode 100644
index 0000000..3c1feac
--- /dev/null
+++ b/frontend_en/src/components/notes/SlashMenu.tsx
@@ -0,0 +1,78 @@
+import React, { useState, useEffect } from 'react';
+import { BlockType } from './types';
+import {
+ Heading1, Heading2, Heading3, List, ListOrdered, CheckSquare,
+ Quote, Code, Minus, Image, FileSpreadsheet, Video, Type
+} from 'lucide-react';
+
+interface SlashMenuProps {
+ onSelect: (type: BlockType) => void;
+ onClose: () => void;
+ // kept for API compatibility but unused here
+ onInsertText?: (text: string) => void;
+ noteContext?: string;
+ user?: any;
+}
+
+const commands = [
+ { type: 'text' as BlockType, label: 'Text', icon: Type, desc: 'Plain text' },
+ { type: 'heading1' as BlockType, label: 'Heading 1', icon: Heading1, desc: 'Big section heading' },
+ { type: 'heading2' as BlockType, label: 'Heading 2', icon: Heading2, desc: 'Medium section heading' },
+ { type: 'heading3' as BlockType, label: 'Heading 3', icon: Heading3, desc: 'Small section heading' },
+ { type: 'bulletList' as BlockType, label: 'Bullet List', icon: List, desc: 'Create a simple list' },
+ { type: 'numberedList' as BlockType, label: 'Numbered List', icon: ListOrdered, desc: 'Create a numbered list' },
+ { type: 'todo' as BlockType, label: 'To-do', icon: CheckSquare, desc: 'Track tasks with a checkbox' },
+ { type: 'quote' as BlockType, label: 'Quote', icon: Quote, desc: 'Capture a quote' },
+ { type: 'code' as BlockType, label: 'Code', icon: Code, desc: 'Capture a code snippet' },
+ { type: 'divider' as BlockType, label: 'Divider', icon: Minus, desc: 'Visually divide blocks' },
+ { type: 'image' as BlockType, label: 'Image', icon: Image, desc: 'Embed an image' },
+ { type: 'excel' as BlockType, label: 'Excel', icon: FileSpreadsheet, desc: 'Embed an Excel file' },
+ { type: 'video' as BlockType, label: 'Video', icon: Video, desc: 'Embed a video' },
+];
+
+export const SlashMenu: React.FC = ({ onSelect, onClose }) => {
+ const [selectedIndex, setSelectedIndex] = useState(0);
+
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ setSelectedIndex(prev => (prev + 1) % commands.length);
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ setSelectedIndex(prev => (prev - 1 + commands.length) % commands.length);
+ } else if (e.key === 'Enter') {
+ e.preventDefault();
+ onSelect(commands[selectedIndex].type);
+ } else if (e.key === 'Escape') {
+ e.preventDefault();
+ onClose();
+ }
+ };
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [selectedIndex, onSelect, onClose]);
+
+ return (
+
+
+ Block types
+
+ {commands.map((cmd, idx) => (
+
onSelect(cmd.type)}
+ className={`w-full px-3 py-2 text-left flex items-start gap-3 transition-colors ${
+ idx === selectedIndex ? 'bg-blue-50' : 'hover:bg-gray-50'
+ }`}
+ >
+
+
+
{cmd.label}
+
{cmd.desc}
+
+
+ ))}
+
+ );
+};
diff --git a/frontend_en/src/components/notes/TextSelectionToolbar.tsx b/frontend_en/src/components/notes/TextSelectionToolbar.tsx
new file mode 100644
index 0000000..41a4fae
--- /dev/null
+++ b/frontend_en/src/components/notes/TextSelectionToolbar.tsx
@@ -0,0 +1,352 @@
+import React, { useState, useRef, useEffect } from 'react';
+import { Sparkles, BookOpen, AlignLeft, ChevronDown, Send, Copy, X } from 'lucide-react';
+import { apiFetch } from '../../config/api';
+import { getApiSettings } from '../../services/apiSettingsService';
+import type { KnowledgeFile } from '../../types';
+
+interface TextSelectionToolbarProps {
+ selectedText: string;
+ position: { x: number; y: number };
+ files: KnowledgeFile[];
+ user: any;
+ noteMemory: string;
+ noteContext: string;
+ onDiffResult: (originalText: string, revisedText: string) => void;
+ onInsertBelow: (text: string) => void;
+ onUpdateMemory: (memory: string) => void;
+ onClose: () => void;
+}
+
+const POLISH_STYLES = [
+ { label: 'Formal', prompt: 'Rewrite the following text in a formal, professional tone. Return only the rewritten text:' },
+ { label: 'Casual', prompt: 'Rewrite the following text in a friendly, casual conversational tone. Return only the rewritten text:' },
+ { label: 'Concise', prompt: 'Rewrite the following text more concisely, keeping only the essential information. Return only the rewritten text:' },
+ { label: 'Academic', prompt: 'Rewrite the following text in a scholarly, academic writing style. Return only the rewritten text:' },
+ { label: 'Creative', prompt: 'Rewrite the following text in a more engaging, vivid, and creative style. Return only the rewritten text:' },
+];
+
+const ORGANIZE_PRESETS = [
+ { label: 'Summarize key points', prompt: 'Summarize the key points of the following text into a structured list:' },
+ { label: 'Organize by theme', prompt: 'Organize the following content by main themes with clear section headings:' },
+ { label: 'Create bullet points', prompt: 'Convert the following text into a clear, structured bullet-point list:' },
+ { label: 'Create an outline', prompt: 'Create a structured hierarchical outline from the following content:' },
+ { label: 'Expand and elaborate', prompt: 'Expand on the following text with more detail, examples, and depth:' },
+];
+
+type ToolbarMode = 'toolbar' | 'polish' | 'understand' | 'organize' | 'result-understand' | 'result-organize';
+
+export const TextSelectionToolbar: React.FC = ({
+ selectedText,
+ position,
+ files,
+ user,
+ noteMemory,
+ noteContext,
+ onDiffResult,
+ onInsertBelow,
+ onUpdateMemory,
+ onClose,
+}) => {
+ const [mode, setMode] = useState('toolbar');
+ const [selectedFileIds, setSelectedFileIds] = useState>(new Set());
+ const [customPrompt, setCustomPrompt] = useState('');
+ const [loading, setLoading] = useState(false);
+ const [resultText, setResultText] = useState('');
+ const toolbarRef = useRef(null);
+
+ // Close on outside click
+ useEffect(() => {
+ const handleMouseDown = (e: MouseEvent) => {
+ if (toolbarRef.current && !toolbarRef.current.contains(e.target as Node)) {
+ onClose();
+ }
+ };
+ setTimeout(() => document.addEventListener('mousedown', handleMouseDown), 100);
+ return () => document.removeEventListener('mousedown', handleMouseDown);
+ }, [onClose]);
+
+ const callAI = async (instruction: string): Promise => {
+ const apiSettings = getApiSettings(user?.id || null);
+ const selectedFilePaths = files
+ .filter(f => selectedFileIds.has(f.id))
+ .map(f => f.url)
+ .filter((url): url is string => Boolean(url));
+
+ const systemContext = noteMemory
+ ? `You are a helpful writing assistant.\n\nContext:\n${noteMemory}`
+ : `You are a helpful writing assistant.`;
+
+ const prompt = `${systemContext}\n\n${instruction}\n\n"${selectedText}"\n\nReturn only the processed result without any explanation or preamble.`;
+
+ const res = await apiFetch('/api/v1/kb/chat', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ files: selectedFilePaths,
+ query: prompt,
+ history: [],
+ api_key: apiSettings?.apiKey?.trim() || undefined,
+ api_url: apiSettings?.apiUrl?.trim() || undefined,
+ }),
+ });
+ const data = await res.json();
+ return data.answer || data.response || 'No response';
+ };
+
+ const handlePolish = async (stylePrompt: string, styleName: string) => {
+ setLoading(true);
+ try {
+ const result = await callAI(stylePrompt);
+ onDiffResult(selectedText, result);
+ // Update memory
+ const entry = `- Polish (${styleName}): "${selectedText.slice(0, 60)}..." → "${result.slice(0, 60)}..."`;
+ onUpdateMemory(noteMemory ? `${noteMemory}\n${entry}`.slice(-2000) : `## Operations\n${entry}`);
+ onClose();
+ } catch {
+ setLoading(false);
+ }
+ };
+
+ const handleUnderstand = async () => {
+ setMode('understand');
+ setLoading(true);
+ try {
+ const result = await callAI(
+ 'Please explain and analyze the following text clearly and insightfully. What does it mean? What are the key ideas and implications?'
+ );
+ setResultText(result);
+ setMode('result-understand');
+ const entry = `- Understand: "${selectedText.slice(0, 60)}..."`;
+ onUpdateMemory(noteMemory ? `${noteMemory}\n${entry}`.slice(-2000) : `## Operations\n${entry}`);
+ } catch {
+ setResultText('Failed to get AI response.');
+ setMode('result-understand');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleOrganize = async (prompt: string) => {
+ setLoading(true);
+ try {
+ const result = await callAI(prompt);
+ setResultText(result);
+ setMode('result-organize');
+ const entry = `- Organize: "${selectedText.slice(0, 60)}..."`;
+ onUpdateMemory(noteMemory ? `${noteMemory}\n${entry}`.slice(-2000) : `## Operations\n${entry}`);
+ } catch {
+ setResultText('Failed to get AI response.');
+ setMode('result-organize');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const toggleFile = (fileId: string) => {
+ setSelectedFileIds(prev => {
+ const next = new Set(prev);
+ if (next.has(fileId)) next.delete(fileId);
+ else next.add(fileId);
+ return next;
+ });
+ };
+
+ // Clamp position so toolbar stays on screen
+ const left = Math.min(Math.max(position.x, 8), window.innerWidth - 320);
+ const top = Math.max(position.y - 48, 8);
+
+ const SourceSelector = () =>
+ files.length > 0 ? (
+
+
Sources (optional):
+
+ {files.map(f => (
+ toggleFile(f.id)}
+ className={`px-2 py-0.5 text-xs rounded-full border transition-all ${
+ selectedFileIds.has(f.id)
+ ? 'bg-blue-500 text-white border-blue-500'
+ : 'bg-white text-gray-500 border-gray-200 hover:border-blue-300'
+ }`}
+ >
+ {f.name || f.id}
+
+ ))}
+
+
+ ) : null;
+
+ return (
+
+ {/* Header */}
+ {mode !== 'toolbar' && (
+
+ setMode('toolbar')}
+ className="text-xs text-gray-500 hover:text-gray-700 flex items-center gap-1"
+ >
+ ← Back
+
+
+
+
+
+ )}
+
+ {/* Main toolbar */}
+ {mode === 'toolbar' && (
+
+
setMode('polish')}
+ className="flex items-center gap-1.5 px-3 py-2 text-sm hover:bg-purple-50 text-purple-600 rounded-lg font-medium transition-colors"
+ >
+ Polish
+
+
+
+ Understand
+
+
+
setMode('organize')}
+ className="flex items-center gap-1.5 px-3 py-2 text-sm hover:bg-green-50 text-green-600 rounded-lg font-medium transition-colors"
+ >
+ Organize
+
+
+
+
+
+
+ )}
+
+ {/* Polish style selection */}
+ {mode === 'polish' && (
+
+
+
+
+ Choose polish style
+
+ {POLISH_STYLES.map(s => (
+
handlePolish(s.prompt, s.label)}
+ disabled={loading}
+ className="w-full flex items-center gap-2 px-3 py-2 text-sm text-left hover:bg-purple-50 text-purple-700 rounded-lg disabled:opacity-50 transition-colors"
+ >
+ {loading ? (
+
+ ) : (
+
+ )}
+ {s.label}
+
+ ))}
+
+
+ )}
+
+ {/* Organize options */}
+ {mode === 'organize' && (
+
+
+
+
+ Organize as
+
+ {ORGANIZE_PRESETS.map(p => (
+
handleOrganize(p.prompt)}
+ disabled={loading}
+ className="w-full flex items-center gap-2 px-3 py-2 text-sm text-left hover:bg-green-50 text-green-700 rounded-lg disabled:opacity-50 transition-colors"
+ >
+ {loading ? (
+
+ ) : (
+
+ )}
+ {p.label}
+
+ ))}
+
+ setCustomPrompt(e.target.value)}
+ onKeyDown={e => e.key === 'Enter' && customPrompt.trim() && handleOrganize(customPrompt)}
+ placeholder="Custom instruction..."
+ className="flex-1 px-2.5 py-1.5 text-xs border border-gray-200 rounded-lg outline-none focus:border-green-400"
+ />
+ customPrompt.trim() && handleOrganize(customPrompt)}
+ disabled={loading || !customPrompt.trim()}
+ className="px-2.5 py-1.5 bg-green-500 text-white rounded-lg text-xs disabled:opacity-40 hover:bg-green-600 transition-colors"
+ >
+
+
+
+
+
+ )}
+
+ {/* Loading state for understand */}
+ {mode === 'understand' && loading && (
+
+ )}
+
+ {/* Result: Understand */}
+ {mode === 'result-understand' && (
+
+
+ AI Understanding
+
+
+ {resultText}
+
+
{
+ onInsertBelow(resultText);
+ onClose();
+ }}
+ className="mt-2.5 px-3 py-1.5 bg-green-500 text-white rounded-lg text-xs hover:bg-green-600 flex items-center gap-1 transition-colors"
+ >
+ Paste to note
+
+
+ )}
+
+ {/* Result: Organize */}
+ {mode === 'result-organize' && (
+
+
+
+ {resultText}
+
+
{
+ onInsertBelow(resultText);
+ onClose();
+ }}
+ className="mt-2.5 px-3 py-1.5 bg-green-500 text-white rounded-lg text-xs hover:bg-green-600 flex items-center gap-1 transition-colors"
+ >
+ Paste to note
+
+
+ )}
+
+ );
+};
diff --git a/frontend_en/src/components/notes/types.ts b/frontend_en/src/components/notes/types.ts
new file mode 100644
index 0000000..8a406f0
--- /dev/null
+++ b/frontend_en/src/components/notes/types.ts
@@ -0,0 +1,37 @@
+export type BlockType =
+ | 'text'
+ | 'heading1'
+ | 'heading2'
+ | 'heading3'
+ | 'heading4'
+ | 'heading5'
+ | 'heading6'
+ | 'bulletList'
+ | 'numberedList'
+ | 'todo'
+ | 'quote'
+ | 'code'
+ | 'divider'
+ | 'table'
+ | 'image'
+ | 'excel'
+ | 'video';
+
+export interface Block {
+ id: string;
+ type: BlockType;
+ content: string;
+ checked?: boolean;
+ url?: string;
+ scale?: number;
+ number?: number;
+}
+
+export interface NoteDocument {
+ id: string;
+ title: string;
+ coverImage?: string;
+ blocks: Block[];
+ createdAt: string;
+ updatedAt: string;
+}
diff --git a/frontend_en/src/components/quiz/QuizContainer.tsx b/frontend_en/src/components/quiz/QuizContainer.tsx
index cd1d5f2..92ee5b6 100644
--- a/frontend_en/src/components/quiz/QuizContainer.tsx
+++ b/frontend_en/src/components/quiz/QuizContainer.tsx
@@ -1,4 +1,5 @@
import React, { useState } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
import { QuizQuestion } from './QuizQuestion';
import { QuizResults } from './QuizResults';
import { QuizReview } from './QuizReview';
@@ -25,6 +26,8 @@ interface QuizContainerProps {
type QuizState = 'taking' | 'results' | 'review';
+const springTransition = { type: 'spring', stiffness: 300, damping: 30 };
+
export const QuizContainer: React.FC = ({
questions,
onClose,
@@ -32,9 +35,11 @@ export const QuizContainer: React.FC = ({
const [currentIndex, setCurrentIndex] = useState(0);
const [userAnswers, setUserAnswers] = useState>({});
const [quizState, setQuizState] = useState('taking');
+ const [direction, setDirection] = useState(1);
const currentQuestion = questions[currentIndex];
const currentAnswer = userAnswers[currentQuestion?.id] || null;
+ const progress = ((currentIndex + 1) / questions.length) * 100;
const handleSelectAnswer = (answer: string) => {
setUserAnswers({
@@ -53,15 +58,16 @@ export const QuizContainer: React.FC = ({
const handleNext = () => {
if (currentIndex < questions.length - 1) {
+ setDirection(1);
setCurrentIndex(currentIndex + 1);
} else {
- // 最后一题,显示结果
setQuizState('results');
}
};
const handlePrevious = () => {
if (currentIndex > 0) {
+ setDirection(-1);
setCurrentIndex(currentIndex - 1);
}
};
@@ -76,7 +82,6 @@ export const QuizContainer: React.FC = ({
setQuizState('review');
};
- // 计算统计数据
const calculateStats = () => {
let correct = 0;
let wrong = 0;
@@ -96,7 +101,6 @@ export const QuizContainer: React.FC = ({
return { correct, wrong, skipped };
};
- // 结果页面
if (quizState === 'results') {
const stats = calculateStats();
return (
@@ -111,7 +115,6 @@ export const QuizContainer: React.FC = ({
);
}
- // 复习页面
if (quizState === 'review') {
return (
= ({
);
}
- // 答题页面
return (
- {/* Progress */}
+ {/* iOS Progress Bar */}
-
+
Question {currentIndex + 1} of {questions.length}
-
- {/* Question */}
-
-
-
+ {/* Question with spring transition */}
+
+ 0 ? 30 : -30, opacity: 0 }}
+ animate={{ x: 0, opacity: 1 }}
+ exit={{ x: direction > 0 ? -30 : 30, opacity: 0 }}
+ transition={springTransition}
+ className="bg-white border border-ios-gray-100 rounded-ios-lg p-6 mb-6 shadow-ios-sm"
+ >
+
+
+
{/* Navigation */}
-
Previous
-
+
-
Skip
-
+
-
{currentIndex === questions.length - 1 ? 'Finish' : 'Next'}
-
+
);
diff --git a/frontend_en/src/components/quiz/QuizQuestion.tsx b/frontend_en/src/components/quiz/QuizQuestion.tsx
index ad3ec2f..8bb1d11 100644
--- a/frontend_en/src/components/quiz/QuizQuestion.tsx
+++ b/frontend_en/src/components/quiz/QuizQuestion.tsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { CheckCircle, XCircle, Circle } from 'lucide-react';
+import { motion } from 'framer-motion';
interface QuizOption {
label: string;
@@ -27,61 +27,88 @@ export const QuizQuestion: React.FC
= ({
}) => {
const getOptionStyle = (optionLabel: string) => {
if (!showResult) {
- // 答题模式
if (selectedAnswer === optionLabel) {
- return 'border-blue-500 bg-blue-50';
+ return 'border-primary bg-primary/5 shadow-ios-sm';
}
- return 'border-gray-300 hover:border-blue-400 hover:bg-gray-50';
+ return 'border-ios-gray-200 hover:border-primary/40 hover:bg-ios-gray-50';
} else {
- // 结果展示模式
if (optionLabel === correctAnswer) {
- return 'border-green-500 bg-green-50';
+ return 'border-green-500 bg-green-50 shadow-ios-sm';
}
if (selectedAnswer === optionLabel && !isCorrect) {
return 'border-red-500 bg-red-50';
}
- return 'border-gray-300 bg-gray-50';
+ return 'border-ios-gray-200 bg-ios-gray-50';
}
};
- const getOptionIcon = (optionLabel: string) => {
+ const getRadioStyle = (optionLabel: string) => {
if (!showResult) {
- return selectedAnswer === optionLabel ? (
-
- ) : (
-
- );
+ if (selectedAnswer === optionLabel) {
+ return (
+
+
+
+
+
+ );
+ }
+ return
;
} else {
if (optionLabel === correctAnswer) {
- return ;
+ return (
+
+
+
+
+
+ );
}
if (selectedAnswer === optionLabel && !isCorrect) {
- return ;
+ return (
+
+ );
}
- return ;
+ return
;
}
};
return (
-
{question}
+
{question}
{options.map((option) => (
-
!showResult && onSelectAnswer(option.label)}
disabled={showResult}
- className={`w-full p-4 rounded-lg border-2 transition-all text-left flex items-start gap-3 ${getOptionStyle(option.label)} ${
+ className={`w-full p-4 rounded-ios-lg border-2 transition-all text-left flex items-start gap-3 ${getOptionStyle(option.label)} ${
showResult ? 'cursor-default' : 'cursor-pointer'
}`}
>
- {getOptionIcon(option.label)}
+ {getRadioStyle(option.label)}
- {option.label}. {' '}
- {option.text}
+ {option.label}. {' '}
+ {option.text}
-
+
))}
diff --git a/frontend_en/src/components/quiz/QuizResults.tsx b/frontend_en/src/components/quiz/QuizResults.tsx
index 3c8ce08..c4e6ec1 100644
--- a/frontend_en/src/components/quiz/QuizResults.tsx
+++ b/frontend_en/src/components/quiz/QuizResults.tsx
@@ -1,5 +1,6 @@
import React from 'react';
-import { CheckCircle, XCircle, SkipForward, RotateCcw, Eye } from 'lucide-react';
+import { motion } from 'framer-motion';
+import { RotateCcw, Eye } from 'lucide-react';
interface QuizResultsProps {
totalQuestions: number;
@@ -21,101 +22,133 @@ export const QuizResults: React.FC = ({
const percentage = Math.round((correctCount / totalQuestions) * 100);
const getScoreColor = () => {
+ if (percentage >= 80) return '#34C759';
+ if (percentage >= 60) return '#FF9500';
+ return '#FF3B30';
+ };
+
+ const getScoreTextColor = () => {
if (percentage >= 80) return 'text-green-600';
- if (percentage >= 60) return 'text-yellow-600';
- return 'text-red-600';
+ if (percentage >= 60) return 'text-orange-500';
+ return 'text-red-500';
};
const getScoreMessage = () => {
- if (percentage >= 80) return 'Excellent! 🎉';
- if (percentage >= 60) return 'Good job! 👍';
- return 'Keep practicing! 💪';
+ if (percentage >= 80) return 'Excellent!';
+ if (percentage >= 60) return 'Good job!';
+ return 'Keep practicing!';
};
+ const circumference = 2 * Math.PI * 88;
+
return (
{/* Score Display */}
-
Quiz Complete!
-
{getScoreMessage()}
+
+ Quiz Complete!
+
+
+ {getScoreMessage()}
+
{/* Score Circle */}
-
+
- = 80 ? '#10b981' : percentage >= 60 ? '#f59e0b' : '#ef4444'}
- strokeWidth="12"
+ stroke={getScoreColor()}
+ strokeWidth="10"
fill="none"
- strokeDasharray={`${2 * Math.PI * 88}`}
- strokeDashoffset={`${2 * Math.PI * 88 * (1 - percentage / 100)}`}
strokeLinecap="round"
- className="transition-all duration-1000"
+ initial={{ strokeDasharray: circumference, strokeDashoffset: circumference }}
+ animate={{ strokeDashoffset: circumference * (1 - percentage / 100) }}
+ transition={{ type: 'spring', stiffness: 60, damping: 15, delay: 0.3 }}
/>
-
+
{percentage}%
-
-
+
+
{correctCount}/{totalQuestions}
-
+
{/* Statistics */}
-
-
-
-
{correctCount}
-
Correct
-
-
-
-
-
{wrongCount}
-
Wrong
-
-
-
-
-
{skippedCount}
-
Skipped
-
+
+ {[
+ { label: 'Correct', count: correctCount, color: 'text-green-600', bg: 'bg-green-50', border: 'border-green-100' },
+ { label: 'Wrong', count: wrongCount, color: 'text-red-500', bg: 'bg-red-50', border: 'border-red-100' },
+ { label: 'Skipped', count: skippedCount, color: 'text-ios-gray-500', bg: 'bg-ios-gray-50', border: 'border-ios-gray-100' },
+ ].map((stat, idx) => (
+
+ {stat.count}
+ {stat.label}
+
+ ))}
{/* Action Buttons */}
-
-
+
Review Quiz
-
+
-
Retake Quiz
-
+
);
diff --git a/frontend_en/src/components/ui/Badge.tsx b/frontend_en/src/components/ui/Badge.tsx
new file mode 100644
index 0000000..fecccd7
--- /dev/null
+++ b/frontend_en/src/components/ui/Badge.tsx
@@ -0,0 +1,131 @@
+import React from 'react';
+
+type BadgeVariant = 'default' | 'accent' | 'success' | 'warning' | 'error' | 'neutral';
+type BadgeSize = 'sm' | 'md' | 'lg';
+
+interface BadgeProps {
+ children: React.ReactNode;
+ variant?: BadgeVariant;
+ size?: BadgeSize;
+ className?: string;
+ icon?: React.ReactNode;
+ removable?: boolean;
+ onRemove?: () => void;
+}
+
+/**
+ * Editorial Workspace Badge Component
+ *
+ * Features:
+ * - Multiple variants for different semantic meanings
+ * - Clean, minimal pill-shaped design
+ * - Optional icon support
+ * - Optional removable (close button)
+ * - Warm editorial color palette
+ * - Proper accessibility (aria-label for remove button)
+ */
+export const Badge: React.FC
= ({
+ children,
+ variant = 'default',
+ size = 'md',
+ className = '',
+ icon,
+ removable = false,
+ onRemove,
+}) => {
+ // Base styles
+ const baseStyles =
+ 'inline-flex items-center font-sans font-medium rounded-full transition-colors';
+
+ // Variant styles
+ const variantStyles: Record = {
+ default: 'bg-neutral-100 text-neutral-700 border border-neutral-200',
+ accent: 'bg-accent-100 text-accent-700 border border-accent-200',
+ success: 'bg-success-50 text-success-600 border border-success-600/20',
+ warning: 'bg-warning-50 text-warning-600 border border-warning-600/20',
+ error: 'bg-error-50 text-error-600 border border-error-600/20',
+ neutral: 'bg-neutral-800 text-white',
+ };
+
+ // Size styles
+ const sizeStyles: Record = {
+ sm: 'px-2 py-0.5 text-xs gap-1',
+ md: 'px-2.5 py-1 text-sm gap-1.5',
+ lg: 'px-3 py-1.5 text-base gap-2',
+ };
+
+ // Icon size based on badge size
+ const iconSizeMap = {
+ sm: 12,
+ md: 14,
+ lg: 16,
+ };
+
+ // Combine all styles
+ const combinedStyles = `${baseStyles} ${variantStyles[variant]} ${sizeStyles[size]} ${className}`;
+
+ return (
+
+ {icon && (
+
+ {icon}
+
+ )}
+ {children}
+ {removable && onRemove && (
+ {
+ e.stopPropagation();
+ onRemove();
+ }}
+ className="shrink-0 ml-0.5 rounded-full hover:bg-black/10 transition-colors"
+ aria-label="移除标签"
+ style={{ padding: 2 }}
+ >
+
+
+
+
+ )}
+
+ );
+};
+
+Badge.displayName = 'Badge';
+
+/**
+ * Badge Group Component
+ * For displaying multiple badges with consistent spacing
+ */
+interface BadgeGroupProps {
+ children: React.ReactNode;
+ className?: string;
+}
+
+export const BadgeGroup: React.FC = ({
+ children,
+ className = '',
+}) => {
+ return (
+
+ {children}
+
+ );
+};
+
+BadgeGroup.displayName = 'BadgeGroup';
diff --git a/frontend_en/src/components/ui/Button.tsx b/frontend_en/src/components/ui/Button.tsx
new file mode 100644
index 0000000..4006c4e
--- /dev/null
+++ b/frontend_en/src/components/ui/Button.tsx
@@ -0,0 +1,62 @@
+import React from 'react';
+import { motion, HTMLMotionProps } from 'framer-motion';
+
+type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'accent';
+type ButtonSize = 'sm' | 'md' | 'lg';
+
+interface ButtonProps extends Omit, 'size'> {
+ variant?: ButtonVariant;
+ size?: ButtonSize;
+ children: React.ReactNode;
+ className?: string;
+}
+
+/**
+ * Editorial Workspace Button Component
+ *
+ * Features:
+ * - Lifted shadow effect (signature editorial style)
+ * - Warm neutral palette with coral accent
+ * - Refined typography and spacing
+ * - Smooth hover/tap animations
+ * - Accessible focus states
+ */
+export const Button = React.forwardRef(
+ ({ variant = 'primary', size = 'md', children, className = '', disabled, ...props }, ref) => {
+ // Base styles - shared across all variants
+ const baseStyles = 'inline-flex items-center justify-center font-sans font-medium tracking-tight transition-all duration-200 focus-editorial disabled:opacity-40 disabled:cursor-not-allowed disabled:shadow-none';
+
+ // Variant styles
+ const variantStyles: Record = {
+ primary: 'bg-neutral-900 text-white hover:bg-neutral-800 lifted-hover',
+ secondary: 'bg-neutral-100 text-neutral-900 hover:bg-neutral-200 border border-neutral-200',
+ ghost: 'bg-transparent text-neutral-700 hover:bg-neutral-50 border border-neutral-300 hover:border-neutral-400',
+ accent: 'bg-accent-500 text-white hover:bg-accent-600 lifted-hover',
+ };
+
+ // Size styles
+ const sizeStyles: Record = {
+ sm: 'px-3 py-1.5 text-sm rounded-md gap-1.5',
+ md: 'px-4 py-2.5 text-base rounded-lg gap-2',
+ lg: 'px-6 py-3 text-lg rounded-lg gap-2.5',
+ };
+
+ // Combine all styles
+ const combinedStyles = `${baseStyles} ${variantStyles[variant]} ${sizeStyles[size]} ${className}`;
+
+ return (
+
+ {children}
+
+ );
+ }
+);
+
+Button.displayName = 'Button';
diff --git a/frontend_en/src/components/ui/Card.tsx b/frontend_en/src/components/ui/Card.tsx
new file mode 100644
index 0000000..f23226c
--- /dev/null
+++ b/frontend_en/src/components/ui/Card.tsx
@@ -0,0 +1,159 @@
+import React from 'react';
+import { motion, HTMLMotionProps } from 'framer-motion';
+
+interface CardProps extends HTMLMotionProps<"div"> {
+ children: React.ReactNode;
+ variant?: 'default' | 'elevated' | 'outlined';
+ padding?: 'none' | 'sm' | 'md' | 'lg';
+ className?: string;
+ interactive?: boolean;
+}
+
+/**
+ * Editorial Workspace Card Component
+ *
+ * Features:
+ * - Clean, minimal container styling
+ * - Lifted shadow effect for editorial feel
+ * - Multiple variants (default, elevated, outlined)
+ * - Flexible padding options
+ * - Optional interactive state (hover/tap animations)
+ * - Warm neutral backgrounds
+ */
+export const Card = React.forwardRef(
+ (
+ {
+ children,
+ variant = 'default',
+ padding = 'md',
+ className = '',
+ interactive = false,
+ ...props
+ },
+ ref
+ ) => {
+ // Base styles
+ const baseStyles = 'bg-white rounded-xl transition-all duration-200';
+
+ // Variant styles
+ const variantStyles = {
+ default: 'border border-neutral-200',
+ elevated: 'lifted',
+ outlined: 'border-2 border-neutral-300',
+ };
+
+ // Padding styles
+ const paddingStyles = {
+ none: '',
+ sm: 'p-3',
+ md: 'p-4',
+ lg: 'p-6',
+ };
+
+ // Interactive styles
+ const interactiveStyles = interactive
+ ? 'cursor-pointer hover:border-neutral-300 hover:shadow-md'
+ : '';
+
+ // Combine all styles
+ const combinedStyles = `${baseStyles} ${variantStyles[variant]} ${paddingStyles[padding]} ${interactiveStyles} ${className}`;
+
+ if (interactive) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ return (
+
+ {children}
+
+ );
+ }
+);
+
+Card.displayName = 'Card';
+
+/**
+ * Card Header Component
+ * For consistent card header styling
+ */
+interface CardHeaderProps {
+ title: string;
+ subtitle?: string;
+ action?: React.ReactNode;
+ className?: string;
+}
+
+export const CardHeader: React.FC = ({
+ title,
+ subtitle,
+ action,
+ className = '',
+}) => {
+ return (
+
+
+
+ {title}
+
+ {subtitle && (
+
{subtitle}
+ )}
+
+ {action &&
{action}
}
+
+ );
+};
+
+CardHeader.displayName = 'CardHeader';
+
+/**
+ * Card Content Component
+ * For consistent card content spacing
+ */
+interface CardContentProps {
+ children: React.ReactNode;
+ className?: string;
+}
+
+export const CardContent: React.FC = ({
+ children,
+ className = '',
+}) => {
+ return {children}
;
+};
+
+CardContent.displayName = 'CardContent';
+
+/**
+ * Card Footer Component
+ * For consistent card footer actions
+ */
+interface CardFooterProps {
+ children: React.ReactNode;
+ className?: string;
+}
+
+export const CardFooter: React.FC = ({
+ children,
+ className = '',
+}) => {
+ return (
+
+ {children}
+
+ );
+};
+
+CardFooter.displayName = 'CardFooter';
diff --git a/frontend_en/src/components/ui/Input.tsx b/frontend_en/src/components/ui/Input.tsx
new file mode 100644
index 0000000..0979db5
--- /dev/null
+++ b/frontend_en/src/components/ui/Input.tsx
@@ -0,0 +1,101 @@
+import React from 'react';
+
+interface InputProps extends React.InputHTMLAttributes {
+ label?: string;
+ error?: string;
+ helperText?: string;
+ leftIcon?: React.ReactNode;
+ rightIcon?: React.ReactNode;
+}
+
+/**
+ * Editorial Workspace Input Component
+ *
+ * Features:
+ * - Clean, minimal styling with editorial color palette
+ * - Optional label, error, and helper text
+ * - Icon support (left/right)
+ * - Accessible focus states with accent ring
+ * - Proper touch target sizing (44px height)
+ */
+export const Input = React.forwardRef(
+ ({ label, error, helperText, leftIcon, rightIcon, className = '', ...props }, ref) => {
+ const inputId = props.id || `input-${Math.random().toString(36).substr(2, 9)}`;
+
+ return (
+
+ {/* Label */}
+ {label && (
+
+ {label}
+
+ )}
+
+ {/* Input container */}
+
+ {/* Left icon */}
+ {leftIcon && (
+
+ {leftIcon}
+
+ )}
+
+ {/* Input field */}
+
+
+ {/* Right icon */}
+ {rightIcon && (
+
+ {rightIcon}
+
+ )}
+
+
+ {/* Error message */}
+ {error && (
+
+
+
+
+ {error}
+
+ )}
+
+ {/* Helper text */}
+ {!error && helperText && (
+
{helperText}
+ )}
+
+ );
+ }
+);
+
+Input.displayName = 'Input';
diff --git a/frontend_en/src/components/ui/Modal.tsx b/frontend_en/src/components/ui/Modal.tsx
new file mode 100644
index 0000000..fbf1c95
--- /dev/null
+++ b/frontend_en/src/components/ui/Modal.tsx
@@ -0,0 +1,167 @@
+import React, { useEffect } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { X } from 'lucide-react';
+
+interface ModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ title?: string;
+ subtitle?: string;
+ children: React.ReactNode;
+ size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
+ showCloseButton?: boolean;
+ closeOnOverlayClick?: boolean;
+ closeOnEscape?: boolean;
+}
+
+/**
+ * Editorial Workspace Modal Component
+ *
+ * Features:
+ * - Smooth fade-in/scale-in animations
+ * - Multiple size options
+ * - Warm neutral backdrop with proper contrast
+ * - Editorial typography for title/subtitle
+ * - Keyboard accessibility (ESC to close, focus trap)
+ * - Close on overlay click (optional)
+ * - Lifted shadow effect
+ */
+export const Modal: React.FC = ({
+ isOpen,
+ onClose,
+ title,
+ subtitle,
+ children,
+ size = 'md',
+ showCloseButton = true,
+ closeOnOverlayClick = true,
+ closeOnEscape = true,
+}) => {
+ // Size mappings
+ const sizeClasses = {
+ sm: 'max-w-md',
+ md: 'max-w-lg',
+ lg: 'max-w-2xl',
+ xl: 'max-w-4xl',
+ full: 'max-w-[90vw]',
+ };
+
+ // Handle ESC key
+ useEffect(() => {
+ if (!closeOnEscape || !isOpen) return;
+
+ const handleEscape = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ onClose();
+ }
+ };
+
+ document.addEventListener('keydown', handleEscape);
+ return () => document.removeEventListener('keydown', handleEscape);
+ }, [isOpen, onClose, closeOnEscape]);
+
+ // Prevent body scroll when modal is open
+ useEffect(() => {
+ if (isOpen) {
+ document.body.style.overflow = 'hidden';
+ } else {
+ document.body.style.overflow = 'unset';
+ }
+
+ return () => {
+ document.body.style.overflow = 'unset';
+ };
+ }, [isOpen]);
+
+ return (
+
+ {isOpen && (
+ <>
+ {/* Backdrop */}
+
+
+ {/* Modal Container */}
+
+
+ {/* Header */}
+ {(title || showCloseButton) && (
+
+
+ {title && (
+
+ {title}
+
+ )}
+ {subtitle && (
+
{subtitle}
+ )}
+
+ {showCloseButton && (
+
+
+
+ )}
+
+ )}
+
+ {/* Content */}
+
+ {children}
+
+
+
+ >
+ )}
+
+ );
+};
+
+Modal.displayName = 'Modal';
+
+/**
+ * Modal Footer Component
+ * For consistent modal footer actions
+ */
+interface ModalFooterProps {
+ children: React.ReactNode;
+ className?: string;
+}
+
+export const ModalFooter: React.FC = ({
+ children,
+ className = '',
+}) => {
+ return (
+
+ {children}
+
+ );
+};
+
+ModalFooter.displayName = 'ModalFooter';
diff --git a/frontend_en/src/components/ui/Toast.tsx b/frontend_en/src/components/ui/Toast.tsx
new file mode 100644
index 0000000..9425c82
--- /dev/null
+++ b/frontend_en/src/components/ui/Toast.tsx
@@ -0,0 +1,54 @@
+import { useEffect } from 'react';
+import { CheckCircle, XCircle, AlertCircle, X } from 'lucide-react';
+
+type ToastType = 'success' | 'error' | 'warning';
+
+interface ToastProps {
+ message: string;
+ type?: ToastType;
+ onClose: () => void;
+ duration?: number;
+}
+
+export const Toast = ({ message, type = 'success', onClose, duration = 3000 }: ToastProps) => {
+ useEffect(() => {
+ const timer = setTimeout(onClose, duration);
+ return () => clearTimeout(timer);
+ }, [duration, onClose]);
+
+ const icons = {
+ success: ,
+ error: ,
+ warning: ,
+ };
+
+ const bgColors = {
+ success: 'bg-green-50 border-green-200',
+ error: 'bg-red-50 border-red-200',
+ warning: 'bg-amber-50 border-amber-200',
+ };
+
+ const textColors = {
+ success: 'text-green-900',
+ error: 'text-red-900',
+ warning: 'text-amber-900',
+ };
+
+ const closeColors = {
+ success: 'text-green-500 hover:text-green-700',
+ error: 'text-red-500 hover:text-red-700',
+ warning: 'text-amber-500 hover:text-amber-700',
+ };
+
+ return (
+
+
+ {icons[type]}
+ {message}
+
+
+
+
+
+ );
+};
diff --git a/frontend_en/src/components/ui/index.ts b/frontend_en/src/components/ui/index.ts
new file mode 100644
index 0000000..08eed62
--- /dev/null
+++ b/frontend_en/src/components/ui/index.ts
@@ -0,0 +1,19 @@
+/**
+ * Editorial Workspace UI Component Library
+ *
+ * A cohesive set of components implementing the Editorial Workspace design system:
+ * - Warm neutral palette (#FAFAF9 to #1C1917)
+ * - Electric coral accent (#F43F5E)
+ * - Distinctive typography (Newsreader display, Inter body)
+ * - Lifted shadow effects
+ * - Refined interactions
+ *
+ * Usage:
+ * import { Button, Input, Card, Modal, Badge } from '@/components/ui';
+ */
+
+export { Button } from './Button';
+export { Input } from './Input';
+export { Card, CardHeader, CardContent, CardFooter } from './Card';
+export { Modal, ModalFooter } from './Modal';
+export { Badge, BadgeGroup } from './Badge';
diff --git a/frontend_en/src/config/api.ts b/frontend_en/src/config/api.ts
index 669126a..2918579 100644
--- a/frontend_en/src/config/api.ts
+++ b/frontend_en/src/config/api.ts
@@ -2,6 +2,25 @@
* API configuration for backend calls.
*/
+import { getAccessToken } from '../stores/authStore';
+
+// Backend API base URL with smart detection
+function getApiBaseUrl(): string {
+ const configuredUrl = import.meta.env.VITE_API_BASE_URL || '';
+
+ if (configuredUrl.includes('localhost') || configuredUrl.includes('127.0.0.1')) {
+ const currentHost = window.location.hostname;
+ if (currentHost !== 'localhost' && currentHost !== '127.0.0.1') {
+ console.info('[API] Public access detected, using relative path instead of localhost');
+ return '';
+ }
+ }
+
+ return configuredUrl;
+}
+
+export const API_BASE_URL = getApiBaseUrl();
+
// API key for backend authentication
export const API_KEY = import.meta.env.VITE_API_KEY || 'df-internal-2024-workflow-key';
@@ -15,9 +34,14 @@ export const API_URL_OPTIONS = (import.meta.env.VITE_LLM_API_URLS || 'https://ap
* Get headers for API calls including the API key.
*/
export function getApiHeaders(): HeadersInit {
- return {
+ const token = getAccessToken();
+ const headers: HeadersInit = {
'X-API-Key': API_KEY,
};
+ if (token) {
+ (headers as Record).Authorization = `Bearer ${token}`;
+ }
+ return headers;
}
/**
@@ -29,8 +53,14 @@ export async function apiFetch(
): Promise {
const headers = new Headers(options.headers);
headers.set('X-API-Key', API_KEY);
+ const token = getAccessToken();
+ if (token) {
+ headers.set('Authorization', `Bearer ${token}`);
+ }
+
+ const fullUrl = url.startsWith('http') ? url : `${API_BASE_URL}${url}`;
- return fetch(url, {
+ return fetch(fullUrl, {
...options,
headers,
});
diff --git a/frontend_en/src/hooks/useToast.tsx b/frontend_en/src/hooks/useToast.tsx
new file mode 100644
index 0000000..55e0e32
--- /dev/null
+++ b/frontend_en/src/hooks/useToast.tsx
@@ -0,0 +1,33 @@
+import { useState, useCallback } from 'react';
+import { Toast } from '../components/ui/Toast';
+
+type ToastType = 'success' | 'error' | 'warning';
+
+interface ToastState {
+ message: string;
+ type: ToastType;
+ id: number;
+}
+
+export const useToast = () => {
+ const [toast, setToast] = useState(null);
+
+ const showToast = useCallback((message: string, type: ToastType = 'success') => {
+ setToast({ message, type, id: Date.now() });
+ }, []);
+
+ const hideToast = useCallback(() => {
+ setToast(null);
+ }, []);
+
+ const ToastContainer = toast ? (
+
+ ) : null;
+
+ return { showToast, ToastContainer };
+};
diff --git a/frontend_en/src/index.css b/frontend_en/src/index.css
index 1712500..e406ceb 100644
--- a/frontend_en/src/index.css
+++ b/frontend_en/src/index.css
@@ -1,13 +1,289 @@
+/* Import Editorial Fonts - Must be first! */
+@import url('https://fonts.googleapis.com/css2?family=Newsreader:wght@300;400;500;600;700;800&display=swap');
+@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
+@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap');
+
@tailwind base;
@tailwind components;
@tailwind utilities;
+/**
+ * Editorial Workspace Global Styles
+ * Magazine-inspired design with warm neutrals and vibrant accents
+ */
+
:root {
- background-color: #f8f9fa;
+ /* Base background - warm off-white */
+ background-color: #FAFAF9;
+
+ /* Set default font smoothing for crisp text */
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ text-rendering: optimizeLegibility;
+}
+
+@layer base {
+ body {
+ @apply bg-neutral-50 text-neutral-900 font-sans;
+ margin: 0;
+ }
+
+ /* Typography defaults */
+ h1, h2, h3, h4, h5, h6 {
+ @apply font-display font-semibold text-neutral-800 tracking-tight;
+ }
+
+ h1 {
+ @apply text-5xl md:text-6xl;
+ }
+
+ h2 {
+ @apply text-3xl md:text-4xl;
+ }
+
+ h3 {
+ @apply text-2xl md:text-3xl;
+ }
+
+ h4 {
+ @apply text-xl md:text-2xl;
+ }
+
+ /* Links - editorial style */
+ a {
+ @apply text-accent-600 hover:text-accent-700 transition-colors underline-offset-2;
+ }
+
+ /* Strong emphasis */
+ strong, b {
+ @apply font-semibold text-neutral-900;
+ }
+
+ /* Code elements */
+ code {
+ @apply font-mono text-sm bg-neutral-100 text-neutral-800 px-1.5 py-0.5 rounded;
+ }
+
+ pre code {
+ @apply bg-transparent p-0;
+ }
+
+ /* Selection color - accent coral */
+ ::selection {
+ @apply bg-accent-200 text-accent-900;
+ }
}
-body {
- margin: 0;
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+@layer components {
+ /**
+ * Custom scrollbar - subtle and editorial
+ */
+ .scrollbar-thin::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+ }
+
+ .scrollbar-thin::-webkit-scrollbar-track {
+ @apply bg-transparent;
+ }
+
+ .scrollbar-thin::-webkit-scrollbar-thumb {
+ @apply bg-neutral-300 rounded-full hover:bg-neutral-400 transition-colors;
+ }
+
+ /**
+ * Glass effect (use sparingly - editorial is more matte)
+ */
+ .glass-subtle {
+ background: rgba(255, 255, 255, 0.8);
+ backdrop-filter: blur(12px) saturate(150%);
+ -webkit-backdrop-filter: blur(12px) saturate(150%);
+ }
+
+ /**
+ * Lifted effect - editorial signature
+ * Creates shadow underneath like printed material
+ */
+ .lifted {
+ @apply shadow-lifted hover:shadow-lifted-hover transition-shadow;
+ }
+
+ .lifted-hover {
+ @apply hover:shadow-lifted-hover hover:-translate-y-0.5 transition-all;
+ }
+
+ /**
+ * Text gradient - use for special headings
+ */
+ .text-gradient-accent {
+ @apply bg-gradient-to-r from-accent-600 to-accent-500 bg-clip-text text-transparent;
+ }
+
+ /**
+ * Focus styles - editorial appropriate
+ */
+ .focus-editorial {
+ @apply focus:outline-none focus:ring-2 focus:ring-accent-500 focus:ring-offset-2 focus:ring-offset-neutral-50;
+ }
+
+ /**
+ * Asymmetric grid helper
+ * Creates magazine-style layouts
+ */
+ .grid-editorial {
+ @apply grid gap-8;
+ grid-template-columns: repeat(12, minmax(0, 1fr));
+ }
+
+ /**
+ * Loading shimmer - editorial style
+ */
+ .shimmer-editorial {
+ background: linear-gradient(90deg,
+ #F5F5F4 0%,
+ #E7E5E4 50%,
+ #F5F5F4 100%
+ );
+ background-size: 200% 100%;
+ animation: shimmer 1.5s ease-in-out infinite;
+ }
+
+ /**
+ * Divider - editorial style
+ */
+ .divider-editorial {
+ @apply border-t-2 border-neutral-200 my-8;
+ }
+
+ .divider-accent {
+ @apply border-t-4 border-accent-500 my-8 w-16;
+ }
}
+@layer utilities {
+ /**
+ * Container utilities for editorial layouts
+ */
+ .container-editorial {
+ @apply mx-auto px-6 md:px-8 lg:px-12;
+ max-width: 1400px;
+ }
+
+ .container-narrow {
+ @apply mx-auto px-6;
+ max-width: 720px;
+ }
+
+ .container-wide {
+ @apply mx-auto px-6;
+ max-width: 1600px;
+ }
+
+ /**
+ * Aspect ratios for media
+ */
+ .aspect-editorial {
+ aspect-ratio: 16 / 10;
+ }
+
+ .aspect-card {
+ aspect-ratio: 4 / 3;
+ }
+
+ /**
+ * Text balance - for better headline wrapping
+ */
+ .text-balance {
+ text-wrap: balance;
+ }
+
+ /**
+ * Editorial spacing rhythm
+ */
+ .space-editorial > * + * {
+ @apply mt-6;
+ }
+
+ .space-editorial-lg > * + * {
+ @apply mt-12;
+ }
+
+ /**
+ * Prose styling for markdown content
+ */
+ .prose-editorial {
+ @apply text-base leading-relaxed text-neutral-700;
+ }
+
+ .prose-editorial h1,
+ .prose-editorial h2,
+ .prose-editorial h3 {
+ @apply font-display font-semibold text-neutral-900 mt-8 mb-4;
+ }
+
+ .prose-editorial p {
+ @apply mb-4;
+ }
+
+ .prose-editorial a {
+ @apply text-accent-600 hover:text-accent-700 underline underline-offset-2;
+ }
+
+ .prose-editorial ul,
+ .prose-editorial ol {
+ @apply ml-6 mb-4;
+ }
+
+ .prose-editorial li {
+ @apply mb-2;
+ }
+
+ .prose-editorial code {
+ @apply font-mono text-sm bg-neutral-100 text-neutral-800 px-1.5 py-0.5 rounded;
+ }
+
+ .prose-editorial pre {
+ @apply bg-neutral-900 text-neutral-50 p-4 rounded-lg overflow-x-auto mb-4;
+ }
+
+ .prose-editorial blockquote {
+ @apply border-l-4 border-accent-500 pl-4 italic text-neutral-600 my-4;
+ }
+}
+
+/**
+ * Animation utilities
+ */
+@keyframes shimmer {
+ 0% {
+ background-position: -200% 0;
+ }
+ 100% {
+ background-position: 200% 0;
+ }
+}
+
+/**
+ * Print styles - for generating reports/exports
+ */
+@media print {
+ @page {
+ margin: 2cm;
+ }
+
+ body {
+ @apply bg-white text-black;
+ }
+
+ .no-print {
+ display: none !important;
+ }
+
+ h1, h2, h3 {
+ page-break-after: avoid;
+ }
+
+ img {
+ page-break-inside: avoid;
+ }
+}
diff --git a/frontend_en/src/lib/supabase.ts b/frontend_en/src/lib/supabase.ts
index c2dd277..5b320b1 100644
--- a/frontend_en/src/lib/supabase.ts
+++ b/frontend_en/src/lib/supabase.ts
@@ -1,37 +1,203 @@
/**
- * Supabase client singleton for frontend.
+ * Auth client - all requests go through backend proxy.
+ * No direct Supabase connection from frontend.
*/
-import { createClient, SupabaseClient } from "@supabase/supabase-js";
+import { API_BASE_URL } from "../config/api";
-const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
-const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
+interface User {
+ id: string;
+ email: string;
+}
+
+interface AuthResponse {
+ success: boolean;
+ user?: User;
+ message?: string;
+}
+
+let currentUser: User | null = null;
+let authConfigured = false;
+
+/**
+ * Initialize auth - check if backend has auth configured
+ */
+export async function initSupabase(): Promise {
+ try {
+ const url = `${API_BASE_URL}/api/v1/auth/config`;
+ console.info('[Auth] 正在获取配置:', url);
+
+ const response = await fetch(url, {
+ method: 'GET',
+ credentials: 'include',
+ });
+
+ if (!response.ok) {
+ console.error('[Auth] 配置请求失败:', response.status);
+ return false;
+ }
+
+ const data = await response.json();
+ authConfigured = data.supabaseConfigured;
+ console.info('[Auth] 配置响应:', { configured: authConfigured, mode: data.authMode });
+
+ // 如果配置了认证,尝试获取当前会话
+ if (authConfigured) {
+ await refreshSession();
+ }
+
+ return authConfigured;
+ } catch (error) {
+ console.error('[Auth] 初始化失败:', error);
+ return false;
+ }
+}
+
+/**
+ * Login with email and password
+ */
+export async function signIn(email: string, password: string): Promise {
+ try {
+ const response = await fetch(`${API_BASE_URL}/api/v1/auth/login`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ email, password }),
+ });
+
+ const data = await response.json();
+ if (data.success && data.user) {
+ currentUser = data.user;
+ }
+ return data;
+ } catch (error) {
+ console.error('[Auth] Login failed:', error);
+ throw error;
+ }
+}
+
+/**
+ * Sign up with email and password
+ */
+export async function signUp(email: string, password: string): Promise {
+ try {
+ const response = await fetch(`${API_BASE_URL}/api/v1/auth/signup`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ email, password }),
+ });
+
+ const data = await response.json();
+ if (data.success && data.user) {
+ currentUser = data.user;
+ }
+ return data;
+ } catch (error) {
+ console.error('[Auth] Signup failed:', error);
+ throw error;
+ }
+}
/**
- * Check if Supabase is properly configured.
+ * Refresh session
*/
-export function isSupabaseConfigured(): boolean {
- return Boolean(supabaseUrl && supabaseAnonKey);
+export async function refreshSession(): Promise {
+ try {
+ const response = await fetch(`${API_BASE_URL}/api/v1/auth/session`, {
+ method: 'GET',
+ credentials: 'include',
+ });
+
+ if (response.status === 401) {
+ currentUser = null;
+ return null;
+ }
+
+ if (!response.ok) {
+ console.error('[Auth] Session refresh failed:', response.status);
+ currentUser = null;
+ return null;
+ }
+
+ const data = await response.json();
+ currentUser = data.user || null;
+ return currentUser;
+ } catch (error) {
+ console.error('[Auth] Session refresh failed:', error);
+ currentUser = null;
+ return null;
+ }
}
-// Only create client when configured
-const supabaseClient: SupabaseClient | null = isSupabaseConfigured()
- ? createClient(supabaseUrl!, supabaseAnonKey!, {
- auth: {
- autoRefreshToken: true,
- persistSession: true,
- detectSessionInUrl: true,
- },
- })
- : null;
+/**
+ * Verify OTP token
+ */
+export async function verifyOtp(email: string, token: string): Promise {
+ try {
+ const response = await fetch(`${API_BASE_URL}/api/v1/auth/verify`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ email, token }),
+ });
+
+ const data = await response.json();
+ if (data.success && data.user) {
+ currentUser = data.user;
+ }
+ return data;
+ } catch (error) {
+ console.error('[Auth] Verify OTP failed:', error);
+ throw error;
+ }
+}
/**
- * Get Supabase client.
+ * Resend OTP token
*/
-export const supabase = supabaseClient as SupabaseClient;
+export async function resendOtp(email: string): Promise {
+ try {
+ const response = await fetch(`${API_BASE_URL}/api/v1/auth/resend`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ email }),
+ });
+
+ return await response.json();
+ } catch (error) {
+ console.error('[Auth] Resend OTP failed:', error);
+ throw error;
+ }
+}
-if (!isSupabaseConfigured()) {
- console.info(
- "[Supabase] Not configured. Auth, quotas, and cloud storage disabled."
- );
+/**
+ * Sign out
+ */
+export async function signOut(): Promise {
+ try {
+ await fetch(`${API_BASE_URL}/api/v1/auth/logout`, {
+ method: 'POST',
+ credentials: 'include',
+ });
+ currentUser = null;
+ } catch (error) {
+ console.error('[Auth] Logout failed:', error);
+ throw error;
+ }
+}
+
+/**
+ * Get current user
+ */
+export function getCurrentUser(): User | null {
+ return currentUser;
+}
+
+/**
+ * Check if auth is configured
+ */
+export function isAuthConfigured(): boolean {
+ return authConfigured;
}
diff --git a/frontend_en/src/pages/AuthPage.tsx b/frontend_en/src/pages/AuthPage.tsx
new file mode 100644
index 0000000..c6ed739
--- /dev/null
+++ b/frontend_en/src/pages/AuthPage.tsx
@@ -0,0 +1,355 @@
+import { FormEvent, useEffect, useMemo, useState } from 'react';
+import { motion } from 'framer-motion';
+import { ArrowRight, Loader2, Lock, Mail } from 'lucide-react';
+import { useAuthStore } from '../stores/authStore';
+
+type AuthMode = 'login' | 'register' | 'verify';
+const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+
+export default function AuthPage() {
+ const {
+ loading,
+ error,
+ pendingEmail,
+ needsOtpVerification,
+ signInWithEmail,
+ signUpWithEmail,
+ verifyOtp,
+ resendOtp,
+ continueAsGuest,
+ clearError,
+ clearPendingVerification,
+ } = useAuthStore();
+
+ const [mode, setMode] = useState('login');
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [confirmPassword, setConfirmPassword] = useState('');
+ const [otpCode, setOtpCode] = useState('');
+ const [emailTouched, setEmailTouched] = useState(false);
+ const [localMessage, setLocalMessage] = useState('');
+ const [localError, setLocalError] = useState('');
+
+ useEffect(() => {
+ if (needsOtpVerification && pendingEmail) {
+ setMode('verify');
+ setEmail(pendingEmail);
+ setLocalMessage(`We sent a verification email or code to ${pendingEmail}.`);
+ }
+ }, [needsOtpVerification, pendingEmail]);
+
+ const normalizedEmail = useMemo(() => email.trim(), [email]);
+ const isEmailValid = normalizedEmail.length > 0 && EMAIL_REGEX.test(normalizedEmail);
+ const showEmailError = emailTouched && normalizedEmail.length > 0 && !isEmailValid;
+ const displayError = localError || error || '';
+
+ const resetMessages = () => {
+ clearError();
+ setLocalError('');
+ setLocalMessage('');
+ };
+
+ const handleLogin = async (event: FormEvent) => {
+ event.preventDefault();
+ resetMessages();
+ setEmailTouched(true);
+ if (!normalizedEmail || !password) {
+ setLocalError('Enter your email and password.');
+ return;
+ }
+ if (!isEmailValid) {
+ setLocalError('Enter a valid email address.');
+ return;
+ }
+ await signInWithEmail(normalizedEmail, password);
+ };
+
+ const handleRegister = async (event: FormEvent) => {
+ event.preventDefault();
+ resetMessages();
+ setEmailTouched(true);
+ if (!normalizedEmail || !password || !confirmPassword) {
+ setLocalError('Complete all registration fields.');
+ return;
+ }
+ if (!isEmailValid) {
+ setLocalError('Enter a valid email address.');
+ return;
+ }
+ if (password !== confirmPassword) {
+ setLocalError('Passwords do not match.');
+ return;
+ }
+ if (password.length < 6) {
+ setLocalError('Password must be at least 6 characters.');
+ return;
+ }
+
+ const result = await signUpWithEmail(normalizedEmail, password);
+ if (!result.needsVerification) {
+ setLocalMessage('Account created and signed in.');
+ }
+ };
+
+ const handleVerify = async (event: FormEvent) => {
+ event.preventDefault();
+ resetMessages();
+ const emailToVerify = pendingEmail || normalizedEmail;
+ if (!emailToVerify || !otpCode.trim()) {
+ setLocalError('Enter the verification code.');
+ return;
+ }
+ await verifyOtp(emailToVerify, otpCode);
+ };
+
+ const handleResend = async () => {
+ resetMessages();
+ const emailToVerify = pendingEmail || normalizedEmail;
+ if (!emailToVerify) {
+ setLocalError('Missing pending email.');
+ return;
+ }
+ await resendOtp(emailToVerify);
+ setLocalMessage(`Resent to ${emailToVerify}.`);
+ };
+
+ const switchMode = (nextMode: Exclude) => {
+ resetMessages();
+ if (mode === 'verify') {
+ clearPendingVerification();
+ setOtpCode('');
+ }
+ setMode(nextMode);
+ };
+
+ return (
+
+
+
+
+
+
OpenNotebookLM
+
+ {mode === 'register' ? 'Create account' : mode === 'verify' ? 'Verify email' : 'Sign in'}
+
+
+
+ {mode !== 'verify' && (
+
+ switchMode('login')}
+ className={`flex-1 rounded-[14px] px-4 py-2.5 text-sm font-medium transition ${
+ mode === 'login' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500'
+ }`}
+ >
+ Sign in
+
+ switchMode('register')}
+ className={`flex-1 rounded-[14px] px-4 py-2.5 text-sm font-medium transition ${
+ mode === 'register' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500'
+ }`}
+ >
+ Register
+
+
+ )}
+
+ {mode === 'login' && (
+
+ )}
+
+ {mode === 'register' && (
+
+ )}
+
+ {mode === 'verify' && (
+
+ )}
+
+ {localMessage && (
+
+ {localMessage}
+
+ )}
+
+ {displayError && (
+
+ {displayError}
+
+ )}
+
+ {mode !== 'verify' && (
+
+
+ Continue as Guest
+
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend_en/src/pages/Dashboard.tsx b/frontend_en/src/pages/Dashboard.tsx
index 7139332..5907410 100644
--- a/frontend_en/src/pages/Dashboard.tsx
+++ b/frontend_en/src/pages/Dashboard.tsx
@@ -1,9 +1,11 @@
import React, { useState, useEffect } from 'react';
-import { Settings, Plus, User, Loader2, BookOpen, Key, CheckCircle2 } from 'lucide-react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { Settings, Plus, User, Loader2, BookOpen, Key, CheckCircle2, LogOut, Info } from 'lucide-react';
import { useAuthStore } from '../stores/authStore';
import { apiFetch } from '../config/api';
import { API_URL_OPTIONS, DEFAULT_LLM_API_URL } from '../config/api';
import { getApiSettings, saveApiSettings, type ApiSettings, type SearchProvider, type SearchEngine } from '../services/apiSettingsService';
+import { fetchWithCache, getCachedValue, setCachedValue } from '../services/clientCache';
export interface Notebook {
id: string;
@@ -19,8 +21,10 @@ export interface Notebook {
updated_at?: string;
}
-const Dashboard = ({ onOpenNotebook, refreshTrigger = 0 }: { onOpenNotebook: (n: Notebook) => void; refreshTrigger?: number }) => {
- const { user } = useAuthStore();
+const NOTEBOOK_LIST_CACHE_TTL_MS = 2 * 60 * 1000;
+
+const Dashboard = ({ onOpenNotebook, refreshTrigger = 0, supabaseConfigured }: { onOpenNotebook: (n: Notebook) => void; refreshTrigger?: number; supabaseConfigured: boolean | null }) => {
+ const { user, signOut } = useAuthStore();
const [notebooks, setNotebooks] = useState([]);
const [loading, setLoading] = useState(true);
const [createModalOpen, setCreateModalOpen] = useState(false);
@@ -36,8 +40,9 @@ const Dashboard = ({ onOpenNotebook, refreshTrigger = 0 }: { onOpenNotebook: (n:
const [configSaving, setConfigSaving] = useState(false);
const [configSaved, setConfigSaved] = useState(false);
- // 不做用户管理时用默认用户,数据从 outputs 取
- const effectiveUserId = user?.id || 'default';
+ const effectiveUserId = user?.id || 'local';
+ const effectiveEmail = user?.email || '';
+ const notebookListCacheKey = `notebooks:${effectiveUserId}:${effectiveEmail || 'anonymous'}`;
useEffect(() => {
const s = getApiSettings(effectiveUserId);
@@ -68,37 +73,47 @@ const Dashboard = ({ onOpenNotebook, refreshTrigger = 0 }: { onOpenNotebook: (n:
}, 1500);
};
- const fetchNotebooks = async () => {
- setLoading(true);
+ const fetchNotebooks = async (options?: { force?: boolean }) => {
+ const cached = getCachedValue(notebookListCacheKey);
+ if (cached) {
+ setNotebooks(cached);
+ setLoading(false);
+ if (!options?.force) return;
+ } else {
+ setLoading(true);
+ }
try {
- const res = await apiFetch(`/api/v1/kb/notebooks?user_id=${encodeURIComponent(effectiveUserId)}`);
- const data = await res.json();
- if (data?.success && Array.isArray(data.notebooks)) {
- const list: Notebook[] = data.notebooks.map((row: any) => ({
- id: row.id,
- title: row.name,
- name: row.name,
- description: row.description,
- created_at: row.created_at,
- updated_at: row.updated_at,
- date: row.updated_at ? new Date(row.updated_at).toLocaleDateString('zh-CN') : '',
- sources: typeof row.sources === 'number' ? row.sources : 0,
- }));
- // 本地笔记本的 sources 已由后端从 outputs 扫描返回,无需再读 localStorage
- setNotebooks(list);
- } else {
- setNotebooks([]);
- }
+ const list = await fetchWithCache(
+ notebookListCacheKey,
+ NOTEBOOK_LIST_CACHE_TTL_MS,
+ async () => {
+ const res = await apiFetch(`/api/v1/kb/notebooks?user_id=${encodeURIComponent(effectiveUserId)}&email=${encodeURIComponent(effectiveEmail)}`);
+ const data = await res.json();
+ if (!data?.success || !Array.isArray(data.notebooks)) return [];
+ return data.notebooks.map((row: any) => ({
+ id: row.id,
+ title: row.name,
+ name: row.name,
+ description: row.description,
+ created_at: row.created_at,
+ updated_at: row.updated_at,
+ date: row.updated_at ? new Date(row.updated_at).toLocaleDateString('zh-CN') : '',
+ sources: typeof row.sources === 'number' ? row.sources : 0,
+ }));
+ },
+ { force: options?.force, useStaleOnError: true }
+ );
+ setNotebooks(list);
} catch (err) {
console.error('Failed to fetch notebooks:', err);
- setNotebooks([]);
+ if (!cached) setNotebooks([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
- fetchNotebooks();
+ fetchNotebooks({ force: refreshTrigger > 0 });
}, [effectiveUserId, refreshTrigger]);
const handleCreateNotebook = async () => {
@@ -110,7 +125,7 @@ const Dashboard = ({ onOpenNotebook, refreshTrigger = 0 }: { onOpenNotebook: (n:
const res = await apiFetch('/api/v1/kb/notebooks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ name, description: '', user_id: effectiveUserId }),
+ body: JSON.stringify({ name, description: '', user_id: effectiveUserId, email: effectiveEmail }),
});
const data = await res.json();
if (data?.success && data?.notebook) {
@@ -125,7 +140,11 @@ const Dashboard = ({ onOpenNotebook, refreshTrigger = 0 }: { onOpenNotebook: (n:
date: nb.updated_at ? new Date(nb.updated_at).toLocaleDateString('zh-CN') : '',
sources: 0,
};
- setNotebooks(prev => [newNb, ...prev]);
+ setNotebooks(prev => {
+ const next = [newNb, ...prev.filter(item => item.id !== newNb.id)];
+ setCachedValue(notebookListCacheKey, next, NOTEBOOK_LIST_CACHE_TTL_MS);
+ return next;
+ });
setCreateModalOpen(false);
setNewNotebookName('');
onOpenNotebook(newNb);
@@ -141,41 +160,84 @@ const Dashboard = ({ onOpenNotebook, refreshTrigger = 0 }: { onOpenNotebook: (n:
return (
-
-
-
-
open NoteBookLM
-
-
-
setConfigOpen((o) => !o)}
- className="text-gray-600 hover:text-gray-900 flex items-center gap-2"
- >
-
- API Settings
-
-
-
+ {/* Glass Header */}
+
+
+
+
+
OpenNotebookLM
+
+
+
+ {user?.email || user?.id}
+
+
setConfigOpen((o) => !o)}
+ className="text-ios-gray-600 hover:text-ios-gray-900 flex items-center gap-2 px-3 py-2 rounded-ios hover:bg-white/50 transition-colors"
+ >
+
+ API Settings
+
+
void signOut()}
+ className="text-ios-gray-600 hover:text-ios-gray-900 flex items-center gap-2 px-3 py-2 rounded-ios hover:bg-white/50 transition-colors"
+ >
+
+ Sign out
+
+
+
+
+ {/* Supabase Configuration Notice - Only shown when not configured */}
+ {!supabaseConfigured && (
+
+
+
+ )}
+
{configOpen && (
-
-
+
+
Home config (used when you open a notebook)
-
LLM API
+
LLM API
- API URL
+ API URL
setApiUrl(e.target.value)}
- className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
+ className="w-full px-3 py-2.5 border border-ios-gray-200 rounded-ios text-sm focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
>
{[apiUrl, ...API_URL_OPTIONS].filter((v, i, a) => a.indexOf(v) === i).map((url: string) => (
{url}
@@ -183,24 +245,24 @@ const Dashboard = ({ onOpenNotebook, refreshTrigger = 0 }: { onOpenNotebook: (n:
- API Key
+ API Key
setApiKey(e.target.value)}
placeholder="sk-..."
- className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
+ className="w-full px-3 py-2.5 border border-ios-gray-200 rounded-ios text-sm focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
/>
-
Search API
+
Search API
- Search provider
+ Search provider
setSearchProvider(e.target.value as SearchProvider)}
- className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
+ className="w-full px-3 py-2.5 border border-ios-gray-200 rounded-ios text-sm focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
>
Serper (Google, env)
SerpAPI (Google/Baidu)
@@ -209,23 +271,23 @@ const Dashboard = ({ onOpenNotebook, refreshTrigger = 0 }: { onOpenNotebook: (n:
{(searchProvider === 'serpapi' || searchProvider === 'bocha') && (
- Search API Key
+ Search API Key
setSearchApiKey(e.target.value)}
placeholder={searchProvider === 'bocha' ? 'Bocha API Key' : 'SerpAPI Key'}
- className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
+ className="w-full px-3 py-2.5 border border-ios-gray-200 rounded-ios text-sm focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
/>
)}
{searchProvider === 'serpapi' && (
- Search engine
+ Search engine
setSearchEngine(e.target.value as SearchEngine)}
- className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
+ className="w-full px-3 py-2.5 border border-ios-gray-200 rounded-ios text-sm focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
>
Google
Baidu
@@ -235,98 +297,127 @@ const Dashboard = ({ onOpenNotebook, refreshTrigger = 0 }: { onOpenNotebook: (n:
-
{configSaving ? : configSaved ? : }
{configSaving ? 'Saving...' : configSaved ? 'Saved' : 'Save config'}
-
+
)}
-
Notebooks
+ Notebooks
{loading ? (
-
+
Loading...
) : (
-
setCreateModalOpen(true)}
>
-
+
New notebook
+
+ {/* Notebook Cards */}
{notebooks.map((nb) => (
-
onOpenNotebook(nb)}
>
-
-
+
{nb.title || nb.name || 'Untitled'}
-
+
{nb.date || (nb.updated_at ? new Date(nb.updated_at).toLocaleDateString('zh-CN') : '')}
{typeof nb.sources === 'number' ? ` · ${nb.sources} sources` : ''}
-
+
))}
)}
- {createModalOpen && (
-
!creating && setCreateModalOpen(false)}>
-
e.stopPropagation()}>
-
New notebook
-
setNewNotebookName(e.target.value)}
+ {/* Create Modal — iOS Sheet */}
+
+ {createModalOpen && (
+ !creating && setCreateModalOpen(false)}>
+
- {createError &&
{createError}
}
-
- !creating && setCreateModalOpen(false)}
- disabled={creating}
- >
- Cancel
-
-
- {creating && }
- Create
-
-
+
e.stopPropagation()}
+ >
+ {/* iOS Drag Indicator */}
+
+ New notebook
+ setNewNotebookName(e.target.value)}
+ />
+ {createError && {createError}
}
+
+ !creating && setCreateModalOpen(false)}
+ disabled={creating}
+ >
+ Cancel
+
+
+ {creating && }
+ Create
+
+
+
-
- )}
+ )}
+
);
};
diff --git a/frontend_en/src/pages/NotebookView.tsx b/frontend_en/src/pages/NotebookView.tsx
index fa3acf6..88e2bff 100644
--- a/frontend_en/src/pages/NotebookView.tsx
+++ b/frontend_en/src/pages/NotebookView.tsx
@@ -1,4 +1,5 @@
-import React, { useState, useEffect } from 'react';
+import React, { useState, useEffect, useCallback } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
import {
ChevronLeft, Plus, Share2, Settings, MessageSquare,
BarChart2, Zap, AudioLines, Video, FileText,
@@ -7,48 +8,91 @@ import {
Globe, Link2, Cloud, ChevronRight, LayoutGrid, Download, BookOpen, Brain
} from 'lucide-react';
import { useAuthStore } from '../stores/authStore';
-import { supabase, isSupabaseConfigured } from '../lib/supabase';
import { apiFetch } from '../config/api';
import { getApiSettings } from '../services/apiSettingsService';
+import { fetchWithCache, invalidateCacheByPrefix } from '../services/clientCache';
import type { KnowledgeFile, ChatMessage, ToolType } from '../types';
import ReactMarkdown from 'react-markdown';
-import { MermaidPreview } from '../components/knowledge-base/tools/MermaidPreview';
+import { MindMapPreview } from '../components/knowledge-base/tools/MindMapPreview';
import { SettingsModal } from '../components/SettingsModal';
import DrawioInlineEditor from '../components/DrawioInlineEditor';
-import { FlashcardGenerator } from '../components/flashcards/FlashcardGenerator';
import { FlashcardViewer } from '../components/flashcards/FlashcardViewer';
-import { QuizGenerator } from '../components/quiz/QuizGenerator';
import { QuizContainer } from '../components/quiz/QuizContainer';
+import { NotionEditor } from '../components/notes/NotionEditor';
+import { useToast } from '../hooks/useToast';
import katex from 'katex';
import 'katex/dist/katex.min.css';
// 不做用户管理时使用,数据从 outputs 取
const DEFAULT_USER = { id: 'default', email: 'default' };
+type PendingSourceItem = {
+ id: string;
+ name: string;
+ sourceType: 'upload';
+ status: 'processing' | 'error';
+ message?: string;
+};
+
+type CitationReference = {
+ fileName: string;
+ filePath?: string;
+ preview?: string;
+ chunkIndex?: number | null;
+ sourceNumber?: string;
+};
+
+type CitationTooltipState = {
+ title: string;
+ preview: string;
+ x: number;
+ y: number;
+};
+
+type SourceDetailCacheEntry = {
+ content: string;
+ format: 'text' | 'markdown';
+};
+
+const FILE_LIST_CACHE_TTL_MS = 2 * 60 * 1000;
+const SOURCE_DETAIL_CACHE_TTL_MS = 15 * 60 * 1000;
+
const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void }) => {
const { user } = useAuthStore();
const effectiveUser = user || DEFAULT_USER;
- const [activeTab, setActiveTab] = useState<'chat' | 'retrieval' | 'sources'>('chat');
+ const { showToast, ToastContainer } = useToast();
const [activeTool, setActiveTool] = useState
('chat');
// Files management
const [files, setFiles] = useState([]);
const [selectedIds, setSelectedIds] = useState>(new Set());
+ const [pendingSources, setPendingSources] = useState([]);
// Chat state
const WELCOME_MSG: ChatMessage = {
id: 'welcome',
role: 'assistant',
- content: 'Hello! I\'m your knowledge base assistant. Upload files or select sources on the left, then ask your questions here.',
+ content: '欢迎使用 OpenNotebookLM!我是你的智能知识库助手。\n\n在左侧上传文档,然后与我对话来探索、总结和生成洞察 —— 支持播客、思维导图、PPT、闪卡、测验等多种输出形式。',
time: new Date().toLocaleTimeString()
};
const [chatMessages, setChatMessages] = useState([WELCOME_MSG]);
const chatPersistSkippedRef = React.useRef(false);
const conversationIdRef = React.useRef(null);
const [inputMsg, setInputMsg] = useState('');
+ const handleMindmapNodeClick = useCallback((question: string) => {
+ setInputMsg(question);
+ setActiveTool('chat');
+ setChatSubView('current');
+ setPreviewOutput(null);
+ setTimeout(() => {
+ const input = document.querySelector('input[type="text"]');
+ input?.focus();
+ }, 100);
+ }, []);
const [isChatLoading, setIsChatLoading] = useState(false);
+ const [chatLoadingStage, setChatLoadingStage] = useState('思考中...');
- // Chat历史:本地持久化
+ // 对话历史:本地持久化
type ConversationItem = { id: string; title: string; messages: ChatMessage[]; updatedAt: number };
const getConversationsKey = () => {
const uid = effectiveUser?.id || effectiveUser?.email || '';
@@ -86,7 +130,7 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
const [toolLoading, setToolLoading] = useState(false);
const [outputFeed, setOutputFeed] = useState void
previewUrl?: string;
createdAt: string;
mermaidCode?: string;
+ setId?: string;
+ noteContent?: string;
}>>([]);
// Settings modal
@@ -102,22 +148,19 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
// Output preview
const [previewOutput, setPreviewOutput] = useState<{
id: string;
- type: 'ppt' | 'mindmap' | 'podcast' | 'drawio';
+ type: 'ppt' | 'mindmap' | 'podcast' | 'drawio' | 'flashcard' | 'quiz';
title: string;
sources: string;
url?: string;
previewUrl?: string;
createdAt: string;
mermaidCode?: string;
+ setId?: string;
} | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
/** DrawIO 预览:从 url 拉取后的 xml,用于在弹窗内嵌编辑 */
const [previewDrawioXml, setPreviewDrawioXml] = useState(null);
- const [retrievalQuery, setRetrievalQuery] = useState('');
- const [retrievalResults, setRetrievalResults] = useState([]);
- const [retrievalLoading, setRetrievalLoading] = useState(false);
const [retrievalError, setRetrievalError] = useState('');
- const [retrievalTopK, setRetrievalTopK] = useState(5);
const [retrievalModel, setRetrievalModel] = useState('text-embedding-3-large');
const [vectorFiles, setVectorFiles] = useState([]);
const [vectorLoading, setVectorLoading] = useState(false);
@@ -150,13 +193,23 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
const [introduceTextLoading, setIntroduceTextLoading] = useState(false);
const [introduceTextError, setIntroduceTextError] = useState('');
const [introduceTextSuccess, setIntroduceTextSuccess] = useState('');
- const [introduceUploadSuccess, setIntroduceUploadSuccess] = useState('');
+ const processingUploadCount = pendingSources.filter(
+ item => item.sourceType === 'upload' && item.status === 'processing'
+ ).length;
+ const sourceListCount = files.length + pendingSources.length;
// 来源详情:点击某项后翻转显示解析内容(PDF 等解析为 markdown 展示)
const [sourceDetailView, setSourceDetailView] = useState(null);
const [sourceDetailContent, setSourceDetailContent] = useState('');
const [sourceDetailFormat, setSourceDetailFormat] = useState<'text' | 'markdown'>('text');
const [sourceDetailLoading, setSourceDetailLoading] = useState(false);
+ const [sourceDetailCitationFocus, setSourceDetailCitationFocus] = useState(null);
+ const sourceDetailCitationRef = React.useRef(null);
+ const [hoveredCitation, setHoveredCitation] = useState(null);
+
+ // Cache refs to avoid redundant API calls
+ const lastFetchedNotebookIdForSources = React.useRef(null);
+ const lastFetchedNotebookIdForOutputs = React.useRef(null);
// Flashcard state
const [flashcards, setFlashcards] = useState([]);
@@ -168,6 +221,9 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
const [showQuizContainer, setShowQuizContainer] = useState(false);
const [quizId, setQuizId] = useState('');
+ // Loading state for saved flashcard/quiz sets
+ const [loadingSetId, setLoadingSetId] = useState(null);
+
// 三栏可拖拽宽度(左 / 右,中间 flex 自适应)
const [leftPanelWidth, setLeftPanelWidth] = useState(256);
const [rightPanelWidth, setRightPanelWidth] = useState(320);
@@ -181,11 +237,12 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
document.body.style.userSelect = 'none';
const onMove = (e: MouseEvent) => {
if (!resizeRef.current) return;
- const delta = e.clientX - resizeRef.current.startX;
+ const { startX, startLeft, startRight } = resizeRef.current;
+ const delta = e.clientX - startX;
if (resizing === 'left') {
- setLeftPanelWidth(() => Math.min(480, Math.max(160, resizeRef.current!.startLeft + delta)));
+ setLeftPanelWidth(Math.min(480, Math.max(160, startLeft + delta)));
} else {
- setRightPanelWidth(() => Math.min(600, Math.max(200, resizeRef.current!.startRight - delta)));
+ setRightPanelWidth(Math.min(600, Math.max(200, startRight - delta)));
}
};
const onUp = () => {
@@ -206,18 +263,20 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
// Studio tools
const studioTools: Array<{icon: React.ReactNode, label: string, id: ToolType}> = [
- { icon: , label: 'PPT', id: 'ppt' },
- { icon: , label: 'Mind Map', id: 'mindmap' },
- { icon: , label: 'DrawIO', id: 'drawio' },
- { icon: , label: 'Flashcards', id: 'flashcard' },
- { icon: , label: 'Quiz', id: 'quiz' },
- { icon: , label: 'Knowledge Podcast', id: 'podcast' },
- // Video narration temporarily disabled
- // { icon: , label: 'Video narration', id: 'video' },
+ { icon: , label: 'PPT生成', id: 'ppt' },
+ { icon: , label: '思维导图', id: 'mindmap' },
+ // DrawIO 图表功能暂时隐藏,后续修复
+ // { icon: , label: 'DrawIO 图表', id: 'drawio' },
+ { icon: , label: '闪卡', id: 'flashcard' },
+ { icon: , label: '测验', id: 'quiz' },
+ { icon: , label: '知识播客', id: 'podcast' },
+ { icon: , label: '笔记', id: 'note' },
+ // 视频讲解暂未开放
+ // { icon: , label: '视频讲解', id: 'video' },
];
// Studio:每个功能卡片各自配置,点卡片上的「…」翻转进该卡片的设置
- type StudioToolId = 'ppt' | 'mindmap' | 'drawio' | 'podcast' | 'video';
+ type StudioToolId = 'ppt' | 'mindmap' | 'drawio' | 'flashcard' | 'quiz' | 'podcast' | 'video' | 'note';
const [studioPanelView, setStudioPanelView] = useState<'tools' | 'settings'>('tools');
const [studioSettingsTool, setStudioSettingsTool] = useState(null);
const STORAGE_STUDIO_CONFIG = `kb_studio_config_${effectiveUser?.id || 'default'}`;
@@ -225,8 +284,11 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
ppt: { llmModel: 'deepseek-v3.2', genFigModel: 'gemini-2.5-flash-image', stylePreset: 'modern', stylePrompt: '', language: 'zh', page_count: '10' },
mindmap: { llmModel: 'deepseek-v3.2', mindmapStyle: 'default' },
drawio: { llmModel: 'deepseek-v3.2', diagramType: 'auto', diagramStyle: 'default', language: 'zh' },
- podcast: { llmModel: 'deepseek-v3.2', ttsModel: 'gemini-2.5-pro-preview-tts', voiceName: 'Kore', voiceNameB: 'Puck' },
+ flashcard: { llmModel: 'deepseek-v3.2', language: 'zh', cardCount: '20' },
+ quiz: { llmModel: 'deepseek-v3.2', language: 'zh', questionCount: '10' },
+ podcast: { llmModel: 'deepseek-v3.2', ttsType: 'qwen-tts-local', ttsModel: 'qwen-tts', voiceName: 'vivian', podcastMode: 'monologue', podcastLanguage: 'zh' },
video: { llmModel: 'deepseek-v3.2' },
+ note: {},
};
const [studioConfigByTool, setStudioConfigByTool] = useState>>(() => {
try {
@@ -271,11 +333,11 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
/** 产出列表是否已完成首次加载(避免刷新时用空数组覆盖 localStorage) */
const hasLoadedOutputsRef = React.useRef(false);
- // 持久化当前Chat到历史(仅在有除 welcome 外的消息时)
+ // 持久化当前对话到历史(仅在有除 welcome 外的消息时)
const persistCurrentConversation = (messages: ChatMessage[]) => {
const list = messages.filter(m => m.id !== 'welcome');
if (list.length === 0) return;
- const title = (list.find(m => m.role === 'user')?.content || 'New Chat').slice(0, 30);
+ const title = (list.find(m => m.role === 'user')?.content || '新对话').slice(0, 30);
const id = currentConversationId || `conv_${Date.now()}`;
setCurrentConversationId(id);
setConversationHistory(prev => {
@@ -323,7 +385,7 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
localStorage.setItem(key, JSON.stringify(items));
};
- const inferOutputType = (urlOrName?: string): 'ppt' | 'mindmap' | 'podcast' | 'drawio' => {
+ const inferOutputType = (urlOrName?: string): 'ppt' | 'mindmap' | 'podcast' | 'drawio' | 'flashcard' | 'quiz' => {
const value = (urlOrName || '').toLowerCase();
if (value.endsWith('.wav') || value.endsWith('.mp3') || value.endsWith('.m4a')) return 'podcast';
if (value.endsWith('.mmd') || value.endsWith('.mermaid')) return 'mindmap';
@@ -331,11 +393,43 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
return 'ppt';
};
- const getOutputTitle = (type: 'ppt' | 'mindmap' | 'podcast' | 'drawio') => {
- if (type === 'mindmap') return 'Mind Map';
- if (type === 'podcast') return 'Podcast';
- if (type === 'drawio') return 'DrawIO';
- return 'PPT';
+ const getOutputTitle = (type: 'ppt' | 'mindmap' | 'podcast' | 'drawio' | 'flashcard' | 'quiz') => {
+ if (type === 'mindmap') return '思维导图';
+ if (type === 'podcast') return '播客生成';
+ if (type === 'drawio') return 'DrawIO 图表';
+ if (type === 'flashcard') return '闪卡';
+ if (type === 'quiz') return '测验';
+ return 'PPT 生成';
+ };
+
+ const handleLoadSavedSet = async (item: typeof outputFeed[number]) => {
+ if (!item.setId) {
+ showToast('加载失败:该条目没有保存的集合 ID,可能是在持久化功能添加之前创建的。', 'error');
+ return;
+ }
+ setLoadingSetId(item.id);
+ try {
+ const endpoint = item.type === 'flashcard'
+ ? `/api/v1/kb/get-flashcard-set?notebook_id=${encodeURIComponent(notebook.id)}&set_id=${encodeURIComponent(item.setId)}`
+ : `/api/v1/kb/get-quiz-set?notebook_id=${encodeURIComponent(notebook.id)}&set_id=${encodeURIComponent(item.setId)}`;
+ const res = await apiFetch(endpoint);
+ const data = await res.json();
+ if (!data.success) throw new Error(data.detail || '加载失败');
+ if (item.type === 'flashcard') {
+ setFlashcards(data.flashcards || []);
+ setFlashcardSetId(data.id || '');
+ setShowFlashcardViewer(true);
+ } else {
+ setQuizQuestions(data.questions || []);
+ setQuizId(data.id || '');
+ setShowQuizContainer(true);
+ }
+ } catch (err) {
+ console.error('Load saved set error:', err);
+ showToast('加载失败,数据可能已被删除。', 'error');
+ } finally {
+ setLoadingSetId(null);
+ }
};
const mergeOutputFeeds = (remote: typeof outputFeed, local: typeof outputFeed) => {
@@ -350,7 +444,8 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
}
map.set(key, {
...item,
- mermaidCode: prev.mermaidCode || item.mermaidCode
+ mermaidCode: prev.mermaidCode || item.mermaidCode,
+ setId: prev.setId || item.setId,
});
};
remote.forEach(add);
@@ -364,30 +459,84 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
const fetchOutputHistory = async () => {
if (!effectiveUser?.email && !effectiveUser?.id) return [];
+ const results: typeof outputFeed = [];
try {
const params = new URLSearchParams({ email: effectiveUser.email || effectiveUser.id });
if (notebook?.id) params.set('notebook_id', notebook.id);
+ const nbTitle = notebook?.title || notebook?.name || '';
+ if (nbTitle) params.set('notebook_title', nbTitle);
const res = await apiFetch(`/api/v1/kb/outputs?${params.toString()}`);
- if (!res.ok) return [];
- const data = await res.json();
- if (!data?.success || !Array.isArray(data.files)) return [];
- return data.files.map((item: any) => {
- const url = item.download_url || item.url || '';
- const type = (item.output_type as 'ppt' | 'mindmap' | 'podcast' | 'drawio') || inferOutputType(item.file_name || url);
- return {
- id: item.id || url || `output_${Date.now()}`,
- type,
- title: getOutputTitle(type),
- sources: 'Past outputs',
- url,
- createdAt: item.created_at ? new Date(item.created_at).toLocaleString() : new Date().toLocaleString(),
- mermaidCode: undefined
- };
- });
+ if (res.ok) {
+ const data = await res.json();
+ console.log('[fetchOutputHistory] Got data from /api/v1/kb/outputs:', data);
+ if (data?.success && Array.isArray(data.files)) {
+ console.log('[fetchOutputHistory] Processing', data.files.length, 'files');
+ for (const item of data.files) {
+ const url = item.download_url || item.url || '';
+ const type = (item.output_type as 'ppt' | 'mindmap' | 'podcast' | 'drawio' | 'flashcard' | 'quiz') || inferOutputType(item.file_name || url);
+ const output = {
+ id: item.id || url || `output_${Date.now()}`,
+ type,
+ title: getOutputTitle(type),
+ sources: '历史产出',
+ url,
+ createdAt: item.created_at ? new Date(item.created_at).toLocaleString() : new Date().toLocaleString(),
+ mermaidCode: undefined
+ };
+ console.log('[fetchOutputHistory] Adding output:', output);
+ results.push(output);
+ }
+ }
+ }
} catch (err) {
console.error('Failed to load output history:', err);
- return [];
}
+
+ // 从专用端点获取闪卡和测验历史
+ if (notebook?.id) {
+ const nbTitle = notebook?.title || notebook?.name || '';
+ const fcParams = new URLSearchParams({ notebook_id: notebook.id, notebook_title: nbTitle });
+ const qzParams = new URLSearchParams({ notebook_id: notebook.id, notebook_title: nbTitle });
+ const [fcRes, qzRes] = await Promise.all([
+ apiFetch(`/api/v1/kb/list-flashcard-sets?${fcParams.toString()}`).catch(() => null),
+ apiFetch(`/api/v1/kb/list-quiz-sets?${qzParams.toString()}`).catch(() => null),
+ ]);
+ if (fcRes?.ok) {
+ const fcData = await fcRes.json().catch(() => null);
+ if (fcData?.success && Array.isArray(fcData.sets)) {
+ for (const s of fcData.sets) {
+ results.push({
+ id: s.id || `flashcard_${s.set_id}`,
+ type: 'flashcard',
+ title: getOutputTitle('flashcard'),
+ sources: Array.isArray(s.source_files) ? s.source_files.map((f: string) => f.split('/').pop() || f).join(', ') : '历史产出',
+ url: '',
+ createdAt: s.created_at ? new Date(s.created_at).toLocaleString() : new Date().toLocaleString(),
+ setId: s.set_id,
+ });
+ }
+ }
+ }
+ if (qzRes?.ok) {
+ const qzData = await qzRes.json().catch(() => null);
+ if (qzData?.success && Array.isArray(qzData.sets)) {
+ for (const s of qzData.sets) {
+ results.push({
+ id: s.id || `quiz_${s.set_id}`,
+ type: 'quiz',
+ title: getOutputTitle('quiz'),
+ sources: Array.isArray(s.source_files) ? s.source_files.map((f: string) => f.split('/').pop() || f).join(', ') : '历史产出',
+ url: '',
+ createdAt: s.created_at ? new Date(s.created_at).toLocaleString() : new Date().toLocaleString(),
+ setId: s.set_id,
+ });
+ }
+ }
+ }
+ }
+
+ console.log('[fetchOutputHistory] Returning results:', results.length, 'items', results);
+ return results;
};
const getChatStorageKey = () => {
@@ -407,7 +556,7 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
const res = await apiFetch(`/api/v1/kb/list?${params.toString()}`);
if (!res.ok) {
const msg = await res.text();
- throw new Error(msg || 'Failed to fetch vector list');
+ throw new Error(msg || '向量列表获取失败');
}
const data = await res.json();
const files = Array.isArray(data?.files) ? data.files : [];
@@ -423,7 +572,7 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
});
setVectorStatusByPath(statusMap);
} catch (err: any) {
- setVectorError(err?.message || 'Failed to fetch vector list');
+ setVectorError(err?.message || '向量列表获取失败');
setVectorFiles([]);
setVectorStatusByPath({});
} finally {
@@ -431,6 +580,25 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
}
};
+ // Force refresh sources list (reset cache)
+ const refreshVectorList = async () => {
+ lastFetchedNotebookIdForSources.current = null;
+ await fetchVectorList();
+ };
+
+ // Force refresh outputs list (reset cache)
+ const refreshOutputHistory = async () => {
+ lastFetchedNotebookIdForOutputs.current = null;
+ hasLoadedOutputsRef.current = false;
+ const local = loadLocalOutputFeed();
+ const remote = await fetchOutputHistory();
+ console.log('[refreshOutputHistory] local:', local.length, 'remote:', remote.length);
+ const merged = mergeOutputFeeds(remote, local);
+ console.log('[refreshOutputHistory] merged:', merged.length, merged);
+ setOutputFeed(merged);
+ hasLoadedOutputsRef.current = true;
+ };
+
const getFileNameFromPath = (path?: string) => {
if (!path) return '';
const parts = path.split('/');
@@ -447,27 +615,19 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
};
const markEmbedded = async (file?: KnowledgeFile, storagePath?: string) => {
- if (isSupabaseConfigured()) {
- if (file?.id) {
- await supabase.from('knowledge_base_files').update({ is_embedded: true }).eq('id', file.id);
- } else if (storagePath) {
- await supabase.from('knowledge_base_files').update({ is_embedded: true }).eq('storage_path', storagePath);
+ const storageKey = `kb_files_${effectiveUser?.id || 'dev'}`;
+ const stored = localStorage.getItem(storageKey);
+ const existingFiles = stored ? JSON.parse(stored) : [];
+ const updated = existingFiles.map((f: KnowledgeFile) => {
+ if (file?.id && f.id === file.id) {
+ return { ...f, isEmbedded: true };
}
- } else {
- const storageKey = `kb_files_${effectiveUser?.id || 'dev'}`;
- const stored = localStorage.getItem(storageKey);
- const existingFiles = stored ? JSON.parse(stored) : [];
- const updated = existingFiles.map((f: KnowledgeFile) => {
- if (file?.id && f.id === file.id) {
- return { ...f, isEmbedded: true };
- }
- if (storagePath && f.url === storagePath) {
- return { ...f, isEmbedded: true };
- }
- return f;
- });
- localStorage.setItem(storageKey, JSON.stringify(updated));
- }
+ if (storagePath && f.url === storagePath) {
+ return { ...f, isEmbedded: true };
+ }
+ return f;
+ });
+ localStorage.setItem(storageKey, JSON.stringify(updated));
};
const handleReembedVector = async (item: any) => {
@@ -479,9 +639,9 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
let apiUrl = settings?.apiUrl?.trim() || '';
const apiKey = settings?.apiKey?.trim() || '';
if (!apiUrl || !apiKey) {
- const msg = 'Please configure API URL and API Key in Settings first';
+ const msg = '请先在设置中配置 API URL 和 API Key';
setVectorError(msg);
- alert(msg);
+ showToast(msg, 'warning');
return;
}
if (!apiUrl.includes('/embeddings')) {
@@ -489,7 +649,7 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
}
const filePath = getOutputsPath(item.original_path);
if (!filePath) {
- setVectorError('Could not get file path');
+ setVectorError('无法获取文件路径');
return;
}
const body: Record = {
@@ -507,7 +667,7 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
body: JSON.stringify(body)
});
if (!res.ok) {
- let msg = 'Re-embed failed';
+ let msg = '重新入库失败';
try {
const body = await res.json();
msg = body?.detail || body?.message || msg;
@@ -515,15 +675,15 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
msg = await res.text() || msg;
}
if (res.status === 401 || (typeof msg === 'string' && msg.includes('401'))) {
- msg = 'API auth failed (401). Please check API Key in Settings.';
+ msg = 'API 认证失败(401),请到设置中检查 API Key 是否正确。';
}
throw new Error(msg);
}
await res.json();
- await fetchVectorList();
+ await refreshVectorList();
await fetchFiles();
} catch (err: any) {
- setVectorError(err?.message || 'Re-embed failed');
+ setVectorError(err?.message || '重新入库失败');
} finally {
setVectorActionLoading(prev => ({ ...prev, [key]: false }));
}
@@ -532,7 +692,7 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
const handleDeleteVector = async (item: any) => {
const key = item.id || item.original_path;
if (!key) return;
- if (!confirm('Delete this vector? Retrieval will no longer return this file.')) {
+ if (!confirm('确认删除该向量吗?删除后检索将不再返回该文件内容。')) {
return;
}
setVectorActionLoading(prev => ({ ...prev, [key]: true }));
@@ -548,74 +708,28 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
});
if (!res.ok) {
const msg = await res.text();
- throw new Error(msg || 'Failed to delete vector');
+ throw new Error(msg || '删除向量失败');
}
await res.json();
- await fetchVectorList();
+ await refreshVectorList();
} catch (err: any) {
- setVectorError(err?.message || 'Failed to delete vector');
+ setVectorError(err?.message || '删除向量失败');
} finally {
setVectorActionLoading(prev => ({ ...prev, [key]: false }));
}
};
- const handleRunRetrieval = async () => {
- if (!retrievalQuery.trim()) {
- setRetrievalError('Please enter a query');
- return;
- }
- if (!user?.email) {
- setRetrievalError('User info missing');
- return;
- }
-
- const settings = getApiSettings(user?.id || null);
- const apiUrl = settings?.apiUrl?.trim() || '';
- const apiKey = settings?.apiKey?.trim() || '';
- if (!apiUrl || !apiKey) {
- setRetrievalError('Please configure API URL and API Key in Settings first');
- return;
- }
-
- try {
- const selectedFiles = files.filter(f => selectedIds.has(f.id));
- const fileIds = selectedFiles.map(f => f.kbFileId).filter(Boolean) as string[];
-
- setRetrievalLoading(true);
- setRetrievalError('');
- const res = await apiFetch('/api/v1/kb/search', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- query: retrievalQuery.trim(),
- top_k: retrievalTopK,
- email: effectiveUser?.email || effectiveUser?.id,
- api_url: apiUrl,
- api_key: apiKey,
- model_name: retrievalModel,
- file_ids: fileIds.length > 0 ? fileIds : undefined
- })
- });
- if (!res.ok) {
- const msg = await res.text();
- throw new Error(msg || 'Retrieval failed');
- }
- const data = await res.json();
- setRetrievalResults(Array.isArray(data.results) ? data.results : []);
- } catch (err: any) {
- setRetrievalError(err?.message || 'Retrieval failed');
- } finally {
- setRetrievalLoading(false);
- }
- };
-
// Fetch files from outputs when notebook changes(不做用户管理,数据从 outputs 取)
useEffect(() => {
if (notebook?.id) fetchFiles();
}, [effectiveUser?.id, notebook?.id]);
useEffect(() => {
- if (effectiveUser?.email || effectiveUser?.id) fetchVectorList();
+ const currentNotebookId = notebook?.id || null;
+ if ((effectiveUser?.email || effectiveUser?.id) && lastFetchedNotebookIdForSources.current !== currentNotebookId) {
+ lastFetchedNotebookIdForSources.current = currentNotebookId;
+ fetchVectorList();
+ }
}, [effectiveUser?.email, effectiveUser?.id, notebook?.id]);
// Load chat: from API when notebook is set, else from localStorage
@@ -645,7 +759,7 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
const list = msgData?.messages || [];
if (list.length > 0) {
const msgs: ChatMessage[] = [
- { id: 'welcome', role: 'assistant', content: 'Hello! I\'m your knowledge base assistant. Upload files or select sources on the left, then ask your questions here.', time: '' },
+ { id: 'welcome', role: 'assistant', content: '你好!我是你的知识库助手。请上传文件或在左侧来源区域选择文件,然后在此处进行提问。', time: '' },
...list.map((m: any, i: number) => ({
id: m.id || `msg_${i}`,
role: m.role as 'user' | 'assistant',
@@ -690,15 +804,33 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
// Load output history (server + local)
useEffect(() => {
+ console.log('[useEffect outputs] Triggered, notebook?.id:', notebook?.id, 'effectiveUser:', effectiveUser?.id);
+ const currentNotebookId = notebook?.id || null;
+ if (lastFetchedNotebookIdForOutputs.current === currentNotebookId && hasLoadedOutputsRef.current) {
+ console.log('[useEffect outputs] Skip, already fetched for this notebook');
+ return; // Skip if already fetched for this notebook
+ }
+ console.log('[useEffect outputs] Will fetch outputs');
hasLoadedOutputsRef.current = false;
let canceled = false;
const loadOutputs = async () => {
- const local = loadLocalOutputFeed();
- const remote = await fetchOutputHistory();
- if (canceled) return;
- const merged = mergeOutputFeeds(remote, local);
- setOutputFeed(merged);
- hasLoadedOutputsRef.current = true;
+ try {
+ const local = loadLocalOutputFeed();
+ const remote = await fetchOutputHistory();
+ console.log('[loadOutputs] local:', local.length, 'remote:', remote.length);
+ console.log('[loadOutputs] canceled:', canceled);
+ if (canceled) {
+ console.log('[loadOutputs] Canceled, returning early');
+ return;
+ }
+ const merged = mergeOutputFeeds(remote, local);
+ console.log('[loadOutputs] merged:', merged.length, merged);
+ setOutputFeed(merged);
+ lastFetchedNotebookIdForOutputs.current = currentNotebookId;
+ hasLoadedOutputsRef.current = true;
+ } catch (error) {
+ console.error('[loadOutputs] Error:', error);
+ }
};
loadOutputs();
return () => {
@@ -725,7 +857,7 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
try {
setPreviewLoading(true);
const res = await fetch(url);
- if (!res.ok) throw new Error('Failed to load mind map');
+ if (!res.ok) throw new Error('读取思维导图失败');
const text = await res.text();
if (!canceled) {
setPreviewOutput(prev => prev ? { ...prev, mermaidCode: text } : prev);
@@ -774,33 +906,48 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
return `kb_files_${uid}`;
};
+ const cacheScopeUser = effectiveUser?.id || effectiveUser?.email || 'anonymous';
+ const cacheScopeNotebook = notebook?.id || 'no-notebook';
+ const encodeCachePart = (value?: string) => encodeURIComponent(value || '');
+ const getFilesCacheKey = () => `notebook-files:${encodeCachePart(cacheScopeUser)}:${encodeCachePart(cacheScopeNotebook)}`;
+ const getSourceDetailCacheKey = (file: KnowledgeFile) => (
+ `source-detail:${encodeCachePart(cacheScopeUser)}:${encodeCachePart(cacheScopeNotebook)}:${encodeCachePart(file.type)}:${encodeCachePart(file.url || file.name)}`
+ );
+ const invalidateNotebookSourceCaches = () => {
+ invalidateCacheByPrefix(`notebook-files:${encodeCachePart(cacheScopeUser)}:${encodeCachePart(cacheScopeNotebook)}`);
+ invalidateCacheByPrefix(`source-detail:${encodeCachePart(cacheScopeUser)}:${encodeCachePart(cacheScopeNotebook)}`);
+ };
+
const fetchFiles = async () => {
try {
- let mappedFiles: KnowledgeFile[] = [];
- // 数据从 outputs 取:调用后端按磁盘扫描
- if (notebook?.id) {
- const params = new URLSearchParams({
- user_id: effectiveUser.id,
- notebook_id: notebook.id,
- email: effectiveUser.email || effectiveUser.id,
- });
- const res = await apiFetch(`/api/v1/kb/files?${params.toString()}`);
- if (res.ok) {
- const data = await res.json();
- const list = Array.isArray(data?.files) ? data.files : [];
- mappedFiles = list.map((row: any) => ({
- id: row.id || `file-${row.name}`,
- name: row.name,
- type: mapFileType(row.file_type || row.name?.split('.').pop() || ''),
- size: formatSize(row.file_size || 0),
- uploadTime: '',
- isEmbedded: false,
- desc: '',
- url: row.url || row.static_url,
- }));
- }
- }
-
+ const mappedFiles = notebook?.id
+ ? await fetchWithCache(
+ getFilesCacheKey(),
+ FILE_LIST_CACHE_TTL_MS,
+ async () => {
+ const params = new URLSearchParams({
+ user_id: effectiveUser.id,
+ notebook_id: notebook.id,
+ email: effectiveUser.email || effectiveUser.id,
+ });
+ const res = await apiFetch(`/api/v1/kb/files?${params.toString()}`);
+ if (!res.ok) throw new Error('来源列表获取失败');
+ const data = await res.json();
+ const list = Array.isArray(data?.files) ? data.files : [];
+ return list.map((row: any) => ({
+ id: row.id || `file-${row.name}`,
+ name: row.name,
+ type: mapFileType(row.file_type || row.name?.split('.').pop() || ''),
+ size: formatSize(row.file_size || 0),
+ uploadTime: '',
+ isEmbedded: false,
+ desc: '',
+ url: row.url || row.static_url,
+ }));
+ },
+ { useStaleOnError: true }
+ )
+ : [];
setFiles(mappedFiles);
setSelectedIds(new Set(mappedFiles.map(f => f.id)));
} catch (err) {
@@ -835,6 +982,30 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
setSelectedIds(newSet);
};
+ const upsertFileInList = (file: KnowledgeFile) => {
+ setFiles(prev => [
+ file,
+ ...prev.filter(existing => {
+ if (file.id && existing.id === file.id) return false;
+ if (file.url && existing.url === file.url) return false;
+ return true;
+ }),
+ ]);
+ setSelectedIds(prev => new Set([...prev, file.id]));
+ };
+
+ const removePendingSource = (pendingId: string) => {
+ setPendingSources(prev => prev.filter(item => item.id !== pendingId));
+ };
+
+ const markPendingSourceError = (pendingId: string, message: string) => {
+ setPendingSources(prev => prev.map(item => (
+ item.id === pendingId
+ ? { ...item, status: 'error', message }
+ : item
+ )));
+ };
+
/** PDF / .md 等可解析为正文并预览 */
const isPreviewableDoc = (f: KnowledgeFile) => {
const name = (f.name || '').toLowerCase();
@@ -842,72 +1013,130 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
return (name.endsWith('.pdf') || name.endsWith('.md')) || (url.endsWith('.pdf') || url.endsWith('.md'));
};
- const openSourceDetail = async (file: KnowledgeFile) => {
+ const normalizeCitationPath = (value?: string) => {
+ if (!value) return '';
+ const normalized = value.replace(/\\/g, '/');
+ const outputsIdx = normalized.indexOf('/outputs/');
+ return outputsIdx >= 0 ? normalized.slice(outputsIdx) : normalized;
+ };
+
+ const findFileForCitation = (ref: CitationReference) => {
+ const targetPath = normalizeCitationPath(ref.filePath);
+ return files.find((file) => {
+ const filePath = normalizeCitationPath(file.url);
+ if (targetPath && filePath && targetPath === filePath) return true;
+ return file.name === ref.fileName;
+ }) || null;
+ };
+
+ const openSourceDetail = async (file: KnowledgeFile, citationFocus?: CitationReference | null) => {
setSourceDetailView(file);
setSourceDetailContent('');
setSourceDetailFormat('text');
setSourceDetailLoading(false);
+ setSourceDetailCitationFocus(citationFocus ?? null);
if (file.type === 'link' && file.url && (file.url.startsWith('http://') || file.url.startsWith('https://'))) {
setSourceDetailLoading(true);
try {
- const res = await apiFetch('/api/v1/kb/fetch-page-content', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ url: file.url })
- });
- if (res.ok) {
- const data = await res.json();
- setSourceDetailContent(data?.content ?? '[No content]');
- } else {
- setSourceDetailContent('[Fetch failed]');
- }
+ const detail = await fetchWithCache(
+ getSourceDetailCacheKey(file),
+ SOURCE_DETAIL_CACHE_TTL_MS,
+ async () => {
+ const res = await apiFetch('/api/v1/kb/fetch-page-content', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ url: file.url })
+ });
+ if (!res.ok) {
+ return { content: '[抓取失败]', format: 'text' };
+ }
+ const data = await res.json();
+ return { content: data?.content ?? '[无内容]', format: 'text' };
+ },
+ { useStaleOnError: true }
+ );
+ setSourceDetailContent(detail.content);
+ setSourceDetailFormat(detail.format);
} catch {
- setSourceDetailContent('[Request failed]');
+ setSourceDetailContent('[请求失败]');
+ setSourceDetailFormat('text');
} finally {
setSourceDetailLoading(false);
}
} else if (isPreviewableDoc(file) && file.url && (file.url.startsWith('/outputs/') || file.url.startsWith('/'))) {
setSourceDetailLoading(true);
try {
- // Prefer MinerU output MD for display; fallback to parse-local-file
- const displayRes = await apiFetch('/api/v1/kb/get-source-display-content', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ path: file.url })
- });
- if (displayRes.ok) {
- const displayData = await displayRes.json();
- if (displayData?.from_mineru && displayData?.content != null) {
- setSourceDetailContent(displayData.content);
- setSourceDetailFormat('markdown');
- setSourceDetailLoading(false);
- return;
- }
- }
- const res = await apiFetch('/api/v1/kb/parse-local-file', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ path_or_url: file.url })
- });
- if (res.ok) {
- const data = await res.json();
- setSourceDetailContent(data?.content ?? '[No content]');
- setSourceDetailFormat((data?.format === 'markdown' ? 'markdown' : 'text') as 'text' | 'markdown');
- } else {
- setSourceDetailContent('[Parse failed]');
- }
+ const detail = await fetchWithCache(
+ getSourceDetailCacheKey(file),
+ SOURCE_DETAIL_CACHE_TTL_MS,
+ async () => {
+ const displayRes = await apiFetch('/api/v1/kb/get-source-display-content', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ path: file.url })
+ });
+ if (displayRes.ok) {
+ const displayData = await displayRes.json();
+ if (displayData?.from_mineru && displayData?.content != null) {
+ return { content: displayData.content, format: 'markdown' };
+ }
+ }
+ const res = await apiFetch('/api/v1/kb/parse-local-file', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ path_or_url: file.url })
+ });
+ if (!res.ok) {
+ return { content: '[解析失败]', format: 'text' };
+ }
+ const data = await res.json();
+ return {
+ content: data?.content ?? '[无内容]',
+ format: (data?.format === 'markdown' ? 'markdown' : 'text') as 'text' | 'markdown',
+ };
+ },
+ { useStaleOnError: true }
+ );
+ setSourceDetailContent(detail.content);
+ setSourceDetailFormat(detail.format);
} catch {
- setSourceDetailContent('[Request failed]');
+ setSourceDetailContent('[请求失败]');
+ setSourceDetailFormat('text');
} finally {
setSourceDetailLoading(false);
}
} else if (file.url && (file.url.startsWith('http') || file.url.startsWith('/'))) {
- setSourceDetailContent(`[File preview] ${file.name}\n\nOpen in new tab: ${file.url}`);
+ setSourceDetailContent(`[文件预览] ${file.name}\n\n可在新标签页打开: ${file.url}`);
} else {
- setSourceDetailContent(`[No parse preview] ${file.name}`);
+ setSourceDetailContent(`[暂无解析预览] ${file.name}`);
}
};
+ const handleCitationClick = async (
+ citationNumber: string,
+ sourceReferenceMapping?: Record
+ ) => {
+ const ref = sourceReferenceMapping?.[citationNumber];
+ if (!ref) return;
+ const targetFile = findFileForCitation(ref);
+ if (!targetFile) return;
+ setSelectedIds(prev => {
+ if (prev.has(targetFile.id)) return prev;
+ const next = new Set(prev);
+ next.add(targetFile.id);
+ return next;
+ });
+ await openSourceDetail(targetFile, { ...ref, sourceNumber: citationNumber });
+ };
+
+ React.useEffect(() => {
+ if (!sourceDetailCitationFocus || sourceDetailLoading) return;
+ const timer = window.setTimeout(() => {
+ sourceDetailCitationRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ }, 80);
+ return () => window.clearTimeout(timer);
+ }, [sourceDetailCitationFocus, sourceDetailLoading, sourceDetailContent]);
+
const runFastResearch = async () => {
if (!fastResearchQuery.trim()) return;
const settings = getApiSettings(effectiveUser?.id || null);
@@ -915,7 +1144,7 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
const searchEngine = (settings?.searchEngine as 'google' | 'baidu') || 'google';
const searchApiKey = settings?.searchApiKey?.trim() ?? '';
if ((searchProvider === 'serpapi' || searchProvider === 'bocha') && !searchApiKey) {
- setFastResearchError('Please configure Search API Key in Settings (top right) first');
+ setFastResearchError('请先在右上角「设置」中配置搜索 API Key');
return;
}
setFastResearchLoading(true);
@@ -929,7 +1158,7 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
search_provider: searchProvider,
search_engine: searchEngine,
};
- if (searchProvider === 'serpapi' || searchProvider === 'bocha') body.search_api_key = searchApiKey;
+ body.search_api_key = searchApiKey;
const res = await apiFetch('/api/v1/kb/fast-research', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -937,14 +1166,14 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
- throw new Error(data?.detail || data?.message || 'Fast Research request failed');
+ throw new Error(data?.detail || data?.message || 'Fast Research 请求失败');
}
const data = await res.json();
const sources = data?.sources || [];
setFastResearchSources(sources);
setFastResearchSelected(new Set(sources.map((_: any, i: number) => i)));
} catch (err: any) {
- setFastResearchError(err?.message || 'Search failed');
+ setFastResearchError(err?.message || '搜索失败');
} finally {
setFastResearchLoading(false);
}
@@ -957,7 +1186,7 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
.map(({ title, link, snippet }) => ({ title, link, snippet }));
if (items.length === 0) return;
if (!notebook?.id || !effectiveUser?.email) {
- alert('Please select a notebook and sign in first');
+ showToast('请先选择笔记本并登录', 'warning');
return;
}
setImportingSources(true);
@@ -975,17 +1204,18 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
- throw new Error(data?.detail || data?.message || 'Import failed');
+ throw new Error(data?.detail || data?.message || '导入失败');
}
const data = await res.json();
+ invalidateNotebookSourceCaches();
await fetchFiles();
- await fetchVectorList();
+ await refreshVectorList();
setFastResearchSources([]);
setFastResearchSelected(new Set());
- const embeddedMsg = data?.embedded ? `, ${data.embedded} embedded` : '';
- alert(`Imported ${data?.imported ?? items.length} source(s)${embeddedMsg}`);
+ const embeddedMsg = data?.embedded ? `,已向量化 ${data.embedded} 个` : '';
+ showToast(`已导入 ${data?.imported ?? items.length} 个来源${embeddedMsg}`, 'success');
} catch (err: any) {
- alert(err?.message || 'Import failed');
+ showToast(err?.message || '导入失败', 'error');
} finally {
setImportingSources(false);
}
@@ -994,11 +1224,11 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
const handleImportUrlAsSource = async () => {
const url = introduceUrl.trim();
if (!url) {
- setIntroduceUrlError('Please enter a page URL');
+ setIntroduceUrlError('请输入网页 URL');
return;
}
if (!notebook?.id || !effectiveUser?.email) {
- setIntroduceUrlError('Please select a notebook first');
+ setIntroduceUrlError('请先选择笔记本');
return;
}
setIntroduceUrlError('');
@@ -1017,7 +1247,7 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
- throw new Error(data?.detail || data?.message || 'Fetch failed');
+ throw new Error(data?.detail || data?.message || '抓取失败');
}
const data = await res.json();
const newFile: KnowledgeFile = {
@@ -1032,12 +1262,13 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
};
setFiles(prev => [newFile, ...prev.filter(f => f.id !== newFile.id)]);
setSelectedIds(prev => new Set([...prev, newFile.id]));
+ invalidateNotebookSourceCaches();
await fetchFiles();
setIntroduceUrl('');
- setIntroduceUrlSuccess('Fetched and added as source');
+ setIntroduceUrlSuccess('已抓取并加入来源');
setTimeout(() => setIntroduceUrlSuccess(''), 3000);
} catch (err: any) {
- setIntroduceUrlError(err?.message || 'Fetch failed');
+ setIntroduceUrlError(err?.message || '抓取失败');
} finally {
setIntroduceUrlLoading(false);
}
@@ -1046,11 +1277,11 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
const handleAddTextSource = async () => {
const content = introduceText.trim();
if (!content) {
- setIntroduceTextError('Please enter or paste text');
+ setIntroduceTextError('请输入或粘贴文字');
return;
}
if (!notebook?.id || !effectiveUser?.email) {
- setIntroduceTextError('Please select a notebook first');
+ setIntroduceTextError('请先选择笔记本');
return;
}
setIntroduceTextError('');
@@ -1064,13 +1295,13 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
email: effectiveUser.email || effectiveUser.id,
user_id: effectiveUser.id,
notebook_title: notebook?.title || notebook?.name || '',
- title: 'Direct input',
+ title: '直接输入',
content,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
- throw new Error(data?.detail || data?.message || 'Add failed');
+ throw new Error(data?.detail || data?.message || '添加失败');
}
const data = await res.json();
const newFile: KnowledgeFile = {
@@ -1085,12 +1316,13 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
};
setFiles(prev => [newFile, ...prev.filter(f => f.id !== newFile.id)]);
setSelectedIds(prev => new Set([...prev, newFile.id]));
+ invalidateNotebookSourceCaches();
await fetchFiles();
setIntroduceText('');
- setIntroduceTextSuccess('Added as source');
+ setIntroduceTextSuccess('已添加为来源');
setTimeout(() => setIntroduceTextSuccess(''), 3000);
} catch (err: any) {
- setIntroduceTextError(err?.message || 'Add failed');
+ setIntroduceTextError(err?.message || '添加失败');
} finally {
setIntroduceTextLoading(false);
}
@@ -1105,15 +1337,15 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
const searchEngine = (settings?.searchEngine as 'google' | 'baidu') || 'google';
const searchApiKey = settings?.searchApiKey?.trim() ?? '';
if (!apiUrl || !apiKey) {
- setDeepResearchError('Please configure API in Settings first');
+ setDeepResearchError('请先在设置中配置 API');
return;
}
- if ((searchProvider === 'serpapi' || searchProvider === 'bocha') && !searchApiKey) {
- setDeepResearchError('Please configure Search API Key in Settings first');
+ if (!searchApiKey) {
+ setDeepResearchError('请先在设置中配置搜索 API Key');
return;
}
if (!notebook?.id || !effectiveUser?.email) {
- setDeepResearchError('Please select a notebook first');
+ setDeepResearchError('请先选择笔记本');
return;
}
setDeepResearchLoading(true);
@@ -1133,14 +1365,14 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
language: 'zh',
add_as_source: true,
search_provider: searchProvider,
- search_api_key: searchProvider === 'serpapi' || searchProvider === 'bocha' ? searchApiKey : undefined,
+ search_api_key: searchApiKey,
search_engine: searchEngine,
search_top_k: 10
})
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
- throw new Error(data?.detail || data?.message || 'Report generation failed');
+ throw new Error(data?.detail || data?.message || '生成报告失败');
}
const data = await res.json();
if (data.added_as_source && data.added_file) {
@@ -1158,6 +1390,7 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
setFiles(prev => [newFile, ...prev.filter(f => f.id !== newFile.id)]);
setSelectedIds(prev => new Set([...prev, newFile.id]));
}
+ invalidateNotebookSourceCaches();
await fetchFiles();
setDeepResearchTopic('');
setDeepResearchSuccess({
@@ -1165,7 +1398,7 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
pdfUrl: data?.pdf_url || data?.report_url,
});
} catch (err: any) {
- setDeepResearchError(err?.message || 'Generation failed');
+ setDeepResearchError(err?.message || '生成失败');
} finally {
setDeepResearchLoading(false);
}
@@ -1183,48 +1416,102 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
return url;
};
- // Upload handler(不做用户管理时用 effectiveUser);可选 onSuccess 用于引入弹框内反馈
- const handleFileUpload = async (
- e: React.ChangeEvent,
- options?: { onSuccess?: () => void }
+ const uploadFiles = async (
+ inputFiles: FileList | File[],
+ options?: { closeModalOnQueue?: boolean }
) => {
- if (!e.target.files) return;
+ const uploadQueue = Array.from(inputFiles || []);
+ if (!uploadQueue.length) return;
if (!notebook?.id) {
- alert('Please select or create a notebook before uploading');
+ showToast('请先选择或创建一个笔记本再上传文件', 'warning');
return;
}
- const file = e.target.files[0];
- const formData = new FormData();
- formData.append('file', file);
- formData.append('email', effectiveUser.email || effectiveUser.id || 'default');
- formData.append('user_id', effectiveUser.id || 'default');
- formData.append('notebook_id', notebook.id);
- formData.append('notebook_title', notebook?.title || notebook?.name || '');
+ const queuedSources: PendingSourceItem[] = uploadQueue.map((file, index) => ({
+ id: `pending-upload-${Date.now()}-${index}-${file.name}`,
+ name: file.name,
+ sourceType: 'upload',
+ status: 'processing',
+ }));
+
+ setPendingSources(prev => [...queuedSources, ...prev]);
+ if (options?.closeModalOnQueue) {
+ setShowIntroduceModal(false);
+ }
setFileUploading(true);
+ showToast(
+ uploadQueue.length > 1
+ ? `已添加 ${uploadQueue.length} 个文件,正在处理`
+ : `已添加 ${uploadQueue[0].name},正在处理`,
+ 'info'
+ );
+
+ let successCount = 0;
+ let failureCount = 0;
+ let embeddedCount = 0;
+
try {
- const res = await apiFetch('/api/v1/kb/upload', {
- method: 'POST',
- body: formData
- });
+ for (let i = 0; i < uploadQueue.length; i += 1) {
+ const file = uploadQueue[i];
+ const pendingItem = queuedSources[i];
+ const formData = new FormData();
+ formData.append('file', file);
+ formData.append('email', effectiveUser.email || effectiveUser.id || 'default');
+ formData.append('user_id', effectiveUser.id || 'default');
+ formData.append('notebook_id', notebook.id);
+ formData.append('notebook_title', notebook?.title || notebook?.name || '');
+
+ try {
+ const res = await apiFetch('/api/v1/kb/upload', {
+ method: 'POST',
+ body: formData
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ throw new Error(data?.detail || data?.message || `上传 ${file.name} 失败`);
+ }
- if (!res.ok) throw new Error('Upload failed');
+ const newFile: KnowledgeFile = {
+ id: data.id || data.storage_path || `file-${data.filename || file.name}`,
+ name: data.filename || file.name,
+ type: mapFileType(data.file_type || file.type || file.name.split('.').pop() || ''),
+ size: typeof data.file_size === 'number' ? formatSize(data.file_size) : formatSize(file.size || 0),
+ uploadTime: '',
+ isEmbedded: !!data.embedded,
+ desc: '',
+ url: data.static_url || '',
+ };
- const data = await res.json();
- await fetchFiles();
- await fetchVectorList();
- if (data.embedded) {
- if (options?.onSuccess) options.onSuccess();
- else alert('Uploaded and embedded successfully!');
- } else {
- if (options?.onSuccess) options.onSuccess();
- else alert('Upload succeeded but auto-embedding failed. You can re-embed from Sources.');
+ removePendingSource(pendingItem.id);
+ upsertFileInList(newFile);
+ successCount += 1;
+ if (data.embedded) embeddedCount += 1;
+ } catch (err: any) {
+ const msg = err?.message || `上传 ${file.name} 失败`;
+ console.error('Upload error:', err);
+ markPendingSourceError(pendingItem.id, msg);
+ setRetrievalError(msg);
+ failureCount += 1;
+ }
+ }
+
+ if (successCount > 0) {
+ invalidateNotebookSourceCaches();
+ await fetchFiles();
+ await refreshVectorList();
+ if (failureCount === 0) {
+ showToast(
+ embeddedCount === successCount
+ ? `已完成 ${successCount} 个来源导入并入库`
+ : `已完成 ${successCount} 个来源导入`,
+ 'success'
+ );
+ } else {
+ showToast(`已完成 ${successCount} 个来源导入,${failureCount} 个失败`, 'warning');
+ }
+ } else if (failureCount > 0) {
+ showToast(`上传失败:${failureCount} 个文件未处理成功`, 'error');
}
- } catch (err: any) {
- console.error('Upload error:', err);
- const msg = err?.message || 'Upload failed, please retry';
- setRetrievalError(msg);
- alert(msg);
} finally {
setFileUploading(false);
}
@@ -1244,13 +1531,14 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
setChatMessages(prev => [...prev, userMsg]);
setInputMsg('');
setIsChatLoading(true);
+ setChatLoadingStage('正在准备来源...');
try {
if (selectedIds.size === 0) {
const botMsg: ChatMessage = {
id: Date.now().toString(),
role: 'assistant',
- content: 'Please select at least one source on the left so I can answer based on those materials.',
+ content: '请先在左侧来源列表中勾选至少一个文件,我才能基于这些资料回答您的问题。',
time: new Date().toLocaleTimeString()
};
setChatMessages(prev => [...prev, botMsg]);
@@ -1270,8 +1558,37 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
}));
const settings = getApiSettings(effectiveUser?.id || null);
+ const assistantMessageId = (Date.now() + 1).toString();
+ const assistantTime = new Date().toLocaleTimeString();
+ let streamedContent = '';
+ let streamedDetails: ChatMessage['details'] | undefined;
+ let streamedSourceMapping: ChatMessage['sourceMapping'] | undefined;
+ let streamedSourcePreviewMapping: ChatMessage['sourcePreviewMapping'] | undefined;
+ let streamedSourceReferenceMapping: ChatMessage['sourceReferenceMapping'] | undefined;
+
+ const syncAssistantMessage = () => {
+ setChatMessages(prev => prev.map(msg => (
+ msg.id === assistantMessageId
+ ? {
+ ...msg,
+ content: streamedContent,
+ details: streamedDetails,
+ sourceMapping: streamedSourceMapping,
+ sourcePreviewMapping: streamedSourcePreviewMapping,
+ sourceReferenceMapping: streamedSourceReferenceMapping,
+ }
+ : msg
+ )));
+ };
+
+ setChatMessages(prev => [...prev, {
+ id: assistantMessageId,
+ role: 'assistant',
+ content: '',
+ time: assistantTime,
+ }]);
- const res = await apiFetch('/api/v1/kb/chat', {
+ const res = await apiFetch('/api/v1/kb/chat/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
@@ -1288,18 +1605,70 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
});
if (!res.ok) throw new Error("Chat request failed");
+ if (!res.body) throw new Error("Chat stream not available");
+
+ const reader = res.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = '';
+
+ const processEvent = (rawLine: string) => {
+ const line = rawLine.trim();
+ if (!line) return;
+ const event = JSON.parse(line);
+ if (event.type === 'meta') {
+ streamedDetails = event.file_analyses || undefined;
+ streamedSourceMapping = event.source_mapping || undefined;
+ streamedSourcePreviewMapping = event.source_preview_mapping || undefined;
+ streamedSourceReferenceMapping = event.source_reference_mapping || undefined;
+ syncAssistantMessage();
+ return;
+ }
+ if (event.type === 'stage') {
+ setChatLoadingStage(event.message || '思考中...');
+ return;
+ }
+ if (event.type === 'delta') {
+ if (!streamedContent) setChatLoadingStage('正在生成回答...');
+ streamedContent += event.delta || '';
+ syncAssistantMessage();
+ return;
+ }
+ if (event.type === 'done') {
+ if (typeof event.answer === 'string' && event.answer.length >= streamedContent.length) {
+ streamedContent = event.answer;
+ syncAssistantMessage();
+ }
+ return;
+ }
+ if (event.type === 'error') {
+ throw new Error(event.message || 'Chat stream failed');
+ }
+ };
+
+ while (true) {
+ const { value, done } = await reader.read();
+ if (done) break;
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split('\n');
+ buffer = lines.pop() || '';
+ for (const line of lines) {
+ processEvent(line);
+ }
+ }
+ buffer += decoder.decode();
+ if (buffer.trim()) processEvent(buffer);
- const data = await res.json();
-
const botMsg: ChatMessage = {
- id: (Date.now() + 1).toString(),
+ id: assistantMessageId,
role: 'assistant',
- content: data.answer || "Sorry, I couldn't answer that.",
- time: new Date().toLocaleTimeString(),
- details: data.file_analyses,
- sourceMapping: data.source_mapping || undefined
+ content: streamedContent || "抱歉,我无法回答这个问题。",
+ time: assistantTime,
+ details: streamedDetails,
+ sourceMapping: streamedSourceMapping,
+ sourcePreviewMapping: streamedSourcePreviewMapping,
+ sourceReferenceMapping: streamedSourceReferenceMapping
};
- setChatMessages(prev => [...prev, botMsg]);
+ setChatMessages(prev => prev.map(msg => msg.id === assistantMessageId ? botMsg : msg));
persistCurrentConversation([...chatMessages, userMsg, botMsg]);
const cid = conversationIdRef.current;
@@ -1317,23 +1686,29 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
}
} catch (err) {
console.error("Chat error:", err);
+ const errorContent = err instanceof Error ? err.message : "发生错误,请稍后重试。";
const errorMsg: ChatMessage = {
id: (Date.now() + 1).toString(),
role: 'assistant',
- content: "An error occurred. Please try again later.",
+ content: errorContent || "发生错误,请稍后重试。",
time: new Date().toLocaleTimeString()
};
- setChatMessages(prev => [...prev, errorMsg]);
+ setChatMessages(prev => {
+ const emptyAssistant = prev.find(msg => msg.role === 'assistant' && !msg.content.trim());
+ if (!emptyAssistant) return [...prev, errorMsg];
+ return prev.map(msg => msg.id === emptyAssistant.id ? errorMsg : msg);
+ });
persistCurrentConversation([...chatMessages, userMsg, errorMsg]);
} finally {
setIsChatLoading(false);
+ setChatLoadingStage('思考中...');
}
};
// Tool handlers (PPT, Mindmap, etc.)
const handleToolGenerate = async (tool: ToolType) => {
if (selectedIds.size === 0) {
- alert('Please select at least one file');
+ showToast('请先选择至少一个文件', 'warning');
return;
}
@@ -1349,7 +1724,7 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
const apiUrl = settings?.apiUrl?.trim() || '';
const apiKey = settings?.apiKey?.trim() || '';
if (!apiUrl || !apiKey) {
- alert('Please configure API URL and API Key in Settings first');
+ showToast('请先在设置中配置 API URL 和 API Key', 'warning');
setToolLoading(false);
return;
}
@@ -1377,6 +1752,12 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
case 'drawio':
endpoint = '/api/v1/kb/generate-drawio';
break;
+ case 'flashcard':
+ endpoint = '/api/v1/kb/generate-flashcards';
+ break;
+ case 'quiz':
+ endpoint = '/api/v1/kb/generate-quiz';
+ break;
default:
throw new Error('Unsupported tool');
}
@@ -1392,13 +1773,13 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
});
const validSources = [...validDocFiles, ...linkFiles];
if (validSources.length === 0) {
- alert('Please select at least 1 document or web source (PDF/PPTX/DOCX/MD or web import).');
+ showToast('请至少选择 1 个文档或网页来源进行生成(支持 PDF/PPTX/DOCX/MD 或网页引入)。', 'warning');
setToolLoading(false);
return;
}
const docPaths = validSources.map(f => f.url).filter(Boolean) as string[];
if (docPaths.length !== validSources.length) {
- alert('Could not get document/web path. Please retry.');
+ showToast('无法获取文档/网页路径,请重试。', 'error');
setToolLoading(false);
return;
}
@@ -1408,10 +1789,10 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
const getStyleDescription = (preset: string): string => {
const styles: Record = {
- modern: 'Modern minimal, clean lines and whitespace',
- business: 'Business professional, formal',
- academic: 'Academic report, clear structure',
- creative: 'Creative, vivid and colorful',
+ modern: '现代简约风格,使用干净的线条和充足的留白',
+ business: '商务专业风格,稳重大气,适合企业演示',
+ academic: '学术报告风格,清晰的层次结构,适合论文汇报',
+ creative: '创意设计风格,活泼生动,色彩丰富',
};
return styles[preset] || styles.modern;
};
@@ -1438,12 +1819,11 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
...baseBody,
file_paths: selectedFileUrls,
model: cfg.llmModel || 'deepseek-v3.2',
- tts_model: cfg.ttsModel || 'gemini-2.5-pro-preview-tts',
- voice_name: cfg.voiceName || 'Kore',
- voice_name_b: cfg.voiceNameB || 'Puck',
- podcast_mode: 'monologue',
- podcast_length: 'standard',
- language: 'zh'
+ tts_model: cfg.ttsModel || 'qwen-tts',
+ voice_name: cfg.voiceName || 'vivian',
+ voice_name_b: cfg.voiceNameB || 'uncle_fu',
+ podcast_mode: cfg.podcastMode || 'monologue',
+ language: cfg.podcastLanguage || 'zh'
};
} else if (tool === 'mindmap') {
const cfg = getStudioConfig('mindmap');
@@ -1463,6 +1843,24 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
diagram_style: cfg.diagramStyle || 'default',
language: cfg.language || 'zh',
};
+ } else if (tool === 'flashcard') {
+ const cfg = getStudioConfig('flashcard');
+ bodyData = {
+ ...baseBody,
+ file_paths: selectedFileUrls,
+ model: cfg.llmModel || 'deepseek-v3.2',
+ language: cfg.language || 'zh',
+ card_count: Math.max(5, Math.min(50, parseInt(String(cfg.cardCount || '20'), 10) || 20)),
+ };
+ } else if (tool === 'quiz') {
+ const cfg = getStudioConfig('quiz');
+ bodyData = {
+ ...baseBody,
+ file_paths: selectedFileUrls,
+ model: cfg.llmModel || 'deepseek-v3.2',
+ language: cfg.language || 'zh',
+ question_count: Math.max(5, Math.min(30, parseInt(String(cfg.questionCount || '10'), 10) || 10)),
+ };
} else {
bodyData = {
...baseBody,
@@ -1493,8 +1891,8 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
{
id: data.output_file_id || `ppt_${Date.now()}`,
type: 'ppt',
- title: 'PPT',
- sources: selectedNames.length ? selectedNames.join(', ') : `${selectedIds.size} source(s)`,
+ title: 'PPT 生成',
+ sources: selectedNames.length ? selectedNames.join('、') : `来源 ${selectedIds.size}`,
url: downloadUrl,
previewUrl: pdfUrl,
createdAt: now,
@@ -1507,8 +1905,8 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
const outputItem = {
id: data.output_file_id || `mindmap_${Date.now()}`,
type: 'mindmap' as const,
- title: 'Mind Map',
- sources: selectedNames.length ? selectedNames.join(', ') : `${selectedIds.size} source(s)`,
+ title: '思维导图',
+ sources: selectedNames.length ? selectedNames.join('、') : `来源 ${selectedIds.size}`,
url,
createdAt: now,
mermaidCode
@@ -1522,8 +1920,8 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
{
id: data.output_file_id || `podcast_${Date.now()}`,
type: 'podcast',
- title: 'Podcast',
- sources: selectedNames.length ? selectedNames.join(', ') : `${selectedIds.size} source(s)`,
+ title: '播客生成',
+ sources: selectedNames.length ? selectedNames.join('、') : `来源 ${selectedIds.size}`,
url,
createdAt: now,
},
@@ -1535,18 +1933,52 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
{
id: data.output_file_id || `drawio_${Date.now()}`,
type: 'drawio',
- title: 'DrawIO',
- sources: selectedNames.length ? selectedNames.join(', ') : `${selectedIds.size} source(s)`,
+ title: 'DrawIO 图表',
+ sources: selectedNames.length ? selectedNames.join('、') : `来源 ${selectedIds.size}`,
url,
createdAt: now,
},
...prev,
]);
+ } else if (tool === 'flashcard') {
+ setFlashcards(data.flashcards || []);
+ setFlashcardSetId(data.flashcard_set_id || '');
+ if (data.flashcards?.length) setShowFlashcardViewer(true);
+ const fcSetId = (data.flashcard_set_id || '').replace('flashcard_', '');
+ setOutputFeed(prev => [
+ {
+ id: data.flashcard_set_id || `flashcard_${Date.now()}`,
+ type: 'flashcard',
+ title: '闪卡',
+ sources: selectedNames.length ? selectedNames.join('、') : `来源 ${selectedIds.size}`,
+ url: '',
+ createdAt: now,
+ setId: fcSetId || String(Date.now()),
+ },
+ ...prev,
+ ]);
+ } else if (tool === 'quiz') {
+ setQuizQuestions(data.questions || []);
+ setQuizId(data.quiz_id || '');
+ if (data.questions?.length) setShowQuizContainer(true);
+ const qzSetId = (data.quiz_id || '').replace('quiz_', '');
+ setOutputFeed(prev => [
+ {
+ id: data.quiz_id || `quiz_${Date.now()}`,
+ type: 'quiz',
+ title: '测验',
+ sources: selectedNames.length ? selectedNames.join('、') : `来源 ${selectedIds.size}`,
+ url: '',
+ createdAt: now,
+ setId: qzSetId || String(Date.now()),
+ },
+ ...prev,
+ ]);
}
} catch (err) {
console.error('Tool generation error:', err);
- alert('Generation failed. Please retry');
+ showToast('生成失败,请重试', 'error');
} finally {
setToolLoading(false);
}
@@ -1566,38 +1998,78 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
}
};
- const renderInline = (text: string, sourceMapping?: Record) => {
- // 1) 先提取行内公式 $...$ 保护起来,避免 escapeHtml 破坏
+ const renderTooltipText = (text: string) => {
+ if (!text) return '';
const mathSlots: string[] = [];
- let protected_ = text.replace(/\$([^$\n]+?)\$/g, (_m, tex) => {
+ let processed = text;
+ // 处理 \(...\) 行内公式
+ processed = processed.replace(/\\\((.+?)\\\)/g, (_m, tex) => {
mathSlots.push(renderKatex(tex, false));
return `\x00MATH${mathSlots.length - 1}\x00`;
});
+ // 处理 \[...\] 块级公式
+ processed = processed.replace(/\\\[(.+?)\\\]/g, (_m, tex) => {
+ mathSlots.push(renderKatex(tex, true));
+ return `\x00MATH${mathSlots.length - 1}\x00`;
+ });
+ // 转义HTML
+ processed = escapeHtml(processed);
+ // 还原公式
+ processed = processed.replace(/\x00MATH(\d+)\x00/g, (_m, idx) => mathSlots[Number(idx)]);
+ return processed;
+ };
+
+ const renderInline = (
+ text: string,
+ sourceMapping?: Record,
+ sourcePreviewMapping?: Record,
+ sourceReferenceMapping?: Record
+ ) => {
+ // 1) 先提取行内公式,支持 $...$ 和 \(...\) 格式
+ const mathSlots: string[] = [];
+ let protected_ = text
+ .replace(/\\\((.+?)\\\)/g, (_m, tex) => {
+ mathSlots.push(renderKatex(tex, false));
+ return `\x00MATH${mathSlots.length - 1}\x00`;
+ })
+ .replace(/\$([^$\n]+?)\$/g, (_m, tex) => {
+ mathSlots.push(renderKatex(tex, false));
+ return `\x00MATH${mathSlots.length - 1}\x00`;
+ });
// 2) 正常 escapeHtml + markdown 处理
let html = escapeHtml(protected_);
html = html.replace(/`([^`]+)`/g, '$1');
html = html.replace(/\*\*(.+?)\*\*/g, '$1 ');
html = html.replace(/(^|[^*])\*([^*]+)\*/g, '$1$2 ');
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 ');
- // Highlight numbered citation markers [1], [2], etc. with hover tooltip showing source name
+ // Highlight numbered citation markers [1], [2], etc. with hover tooltip showing file name + chunk preview
html = html.replace(/\[(\d{1,2})\]/g, (_match, num) => {
- const sourceName = sourceMapping?.[num] || '';
- const dataAttr = sourceName ? ` data-source="${escapeHtml(sourceName)}"` : '';
- return `[${num}] `;
+ const hasSource = sourceReferenceMapping?.[num] || sourceMapping?.[num];
+ return `[${num}] `;
});
// 3) 还原公式占位符
html = html.replace(/\x00MATH(\d+)\x00/g, (_m, idx) => mathSlots[Number(idx)]);
return html;
};
- const renderMarkdownToHtml = (content: string, sourceMapping?: Record) => {
+ const renderMarkdownToHtml = (
+ content: string,
+ sourceMapping?: Record,
+ sourcePreviewMapping?: Record,
+ sourceReferenceMapping?: Record
+ ) => {
if (!content) return '';
- // 先提取 $$...$$ 块级公式,替换为占位符
+ // 先提取块级公式,支持 $$...$$ 和 \[...\] 格式
const blockMathSlots: string[] = [];
- let processed = content.replace(/\$\$([\s\S]+?)\$\$/g, (_m, tex) => {
- blockMathSlots.push(`${renderKatex(tex.trim(), true)}
`);
- return `\n%%BLOCKMATH${blockMathSlots.length - 1}%%\n`;
- });
+ let processed = content
+ .replace(/\\\[([\s\S]+?)\\\]/g, (_m, tex) => {
+ blockMathSlots.push(`${renderKatex(tex.trim(), true)}
`);
+ return `\n%%BLOCKMATH${blockMathSlots.length - 1}%%\n`;
+ })
+ .replace(/\$\$([\s\S]+?)\$\$/g, (_m, tex) => {
+ blockMathSlots.push(`${renderKatex(tex.trim(), true)}
`);
+ return `\n%%BLOCKMATH${blockMathSlots.length - 1}%%\n`;
+ });
const codeBlockRegex = /```([a-zA-Z0-9_-]+)?\n([\s\S]*?)```/g;
let lastIndex = 0;
let html = '';
@@ -1627,7 +2099,7 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
if (headingMatch) {
closeLists();
const level = headingMatch[1].length;
- const headingText = renderInline(headingMatch[2], sourceMapping);
+ const headingText = renderInline(headingMatch[2], sourceMapping, sourcePreviewMapping, sourceReferenceMapping);
blockHtml += `${headingText} `;
continue;
}
@@ -1638,7 +2110,7 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
blockHtml += '';
inUl = true;
}
- blockHtml += `${renderInline(trimmed.replace(/^[-*]\s+/, ''), sourceMapping)} `;
+ blockHtml += `${renderInline(trimmed.replace(/^[-*]\s+/, ''), sourceMapping, sourcePreviewMapping, sourceReferenceMapping)} `;
continue;
}
@@ -1648,7 +2120,7 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
blockHtml += '';
inOl = true;
}
- blockHtml += `${renderInline(trimmed.replace(/^\d+\.\s+/, ''), sourceMapping)} `;
+ blockHtml += `${renderInline(trimmed.replace(/^\d+\.\s+/, ''), sourceMapping, sourcePreviewMapping, sourceReferenceMapping)} `;
continue;
}
@@ -1659,7 +2131,7 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
}
closeLists();
- blockHtml += `${renderInline(line, sourceMapping)}
`;
+ blockHtml += `${renderInline(line, sourceMapping, sourcePreviewMapping, sourceReferenceMapping)}
`;
}
closeLists();
@@ -1680,10 +2152,84 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
return html;
};
- const MarkdownContent = ({ content, sourceMapping }: { content: string; sourceMapping?: Record }) => (
+ const MarkdownContent = ({
+ content,
+ sourceMapping,
+ sourcePreviewMapping,
+ sourceReferenceMapping,
+ }: {
+ content: string;
+ sourceMapping?: Record;
+ sourcePreviewMapping?: Record;
+ sourceReferenceMapping?: Record;
+ }) => (
{
+ const target = event.target as HTMLElement | null;
+ const citeEl = target?.closest('.cite-ref[data-cite]') as HTMLElement | null;
+ if (!citeEl) return;
+ const citationNumber = citeEl.dataset.cite;
+ if (!citationNumber) return;
+ event.preventDefault();
+ event.stopPropagation();
+ void handleCitationClick(citationNumber, sourceReferenceMapping);
+ }}
+ onMouseMove={(event) => {
+ const target = event.target as HTMLElement | null;
+ const citeEl = target?.closest('.cite-ref[data-cite]') as HTMLElement | null;
+ if (!citeEl) {
+ if (hoveredCitation) setHoveredCitation(null);
+ return;
+ }
+ const citationNumber = citeEl.dataset.cite;
+ if (!citationNumber) {
+ if (hoveredCitation) setHoveredCitation(null);
+ return;
+ }
+ const ref = sourceReferenceMapping?.[citationNumber];
+ const title = ref?.fileName || sourceMapping?.[citationNumber] || '';
+ const preview = ref?.preview || sourcePreviewMapping?.[citationNumber] || '';
+ if (!title && !preview) {
+ if (hoveredCitation) setHoveredCitation(null);
+ return;
+ }
+ setHoveredCitation({
+ title,
+ preview,
+ x: event.clientX,
+ y: event.clientY - 18,
+ });
+ }}
+ onMouseLeave={() => setHoveredCitation(null)}
+ onMouseOver={(event) => {
+ const target = event.target as HTMLElement | null;
+ const citeEl = target?.closest('.cite-ref[data-cite]') as HTMLElement | null;
+ if (!citeEl) {
+ setHoveredCitation(null);
+ return;
+ }
+ const citationNumber = citeEl.dataset.cite;
+ if (!citationNumber) {
+ setHoveredCitation(null);
+ return;
+ }
+ const ref = sourceReferenceMapping?.[citationNumber];
+ const title = ref?.fileName || sourceMapping?.[citationNumber] || '';
+ const preview = ref?.preview || sourcePreviewMapping?.[citationNumber] || '';
+ if (!title && !preview) {
+ setHoveredCitation(null);
+ return;
+ }
+ const rect = citeEl.getBoundingClientRect();
+ setHoveredCitation({
+ title,
+ preview,
+ x: rect.left + rect.width / 2,
+ y: rect.top - 12,
+ });
+ }}
+ dangerouslySetInnerHTML={{ __html: renderMarkdownToHtml(content, sourceMapping, sourcePreviewMapping, sourceReferenceMapping) }}
/>
);
@@ -1702,93 +2248,116 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
return trimmed;
};
+ const tooltipViewportWidth = typeof window !== 'undefined' ? window.innerWidth : 1280;
+ const tooltipLeft = hoveredCitation
+ ? Math.min(Math.max(hoveredCitation.x, 220), tooltipViewportWidth - 220)
+ : 220;
return (
-
+ <>
+ {ToastContainer}
+
{/* Citation tooltip styles */}
+ {hoveredCitation && (
+
+
+
+ {hoveredCitation.title}
+
+ {hoveredCitation.preview && (
+
+ )}
+
+
+
+ )}
{/* Header */}
-