From 02e39b03e3523eac6e01a94d3b9145df9890f5f1 Mon Sep 17 00:00:00 2001 From: Huangdingcheng Date: Tue, 10 Feb 2026 10:53:26 +0800 Subject: [PATCH 01/34] feat: Add flashcard generation feature - Add flashcard data models and API endpoint - Implement LLM-based flashcard generation service - Create FlashcardGenerator and FlashcardViewer components - Integrate flashcard feature into NotebookView - Add development startup scripts - Update Vite config for remote development Co-Authored-By: Huangdingcheng --- fastapi_app/routers/kb.py | 92 ++++++++++ fastapi_app/schemas.py | 53 ++++++ fastapi_app/services/flashcard_service.py | 166 ++++++++++++++++++ .../flashcards/FlashcardGenerator.tsx | 140 +++++++++++++++ .../components/flashcards/FlashcardViewer.tsx | 138 +++++++++++++++ frontend_en/src/pages/NotebookView.tsx | 49 +++++- frontend_en/src/types/index.ts | 2 +- frontend_en/vite.config.ts | 9 +- 8 files changed, 640 insertions(+), 9 deletions(-) create mode 100644 fastapi_app/services/flashcard_service.py create mode 100644 frontend_en/src/components/flashcards/FlashcardGenerator.tsx create mode 100644 frontend_en/src/components/flashcards/FlashcardViewer.tsx diff --git a/fastapi_app/routers/kb.py b/fastapi_app/routers/kb.py index cfd85f9..c15122c 100644 --- a/fastapi_app/routers/kb.py +++ b/fastapi_app/routers/kb.py @@ -2015,3 +2015,95 @@ async def save_mindmap_to_file( import traceback traceback.print_exc() raise HTTPException(status_code=500, detail=str(e)) + + +# ===================== 闪卡功能 ===================== + +@router.post("/generate-flashcards") +async def generate_flashcards( + file_paths: List[str] = Body(..., embed=True), + email: str = Body(..., embed=True), + user_id: str = Body(..., embed=True), + notebook_id: Optional[str] = Body(None, embed=True), + api_url: str = Body(..., embed=True), + api_key: str = Body(..., embed=True), + model: str = Body("deepseek-v3.2", embed=True), + language: str = Body("zh", embed=True), + card_count: int = Body(20, embed=True), +): + """ + 从知识库文件生成闪卡 + """ + try: + from fastapi_app.services.flashcard_service import generate_flashcards_with_llm + + # 1. 解析文件路径 + local_paths = [] + for f in file_paths: + ps = (f or "").strip() + if ps.startswith("http://") or ps.startswith("https://"): + local_md = _resolve_link_to_local_md(email, notebook_id, ps) + if local_md and local_md.exists(): + local_paths.append(str(local_md)) + else: + local_path = _resolve_local_path(f) + if local_path.exists(): + local_paths.append(str(local_path)) + + if not local_paths: + raise HTTPException(status_code=400, detail="No valid files provided") + + # 2. 提取文本内容 + text_content = _extract_text_from_files(local_paths, max_chars=50000) + if not text_content.strip(): + raise HTTPException(status_code=400, detail="No text content extracted") + + log.info(f"[generate-flashcards] 文本长度: {len(text_content)}, 文件数: {len(local_paths)}") + + # 3. 调用 LLM 生成闪卡 + flashcards = await generate_flashcards_with_llm( + text_content=text_content, + api_url=api_url, + api_key=api_key, + model=model, + language=language, + card_count=card_count, + ) + + if not flashcards: + raise HTTPException(status_code=500, detail="Failed to generate flashcards") + + # 4. 保存闪卡集到本地 + ts = int(time.time()) + flashcard_set_id = f"flashcard_{ts}" + output_dir = _outputs_dir(email, notebook_id, flashcard_set_id) + output_dir.mkdir(parents=True, exist_ok=True) + + flashcard_data = { + "id": flashcard_set_id, + "notebook_id": notebook_id, + "flashcards": [f.dict() for f in flashcards], + "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "source_files": file_paths, + "total_count": len(flashcards), + } + + json_path = output_dir / "flashcards.json" + json_path.write_text(json.dumps(flashcard_data, ensure_ascii=False, indent=2), encoding="utf-8") + + log.info(f"[generate-flashcards] 成功生成 {len(flashcards)} 张闪卡") + + return { + "success": True, + "flashcards": [f.dict() for f in flashcards], + "flashcard_set_id": flashcard_set_id, + "total_count": len(flashcards), + "result_path": _to_outputs_url(str(output_dir)), + } + + except HTTPException: + raise + except Exception as e: + import traceback + traceback.print_exc() + raise HTTPException(status_code=500, detail=str(e)) diff --git a/fastapi_app/schemas.py b/fastapi_app/schemas.py index 1be9908..095dbd5 100644 --- a/fastapi_app/schemas.py +++ b/fastapi_app/schemas.py @@ -300,3 +300,56 @@ class Paper2PPTResponse(BaseModel): pagecontent: List[Dict[str, Any]] = [] result_path: str = "" all_output_files: List[str] = [] + + +# ===================== Flashcard 闪卡相关 ===================== + +class FlashcardType(str): + """闪卡类型""" + QA = "qa" # 问答型 + FILL_BLANK = "fill_blank" # 填空型 + CONCEPT = "concept" # 概念解释型 + + +class FlashcardDifficulty(str): + """闪卡难度""" + EASY = "easy" + MEDIUM = "medium" + HARD = "hard" + + +class Flashcard(BaseModel): + """单个闪卡""" + id: str + question: str # 问题/正面 + answer: str # 答案/背面 + type: str = "qa" + difficulty: Optional[str] = None + source_file: Optional[str] = None # 来源文件 + source_excerpt: Optional[str] = None # 来源摘录(最多200字) + tags: List[str] = [] + created_at: Optional[str] = None + + +class GenerateFlashcardsRequest(BaseModel): + """生成闪卡请求""" + file_paths: List[str] # 知识库文件路径列表 + email: str + user_id: str + notebook_id: Optional[str] = None + api_url: str + api_key: str + model: str = "deepseek-v3.2" + language: str = "zh" + card_count: int = 20 # 生成闪卡数量 + difficulty: Optional[str] = None + card_types: List[str] = ["qa"] + + +class GenerateFlashcardsResponse(BaseModel): + """生成闪卡响应""" + success: bool + flashcards: List[Flashcard] = [] + flashcard_set_id: str = "" + total_count: int = 0 + result_path: str = "" diff --git a/fastapi_app/services/flashcard_service.py b/fastapi_app/services/flashcard_service.py new file mode 100644 index 0000000..21914f3 --- /dev/null +++ b/fastapi_app/services/flashcard_service.py @@ -0,0 +1,166 @@ +""" +闪卡生成服务 +从知识库文档中提取关键概念并生成闪卡 +""" +import json +import re +import time +import httpx +from typing import List, Dict, Any +from pathlib import Path + +from dataflow_agent.logger import get_logger +from fastapi_app.schemas import Flashcard + +log = get_logger(__name__) + + +async def generate_flashcards_with_llm( + text_content: str, + api_url: str, + api_key: str, + model: str, + language: str, + card_count: int, +) -> List[Flashcard]: + """ + 使用 LLM 从文本内容生成闪卡 + + Args: + text_content: 文档文本内容 + api_url: LLM API 地址 + api_key: API 密钥 + model: 模型名称 + language: 语言(zh/en) + card_count: 生成闪卡数量 + + Returns: + 闪卡列表 + """ + # 限制文本长度,避免超出 token 限制 + max_chars = 10000 + if len(text_content) > max_chars: + text_content = text_content[:max_chars] + "..." + + # 构建 Prompt + prompt = _build_flashcard_prompt(text_content, language, card_count) + + log.info(f"[flashcard_service] 开始调用 LLM 生成闪卡,模型: {model}, 数量: {card_count}") + + try: + # 确保 API URL 包含完整路径 + if not api_url.endswith('/chat/completions'): + if api_url.endswith('/'): + api_url = api_url + 'chat/completions' + else: + api_url = api_url + '/chat/completions' + + # 调用 LLM API + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + } + payload = { + "model": model, + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.7, + } + + async with httpx.AsyncClient(timeout=120.0) as client: + response = await client.post(api_url, json=payload, headers=headers) + response.raise_for_status() + result = response.json() + + # 解析 LLM 返回的内容 + content = result["choices"][0]["message"]["content"] + flashcards = _parse_flashcards_from_llm_response(content, card_count) + + log.info(f"[flashcard_service] 成功生成 {len(flashcards)} 张闪卡") + return flashcards + + except Exception as e: + log.error(f"[flashcard_service] LLM 调用失败: {e}") + raise Exception(f"生成闪卡失败: {str(e)}") + + +def _build_flashcard_prompt(text_content: str, language: str, card_count: int) -> str: + """构建生成闪卡的 Prompt""" + lang_name = "中文" if language == "zh" else "English" + + prompt = f"""你是一个专业的教育内容专家,擅长从学习材料中提取关键知识点并制作闪卡。 + +请从以下内容中提取 {card_count} 个最重要的知识点,并为每个知识点生成一张闪卡。 + +要求: +1. 问题要清晰、具体,便于记忆和理解 +2. 答案要准确、简洁(100字以内) +3. 优先选择核心概念、定义、重要事实、关键术语 +4. 问题和答案使用{lang_name} +5. 可以包含不同类型的问题(概念解释、填空、问答等) + +内容: +{text_content} + +请以 JSON 数组格式返回,每个闪卡包含以下字段: +- question: 问题内容 +- answer: 答案内容 +- type: 类型(qa/concept/fill_blank) +- source_excerpt: 相关原文摘录(可选,最多100字) + +示例格式: +[ + {{ + "question": "什么是机器学习?", + "answer": "机器学习是人工智能的一个分支,通过算法让计算机从数据中学习规律。", + "type": "qa", + "source_excerpt": "机器学习(Machine Learning)是..." + }} +] + +请直接返回 JSON 数组,不要添加其他说明文字。""" + + return prompt + + +def _parse_flashcards_from_llm_response(content: str, card_count: int) -> List[Flashcard]: + """ + 解析 LLM 返回的闪卡数据 + + Args: + content: LLM 返回的文本内容 + card_count: 期望的闪卡数量 + + Returns: + 闪卡列表 + """ + try: + # 提取 JSON(处理可能的 markdown 代码块) + json_match = re.search(r'\[.*\]', content, re.DOTALL) + if json_match: + flashcards_data = json.loads(json_match.group()) + else: + flashcards_data = json.loads(content) + + # 转换为 Flashcard 对象 + flashcards = [] + for i, card_data in enumerate(flashcards_data[:card_count]): + question = card_data.get("question", "").strip() + answer = card_data.get("answer", "").strip() + + if not question or not answer: + continue + + flashcards.append(Flashcard( + id=f"card_{int(time.time())}_{i}", + question=question, + answer=answer, + type=card_data.get("type", "qa"), + source_excerpt=card_data.get("source_excerpt", "")[:200] if card_data.get("source_excerpt") else None, + created_at=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + )) + + return flashcards + + except Exception as e: + log.error(f"[flashcard_service] 解析 LLM 响应失败: {e}") + raise Exception(f"解析闪卡数据失败: {str(e)}") diff --git a/frontend_en/src/components/flashcards/FlashcardGenerator.tsx b/frontend_en/src/components/flashcards/FlashcardGenerator.tsx new file mode 100644 index 0000000..1ad2ff8 --- /dev/null +++ b/frontend_en/src/components/flashcards/FlashcardGenerator.tsx @@ -0,0 +1,140 @@ +import React, { useState } from 'react'; +import { Sparkles, Loader2, AlertCircle } from 'lucide-react'; +import { apiFetch } from '../../config/api'; +import { getApiSettings } from '../../services/apiSettingsService'; + +interface FlashcardGeneratorProps { + selectedFiles: string[]; + notebookId: string; + email: string; + userId: string; + onGenerated: (flashcardSetId: string, flashcards: any[]) => void; +} + +export const FlashcardGenerator: React.FC = ({ + selectedFiles, + notebookId, + email, + userId, + onGenerated, +}) => { + const [loading, setLoading] = useState(false); + const [cardCount, setCardCount] = useState(20); + const [error, setError] = useState(null); + + const handleGenerate = async () => { + if (selectedFiles.length === 0) { + setError('Please select at least one file'); + return; + } + + // 获取 API 配置 + const settings = getApiSettings(userId); + if (!settings?.apiUrl || !settings?.apiKey) { + setError('Please configure API URL and API Key in Settings first'); + return; + } + + setLoading(true); + setError(null); + + try { + const response = await apiFetch('/api/v1/kb/generate-flashcards', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + file_paths: selectedFiles, + notebook_id: notebookId, + email: email, + user_id: userId, + api_url: settings.apiUrl, + api_key: settings.apiKey, + model: 'deepseek-v3.2', + card_count: cardCount, + language: 'en', + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('API Error:', errorText); + throw new Error(`API request failed: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + + if (data.success && data.flashcards) { + onGenerated(data.flashcard_set_id, data.flashcards); + } else { + setError('Failed to generate flashcards'); + } + } catch (err: any) { + console.error('Generate flashcards error:', err); + setError(err.message || 'Failed to generate flashcards'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ +

Generate Flashcards

+
+ + {error && ( +
+ +

{error}

+
+ )} + +
+ +

+ Extract key knowledge points from selected files to generate flashcards +

+
+ +
+ + setCardCount(Number(e.target.value))} + min={5} + max={50} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500" + /> +

+ Recommended: 10-30 cards +

+
+ + +
+ ); +}; diff --git a/frontend_en/src/components/flashcards/FlashcardViewer.tsx b/frontend_en/src/components/flashcards/FlashcardViewer.tsx new file mode 100644 index 0000000..6c0b21f --- /dev/null +++ b/frontend_en/src/components/flashcards/FlashcardViewer.tsx @@ -0,0 +1,138 @@ +import React, { useState } from 'react'; +import { RotateCw, ChevronLeft, ChevronRight } from 'lucide-react'; + +interface Flashcard { + id: string; + question: string; + answer: string; + type: string; + source_excerpt?: string; +} + +interface FlashcardViewerProps { + flashcards: Flashcard[]; + onClose: () => void; +} + +export const FlashcardViewer: React.FC = ({ + flashcards, + onClose, +}) => { + const [currentIndex, setCurrentIndex] = useState(0); + const [isFlipped, setIsFlipped] = useState(false); + + const currentCard = flashcards[currentIndex]; + + const handleNext = () => { + if (currentIndex < flashcards.length - 1) { + setCurrentIndex(currentIndex + 1); + setIsFlipped(false); + } + }; + + const handlePrevious = () => { + if (currentIndex > 0) { + setCurrentIndex(currentIndex - 1); + setIsFlipped(false); + } + }; + + const handleFlip = () => { + setIsFlipped(!isFlipped); + }; + + return ( +
+ {/* Header */} +
+

Flashcard Study

+ +
+ + {/* Progress Indicator */} +
+

+ {currentIndex + 1} / {flashcards.length} +

+
+
+
+
+ + {/* 卡片区域 */} +
+
+
+ {/* Front - Question */} +
+

{currentCard.question}

+
+ + Click to flip and see answer +
+
+ + {/* Back - Answer */} +
+

{currentCard.answer}

+ {currentCard.source_excerpt && ( +
+

Source Excerpt:

+

{currentCard.source_excerpt}

+
+ )} +
+
+
+
+ + {/* Navigation Buttons */} +
+ + + +
+
+ ); +}; diff --git a/frontend_en/src/pages/NotebookView.tsx b/frontend_en/src/pages/NotebookView.tsx index e157987..84b14da 100644 --- a/frontend_en/src/pages/NotebookView.tsx +++ b/frontend_en/src/pages/NotebookView.tsx @@ -1,10 +1,10 @@ import React, { useState, useEffect } from 'react'; -import { - ChevronLeft, Plus, Share2, Settings, MessageSquare, - BarChart2, Zap, AudioLines, Video, FileText, +import { + ChevronLeft, Plus, Share2, Settings, MessageSquare, + BarChart2, Zap, AudioLines, Video, FileText, Filter, MoreVertical, Search, Image as ImageIcon, FileStack, Sparkles, Mic2, Video as VideoIcon, BrainCircuit, Send, Bot, User, Loader2, Upload, X, - Globe, Link2, Cloud, ChevronRight, LayoutGrid, Download + Globe, Link2, Cloud, ChevronRight, LayoutGrid, Download, BookOpen } from 'lucide-react'; import { useAuthStore } from '../stores/authStore'; import { supabase, isSupabaseConfigured } from '../lib/supabase'; @@ -15,6 +15,8 @@ import ReactMarkdown from 'react-markdown'; import { MermaidPreview } from '../components/knowledge-base/tools/MermaidPreview'; import { SettingsModal } from '../components/SettingsModal'; import DrawioInlineEditor from '../components/DrawioInlineEditor'; +import { FlashcardGenerator } from '../components/flashcards/FlashcardGenerator'; +import { FlashcardViewer } from '../components/flashcards/FlashcardViewer'; // 不做用户管理时使用,数据从 outputs 取 const DEFAULT_USER = { id: 'default', email: 'default' }; @@ -93,6 +95,11 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void // Settings modal const [showSettingsModal, setShowSettingsModal] = useState(false); + // Flashcard state + const [flashcards, setFlashcards] = useState([]); + const [showFlashcardViewer, setShowFlashcardViewer] = useState(false); + const [flashcardSetId, setFlashcardSetId] = useState(''); + // Output preview const [previewOutput, setPreviewOutput] = useState<{ id: string; @@ -196,6 +203,7 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void { icon: , label: 'Mind Map', id: 'mindmap' }, { icon: , label: 'DrawIO', id: 'drawio' }, { icon: , label: 'Knowledge Podcast', id: 'podcast' }, + { icon: , label: 'Flashcards', id: 'flashcard' }, // Video narration temporarily disabled // { icon: , label: 'Video narration', id: 'video' }, ]; @@ -2687,6 +2695,21 @@ rel="noopener noreferrer"
)} + {/* Flashcard Generator */} + {activeTool === 'flashcard' && !showFlashcardViewer && ( + selectedIds.has(f.id)).map((f: KnowledgeFile) => f.url).filter(Boolean) as string[]} + notebookId={notebook?.id || ''} + email={effectiveUser?.email || effectiveUser?.id || 'default'} + userId={effectiveUser?.id || 'default'} + onGenerated={(flashcardSetId: string, flashcards: any[]) => { + setFlashcardSetId(flashcardSetId); + setFlashcards(flashcards); + setShowFlashcardViewer(true); + }} + /> + )} + {/* Output Feed */} {outputFeed.length > 0 && (
@@ -3234,6 +3257,24 @@ rel="noopener noreferrer"
)} + + {/* Flashcard Viewer Modal */} + {showFlashcardViewer && flashcards.length > 0 && ( +
setShowFlashcardViewer(false)} + > +
e.stopPropagation()} + > + setShowFlashcardViewer(false)} + /> +
+
+ )} ); }; diff --git a/frontend_en/src/types/index.ts b/frontend_en/src/types/index.ts index 07d7b4f..15b3b06 100644 --- a/frontend_en/src/types/index.ts +++ b/frontend_en/src/types/index.ts @@ -26,4 +26,4 @@ export interface ChatMessage { } export type SectionType = 'library' | 'upload' | 'output' | 'settings'; -export type ToolType = 'chat' | 'ppt' | 'mindmap' | 'podcast' | 'video' | 'search' | 'drawio'; +export type ToolType = 'chat' | 'ppt' | 'mindmap' | 'podcast' | 'video' | 'search' | 'drawio' | 'flashcard'; diff --git a/frontend_en/vite.config.ts b/frontend_en/vite.config.ts index 0a58087..92bebea 100644 --- a/frontend_en/vite.config.ts +++ b/frontend_en/vite.config.ts @@ -4,16 +4,17 @@ import react from '@vitejs/plugin-react' export default defineConfig({ plugins: [react()], server: { - port: 3001, - open: true, + host: '0.0.0.0', // 允许外部访问 + port: 26202, // 使用你的端口范围 + open: false, // 服务器环境不自动打开浏览器 allowedHosts: true, proxy: { '/api': { - target: 'http://localhost:8211', + target: 'http://localhost:26201', // 后端端口 changeOrigin: true, }, '/outputs': { - target: 'http://localhost:8211', + target: 'http://localhost:26201', // 后端端口 changeOrigin: true, }, }, From f45bcfd0cda1e0dbfbb70aa4f734f405eb436d6a Mon Sep 17 00:00:00 2001 From: Huangdingcheng Date: Tue, 10 Feb 2026 17:30:22 +0800 Subject: [PATCH 02/34] feat: Add Quiz feature with LLM-generated questions Implement comprehensive Quiz functionality similar to NotebookLM: - Single-choice questions with 4 options (A/B/C/D) - Skip functionality for questions - Statistics tracking (Right/Wrong/Skipped) - Review Quiz with highlighted correct answers and detailed explanations - Retake Quiz capability - LLM-based question generation with quality prompts Backend changes: - Add Quiz data models (QuizOption, QuizQuestion, etc.) in schemas.py - Create quiz_service.py for LLM-based question generation - Add /generate-quiz API endpoint in kb.py - Implement JSON parsing with error recovery for truncated responses Frontend changes: - Create QuizGenerator component for quiz creation - Create QuizQuestion component for single question display - Create QuizResults component with circular progress and statistics - Create QuizReview component with answer explanations - Create QuizContainer component as main orchestrator - Integrate Quiz into NotebookView with Brain icon - Update types to include 'quiz' in ToolType Co-Authored-By: Huangdingcheng --- fastapi_app/routers/kb.py | 89 +++++++ fastapi_app/schemas.py | 39 +++ fastapi_app/services/quiz_service.py | 246 ++++++++++++++++++ .../src/components/quiz/QuizContainer.tsx | 183 +++++++++++++ .../src/components/quiz/QuizGenerator.tsx | 139 ++++++++++ .../src/components/quiz/QuizQuestion.tsx | 89 +++++++ .../src/components/quiz/QuizResults.tsx | 122 +++++++++ .../src/components/quiz/QuizReview.tsx | 149 +++++++++++ frontend_en/src/pages/NotebookView.tsx | 43 ++- frontend_en/src/types/index.ts | 2 +- 10 files changed, 1099 insertions(+), 2 deletions(-) create mode 100644 fastapi_app/services/quiz_service.py create mode 100644 frontend_en/src/components/quiz/QuizContainer.tsx create mode 100644 frontend_en/src/components/quiz/QuizGenerator.tsx create mode 100644 frontend_en/src/components/quiz/QuizQuestion.tsx create mode 100644 frontend_en/src/components/quiz/QuizResults.tsx create mode 100644 frontend_en/src/components/quiz/QuizReview.tsx diff --git a/fastapi_app/routers/kb.py b/fastapi_app/routers/kb.py index c15122c..e894a28 100644 --- a/fastapi_app/routers/kb.py +++ b/fastapi_app/routers/kb.py @@ -2107,3 +2107,92 @@ async def generate_flashcards( import traceback traceback.print_exc() raise HTTPException(status_code=500, detail=str(e)) + +@router.post("/generate-quiz") +async def generate_quiz( + file_paths: List[str] = Body(..., embed=True), + email: str = Body(..., embed=True), + user_id: str = Body(..., embed=True), + notebook_id: Optional[str] = Body(None, embed=True), + api_url: str = Body(..., embed=True), + api_key: str = Body(..., embed=True), + model: str = Body("deepseek-v3.2", embed=True), + language: str = Body("en", embed=True), + question_count: int = Body(10, embed=True), +): + """ + 生成 Quiz 测验题目 + """ + from fastapi_app.services.quiz_service import generate_quiz_with_llm + + try: + # 1. 解析文件路径 + local_paths = [] + for f in file_paths: + ps = (f or "").strip() + if ps.startswith("http://") or ps.startswith("https://"): + local_md = _resolve_link_to_local_md(email, notebook_id, ps) + if local_md and local_md.exists(): + local_paths.append(str(local_md)) + else: + local_path = _resolve_local_path(f) + if local_path.exists(): + local_paths.append(str(local_path)) + + if not local_paths: + raise HTTPException(status_code=400, detail="No valid files provided") + + # 2. 提取文本内容 + text_content = _extract_text_from_files(local_paths, max_chars=50000) + if not text_content.strip(): + raise HTTPException(status_code=400, detail="No text content extracted") + + log.info(f"[generate-quiz] 文本长度: {len(text_content)}, 文件数: {len(local_paths)}") + + # 3. 调用 LLM 生成 Quiz + questions = await generate_quiz_with_llm( + text_content=text_content, + api_url=api_url, + api_key=api_key, + model=model, + language=language, + question_count=question_count, + ) + + if not questions: + raise HTTPException(status_code=500, detail="Failed to generate quiz") + + # 4. 保存 Quiz 到本地 + ts = int(time.time()) + quiz_id = f"quiz_{ts}" + output_dir = _outputs_dir(email, notebook_id, quiz_id) + output_dir.mkdir(parents=True, exist_ok=True) + + quiz_data = { + "id": quiz_id, + "notebook_id": notebook_id, + "questions": [q.dict() for q in questions], + "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "source_files": file_paths, + "total_count": len(questions), + } + + json_path = output_dir / "quiz.json" + json_path.write_text(json.dumps(quiz_data, ensure_ascii=False, indent=2), encoding="utf-8") + + log.info(f"[generate-quiz] 成功生成 {len(questions)} 道题目") + + return { + "success": True, + "questions": [q.dict() for q in questions], + "quiz_id": quiz_id, + "total_count": len(questions), + "result_path": _to_outputs_url(str(output_dir)), + } + + except HTTPException: + raise + except Exception as e: + import traceback + traceback.print_exc() + raise HTTPException(status_code=500, detail=str(e)) diff --git a/fastapi_app/schemas.py b/fastapi_app/schemas.py index 095dbd5..042a191 100644 --- a/fastapi_app/schemas.py +++ b/fastapi_app/schemas.py @@ -353,3 +353,42 @@ class GenerateFlashcardsResponse(BaseModel): flashcard_set_id: str = "" total_count: int = 0 result_path: str = "" + + +# ===================== Quiz 相关模型 ===================== + +class QuizOption(BaseModel): + """Quiz 选项""" + label: str # A, B, C, D + text: str # 选项内容 + +class QuizQuestion(BaseModel): + """Quiz 题目""" + id: str + question: str + options: List[QuizOption] # 4个选项 + correct_answer: str # 正确答案 (A/B/C/D) + explanation: str # 答案解释 + source_excerpt: Optional[str] = None # 来源摘录 + difficulty: Optional[str] = None # 难度等级 + category: Optional[str] = None # 题目类型(理解/应用/分析) + +class GenerateQuizRequest(BaseModel): + """生成 Quiz 请求""" + file_paths: List[str] + email: str + user_id: str + notebook_id: Optional[str] = None + api_url: str + api_key: str + model: str = "deepseek-v3.2" + language: str = "en" + question_count: int = 10 # 题目数量 + +class GenerateQuizResponse(BaseModel): + """生成 Quiz 响应""" + success: bool + questions: List[QuizQuestion] = [] + quiz_id: str = "" + total_count: int = 0 + result_path: str = "" diff --git a/fastapi_app/services/quiz_service.py b/fastapi_app/services/quiz_service.py new file mode 100644 index 0000000..6c1539c --- /dev/null +++ b/fastapi_app/services/quiz_service.py @@ -0,0 +1,246 @@ +""" +Quiz 生成服务 +从知识库文档中生成单选题测验 +""" +import json +import re +import time +import httpx +from typing import List, Dict, Any +from pathlib import Path + +from dataflow_agent.logger import get_logger +from fastapi_app.schemas import QuizQuestion, QuizOption + +log = get_logger(__name__) + + +async def generate_quiz_with_llm( + text_content: str, + api_url: str, + api_key: str, + model: str, + language: str, + question_count: int, +) -> List[QuizQuestion]: + """ + 使用 LLM 从文本内容生成 Quiz 题目 + + Args: + text_content: 文档文本内容 + api_url: LLM API 地址 + api_key: API 密钥 + model: 模型名称 + language: 语言(zh/en) + question_count: 生成题目数量 + + Returns: + Quiz 题目列表 + """ + # 限制文本长度,避免超出 token 限制 + max_chars = 10000 + if len(text_content) > max_chars: + text_content = text_content[:max_chars] + "..." + + # 构建 Prompt + prompt = _build_quiz_prompt(text_content, language, question_count) + + log.info(f"[quiz_service] 开始调用 LLM 生成 Quiz,模型: {model}, 数量: {question_count}") + + try: + # 确保 API URL 包含完整路径 + if not api_url.endswith('/chat/completions'): + if api_url.endswith('/'): + api_url = api_url + 'chat/completions' + else: + api_url = api_url + '/chat/completions' + + # 调用 LLM API + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + } + payload = { + "model": model, + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.7, + } + + async with httpx.AsyncClient(timeout=120.0) as client: + response = await client.post(api_url, json=payload, headers=headers) + response.raise_for_status() + result = response.json() + + # 解析 LLM 返回的内容 + content = result["choices"][0]["message"]["content"] + questions = _parse_quiz_from_llm_response(content, question_count) + + log.info(f"[quiz_service] 成功生成 {len(questions)} 道题目") + return questions + + except Exception as e: + log.error(f"[quiz_service] LLM 调用失败: {e}") + raise Exception(f"生成 Quiz 失败: {str(e)}") + + +def _build_quiz_prompt(text_content: str, language: str, question_count: int) -> str: + """ + 构建生成 Quiz 的 Prompt + + 出题原则: + 1. 考察理解和应用,而非简单记忆 + 2. 选项设计合理,干扰项有迷惑性 + 3. 答案明确,有据可依 + 4. 覆盖文档的关键知识点 + """ + if language == "zh": + prompt = f"""请基于以下文档内容,生成 {question_count} 道高质量的单选题测验题目。 + +文档内容: +{text_content} + +出题要求: +1. 题目类型:单选题,每题必须有且仅有 4 个选项(A、B、C、D) +2. 题目质量: + - 考察对文档内容的理解和应用,而非简单记忆 + - 题目表述清晰、准确、无歧义 + - 选项设计合理,干扰项要有一定迷惑性 + - 正确答案必须明确且有据可依 +3. 难度分布: + - 简单题(理解):30% + - 中等题(应用):50% + - 困难题(分析):20% +4. 答案解释: + - 必须给出详细的答案解释 + - 解释要引用文档中的具体内容 + - 说明为什么其他选项是错误的 + +请以 JSON 格式返回,格式如下: +```json +[ + {{ + "id": "q1", + "question": "题目内容", + "options": [ + {{"label": "A", "text": "选项A内容"}}, + {{"label": "B", "text": "选项B内容"}}, + {{"label": "C", "text": "选项C内容"}}, + {{"label": "D", "text": "选项D内容"}} + ], + "correct_answer": "A", + "explanation": "详细的答案解释,说明为什么A是正确的,以及为什么其他选项是错误的。", + "source_excerpt": "文档中相关的原文摘录", + "difficulty": "medium", + "category": "application" + }} +] +``` + +请确保返回的是有效的 JSON 格式。""" + else: + prompt = f"""Based on the following document content, generate {question_count} high-quality multiple-choice quiz questions. + +Document Content: +{text_content} + +Requirements: +1. Question Type: Multiple choice, each question must have exactly 4 options (A, B, C, D) +2. Quality Standards: + - Test understanding and application, not just memorization + - Questions should be clear, precise, and unambiguous + - Options should be well-designed with plausible distractors + - Correct answer must be definitive and evidence-based +3. Difficulty Distribution: + - Easy (comprehension): 30% + - Medium (application): 50% + - Hard (analysis): 20% +4. Answer Explanation: + - Provide detailed explanation for the correct answer + - Reference specific content from the document + - Explain why other options are incorrect + +Return in JSON format: +```json +[ + {{ + "id": "q1", + "question": "Question text", + "options": [ + {{"label": "A", "text": "Option A text"}}, + {{"label": "B", "text": "Option B text"}}, + {{"label": "C", "text": "Option C text"}}, + {{"label": "D", "text": "Option D text"}} + ], + "correct_answer": "A", + "explanation": "Detailed explanation of why A is correct and why other options are incorrect.", + "source_excerpt": "Relevant excerpt from the document", + "difficulty": "medium", + "category": "application" + }} +] +``` + +Ensure the response is valid JSON format.""" + + return prompt + + +def _parse_quiz_from_llm_response(content: str, question_count: int) -> List[QuizQuestion]: + """ + 从 LLM 返回的内容中解析 Quiz 题目 + """ + try: + # 尝试提取 JSON(可能包含在 markdown 代码块中) + json_match = re.search(r'```(?:json)?\s*(\[[\s\S]*?\])\s*```', content) + if json_match: + json_str = json_match.group(1) + else: + # 尝试直接解析整个内容 + json_str = content.strip() + + # 尝试修复常见的 JSON 格式问题 + # 1. 移除可能的尾部不完整内容 + if not json_str.endswith(']'): + # 找到最后一个完整的对象 + last_complete = json_str.rfind('}') + if last_complete > 0: + json_str = json_str[:last_complete + 1] + ']' + + # 解析 JSON + questions_data = json.loads(json_str) + + # 转换为 QuizQuestion 对象 + questions = [] + for i, q_data in enumerate(questions_data[:question_count]): + # 确保有 4 个选项 + options = [] + for opt in q_data.get("options", [])[:4]: + options.append(QuizOption( + label=opt.get("label", ""), + text=opt.get("text", "") + )) + + # 如果选项不足 4 个,补充空选项 + while len(options) < 4: + label = chr(65 + len(options)) # A, B, C, D + options.append(QuizOption(label=label, text="")) + + question = QuizQuestion( + id=q_data.get("id", f"q{i+1}"), + question=q_data.get("question", ""), + options=options, + correct_answer=q_data.get("correct_answer", "A"), + explanation=q_data.get("explanation", ""), + source_excerpt=q_data.get("source_excerpt"), + difficulty=q_data.get("difficulty", "medium"), + category=q_data.get("category", "application") + ) + questions.append(question) + + return questions + + except Exception as e: + log.error(f"[quiz_service] 解析 Quiz 失败: {e}") + log.error(f"[quiz_service] LLM 返回内容: {content[:500]}") + raise Exception(f"解析 Quiz 失败: {str(e)}") + diff --git a/frontend_en/src/components/quiz/QuizContainer.tsx b/frontend_en/src/components/quiz/QuizContainer.tsx new file mode 100644 index 0000000..cd1d5f2 --- /dev/null +++ b/frontend_en/src/components/quiz/QuizContainer.tsx @@ -0,0 +1,183 @@ +import React, { useState } from 'react'; +import { QuizQuestion } from './QuizQuestion'; +import { QuizResults } from './QuizResults'; +import { QuizReview } from './QuizReview'; +import { ChevronLeft, ChevronRight, SkipForward } from 'lucide-react'; + +interface QuizOption { + label: string; + text: string; +} + +interface Question { + id: string; + question: string; + options: QuizOption[]; + correct_answer: string; + explanation: string; + source_excerpt?: string; +} + +interface QuizContainerProps { + questions: Question[]; + onClose: () => void; +} + +type QuizState = 'taking' | 'results' | 'review'; + +export const QuizContainer: React.FC = ({ + questions, + onClose, +}) => { + const [currentIndex, setCurrentIndex] = useState(0); + const [userAnswers, setUserAnswers] = useState>({}); + const [quizState, setQuizState] = useState('taking'); + + const currentQuestion = questions[currentIndex]; + const currentAnswer = userAnswers[currentQuestion?.id] || null; + + const handleSelectAnswer = (answer: string) => { + setUserAnswers({ + ...userAnswers, + [currentQuestion.id]: answer, + }); + }; + + const handleSkip = () => { + setUserAnswers({ + ...userAnswers, + [currentQuestion.id]: null, + }); + handleNext(); + }; + + const handleNext = () => { + if (currentIndex < questions.length - 1) { + setCurrentIndex(currentIndex + 1); + } else { + // 最后一题,显示结果 + setQuizState('results'); + } + }; + + const handlePrevious = () => { + if (currentIndex > 0) { + setCurrentIndex(currentIndex - 1); + } + }; + + const handleRetake = () => { + setCurrentIndex(0); + setUserAnswers({}); + setQuizState('taking'); + }; + + const handleReview = () => { + setQuizState('review'); + }; + + // 计算统计数据 + const calculateStats = () => { + let correct = 0; + let wrong = 0; + let skipped = 0; + + questions.forEach((q) => { + const answer = userAnswers[q.id]; + if (!answer) { + skipped++; + } else if (answer === q.correct_answer) { + correct++; + } else { + wrong++; + } + }); + + return { correct, wrong, skipped }; + }; + + // 结果页面 + if (quizState === 'results') { + const stats = calculateStats(); + return ( + + ); + } + + // 复习页面 + if (quizState === 'review') { + return ( + + ); + } + + // 答题页面 + return ( +
+ {/* Progress */} +
+
+ + Question {currentIndex + 1} of {questions.length} + +
+
+
+
+
+ + {/* Question */} +
+ +
+ + {/* Navigation */} +
+ + + + + +
+
+ ); +}; diff --git a/frontend_en/src/components/quiz/QuizGenerator.tsx b/frontend_en/src/components/quiz/QuizGenerator.tsx new file mode 100644 index 0000000..e612e44 --- /dev/null +++ b/frontend_en/src/components/quiz/QuizGenerator.tsx @@ -0,0 +1,139 @@ +import React, { useState } from 'react'; +import { Brain, Loader2, AlertCircle } from 'lucide-react'; +import { apiFetch } from '../../config/api'; +import { getApiSettings } from '../../services/apiSettingsService'; + +interface QuizGeneratorProps { + selectedFiles: string[]; + notebookId: string; + email: string; + userId: string; + onGenerated: (quizId: string, questions: any[]) => void; +} + +export const QuizGenerator: React.FC = ({ + selectedFiles, + notebookId, + email, + userId, + onGenerated, +}) => { + const [loading, setLoading] = useState(false); + const [questionCount, setQuestionCount] = useState(10); + const [error, setError] = useState(null); + + const handleGenerate = async () => { + if (selectedFiles.length === 0) { + setError('Please select at least one file'); + return; + } + + const settings = getApiSettings(userId); + if (!settings?.apiUrl || !settings?.apiKey) { + setError('Please configure API URL and API Key in Settings first'); + return; + } + + setLoading(true); + setError(null); + + try { + const response = await apiFetch('/api/v1/kb/generate-quiz', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + file_paths: selectedFiles, + notebook_id: notebookId, + email: email, + user_id: userId, + api_url: settings.apiUrl, + api_key: settings.apiKey, + model: 'deepseek-v3.2', + question_count: questionCount, + language: 'en', + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('API Error:', errorText); + throw new Error(`API request failed: ${response.status}`); + } + + const data = await response.json(); + + if (data.success && data.questions) { + onGenerated(data.quiz_id, data.questions); + } else { + setError('Failed to generate quiz'); + } + } catch (err: any) { + console.error('Generate quiz error:', err); + setError(err.message || 'Failed to generate quiz'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ +

Generate Quiz

+
+ + {error && ( +
+ +

{error}

+
+ )} + +
+ +

+ Generate multiple-choice questions to test understanding +

+
+ +
+ + setQuestionCount(Number(e.target.value))} + min={5} + max={20} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +

+ Recommended: 10-15 questions +

+
+ + +
+ ); +}; diff --git a/frontend_en/src/components/quiz/QuizQuestion.tsx b/frontend_en/src/components/quiz/QuizQuestion.tsx new file mode 100644 index 0000000..ad3ec2f --- /dev/null +++ b/frontend_en/src/components/quiz/QuizQuestion.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { CheckCircle, XCircle, Circle } from 'lucide-react'; + +interface QuizOption { + label: string; + text: string; +} + +interface QuizQuestionProps { + question: string; + options: QuizOption[]; + selectedAnswer: string | null; + onSelectAnswer: (answer: string) => void; + showResult?: boolean; + correctAnswer?: string; + isCorrect?: boolean; +} + +export const QuizQuestion: React.FC = ({ + question, + options, + selectedAnswer, + onSelectAnswer, + showResult = false, + correctAnswer, + isCorrect, +}) => { + const getOptionStyle = (optionLabel: string) => { + if (!showResult) { + // 答题模式 + if (selectedAnswer === optionLabel) { + return 'border-blue-500 bg-blue-50'; + } + return 'border-gray-300 hover:border-blue-400 hover:bg-gray-50'; + } else { + // 结果展示模式 + if (optionLabel === correctAnswer) { + return 'border-green-500 bg-green-50'; + } + if (selectedAnswer === optionLabel && !isCorrect) { + return 'border-red-500 bg-red-50'; + } + return 'border-gray-300 bg-gray-50'; + } + }; + + const getOptionIcon = (optionLabel: string) => { + if (!showResult) { + return selectedAnswer === optionLabel ? ( + + ) : ( + + ); + } else { + if (optionLabel === correctAnswer) { + return ; + } + if (selectedAnswer === optionLabel && !isCorrect) { + return ; + } + return ; + } + }; + + return ( +
+

{question}

+ +
+ {options.map((option) => ( + + ))} +
+
+ ); +}; diff --git a/frontend_en/src/components/quiz/QuizResults.tsx b/frontend_en/src/components/quiz/QuizResults.tsx new file mode 100644 index 0000000..3c8ce08 --- /dev/null +++ b/frontend_en/src/components/quiz/QuizResults.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import { CheckCircle, XCircle, SkipForward, RotateCcw, Eye } from 'lucide-react'; + +interface QuizResultsProps { + totalQuestions: number; + correctCount: number; + wrongCount: number; + skippedCount: number; + onReview: () => void; + onRetake: () => void; +} + +export const QuizResults: React.FC = ({ + totalQuestions, + correctCount, + wrongCount, + skippedCount, + onReview, + onRetake, +}) => { + const percentage = Math.round((correctCount / totalQuestions) * 100); + + const getScoreColor = () => { + if (percentage >= 80) return 'text-green-600'; + if (percentage >= 60) return 'text-yellow-600'; + return 'text-red-600'; + }; + + const getScoreMessage = () => { + if (percentage >= 80) return 'Excellent! 🎉'; + if (percentage >= 60) return 'Good job! 👍'; + return 'Keep practicing! 💪'; + }; + + return ( +
+ {/* Score Display */} +
+

Quiz Complete!

+

{getScoreMessage()}

+
+ + {/* Score Circle */} +
+
+ + + = 80 ? '#10b981' : percentage >= 60 ? '#f59e0b' : '#ef4444'} + strokeWidth="12" + fill="none" + strokeDasharray={`${2 * Math.PI * 88}`} + strokeDashoffset={`${2 * Math.PI * 88 * (1 - percentage / 100)}`} + strokeLinecap="round" + className="transition-all duration-1000" + /> + +
+
+
+ {percentage}% +
+
+ {correctCount}/{totalQuestions} +
+
+
+
+
+ + {/* Statistics */} +
+
+ +
{correctCount}
+
Correct
+
+ +
+ +
{wrongCount}
+
Wrong
+
+ +
+ +
{skippedCount}
+
Skipped
+
+
+ + {/* Action Buttons */} +
+ + + +
+
+ ); +}; diff --git a/frontend_en/src/components/quiz/QuizReview.tsx b/frontend_en/src/components/quiz/QuizReview.tsx new file mode 100644 index 0000000..b96ac14 --- /dev/null +++ b/frontend_en/src/components/quiz/QuizReview.tsx @@ -0,0 +1,149 @@ +import React, { useState } from 'react'; +import { ChevronLeft, ChevronRight, CheckCircle, XCircle, SkipForward, X } from 'lucide-react'; +import { QuizQuestion } from './QuizQuestion'; + +interface QuizOption { + label: string; + text: string; +} + +interface Question { + id: string; + question: string; + options: QuizOption[]; + correct_answer: string; + explanation: string; + source_excerpt?: string; +} + +interface QuizReviewProps { + questions: Question[]; + userAnswers: Record; + onClose: () => void; +} + +export const QuizReview: React.FC = ({ + questions, + userAnswers, + onClose, +}) => { + const [currentIndex, setCurrentIndex] = useState(0); + const currentQuestion = questions[currentIndex]; + const userAnswer = userAnswers[currentQuestion.id]; + + const getAnswerStatus = () => { + if (!userAnswer) return 'skipped'; + return userAnswer === currentQuestion.correct_answer ? 'correct' : 'wrong'; + }; + + const handleNext = () => { + if (currentIndex < questions.length - 1) { + setCurrentIndex(currentIndex + 1); + } + }; + + const handlePrevious = () => { + if (currentIndex > 0) { + setCurrentIndex(currentIndex - 1); + } + }; + + const answerStatus = getAnswerStatus(); + + return ( +
+ {/* Header */} +
+

Quiz Review

+ +
+ + {/* Progress */} +
+
+ + Question {currentIndex + 1} of {questions.length} + +
+ {answerStatus === 'correct' && ( + + + Correct + + )} + {answerStatus === 'wrong' && ( + + + Wrong + + )} + {answerStatus === 'skipped' && ( + + + Skipped + + )} +
+
+
+
+
+
+ + {/* Question */} +
+ {}} + showResult={true} + correctAnswer={currentQuestion.correct_answer} + isCorrect={answerStatus === 'correct'} + /> +
+ + {/* Explanation */} +
+

Explanation

+

{currentQuestion.explanation}

+ + {currentQuestion.source_excerpt && ( +
+

Source:

+

{currentQuestion.source_excerpt}

+
+ )} +
+ + {/* Navigation */} +
+ + + +
+
+ ); +}; diff --git a/frontend_en/src/pages/NotebookView.tsx b/frontend_en/src/pages/NotebookView.tsx index 84b14da..d6c470a 100644 --- a/frontend_en/src/pages/NotebookView.tsx +++ b/frontend_en/src/pages/NotebookView.tsx @@ -4,7 +4,7 @@ import { BarChart2, Zap, AudioLines, Video, FileText, Filter, MoreVertical, Search, Image as ImageIcon, FileStack, Sparkles, Mic2, Video as VideoIcon, BrainCircuit, Send, Bot, User, Loader2, Upload, X, - Globe, Link2, Cloud, ChevronRight, LayoutGrid, Download, BookOpen + Globe, Link2, Cloud, ChevronRight, LayoutGrid, Download, BookOpen, Brain } from 'lucide-react'; import { useAuthStore } from '../stores/authStore'; import { supabase, isSupabaseConfigured } from '../lib/supabase'; @@ -17,6 +17,8 @@ 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'; // 不做用户管理时使用,数据从 outputs 取 const DEFAULT_USER = { id: 'default', email: 'default' }; @@ -100,6 +102,11 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void const [showFlashcardViewer, setShowFlashcardViewer] = useState(false); const [flashcardSetId, setFlashcardSetId] = useState(''); + // Quiz state + const [quizQuestions, setQuizQuestions] = useState([]); + const [showQuizContainer, setShowQuizContainer] = useState(false); + const [quizId, setQuizId] = useState(''); + // Output preview const [previewOutput, setPreviewOutput] = useState<{ id: string; @@ -204,6 +211,7 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void { icon: , label: 'DrawIO', id: 'drawio' }, { icon: , label: 'Knowledge Podcast', id: 'podcast' }, { icon: , label: 'Flashcards', id: 'flashcard' }, + { icon: , label: 'Quiz', id: 'quiz' }, // Video narration temporarily disabled // { icon: , label: 'Video narration', id: 'video' }, ]; @@ -2710,6 +2718,21 @@ rel="noopener noreferrer" /> )} + {/* Quiz Generator */} + {activeTool === 'quiz' && !showQuizContainer && ( + selectedIds.has(f.id)).map((f: KnowledgeFile) => f.url).filter(Boolean) as string[]} + notebookId={notebook?.id || ''} + email={effectiveUser?.email || effectiveUser?.id || 'default'} + userId={effectiveUser?.id || 'default'} + onGenerated={(quizId: string, questions: any[]) => { + setQuizId(quizId); + setQuizQuestions(questions); + setShowQuizContainer(true); + }} + /> + )} + {/* Output Feed */} {outputFeed.length > 0 && (
@@ -3275,6 +3298,24 @@ rel="noopener noreferrer"
)} + + {/* Quiz Container Modal */} + {showQuizContainer && quizQuestions.length > 0 && ( +
setShowQuizContainer(false)} + > +
e.stopPropagation()} + > + setShowQuizContainer(false)} + /> +
+
+ )}
); }; diff --git a/frontend_en/src/types/index.ts b/frontend_en/src/types/index.ts index 15b3b06..0b5260f 100644 --- a/frontend_en/src/types/index.ts +++ b/frontend_en/src/types/index.ts @@ -26,4 +26,4 @@ export interface ChatMessage { } export type SectionType = 'library' | 'upload' | 'output' | 'settings'; -export type ToolType = 'chat' | 'ppt' | 'mindmap' | 'podcast' | 'video' | 'search' | 'drawio' | 'flashcard'; +export type ToolType = 'chat' | 'ppt' | 'mindmap' | 'podcast' | 'video' | 'search' | 'drawio' | 'flashcard' | 'quiz'; From 5bb647a4baafb8a03acdf9d518a7f14cf820e7a1 Mon Sep 17 00:00:00 2001 From: asd765973346 <23310090@muc.edu.cn> Date: Thu, 12 Feb 2026 03:09:22 +0800 Subject: [PATCH 03/34] =?UTF-8?q?feat:=20flashcard/quiz=20persistence=20?= =?UTF-8?q?=E2=80=94=204=20GET=20endpoints=20+=20outputFeed=20click-to-loa?= =?UTF-8?q?d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add backend read endpoints (list-flashcard-sets, list-quiz-sets, get-flashcard-set, get-quiz-set) and wire frontend outputFeed items to load saved sets from disk, surviving page refresh. Co-Authored-By: Claude --- fastapi_app/routers/kb.py | 118 ++++++++++++ frontend_en/src/pages/NotebookView.tsx | 251 +++++++++++++++++++++---- frontend_zh/src/pages/NotebookView.tsx | 251 +++++++++++++++++++++---- 3 files changed, 538 insertions(+), 82 deletions(-) diff --git a/fastapi_app/routers/kb.py b/fastapi_app/routers/kb.py index 41c9b64..36ce2f3 100644 --- a/fastapi_app/routers/kb.py +++ b/fastapi_app/routers/kb.py @@ -2742,3 +2742,121 @@ async def generate_quiz( except Exception as e: log.exception("[generate-quiz] failed") raise HTTPException(status_code=500, detail=str(e)) + + +# ===================== Flashcard / Quiz 读取端点 ===================== + +@router.get("/list-flashcard-sets") +async def list_flashcard_sets( + notebook_id: str, + notebook_title: Optional[str] = None, + user_id: Optional[str] = None, +): + """列出某 notebook 下所有已保存的闪卡集合(按时间倒序)""" + try: + paths = get_notebook_paths(notebook_id, notebook_title or "", user_id) + flashcard_root = paths.root / "flashcard" + sets = [] + if flashcard_root.exists(): + for ts_dir in flashcard_root.iterdir(): + if not ts_dir.is_dir(): + continue + json_file = ts_dir / "flashcards.json" + if not json_file.exists(): + continue + try: + data = json.loads(json_file.read_text(encoding="utf-8")) + sets.append({ + "set_id": ts_dir.name, + "id": data.get("id", ""), + "created_at": data.get("created_at", ""), + "total_count": data.get("total_count", 0), + "source_files": data.get("source_files", []), + }) + except Exception: + continue + sets.sort(key=lambda x: x["set_id"], reverse=True) + return {"success": True, "sets": sets} + except Exception as e: + log.exception("[list-flashcard-sets] failed") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/list-quiz-sets") +async def list_quiz_sets( + notebook_id: str, + notebook_title: Optional[str] = None, + user_id: Optional[str] = None, +): + """列出某 notebook 下所有已保存的测验集合(按时间倒序)""" + try: + paths = get_notebook_paths(notebook_id, notebook_title or "", user_id) + quiz_root = paths.root / "quiz" + sets = [] + if quiz_root.exists(): + for ts_dir in quiz_root.iterdir(): + if not ts_dir.is_dir(): + continue + json_file = ts_dir / "quiz.json" + if not json_file.exists(): + continue + try: + data = json.loads(json_file.read_text(encoding="utf-8")) + sets.append({ + "set_id": ts_dir.name, + "id": data.get("id", ""), + "created_at": data.get("created_at", ""), + "total_count": data.get("total_count", 0), + "source_files": data.get("source_files", []), + }) + except Exception: + continue + sets.sort(key=lambda x: x["set_id"], reverse=True) + return {"success": True, "sets": sets} + except Exception as e: + log.exception("[list-quiz-sets] failed") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/get-flashcard-set") +async def get_flashcard_set( + notebook_id: str, + set_id: str, + notebook_title: Optional[str] = None, + user_id: Optional[str] = None, +): + """读取指定闪卡集合的完整数据""" + try: + paths = get_notebook_paths(notebook_id, notebook_title or "", user_id) + json_file = paths.root / "flashcard" / set_id / "flashcards.json" + if not json_file.exists(): + raise HTTPException(status_code=404, detail="Flashcard set not found") + data = json.loads(json_file.read_text(encoding="utf-8")) + return {"success": True, **data} + except HTTPException: + raise + except Exception as e: + log.exception("[get-flashcard-set] failed") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/get-quiz-set") +async def get_quiz_set( + notebook_id: str, + set_id: str, + notebook_title: Optional[str] = None, + user_id: Optional[str] = None, +): + """读取指定测验集合的完整数据""" + try: + paths = get_notebook_paths(notebook_id, notebook_title or "", user_id) + json_file = paths.root / "quiz" / set_id / "quiz.json" + if not json_file.exists(): + raise HTTPException(status_code=404, detail="Quiz set not found") + data = json.loads(json_file.read_text(encoding="utf-8")) + return {"success": True, **data} + except HTTPException: + raise + except Exception as e: + log.exception("[get-quiz-set] failed") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/frontend_en/src/pages/NotebookView.tsx b/frontend_en/src/pages/NotebookView.tsx index f180651..824cf38 100644 --- a/frontend_en/src/pages/NotebookView.tsx +++ b/frontend_en/src/pages/NotebookView.tsx @@ -15,9 +15,7 @@ import ReactMarkdown from 'react-markdown'; import { MermaidPreview } from '../components/knowledge-base/tools/MermaidPreview'; 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 katex from 'katex'; import 'katex/dist/katex.min.css'; @@ -86,7 +84,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; }>>([]); // Settings modal @@ -112,13 +111,14 @@ 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,用于在弹窗内嵌编辑 */ @@ -178,6 +178,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); @@ -229,7 +232,7 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void ]; // Studio:每个功能卡片各自配置,点卡片上的「…」翻转进该卡片的设置 - type StudioToolId = 'ppt' | 'mindmap' | 'drawio' | 'podcast' | 'video'; + type StudioToolId = 'ppt' | 'mindmap' | 'drawio' | 'flashcard' | 'quiz' | 'podcast' | 'video'; const [studioPanelView, setStudioPanelView] = useState<'tools' | 'settings'>('tools'); const [studioSettingsTool, setStudioSettingsTool] = useState(null); const STORAGE_STUDIO_CONFIG = `kb_studio_config_${effectiveUser?.id || 'default'}`; @@ -237,6 +240,8 @@ 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' }, + flashcard: { llmModel: 'deepseek-v3.2', language: 'en', cardCount: '20' }, + quiz: { llmModel: 'deepseek-v3.2', language: 'en', questionCount: '10' }, podcast: { llmModel: 'deepseek-v3.2', ttsModel: 'gemini-2.5-pro-preview-tts', voiceName: 'Kore', voiceNameB: 'Puck' }, video: { llmModel: 'deepseek-v3.2' }, }; @@ -335,7 +340,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'; @@ -343,13 +348,45 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void return 'ppt'; }; - const getOutputTitle = (type: 'ppt' | 'mindmap' | 'podcast' | 'drawio') => { + const getOutputTitle = (type: 'ppt' | 'mindmap' | 'podcast' | 'drawio' | 'flashcard' | 'quiz') => { if (type === 'mindmap') return 'Mind Map'; if (type === 'podcast') return 'Podcast'; if (type === 'drawio') return 'DrawIO'; + if (type === 'flashcard') return 'Flashcards'; + if (type === 'quiz') return 'Quiz'; return 'PPT'; }; + const handleLoadSavedSet = async (item: typeof outputFeed[number]) => { + if (!item.setId) { + alert('Failed to load: this item has no saved set ID. It may have been created before persistence was added.'); + 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 || 'Load failed'); + 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); + alert('Failed to load. The data may have been deleted.'); + } finally { + setLoadingSetId(null); + } + }; + const mergeOutputFeeds = (remote: typeof outputFeed, local: typeof outputFeed) => { const map = new Map(); const add = (item: typeof outputFeed[number]) => { @@ -1389,6 +1426,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'); } @@ -1475,6 +1518,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 || 'en', + 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 || 'en', + question_count: Math.max(5, Math.min(30, parseInt(String(cfg.questionCount || '10'), 10) || 10)), + }; } else { bodyData = { ...baseBody, @@ -1554,6 +1615,40 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void }, ...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: 'Flashcards', + sources: selectedNames.length ? selectedNames.join(', ') : `${selectedIds.size} source(s)`, + 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: 'Quiz', + sources: selectedNames.length ? selectedNames.join(', ') : `${selectedIds.size} source(s)`, + url: '', + createdAt: now, + setId: qzSetId || String(Date.now()), + }, + ...prev, + ]); } } catch (err) { @@ -2389,6 +2484,8 @@ rel="noopener noreferrer" {studioSettingsTool === 'mindmap' && 'Mind Map'} {studioSettingsTool === 'drawio' && 'DrawIO'} {studioSettingsTool === 'podcast' && 'Knowledge Podcast'} + {studioSettingsTool === 'flashcard' && 'Flashcards'} + {studioSettingsTool === 'quiz' && 'Quiz'} {/* {studioSettingsTool === 'video' && 'Video narration'} */}
@@ -2533,6 +2630,86 @@ rel="noopener noreferrer" ); })()} + {studioSettingsTool === 'flashcard' && (() => { + const c = getStudioConfig('flashcard'); + return ( + <> +
+ + +
+
+ + { + const v = e.target.value.replace(/\D/g, ''); + if (v === '') { setStudioConfigForTool('flashcard', { cardCount: '' }); return; } + const n = parseInt(v, 10); + if (!Number.isNaN(n)) setStudioConfigForTool('flashcard', { cardCount: String(Math.max(5, Math.min(50, n))) }); + }} + onBlur={(e) => { + const n = parseInt(e.target.value || '20', 10); + if (Number.isNaN(n) || n < 5 || n > 50) setStudioConfigForTool('flashcard', { cardCount: '20' }); + }} + placeholder="5–50" + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500" + /> +

5–50 cards

+
+
+ + setStudioConfigForTool('flashcard', { llmModel: e.target.value })} placeholder="deepseek-v3.2" className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500" /> +
+ + ); + })()} + {studioSettingsTool === 'quiz' && (() => { + const c = getStudioConfig('quiz'); + return ( + <> +
+ + +
+
+ + { + const v = e.target.value.replace(/\D/g, ''); + if (v === '') { setStudioConfigForTool('quiz', { questionCount: '' }); return; } + const n = parseInt(v, 10); + if (!Number.isNaN(n)) setStudioConfigForTool('quiz', { questionCount: String(Math.max(5, Math.min(30, n))) }); + }} + onBlur={(e) => { + const n = parseInt(e.target.value || '10', 10); + if (Number.isNaN(n) || n < 5 || n > 30) setStudioConfigForTool('quiz', { questionCount: '10' }); + }} + placeholder="5–30" + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500" + /> +

5–30 questions

+
+
+ + setStudioConfigForTool('quiz', { llmModel: e.target.value })} placeholder="deepseek-v3.2" className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500" /> +
+ + ); + })()} {/* Video narration temporarily disabled {studioSettingsTool === 'video' && (() => { const c = getStudioConfig('video'); @@ -2653,35 +2830,6 @@ rel="noopener noreferrer"
)} - {/* Flashcard Generator */} - {activeTool === 'flashcard' && !showFlashcardViewer && ( - selectedIds.has(f.id)).map(f => f.url || f.name)} - notebookId={notebook?.id || ''} - email={effectiveUser.email || ''} - userId={effectiveUser.id || ''} - onGenerated={(id: string, cards: any[]) => { - setFlashcardSetId(id); - setFlashcards(cards); - setShowFlashcardViewer(true); - }} - /> - )} - - {/* Quiz Generator */} - {activeTool === 'quiz' && !showQuizContainer && ( - selectedIds.has(f.id)).map(f => f.url || f.name)} - notebookId={notebook?.id || ''} - email={effectiveUser.email || ''} - userId={effectiveUser.id || ''} - onGenerated={(id: string, questions: any[]) => { - setQuizId(id); - setQuizQuestions(questions); - setShowQuizContainer(true); - }} - /> - )} {/* Output Feed */} {outputFeed.length > 0 && ( @@ -2692,20 +2840,41 @@ rel="noopener noreferrer"
{outputFeed.map(item => ( -
setPreviewOutput(item)} + onClick={() => { + if (item.type === 'flashcard' || item.type === 'quiz') { + handleLoadSavedSet(item); + } else { + setPreviewOutput(item); + } + }} >
-
{item.title}
+
+ {item.type === 'flashcard' && } + {item.type === 'quiz' && } + {item.title} +
{item.createdAt}
Sources: {item.sources}
- {item.url ? ( + {(item.type === 'flashcard' || item.type === 'quiz') ? ( + + ) : item.url ? ( <>
{outputFeed.map(item => ( -
setPreviewOutput(item)} + onClick={() => { + if (item.type === 'flashcard' || item.type === 'quiz') { + handleLoadSavedSet(item); + } else { + setPreviewOutput(item); + } + }} >
-
{item.title}
+
+ {item.type === 'flashcard' && } + {item.type === 'quiz' && } + {item.title} +
{item.createdAt}
来源:{item.sources}
- {item.url ? ( + {(item.type === 'flashcard' || item.type === 'quiz') ? ( + + ) : item.url ? ( <> +
- {/* Progress Indicator */} + {/* iOS Progress Bar */}
-

+

{currentIndex + 1} / {flashcards.length}

-
-
+
- {/* 卡片区域 */} + {/* Card Area */}
-
{/* Front - Question */}
-

{currentCard.question}

-
+

{currentCard.question}

+
Click to flip and see answer
@@ -95,43 +101,45 @@ export const FlashcardViewer: React.FC = ({ {/* Back - Answer */}
-

{currentCard.answer}

+

{currentCard.answer}

{currentCard.source_excerpt && ( -
-

Source Excerpt:

-

{currentCard.source_excerpt}

+
+

Source Excerpt:

+

{currentCard.source_excerpt}

)}
-
+
{/* Navigation Buttons */}
- + - +
); 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 */}
- + - + - +
); 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) => ( - + ))}
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 */} -
- + - +
); diff --git a/frontend_en/src/index.css b/frontend_en/src/index.css index 1712500..176508d 100644 --- a/frontend_en/src/index.css +++ b/frontend_en/src/index.css @@ -3,11 +3,58 @@ @tailwind utilities; :root { - background-color: #f8f9fa; + background-color: #f2f2f7; } body { margin: 0; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text', 'Helvetica Neue', Arial, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } +@layer utilities { + .glass { + background: rgba(255, 255, 255, 0.72); + backdrop-filter: blur(20px) saturate(180%); + -webkit-backdrop-filter: blur(20px) saturate(180%); + } + .glass-heavy { + background: rgba(255, 255, 255, 0.85); + backdrop-filter: blur(40px) saturate(200%); + -webkit-backdrop-filter: blur(40px) saturate(200%); + } + .glass-dark { + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(20px) saturate(180%); + -webkit-backdrop-filter: blur(20px) saturate(180%); + } +} + +.ios-shimmer { + background: linear-gradient(90deg, #f2f2f7 25%, #e5e5ea 50%, #f2f2f7 75%); + background-size: 200% 100%; + animation: ios-shimmer 1.5s ease-in-out infinite; +} + +/* Slim scrollbar */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.15); + border-radius: 3px; +} +::-webkit-scrollbar-thumb:hover { + background: rgba(0, 0, 0, 0.25); +} + +/* iOS blue selection */ +::selection { + background: rgba(0, 122, 255, 0.25); + color: inherit; +} diff --git a/frontend_en/src/pages/Dashboard.tsx b/frontend_en/src/pages/Dashboard.tsx index 7139332..f3d6f2b 100644 --- a/frontend_en/src/pages/Dashboard.tsx +++ b/frontend_en/src/pages/Dashboard.tsx @@ -1,4 +1,5 @@ import React, { useState, useEffect } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; import { Settings, Plus, User, Loader2, BookOpen, Key, CheckCircle2 } from 'lucide-react'; import { useAuthStore } from '../stores/authStore'; import { apiFetch } from '../config/api'; @@ -36,7 +37,6 @@ const Dashboard = ({ onOpenNotebook, refreshTrigger = 0 }: { onOpenNotebook: (n: const [configSaving, setConfigSaving] = useState(false); const [configSaved, setConfigSaved] = useState(false); - // 不做用户管理时用默认用户,数据从 outputs 取 const effectiveUserId = user?.id || 'default'; useEffect(() => { @@ -84,7 +84,6 @@ const Dashboard = ({ onOpenNotebook, refreshTrigger = 0 }: { onOpenNotebook: (n: 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([]); @@ -141,41 +140,45 @@ const Dashboard = ({ onOpenNotebook, refreshTrigger = 0 }: { onOpenNotebook: (n: return (
-
-
- Logo -

open NoteBookLM

-
-
- -
- + {/* Glass Header */} +
+
+
+ Logo +

open NoteBookLM

+
+
+ 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 + +
+ +
{configOpen && ( -
-

+
+

Home config (used when you open a notebook)

-

LLM API

+

LLM API

- +
- + 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

- + 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' && (
- + setNewNotebookName(e.target.value)} + {/* Create Modal — iOS Sheet */} + + {createModalOpen && ( +
!creating && setCreateModalOpen(false)}> + - {createError &&

{createError}

} -
- - -
+ 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 010fa58..ba1829d 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 { motion, AnimatePresence } from 'framer-motion'; import { ChevronLeft, Plus, Share2, Settings, MessageSquare, BarChart2, Zap, AudioLines, Video, FileText, @@ -26,7 +27,6 @@ const DEFAULT_USER = { id: 'default', email: 'default' }; 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 [activeTool, setActiveTool] = useState('chat'); // Files management @@ -37,7 +37,7 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void 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: 'Welcome to OpenNotebookLM! I\'m your intelligent knowledge base assistant.\n\nUpload documents on the left, then chat with me to explore, summarize, and generate insights from your sources — including podcasts, mind maps, presentations, flashcards, and quizzes.', time: new Date().toLocaleTimeString() }; const [chatMessages, setChatMessages] = useState([WELCOME_MSG]); @@ -123,11 +123,7 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void 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); @@ -168,16 +164,6 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void const [sourceDetailFormat, setSourceDetailFormat] = useState<'text' | 'markdown'>('text'); const [sourceDetailLoading, setSourceDetailLoading] = useState(false); - // Flashcard state - const [flashcards, setFlashcards] = useState([]); - const [showFlashcardViewer, setShowFlashcardViewer] = useState(false); - const [flashcardSetId, setFlashcardSetId] = useState(''); - - // Quiz state - const [quizQuestions, setQuizQuestions] = useState([]); - const [showQuizContainer, setShowQuizContainer] = useState(false); - const [quizId, setQuizId] = useState(''); - // Loading state for saved flashcard/quiz sets const [loadingSetId, setLoadingSetId] = useState(null); @@ -658,56 +644,6 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void } }; - 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(); @@ -1908,44 +1844,29 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void } `} {/* Header */} -
+
- - Logo -

+ + Logo +

{notebook?.title || 'Semantic Rewards for Low-Resource Language Alignment'}

- +
- {/* 右上方添加笔记 - 暂未使用,先注释 - - */} - {/* 右侧上方分析和分享 - 暂未使用,先注释 - - - */} - -
-
PRO
-
+ + +
+
PRO
+
{(effectiveUser?.email || effectiveUser?.id || 'U').charAt(0).toUpperCase()}
@@ -1955,12 +1876,12 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
{/* Left Sidebar: Sources */}
{retrievalError && ( @@ -2017,12 +1939,12 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void {!sourceDetailView ? ( <>
- + {selectedIds.size > 0 ? `${selectedIds.size} selected` : 'All sources'} 0} onChange={(e) => { if (e.target.checked) { @@ -2036,21 +1958,24 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
{files.length === 0 ? ( -
+
No files. Please upload.
) : ( files.map((file, fileIdx) => ( -
openSourceDetail(file)} > -
- {fileIdx + 1} +
+ {fileIdx + 1}
-
+
{file.name}
@@ -2063,7 +1988,7 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
{ setSelectedIds(prev => { @@ -2075,7 +2000,7 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void }} onClick={(e) => e.stopPropagation()} /> -
+ )) )}
@@ -2127,61 +2052,44 @@ rel="noopener noreferrer"
{ e.preventDefault(); setResizing('left'); resizeRef.current = { startX: e.clientX, startLeft: leftPanelWidth, startRight: rightPanelWidth }; }} > - +
{/* Center: Chat/Content Area */}
-
-
- - - -
+
+ Chat
- - +
- {activeTab === 'chat' && chatSubView === 'history' && ( + {chatSubView === 'history' && (

Chat history (click to restore)

@@ -2215,17 +2123,22 @@ rel="noopener noreferrer"
)} - {activeTab === 'chat' && chatSubView === 'current' && ( + {chatSubView === 'current' && (
+ {chatMessages.length <= 1 && chatMessages[0]?.id === 'welcome' && ( +
+ OpenNotebookLM +
+ )} {chatMessages.map(msg => (
-
{msg.role === 'assistant' ? : }
-
{msg.role === 'assistant' ? ( @@ -2237,10 +2150,10 @@ rel="noopener noreferrer" ))} {isChatLoading && (
-
+
-
+
Thinking...
@@ -2248,248 +2161,35 @@ rel="noopener noreferrer"
)} - {activeTab === 'retrieval' && ( -
- {!apiConfigured && ( -
-

Configure API URL and API Key in Settings (top right) to use retrieval and embeddings.

- -
- )} - {retrievalError && ( -
-

{retrievalError}

- -
- )} -
-
-
- -
-
-

Knowledge base retrieval

-

Enter a question to search over embedded sources.

-
-
-
- setRetrievalQuery(e.target.value)} - placeholder="e.g. What is the main contribution of the model?" - className="w-full bg-white border border-gray-200 rounded-xl px-4 py-3 text-sm text-gray-700 outline-none focus:border-blue-400" - /> -
-
- TopK - setRetrievalTopK(Math.max(1, Number(e.target.value || 1)))} - className="w-16 bg-white border border-gray-200 rounded-lg px-2 py-1 text-xs text-gray-700" - /> -
-
- Embedding Model - setRetrievalModel(e.target.value)} - className="w-56 bg-white border border-gray-200 rounded-lg px-2 py-1 text-xs text-gray-700" - /> -
-
- -
-
- {retrievalError && ( -
{retrievalError}
- )} -
-
- -
- {retrievalResults.length === 0 && !retrievalLoading && ( -
- No results -
- )} - {retrievalResults.map((item, idx) => ( -
-
-
- Score:{item.score?.toFixed ? item.score.toFixed(3) : item.score} -
- {item.source_file?.url && ( - - )} -
-
- {item.content || '(no content)'} -
- {item.media?.url && ( -
- {item.type === 'image' ? ( - media - ) : ( - - )} -
- )} -
- ))} -
-
- )} - - {activeTab === 'sources' && ( -
-
-

Vector store files

-

- Sources are managed on the left. This list shows embedded files and status. -

-
- - {vectorError && {vectorError}} -
-
- - {vectorLoading && ( -
Loading vector list...
- )} - - {!vectorLoading && vectorFiles.length === 0 && ( -
No vector files
- )} - -
- {vectorFiles.map((item, idx) => { - const fileName = getFileNameFromPath(item.original_path); - const status = item.status || 'unknown'; - const actionKey = item.id || item.original_path; - const isBusy = actionKey ? vectorActionLoading[actionKey] : false; - const statusColor = - status === 'embedded' - ? 'text-green-600 bg-green-50' - : status === 'failed' - ? 'text-red-600 bg-red-50' - : status === 'skipped' - ? 'text-gray-600 bg-gray-100' - : 'text-blue-600 bg-blue-50'; - return ( -
-
-
- -
-
{fileName || 'Untitled'}
-
- Type: {item.file_type || '-'} | chunks: {item.chunks_count ?? 0} | media: {item.media_desc_count ?? 0} -
-
-
-
- - - - {status} - -
-
- {item.error && ( -
- - {/401|Unauthorized/i.test(String(item.error)) - ? 'API auth failed. Check API Key in Settings.' - : `Error: ${item.error}`} - - {(/401|Unauthorized/i.test(String(item.error))) && ( - - )} -
- )} -
- ); - })} -
-
- )}
- {activeTab === 'chat' && chatSubView === 'current' && ( + {chatSubView === 'current' && (
- setInputMsg(e.target.value)} - onKeyDown={e => e.key === 'Enter' && handleSendMessage()} - placeholder={selectedIds.size > 0 ? "Type here..." : "Select files first..."} - disabled={selectedIds.size === 0} - className="w-full bg-[#f8f9fa] border border-gray-200 rounded-3xl py-4 pl-6 pr-24 focus:outline-none focus:ring-1 focus:ring-blue-500 text-lg disabled:opacity-50" - /> -
- {selectedIds.size} sources - +
+ setInputMsg(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleSendMessage()} + placeholder={selectedIds.size > 0 ? "Type here..." : "Select files first..."} + disabled={selectedIds.size === 0} + className="w-full bg-transparent rounded-ios-xl py-4 pl-6 pr-24 focus:outline-none text-lg disabled:opacity-50" + /> +
+ {selectedIds.size} sources + + + +
-

+

Answers may not be fully accurate. Please verify important content.

@@ -2500,23 +2200,23 @@ rel="noopener noreferrer"
{ e.preventDefault(); setResizing('right'); resizeRef.current = { startX: e.clientX, startLeft: leftPanelWidth, startRight: rightPanelWidth }; }} > - +
{/* Right Sidebar: Studio 功能卡片,每卡片「…」翻转进该卡片设置 */}

+ Close - +
- {/* Progress Indicator */} + {/* iOS Progress Bar */}
-

+

{currentIndex + 1} / {flashcards.length}

-
-
+
- {/* 卡片区域 */} + {/* Card Area */}
-
{/* Front - Question */}
-

{currentCard.question}

-
+

{currentCard.question}

+
Click to flip and see answer
@@ -95,43 +101,45 @@ export const FlashcardViewer: React.FC = ({ {/* Back - Answer */}
-

{currentCard.answer}

+

{currentCard.answer}

{currentCard.source_excerpt && ( -
-

Source Excerpt:

-

{currentCard.source_excerpt}

+
+

Source Excerpt:

+

{currentCard.source_excerpt}

)}
-
+
{/* Navigation Buttons */}
- + - +
); diff --git a/frontend_zh/src/components/quiz/QuizContainer.tsx b/frontend_zh/src/components/quiz/QuizContainer.tsx index cd1d5f2..92ee5b6 100644 --- a/frontend_zh/src/components/quiz/QuizContainer.tsx +++ b/frontend_zh/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 */}
- + - + - +
); diff --git a/frontend_zh/src/components/quiz/QuizQuestion.tsx b/frontend_zh/src/components/quiz/QuizQuestion.tsx index ad3ec2f..8bb1d11 100644 --- a/frontend_zh/src/components/quiz/QuizQuestion.tsx +++ b/frontend_zh/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) => ( - + ))}
diff --git a/frontend_zh/src/components/quiz/QuizResults.tsx b/frontend_zh/src/components/quiz/QuizResults.tsx index 3c8ce08..c4e6ec1 100644 --- a/frontend_zh/src/components/quiz/QuizResults.tsx +++ b/frontend_zh/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 */} -
- + - +
); diff --git a/frontend_zh/src/index.css b/frontend_zh/src/index.css index 1712500..176508d 100644 --- a/frontend_zh/src/index.css +++ b/frontend_zh/src/index.css @@ -3,11 +3,58 @@ @tailwind utilities; :root { - background-color: #f8f9fa; + background-color: #f2f2f7; } body { margin: 0; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text', 'Helvetica Neue', Arial, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } +@layer utilities { + .glass { + background: rgba(255, 255, 255, 0.72); + backdrop-filter: blur(20px) saturate(180%); + -webkit-backdrop-filter: blur(20px) saturate(180%); + } + .glass-heavy { + background: rgba(255, 255, 255, 0.85); + backdrop-filter: blur(40px) saturate(200%); + -webkit-backdrop-filter: blur(40px) saturate(200%); + } + .glass-dark { + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(20px) saturate(180%); + -webkit-backdrop-filter: blur(20px) saturate(180%); + } +} + +.ios-shimmer { + background: linear-gradient(90deg, #f2f2f7 25%, #e5e5ea 50%, #f2f2f7 75%); + background-size: 200% 100%; + animation: ios-shimmer 1.5s ease-in-out infinite; +} + +/* Slim scrollbar */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.15); + border-radius: 3px; +} +::-webkit-scrollbar-thumb:hover { + background: rgba(0, 0, 0, 0.25); +} + +/* iOS blue selection */ +::selection { + background: rgba(0, 122, 255, 0.25); + color: inherit; +} diff --git a/frontend_zh/src/pages/Dashboard.tsx b/frontend_zh/src/pages/Dashboard.tsx index 60bead9..0cfb63e 100644 --- a/frontend_zh/src/pages/Dashboard.tsx +++ b/frontend_zh/src/pages/Dashboard.tsx @@ -1,4 +1,5 @@ import React, { useState, useEffect } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; import { Settings, Plus, User, Loader2, BookOpen, Key, CheckCircle2 } from 'lucide-react'; import { useAuthStore } from '../stores/authStore'; import { apiFetch } from '../config/api'; @@ -84,7 +85,6 @@ const Dashboard = ({ onOpenNotebook, refreshTrigger = 0 }: { onOpenNotebook: (n: 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([]); @@ -141,41 +141,45 @@ const Dashboard = ({ onOpenNotebook, refreshTrigger = 0 }: { onOpenNotebook: (n: return (
-
-
- Logo -

open NoteBookLM

-
-
- -
- + {/* Glass Header */} +
+
+
+ Logo +

open NoteBookLM

+
+
+ 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 配置 + +
+ +
{configOpen && ( -
-

+
+

首页配置(进入笔记本后直接使用)

-

LLM 调用

+

LLM 调用

- +
- + 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" />
-

搜索来源 API

+

搜索来源 API

- + setSearchApiKey(e.target.value)} placeholder={searchProvider === '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' && (
- + setNewNotebookName(e.target.value)} + {/* Create Modal — iOS Sheet */} + + {createModalOpen && ( +
!creating && setCreateModalOpen(false)}> + - {createError &&

{createError}

} -
- - -
+ e.stopPropagation()} + > + {/* iOS Drag Indicator */} +
+
+
+

新建笔记本

+ setNewNotebookName(e.target.value)} + /> + {createError &&

{createError}

} +
+ !creating && setCreateModalOpen(false)} + disabled={creating} + > + 取消 + + + {creating && } + 创建 + +
+
-
- )} + )} +
); }; diff --git a/frontend_zh/src/pages/NotebookView.tsx b/frontend_zh/src/pages/NotebookView.tsx index ebf180d..6f98579 100644 --- a/frontend_zh/src/pages/NotebookView.tsx +++ b/frontend_zh/src/pages/NotebookView.tsx @@ -1,4 +1,5 @@ import React, { useState, useEffect } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; import { ChevronLeft, Plus, Share2, Settings, MessageSquare, BarChart2, Zap, AudioLines, Video, FileText, @@ -26,7 +27,6 @@ const DEFAULT_USER = { id: 'default', email: 'default' }; 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 [activeTool, setActiveTool] = useState('chat'); // Files management @@ -37,7 +37,7 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void const WELCOME_MSG: ChatMessage = { id: 'welcome', role: 'assistant', - content: '你好!我是你的知识库助手。请上传文件或在左侧来源区域选择文件,然后在此处进行提问。', + content: '欢迎使用 OpenNotebookLM!我是你的智能知识库助手。\n\n在左侧上传文档,然后与我对话来探索、总结和生成洞察 —— 支持播客、思维导图、PPT、闪卡、测验等多种输出形式。', time: new Date().toLocaleTimeString() }; const [chatMessages, setChatMessages] = useState([WELCOME_MSG]); @@ -113,11 +113,7 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void 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); @@ -646,56 +642,6 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void } }; - const handleRunRetrieval = async () => { - if (!retrievalQuery.trim()) { - setRetrievalError('请输入检索问题'); - return; - } - if (!user?.email) { - setRetrievalError('缺少用户信息'); - return; - } - - const settings = getApiSettings(user?.id || null); - const apiUrl = settings?.apiUrl?.trim() || ''; - const apiKey = settings?.apiKey?.trim() || ''; - if (!apiUrl || !apiKey) { - setRetrievalError('请先在设置中配置 API URL 和 API Key'); - 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 || '检索失败'); - } - const data = await res.json(); - setRetrievalResults(Array.isArray(data.results) ? data.results : []); - } catch (err: any) { - setRetrievalError(err?.message || '检索失败'); - } finally { - setRetrievalLoading(false); - } - }; - // Fetch files from outputs when notebook changes(不做用户管理,数据从 outputs 取) useEffect(() => { if (notebook?.id) fetchFiles(); @@ -1896,17 +1842,17 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void } `} {/* Header */} -
+
- - Logo -

+ + Logo +

{notebook?.title || 'Semantic Rewards for Low-Resource Language Alignment'}

- +
{/* 右上方添加笔记 - 暂未使用,先注释 */} - -
-
PRO
-
+ + +
+
PRO
+
{(effectiveUser?.email || effectiveUser?.id || 'U').charAt(0).toUpperCase()}
@@ -1943,12 +1890,12 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
{/* Left Sidebar: Sources */}
{retrievalError && ( @@ -2005,12 +1953,12 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void {!sourceDetailView ? ( <>
- + {selectedIds.size > 0 ? `已选 ${selectedIds.size} 个` : '全部来源'} 0} onChange={(e) => { if (e.target.checked) { @@ -2024,21 +1972,24 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
{files.length === 0 ? ( -
+
暂无文件,请上传
) : ( files.map((file, fileIdx) => ( -
openSourceDetail(file)} > -
- {fileIdx + 1} +
+ {fileIdx + 1}
-
+
{file.name}
@@ -2051,7 +2002,7 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
{ setSelectedIds(prev => { @@ -2063,7 +2014,7 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void }} onClick={(e) => e.stopPropagation()} /> -
+ )) )}
@@ -2115,61 +2066,44 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
{ e.preventDefault(); setResizing('left'); resizeRef.current = { startX: e.clientX, startLeft: leftPanelWidth, startRight: rightPanelWidth }; }} > - +
{/* Center: Chat/Content Area */}
-
-
- - - -
+
+ 对话
- - +
- {activeTab === 'chat' && chatSubView === 'history' && ( + {chatSubView === 'history' && (

对话历史(点击可回滚到该对话)

@@ -2203,17 +2137,22 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
)} - {activeTab === 'chat' && chatSubView === 'current' && ( + {chatSubView === 'current' && (
+ {chatMessages.length <= 1 && chatMessages[0]?.id === 'welcome' && ( +
+ OpenNotebookLM +
+ )} {chatMessages.map(msg => (
-
{msg.role === 'assistant' ? : }
-
{msg.role === 'assistant' ? ( @@ -2225,10 +2164,10 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void ))} {isChatLoading && (
-
+
-
+
思考中...
@@ -2236,248 +2175,35 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
)} - {activeTab === 'retrieval' && ( -
- {!apiConfigured && ( -
-

请先在右上角设置中配置 API URL 和 API Key,否则无法进行检索和生成向量。

- -
- )} - {retrievalError && ( -
-

{retrievalError}

- -
- )} -
-
-
- -
-
-

多模态知识库检索

-

输入问题并基于已入库的向量进行检索。

-
-
-
- setRetrievalQuery(e.target.value)} - placeholder="例如:模型的主要贡献是什么?" - className="w-full bg-white border border-gray-200 rounded-xl px-4 py-3 text-sm text-gray-700 outline-none focus:border-blue-400" - /> -
-
- TopK - setRetrievalTopK(Math.max(1, Number(e.target.value || 1)))} - className="w-16 bg-white border border-gray-200 rounded-lg px-2 py-1 text-xs text-gray-700" - /> -
-
- Embedding Model - setRetrievalModel(e.target.value)} - className="w-56 bg-white border border-gray-200 rounded-lg px-2 py-1 text-xs text-gray-700" - /> -
-
- -
-
- {retrievalError && ( -
{retrievalError}
- )} -
-
- -
- {retrievalResults.length === 0 && !retrievalLoading && ( -
- 暂无检索结果 -
- )} - {retrievalResults.map((item, idx) => ( -
-
-
- 相似度:{item.score?.toFixed ? item.score.toFixed(3) : item.score} -
- {item.source_file?.url && ( - - )} -
-
- {item.content || '(无内容)'} -
- {item.media?.url && ( -
- {item.type === 'image' ? ( - media - ) : ( - - )} -
- )} -
- ))} -
-
- )} - - {activeTab === 'sources' && ( -
-
-

向量库文件列表

-

- 来源管理已在左侧完成,此处展示已入库的向量文件与状态。 -

-
- - {vectorError && {vectorError}} -
-
- - {vectorLoading && ( -
正在加载向量列表...
- )} - - {!vectorLoading && vectorFiles.length === 0 && ( -
暂无向量文件
- )} - -
- {vectorFiles.map((item, idx) => { - const fileName = getFileNameFromPath(item.original_path); - const status = item.status || 'unknown'; - const actionKey = item.id || item.original_path; - const isBusy = actionKey ? vectorActionLoading[actionKey] : false; - const statusColor = - status === 'embedded' - ? 'text-green-600 bg-green-50' - : status === 'failed' - ? 'text-red-600 bg-red-50' - : status === 'skipped' - ? 'text-gray-600 bg-gray-100' - : 'text-blue-600 bg-blue-50'; - return ( -
-
-
- -
-
{fileName || '未命名文件'}
-
- 类型:{item.file_type || '-'} | chunks:{item.chunks_count ?? 0} | media:{item.media_desc_count ?? 0} -
-
-
-
- - - - {status} - -
-
- {item.error && ( -
- - {/401|Unauthorized/i.test(String(item.error)) - ? 'API 认证失败,请到设置中检查 API Key 是否正确。' - : `错误:${item.error}`} - - {(/401|Unauthorized/i.test(String(item.error))) && ( - - )} -
- )} -
- ); - })} -
-
- )}
- {activeTab === 'chat' && chatSubView === 'current' && ( + {chatSubView === 'current' && (
- setInputMsg(e.target.value)} - onKeyDown={e => e.key === 'Enter' && handleSendMessage()} - placeholder={selectedIds.size > 0 ? "开始输入..." : "请先选择文件..."} - disabled={selectedIds.size === 0} - className="w-full bg-[#f8f9fa] border border-gray-200 rounded-3xl py-4 pl-6 pr-24 focus:outline-none focus:ring-1 focus:ring-blue-500 text-lg disabled:opacity-50" - /> -
- {selectedIds.size} 个来源 - +
+ setInputMsg(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleSendMessage()} + placeholder={selectedIds.size > 0 ? "开始输入..." : "请先选择文件..."} + disabled={selectedIds.size === 0} + className="w-full bg-transparent rounded-ios-xl py-4 pl-6 pr-24 focus:outline-none text-lg disabled:opacity-50" + /> +
+ {selectedIds.size} 个来源 + + + +
-

+

NotebookLM 提供的内容未必准确,因此请仔细核查回答内容。

@@ -2488,23 +2214,23 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
{ e.preventDefault(); setResizing('right'); resizeRef.current = { startX: e.clientX, startLeft: leftPanelWidth, startRight: rightPanelWidth }; }} > - +
{/* Right Sidebar: Studio 功能卡片,每卡片「…」翻转进该卡片设置 */}