diff --git a/.gitignore b/.gitignore index dae2245..32ed417 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,10 @@ htmlcov/ # Environment .env +# Worktrees +.worktrees/ +worktrees/ + # AI agent config (local only) .claude/ CLAUDE.md diff --git a/README.md b/README.md index b39a062..f40d462 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,17 @@ PaperMind 是一个面向科研工作者的 AI 增强平台,帮你从「搜索 ## ✨ 核心能力 +### 🧠 认知重构工作流 (PaperSenseMaking) + +「阅读→理解→重构」的完整论文工作流: + +- 📝 **Act 1 理解** —— 摘要 + 关键发现,厘清论文核心 +- ⚡ **Act 2 碰撞** —— 冲突 + 疑问,与已有知识对话 +- 🔄 **Act 3 重构** —— 前后对比 + 认知变化,形成新认知 +- 📖 **全文对照翻译** —— 段落级中英对照,支持两种模式 + - ⚡ **快速翻译**:1-2 分钟,PyMuPDF 分段 + 并发翻译 + - 📐 **布局保留**:3-5 分钟,PDFMathTranslate 完整排版(公式/图表保留) + ### 🤖 AI Agent 对话 你的智能研究助理,自然语言交互搞定一切: @@ -162,6 +173,21 @@ PaperMind 是一个面向科研工作者的 AI 增强平台,帮你从「搜索 - 🎫 **JWT Token** —— 7 天有效期,自动续期 - 🛡️ **全站保护** —— 所有 API 都需要认证 +### ⚙️ LLM 模型管理 + +灵活控制成本,按场景分配模型: + +- 📊 **统一配置** —— 默认使用 GLM-4.7(文本)+ GLM-4.6V(视觉) +- 🔄 **一键切换** —— 在设置页面随时切换配置 +- 🎯 **场景映射** —— 所有文本任务自动使用 GLM-4.7 +- 💰 **成本优化** —— 单一模型配置,避免管理复杂度 +- 📈 **Token 追踪** —— 所有 API 调用自动记录成本和用量 + +**默认模型配置**: +- 文本任务(粗读/精读/翻译/写作):GLM-4.7 +- 视觉任务(图表分析/OCR):GLM-4.6V +- 降级备用:GLM-4.7 + --- ## 🏗️ 架构总览 @@ -384,6 +410,7 @@ alembic upgrade head - **[Semantic Scholar](https://www.semanticscholar.org)** — 引用数据来源 - **[CSFeeds](https://csarxiv.org)** — 论文源订阅服务 - **[learn-claude-code](https://github.com/shareAI-lab/learn-claude-code)** — Agent Harness 工程体系启发,s01-s12 渐进式解构:Loop → Tools → Planning → Subagents → Skills → Context → Tasks → Background → Teams → Protocols → Autonomous +- **[PaperSenseMaking](https://github.com/edu-ai-builders/paper-sense-making)** — 论文阅读「阅读→理解→重构」工作流设计灵感 --- diff --git a/apps/api/main.py b/apps/api/main.py index ea8abc5..efe91f0 100644 --- a/apps/api/main.py +++ b/apps/api/main.py @@ -155,10 +155,13 @@ async def app_error_handler(_request: Request, exc: AppError): cs_feeds, graph, jobs, + llm_configs, papers, pipelines, + sensemaking, system, topics, + translate, writing, ) from apps.api.routers import ( @@ -177,3 +180,6 @@ async def app_error_handler(_request: Request, exc: AppError): app.include_router(writing.router) app.include_router(jobs.router) app.include_router(auth.router) +app.include_router(sensemaking.router) +app.include_router(translate.router) +app.include_router(llm_configs.router) diff --git a/apps/api/routers/llm_configs.py b/apps/api/routers/llm_configs.py new file mode 100644 index 0000000..c58abc5 --- /dev/null +++ b/apps/api/routers/llm_configs.py @@ -0,0 +1,222 @@ +""" +LLM 模型管理路由 - 配置管理 + 场景化切换 +""" + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +from packages.storage.db import session_scope +from packages.storage.repositories import LLMConfigRepository + +router = APIRouter(prefix="/llm-configs", tags=["llm-configs"]) + + +class LLMConfigItem(BaseModel): + id: str + name: str + provider: str + api_base_url: str | None + model_skim: str + model_deep: str + model_vision: str | None + model_embedding: str + model_fallback: str + is_active: bool + + +class LLMConfigCreate(BaseModel): + name: str = Field(..., description="配置名称") + provider: str = Field(..., description="提供商:zhipu/openai/anthropic/siliconflow") + api_key: str = Field(..., description="API Key") + api_base_url: str | None = Field(None, description="自定义 API Base URL") + model_skim: str = Field(..., description="粗读/简单任务模型") + model_deep: str = Field(..., description="精读/复杂任务模型") + model_vision: str | None = Field(None, description="视觉模型") + model_embedding: str = Field(..., description="嵌入模型") + model_fallback: str = Field(..., description="降级备用模型") + + +class LLMConfigUpdate(BaseModel): + name: str | None = None + provider: str | None = None + api_key: str | None = None + api_base_url: str | None = None + model_skim: str | None = None + model_deep: str | None = None + model_vision: str | None = None + model_embedding: str | None = None + model_fallback: str | None = None + + +class LLMConfigActivate(BaseModel): + config_id: str + + +class LLMConfigList(BaseModel): + configs: list[LLMConfigItem] + active_id: str | None + + +class LLMConfigDetail(BaseModel): + config: LLMConfigItem + + +@router.get("", response_model=LLMConfigList) +def list_configs(): + """获取所有 LLM 配置列表""" + with session_scope() as session: + repo = LLMConfigRepository(session) + configs = repo.list_all() + active_cfg = repo.get_active() + return LLMConfigList( + configs=[ + LLMConfigItem( + id=c.id, + name=c.name, + provider=c.provider, + api_base_url=c.api_base_url, + model_skim=c.model_skim, + model_deep=c.model_deep, + model_vision=c.model_vision, + model_embedding=c.model_embedding, + model_fallback=c.model_fallback, + is_active=c.is_active, + ) + for c in configs + ], + active_id=active_cfg.id if active_cfg else None, + ) + + +@router.get("/{config_id}", response_model=LLMConfigDetail) +def get_config(config_id: str): + """获取单个配置详情""" + with session_scope() as session: + repo = LLMConfigRepository(session) + try: + cfg = repo.get_by_id(config_id) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + return LLMConfigDetail( + config=LLMConfigItem( + id=cfg.id, + name=cfg.name, + provider=cfg.provider, + api_base_url=cfg.api_base_url, + model_skim=cfg.model_skim, + model_deep=cfg.model_deep, + model_vision=cfg.model_vision, + model_embedding=cfg.model_embedding, + model_fallback=cfg.model_fallback, + is_active=cfg.is_active, + ) + ) + + +@router.post("", response_model=LLMConfigDetail) +def create_config(req: LLMConfigCreate): + """创建新的 LLM 配置""" + with session_scope() as session: + repo = LLMConfigRepository(session) + cfg = repo.create( + name=req.name, + provider=req.provider, + api_key=req.api_key, + api_base_url=req.api_base_url, + model_skim=req.model_skim, + model_deep=req.model_deep, + model_vision=req.model_vision, + model_embedding=req.model_embedding, + model_fallback=req.model_fallback, + ) + session.commit() + return LLMConfigDetail( + config=LLMConfigItem( + id=cfg.id, + name=cfg.name, + provider=cfg.provider, + api_base_url=cfg.api_base_url, + model_skim=cfg.model_skim, + model_deep=cfg.model_deep, + model_vision=cfg.model_vision, + model_embedding=cfg.model_embedding, + model_fallback=cfg.model_fallback, + is_active=cfg.is_active, + ) + ) + + +@router.patch("/{config_id}", response_model=LLMConfigDetail) +def update_config(config_id: str, req: LLMConfigUpdate): + """更新配置""" + with session_scope() as session: + repo = LLMConfigRepository(session) + try: + cfg = repo.update( + config_id, + name=req.name, + provider=req.provider, + api_key=req.api_key, + api_base_url=req.api_base_url, + model_skim=req.model_skim, + model_deep=req.model_deep, + model_vision=req.model_vision, + model_embedding=req.model_embedding, + model_fallback=req.model_fallback, + ) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + session.commit() + return LLMConfigDetail( + config=LLMConfigItem( + id=cfg.id, + name=cfg.name, + provider=cfg.provider, + api_base_url=cfg.api_base_url, + model_skim=cfg.model_skim, + model_deep=cfg.model_deep, + model_vision=cfg.model_vision, + model_embedding=cfg.model_embedding, + model_fallback=cfg.model_fallback, + is_active=cfg.is_active, + ) + ) + + +@router.delete("/{config_id}") +def delete_config(config_id: str): + """删除配置(不能删除当前激活的配置)""" + with session_scope() as session: + repo = LLMConfigRepository(session) + cfg = repo.get_by_id(config_id) + if cfg.is_active: + raise HTTPException(status_code=400, detail="不能删除当前激活的配置") + repo.delete(config_id) + session.commit() + return {"message": "删除成功"} + + +@router.post("/activate", response_model=LLMConfigDetail) +def activate_config(req: LLMConfigActivate): + """激活指定配置""" + with session_scope() as session: + repo = LLMConfigRepository(session) + try: + cfg = repo.activate(req.config_id) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + session.commit() + return LLMConfigDetail( + config=LLMConfigItem( + id=cfg.id, + name=cfg.name, + provider=cfg.provider, + api_base_url=cfg.api_base_url, + model_skim=cfg.model_skim, + model_deep=cfg.model_deep, + model_vision=cfg.model_vision, + model_embedding=cfg.model_embedding, + model_fallback=cfg.model_fallback, + is_active=cfg.is_active, + ) + ) diff --git a/apps/api/routers/papers.py b/apps/api/routers/papers.py index 0f21555..2acf808 100644 --- a/apps/api/routers/papers.py +++ b/apps/api/routers/papers.py @@ -324,6 +324,59 @@ def serve_paper_pdf(paper_id: UUID) -> FileResponse: ) +@router.get("/papers/{paper_id}/segments") +def get_paper_segments(paper_id: UUID) -> dict: + """获取论文分段(用于全文对照翻译)- 包含页码信息""" + with session_scope() as session: + repo = PaperRepository(session) + try: + paper = repo.get_by_id(paper_id) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + pdf_path = paper.pdf_path + + if not pdf_path: + raise HTTPException(status_code=404, detail="论文没有 PDF 文件") + + full_path = Path(pdf_path) + if not full_path.exists(): + raise HTTPException(status_code=404, detail="PDF 文件不存在") + + try: + import fitz + + doc = fitz.open(str(full_path)) + paragraphs: list[dict] = [] + para_idx = 0 + max_pages = 30 + + for page_num in range(min(max_pages, len(doc))): + page = doc.load_page(page_num) + page_text = page.get_text("text").strip() + if not page_text: + continue + + blocks = page_text.split("\n\n") + for block in blocks: + block = block.strip() + if len(block) < 20: + continue + para_idx += 1 + paragraphs.append( + { + "id": f"p-{para_idx}", + "type": "paragraph", + "content": block[:2000], + "pageNumber": page_num + 1, + } + ) + + doc.close() + return {"segments": paragraphs} + except Exception as e: + return {"segments": [], "error": str(e)} + + @router.post("/papers/{paper_id}/ai/explain") def ai_explain_text(paper_id: UUID, body: AIExplainReq) -> dict: """AI 解释/翻译选中文本""" diff --git a/apps/api/routers/sensemaking.py b/apps/api/routers/sensemaking.py new file mode 100644 index 0000000..5fa27ba --- /dev/null +++ b/apps/api/routers/sensemaking.py @@ -0,0 +1,277 @@ +""" +Sensemaking API - 论文认知重构流程 +""" + +from datetime import UTC, datetime + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from packages.storage.db import session_scope +from packages.storage.models import SchemaPaperInteraction, SensemakingSession, UserSchema + +router = APIRouter(prefix="/sensemaking", tags=["sensemaking"]) + + +class UserSchemaCreate(BaseModel): + name: str + user_id: str | None = None + research_topics: list[str] = [] + academic_level: str | None = None + current_challenges: list[str] = [] + beliefs: list[str] = [] + knowledge_gaps: list[str] = [] + + +class SensemakingSessionCreate(BaseModel): + paper_id: str + user_schema_id: str + + +@router.post("/schemas") +async def create_user_schema(data: UserSchemaCreate): + with session_scope() as session: + schema = UserSchema( + user_id=data.user_id or "default", + name=data.name, + research_topics=data.research_topics, + academic_level=data.academic_level, + current_challenges=data.current_challenges, + beliefs=data.beliefs, + knowledge_gaps=data.knowledge_gaps, + ) + session.add(schema) + session.commit() + session.refresh(schema) + return { + "id": schema.id, + "user_id": schema.user_id, + "name": schema.name, + "research_topics": schema.research_topics, + "academic_level": schema.academic_level, + "current_challenges": schema.current_challenges, + "beliefs": schema.beliefs, + "knowledge_gaps": schema.knowledge_gaps, + "version": schema.version, + "created_at": schema.created_at, + "updated_at": schema.updated_at, + } + + +@router.get("/schemas/{schema_id}") +async def get_user_schema(schema_id: str): + with session_scope() as session: + schema = session.query(UserSchema).filter_by(id=schema_id).first() + if not schema: + raise HTTPException(status_code=404, detail="Schema not found") + return { + "id": schema.id, + "user_id": schema.user_id, + "name": schema.name, + "research_topics": schema.research_topics, + "academic_level": schema.academic_level, + "current_challenges": schema.current_challenges, + "beliefs": schema.beliefs, + "knowledge_gaps": schema.knowledge_gaps, + "version": schema.version, + "created_at": schema.created_at, + "updated_at": schema.updated_at, + } + + +@router.get("/schemas") +async def list_user_schemas(user_id: str | None = None): + with session_scope() as session: + query = session.query(UserSchema) + if user_id: + query = query.filter_by(user_id=user_id) + schemas = query.all() + return [ + { + "id": s.id, + "user_id": s.user_id, + "name": s.name, + "research_topics": s.research_topics, + "academic_level": s.academic_level, + "current_challenges": s.current_challenges, + "beliefs": s.beliefs, + "knowledge_gaps": s.knowledge_gaps, + "version": s.version, + "created_at": s.created_at, + "updated_at": s.updated_at, + } + for s in schemas + ] + + +@router.post("/sessions") +async def create_session(data: SensemakingSessionCreate): + with session_scope() as session: + schema = session.query(UserSchema).filter_by(id=data.user_schema_id).first() + if not schema: + raise HTTPException(status_code=404, detail="UserSchema not found") + + session_obj = SensemakingSession( + paper_id=data.paper_id, user_schema_id=data.user_schema_id, status="in_progress" + ) + session.add(session_obj) + session.commit() + session.refresh(session_obj) + return { + "id": session_obj.id, + "paper_id": session_obj.paper_id, + "user_schema_id": session_obj.user_schema_id, + "act1_comprehension": session_obj.act1_comprehension, + "act2_collision": session_obj.act2_collision, + "act3_reconstruction": session_obj.act3_reconstruction, + "status": session_obj.status, + "conversation_history": session_obj.conversation_history, + "created_at": session_obj.created_at, + "updated_at": session_obj.updated_at, + "completed_at": session_obj.completed_at, + } + + +@router.get("/sessions/{session_id}") +async def get_session(session_id: str): + with session_scope() as session: + session_obj = session.query(SensemakingSession).filter_by(id=session_id).first() + if not session_obj: + raise HTTPException(status_code=404, detail="Session not found") + return { + "id": session_obj.id, + "paper_id": session_obj.paper_id, + "user_schema_id": session_obj.user_schema_id, + "act1_comprehension": session_obj.act1_comprehension, + "act2_collision": session_obj.act2_collision, + "act3_reconstruction": session_obj.act3_reconstruction, + "status": session_obj.status, + "conversation_history": session_obj.conversation_history, + "created_at": session_obj.created_at, + "updated_at": session_obj.updated_at, + "completed_at": session_obj.completed_at, + } + + +@router.get("/sessions") +async def list_sessions(paper_id: str | None = None, user_schema_id: str | None = None): + with session_scope() as session: + query = session.query(SensemakingSession) + if paper_id: + query = query.filter_by(paper_id=paper_id) + if user_schema_id: + query = query.filter_by(user_schema_id=user_schema_id) + sessions = query.all() + return [ + { + "id": s.id, + "paper_id": s.paper_id, + "user_schema_id": s.user_schema_id, + "act1_comprehension": s.act1_comprehension, + "act2_collision": s.act2_collision, + "act3_reconstruction": s.act3_reconstruction, + "status": s.status, + "conversation_history": s.conversation_history, + "created_at": s.created_at, + "updated_at": s.updated_at, + "completed_at": s.completed_at, + } + for s in sessions + ] + + +@router.patch("/sessions/{session_id}/act1") +async def update_act1(session_id: str, act1_data: dict): + with session_scope() as session: + session_obj = session.query(SensemakingSession).filter_by(id=session_id).first() + if not session_obj: + raise HTTPException(status_code=404, detail="Session not found") + + session_obj.act1_comprehension = act1_data + session.commit() + session.refresh(session_obj) + return { + "id": session_obj.id, + "paper_id": session_obj.paper_id, + "user_schema_id": session_obj.user_schema_id, + "act1_comprehension": session_obj.act1_comprehension, + "act2_collision": session_obj.act2_collision, + "act3_reconstruction": session_obj.act3_reconstruction, + "status": session_obj.status, + "conversation_history": session_obj.conversation_history, + "created_at": session_obj.created_at, + "updated_at": session_obj.updated_at, + "completed_at": session_obj.completed_at, + } + + +@router.patch("/sessions/{session_id}/act2") +async def update_act2(session_id: str, act2_data: dict): + with session_scope() as session: + session_obj = session.query(SensemakingSession).filter_by(id=session_id).first() + if not session_obj: + raise HTTPException(status_code=404, detail="Session not found") + + session_obj.act2_collision = act2_data + session.commit() + session.refresh(session_obj) + return { + "id": session_obj.id, + "paper_id": session_obj.paper_id, + "user_schema_id": session_obj.user_schema_id, + "act1_comprehension": session_obj.act1_comprehension, + "act2_collision": session_obj.act2_collision, + "act3_reconstruction": session_obj.act3_reconstruction, + "status": session_obj.status, + "conversation_history": session_obj.conversation_history, + "created_at": session_obj.created_at, + "updated_at": session_obj.updated_at, + "completed_at": session_obj.completed_at, + } + + +@router.patch("/sessions/{session_id}/act3") +async def complete_act3(session_id: str, act3_data: dict): + with session_scope() as session: + session_obj = session.query(SensemakingSession).filter_by(id=session_id).first() + if not session_obj: + raise HTTPException(status_code=404, detail="Session not found") + + session_obj.act3_reconstruction = act3_data + session_obj.status = "completed" + session_obj.completed_at = datetime.now(UTC) + session.commit() + session.refresh(session_obj) + return { + "id": session_obj.id, + "paper_id": session_obj.paper_id, + "user_schema_id": session_obj.user_schema_id, + "act1_comprehension": session_obj.act1_comprehension, + "act2_collision": session_obj.act2_collision, + "act3_reconstruction": session_obj.act3_reconstruction, + "status": session_obj.status, + "conversation_history": session_obj.conversation_history, + "created_at": session_obj.created_at, + "updated_at": session_obj.updated_at, + "completed_at": session_obj.completed_at, + } + + +@router.post("/interactions") +async def create_interaction( + user_schema_id: str, + paper_id: str, + interaction_type: str, + cognitive_delta: dict | None = None, +): + with session_scope() as session: + interaction = SchemaPaperInteraction( + user_schema_id=user_schema_id, + paper_id=paper_id, + interaction_type=interaction_type, + cognitive_delta=cognitive_delta, + ) + session.add(interaction) + session.commit() + session.refresh(interaction) + return {"id": interaction.id, "status": "created"} diff --git a/apps/api/routers/topics.py b/apps/api/routers/topics.py index e46c36e..a6903bb 100644 --- a/apps/api/routers/topics.py +++ b/apps/api/routers/topics.py @@ -31,7 +31,6 @@ def _topic_dict(t, session=None) -> dict: "schedule_time_utc": getattr(t, "schedule_time_utc", 21), "enable_date_filter": getattr(t, "enable_date_filter", False), "date_filter_days": getattr(t, "date_filter_days", 7), - "schedule_time_utc": getattr(t, "schedule_time_utc", 21), "paper_count": 0, "last_run_at": None, "last_run_count": None, @@ -193,13 +192,26 @@ def ingest_arxiv( sort_by: str = Query( default="submittedDate", pattern="^(submittedDate|relevance|lastUpdatedDate)$" ), + days_back: int = Query( + default=0, + ge=0, + le=3650, + description="只检索最近 N 天提交的论文,默认 0 = 不限日期(历史关键词搜索);订阅可传 7/30", + ), ) -> dict: - logger.info("ArXiv ingest: query=%r max_results=%d sort=%s", query, max_results, sort_by) + logger.info( + "ArXiv ingest: query=%r max_results=%d sort=%s days_back=%d", + query, + max_results, + sort_by, + days_back, + ) count, inserted_ids, _ = pipelines.ingest_arxiv( query=query, max_results=max_results, topic_id=topic_id, sort_by=sort_by, + days_back=days_back, ) # 查询插入论文的基本信息 papers_info: list[dict] = [] diff --git a/apps/api/routers/translate.py b/apps/api/routers/translate.py new file mode 100644 index 0000000..b697f39 --- /dev/null +++ b/apps/api/routers/translate.py @@ -0,0 +1,149 @@ +""" +翻译 API - 段落对照翻译 / 布局保留翻译 +""" + +from uuid import UUID + +from fastapi import APIRouter, BackgroundTasks, HTTPException +from pydantic import BaseModel + +from apps.api.services.translate import ( + extract_segments_from_pdf, + translate_segments, + translate_text, +) + +router = APIRouter(prefix="/translate", tags=["translate"]) + + +class TranslateRequest(BaseModel): + text: str + target_lang: str = "zh" + + +class TranslateResponse(BaseModel): + original: str + translation: str + + +class SegmentTranslationItem(BaseModel): + id: str + type: str + content: str + translation: str | None = None + pageNumber: int | None = None + + +class BilingualPdfRequest(BaseModel): + paper_id: UUID + target_lang: str = "zh" + mode: str = "fast" # "fast" | "layout" + + +class BilingualPdfResponse(BaseModel): + job_id: str + status: str + message: str + + +@router.post("/selection", response_model=TranslateResponse) +def translate_selection(req: TranslateRequest): + try: + translation = translate_text(req.text, req.target_lang) + return TranslateResponse(original=req.text, translation=translation) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/segments") +def translate_segments_endpoint(segments: list[SegmentTranslationItem], target_lang: str = "zh"): + try: + seg_dicts = [s.model_dump() for s in segments] + results = translate_segments(seg_dicts, target_lang) + return {"segments": results} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/bilingual-pdf", response_model=BilingualPdfResponse) +async def create_bilingual_pdf(req: BilingualPdfRequest, background_tasks: BackgroundTasks): + """ + 生成双语 PDF(异步任务) + + - **fast**: 快速翻译,生成 JSON 对照数据(前端渲染) + - **layout**: 布局保留,调用 PDFMathTranslate 生成完整双语 PDF + """ + from uuid import uuid4 + + from packages.storage.db import session_scope + from packages.storage.repositories import PaperRepository + + job_id = str(uuid4()) + + with session_scope() as session: + repo = PaperRepository(session) + try: + paper = repo.get_by_id(req.paper_id) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + if not paper.pdf_path: + raise HTTPException(status_code=400, detail="论文没有 PDF 文件") + + # 根据模式选择处理方式 + if req.mode == "fast": + # 快速模式:提取分段 + 并发翻译 + background_tasks.add_task( + _process_fast_translation, job_id, paper.pdf_path, req.target_lang + ) + return BilingualPdfResponse( + job_id=job_id, status="processing", message="快速翻译中,预计 1-2 分钟完成" + ) + else: + # 布局保留模式:调用 PDFMathTranslate + background_tasks.add_task( + _process_layout_translation, job_id, paper.pdf_path, req.target_lang + ) + return BilingualPdfResponse( + job_id=job_id, status="processing", message="布局保留翻译中,预计 3-5 分钟完成" + ) + + +def _process_fast_translation(job_id: str, pdf_path: str, target_lang: str): + """快速翻译处理""" + try: + # 1. 提取分段 + segments = extract_segments_from_pdf(pdf_path) + # 2. 并发翻译 + translate_segments(segments, target_lang) + # 3. 保存结果(可以存到文件或数据库) + # TODO: 保存翻译结果 + except Exception: + # TODO: 更新任务状态为失败 + pass + + +def _process_layout_translation(job_id: str, pdf_path: str, target_lang: str): + """布局保留翻译处理(调用 PDFMathTranslate)""" + try: + import subprocess + from pathlib import Path + + output_dir = Path("/tmp/pdf2zh_output") + output_dir.mkdir(exist_ok=True) + + # 调用 PDFMathTranslate CLI + result = subprocess.run( + ["pdf2zh", pdf_path, "-lo", target_lang, "-o", str(output_dir)], + capture_output=True, + text=True, + timeout=600, # 10 分钟超时 + ) + + if result.returncode != 0: + raise RuntimeError(f"PDFMathTranslate 失败:{result.stderr}") + + # TODO: 移动生成的 PDF 到存储目录 + except Exception: + # TODO: 更新任务状态为失败 + pass diff --git a/apps/api/services/__init__.py b/apps/api/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/services/translate.py b/apps/api/services/translate.py new file mode 100644 index 0000000..1bb9947 --- /dev/null +++ b/apps/api/services/translate.py @@ -0,0 +1,79 @@ +""" +翻译服务 - 段落对照翻译 +""" + +from concurrent.futures import ThreadPoolExecutor + +import fitz # PyMuPDF + +from packages.integrations.llm_client import LLMClient + +llm = LLMClient() + +# 翻译并发控制 +_max_workers = 5 + + +def translate_text(text: str, target_lang: str = "zh") -> str: + prompt = f"""Translate the following academic text to {target_lang}. +Maintain the academic tone and technical terminology. + +Text: +{text} + +Translation:""" + result = llm.summarize_text(prompt, stage="translate", max_tokens=4096) + # 追踪 token 到数据库 + llm.trace_result( + result, stage="translate", prompt_digest=f"translate to {target_lang}: {text[:50]}" + ) + return result.content + + +def translate_segments(segments: list[dict], target_lang: str = "zh") -> list[dict]: + """批量翻译分段(并发)""" + results = [] + + def _translate_single(seg: dict) -> dict: + if seg.get("type") == "paragraph": + translation = translate_text(seg["content"], target_lang) + return {**seg, "translation": translation} + return {**seg, "translation": seg.get("content", "")} + + with ThreadPoolExecutor(max_workers=_max_workers) as executor: + results = list(executor.map(_translate_single, segments)) + + return results + + +def extract_segments_from_pdf(pdf_path: str, max_pages: int = 30) -> list[dict]: + """从 PDF 提取带页码的分段(快速版本)""" + doc = fitz.open(pdf_path) + paragraphs: list[dict] = [] + para_idx = 0 + max_pages = min(max_pages, len(doc)) + + for page_num in range(max_pages): + page = doc.load_page(page_num) + page_text = page.get_text("text").strip() + if not page_text: + continue + + # 按双换行分割段落 + blocks = page_text.split("\n\n") + for block in blocks: + block = block.strip() + if len(block) < 20: + continue + para_idx += 1 + paragraphs.append( + { + "id": f"p-{para_idx}", + "type": "paragraph", + "content": block[:2000], + "pageNumber": page_num + 1, + } + ) + + doc.close() + return paragraphs diff --git a/docs/plans/2026-03-23-paper-sense-making-integration-plan.md b/docs/plans/2026-03-23-paper-sense-making-integration-plan.md new file mode 100644 index 0000000..a9f4233 --- /dev/null +++ b/docs/plans/2026-03-23-paper-sense-making-integration-plan.md @@ -0,0 +1,823 @@ +# PaperSenseMaking 整合 - 实施计划 + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 将 paper-sense-making 的认知重构理念融入 PaperMind,打造"阅读→理解→重构"的完整论文工作流 + +**Architecture:** +- 数据层: UserSchema + SensemakingSession 数据模型 +- 前端: PdfReader ToolPanel Tab 化 + Canvas MVP 可视化 +- 后端: Sensemaking API (Act 1/2/3 流程) +- 翻译: 段落对照翻译 (MVP) + +**Tech Stack:** React 18 + TypeScript + FastAPI + SQLite + react-pdf + D3.js + +--- + +## Phase 1: 基础层 (UserSchema + ToolPanel Tab 化) + +--- + +### Task 1: 后端数据模型 + +**Files:** +- Create: `apps/api/models/sensemaking.py` +- Modify: `apps/api/models/__init__.py` (导出新模型) +- Modify: `apps/api/main.py` (注册路由) +- Test: `tests/test_sensemaking_models.py` + +**Step 1: 创建 sensemaking.py 数据模型** + +```python +# apps/api/models/sensemaking.py +from sqlalchemy import Column, String, JSON, Integer, DateTime, ForeignKey +from sqlalchemy.orm import relationship +from apps.api.models.base import Base +import datetime +import uuid + +class UserSchema(Base): + """用户认知 Schema - 存储用户的学术背景和关注领域""" + __tablename__ = "user_schemas" + + id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) + user_id = Column(String, nullable=False, index=True) + name = Column(String, nullable=False) + + # 研究方向 + research_topics = Column(JSON, default=list) + # 学术背景 + academic_level = Column(String, nullable=True) + # 当前挑战 + current_challenges = Column(JSON, default=list) + # 信仰/立场 + beliefs = Column(JSON, default=list) + # 知识缺口 + knowledge_gaps = Column(JSON, default=list) + + version = Column(Integer, default=1) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # 关联 + sessions = relationship("SensemakingSession", back_populates="user_schema") + interactions = relationship("SchemaPaperInteraction", back_populates="user_schema") + + +class SensemakingSession(Base): + """论文认知重构会话""" + __tablename__ = "sensemaking_sessions" + + id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) + paper_id = Column(String, nullable=False, index=True) + user_schema_id = Column(String, ForeignKey("user_schemas.id"), nullable=False) + + # 三幕结构 + act1_comprehension = Column(JSON, nullable=True) + act2_collision = Column(JSON, nullable=True) + act3_reconstruction = Column(JSON, nullable=True) + + status = Column(String, default="in_progress") + conversation_history = Column(JSON, default=list) + + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + completed_at = Column(DateTime, nullable=True) + + # 关联 + user_schema = relationship("UserSchema", back_populates="sessions") + + +class SchemaPaperInteraction(Base): + """用户 Schema 与论文的交互记录""" + __tablename__ = "schema_paper_interactions" + + id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) + user_schema_id = Column(String, ForeignKey("user_schemas.id"), nullable=False) + paper_id = Column(String, nullable=False, index=True) + + interaction_type = Column(String, nullable=False) + cognitive_delta = Column(JSON, nullable=True) + + created_at = Column(DateTime, default=datetime.utcnow) + + # 关联 + user_schema = relationship("UserSchema", back_populates="interactions") +``` + +**Step 2: 导出新模型** + +修改 `apps/api/models/__init__.py`: +```python +from apps.api.models.sensemaking import UserSchema, SensemakingSession, SchemaPaperInteraction + +__all__ = [ + # ... existing + "UserSchema", + "SensemakingSession", + "SchemaPaperInteraction", +] +``` + +**Step 3: 创建 sensemaking 路由** + +```python +# apps/api/routes/sensemaking.py +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from typing import Optional +from apps.api.models.sensemaking import UserSchema, SensemakingSession, SchemaPaperInteraction +from apps.api.db import get_db + +router = APIRouter(prefix="/sensemaking", tags=["sensemaking"]) + +class UserSchemaCreate(BaseModel): + name: str + research_topics: list[str] = [] + academic_level: Optional[str] = None + current_challenges: list[str] = [] + beliefs: list[str] = [] + knowledge_gaps: list[str] = [] + +class SensemakingSessionCreate(BaseModel): + paper_id: str + user_schema_id: str + +# TODO: 实现 CRUD API +``` + +**Step 4: 注册路由** + +修改 `apps/api/main.py`: +```python +from apps.api.routes import sensemaking +app.include_router(sensemaking.router) +``` + +--- + +### Task 2: 前端 ToolPanel 组件 + +**Files:** +- Create: `frontend/src/hooks/useSelectedText.ts` +- Create: `frontend/src/components/ToolPanel/ToolPanel.tsx` +- Create: `frontend/src/components/ToolPanel/ToolPanelTab.tsx` +- Create: `frontend/src/components/ToolPanel/TranslationPanel.tsx` +- Create: `frontend/src/components/ToolPanel/AggregationPanel.tsx` +- Create: `frontend/src/components/ToolPanel/CanvasPanel.tsx` +- Modify: `frontend/src/components/PdfReader.tsx` +- Test: `frontend/src/components/ToolPanel/ToolPanel.test.tsx` + +**Step 1: 创建 useSelectedText Hook** + +```typescript +// frontend/src/hooks/useSelectedText.ts +import { useState, useCallback, useEffect } from 'react'; + +export function useSelectedText() { + const [selectedText, setSelectedText] = useState(''); + + useEffect(() => { + const handler = () => { + const sel = window.getSelection()?.toString().trim(); + if (sel && sel.length > 2) { + setSelectedText(sel); + } + }; + document.addEventListener('mouseup', handler); + return () => document.removeEventListener('mouseup', handler); + }, []); + + return selectedText; +} +``` + +**Step 2: 创建 ToolPanel Tab 系统** + +```typescript +// frontend/src/components/ToolPanel/ToolPanel.tsx +import { useState } from 'react'; +import { PanelLeftClose, PanelLeft, Languages, Sparkles, GitBranch } from 'lucide-react'; + +export type TabId = 'translation' | 'aggregation' | 'canvas'; + +interface Tab { + id: TabId; + label: string; + icon: React.ReactNode; +} + +const TABS: Tab[] = [ + { id: 'translation', label: '翻译', icon: }, + { id: 'aggregation', label: 'AI聚合', icon: }, + { id: 'canvas', label: 'Canvas', icon: }, +]; + +interface ToolPanelProps { + selectedText: string; + paperId: string; + children: { + translation: React.ReactNode; + aggregation: React.ReactNode; + canvas: React.ReactNode; + }; +} + +export function ToolPanel({ selectedText, paperId, children }: ToolPanelProps) { + const [activeTab, setActiveTab] = useState('translation'); + const [collapsed, setCollapsed] = useState(false); + + if (collapsed) { + return ( + + ))} + + ); + } + + return ( +
+ {/* 头部 */} +
+
+ {TABS.map(tab => ( + + ))} +
+ +
+ + {/* 内容区 */} +
+ {activeTab === 'translation' && children.translation} + {activeTab === 'aggregation' && children.aggregation} + {activeTab === 'canvas' && children.canvas} +
+
+ ); +} +``` + +**Step 3: 改造 PdfReader.tsx** + +修改 `frontend/src/components/PdfReader.tsx`: +```typescript +// 1. 添加导入 +import { ToolPanel } from './ToolPanel/ToolPanel'; +import { TranslationPanel } from './ToolPanel/TranslationPanel'; +import { AggregationPanel } from './ToolPanel/AggregationPanel'; +import { CanvasPanel } from './ToolPanel/CanvasPanel'; +import { useSelectedText } from '@/hooks/useSelectedText'; + +// 2. 替换右侧 AI Panel +// 旧代码 (line 391-504): +// {/* AI 侧边栏 */} +//
+// ... +//
+ +// 新代码: +// 替换为 ToolPanel +
+ {aiPanelOpen && ( + + {{ + translation: , + aggregation: , + canvas: , + }} + + )} +
+``` + +--- + +### Task 3: TranslationPanel (迁移现有 AI Panel 逻辑) + +**Files:** +- Create: `frontend/src/components/ToolPanel/TranslationPanel.tsx` + +**Step 1: 创建 TranslationPanel** + +```typescript +// frontend/src/components/ToolPanel/TranslationPanel.tsx +import { useState, useCallback } from 'react'; +import { Loader2, Copy, Check, Languages } from 'lucide-react'; +import Markdown from '@/components/Markdown'; +import { paperApi } from '@/services/api'; + +type AiAction = 'explain' | 'translate' | 'summarize'; + +interface AiResult { + action: AiAction; + text: string; + result: string; +} + +interface TranslationPanelProps { + selectedText: string; + paperId: string; +} + +export function TranslationPanel({ selectedText, paperId }: TranslationPanelProps) { + const [aiLoading, setAiLoading] = useState(false); + const [aiResults, setAiResults] = useState([]); + const [copiedIdx, setCopiedIdx] = useState(null); + + const handleAiAction = useCallback(async (action: AiAction, text?: string) => { + const t = text || selectedText; + if (!t) return; + setAiLoading(true); + try { + const res = await paperApi.aiExplain(paperId, t, action); + setAiResults((prev) => [{ action, text: t.slice(0, 100), result: res.result }, ...prev]); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setAiResults((prev) => [{ action, text: t.slice(0, 100), result: `错误: ${msg}` }, ...prev]); + } finally { + setAiLoading(false); + } + }, [paperId, selectedText]); + + const handleCopy = useCallback((idx: number, text: string) => { + navigator.clipboard.writeText(text); + setCopiedIdx(idx); + setTimeout(() => setCopiedIdx(null), 2000); + }, []); + + const actionLabels: Record = { + explain: { label: '解释', color: 'text-amber-600 bg-amber-50' }, + translate: { label: '翻译', color: 'text-blue-600 bg-blue-50' }, + summarize: { label: '总结', color: 'text-emerald-600 bg-emerald-50' }, + }; + + return ( +
+ {/* 快捷操作 */} + {selectedText && ( +
+

选中文本

+

+ {selectedText} +

+
+ + + +
+
+ )} + + {/* 结果列表 */} +
+ {aiLoading && ( +
+ + AI 分析中... +
+ )} + + {aiResults.length === 0 && !aiLoading && ( +
+ +

选中论文文本

+

即可使用 AI 解释、翻译、总结

+
+ )} + + {aiResults.map((r, i) => ( +
+
+ + {actionLabels[r.action].label} + + +
+
+

+ {r.text} +

+
+
+ {r.result} +
+
+ ))} +
+
+ ); +} +``` + +--- + +### Task 4: AggregationPanel (集成 feat/ieee 多源搜索) + +**Files:** +- Create: `frontend/src/components/ToolPanel/AggregationPanel.tsx` + +**Step 1: 创建 AggregationPanel** + +```typescript +// frontend/src/components/ToolPanel/AggregationPanel.tsx +// 从 feat/ieee 分支迁移 MultiSourceSearchBar 和 SearchResultsList 相关逻辑 +// 具体实现略,参考 docs/plans/2026-03-23-multi-source-implementation.md +``` + +--- + +## Phase 2: Sensemaking 核心流程 + Canvas MVP + +--- + +### Task 5: CanvasPanel MVP (Before/After 对比视图) + +**Files:** +- Create: `frontend/src/components/ToolPanel/CanvasPanel.tsx` +- Create: `frontend/src/components/SensemakingCanvas/DeltaCard.tsx` +- Modify: `apps/api/routes/sensemaking.py` +- Test: `frontend/src/components/SensemakingCanvas/DeltaCard.test.tsx` + +**Step 1: 创建 DeltaCard 组件** + +```typescript +// frontend/src/components/SensemakingCanvas/DeltaCard.tsx +interface DeltaCardProps { + label: string; + content: string; + variant: 'before' | 'after' | 'delta'; +} + +export function DeltaCard({ label, content, variant }: DeltaCardProps) { + const colors = { + before: 'border-white/20 bg-white/5', + after: 'border-primary/30 bg-primary/5', + delta: 'border-amber-500/30 bg-amber-500/5', + }; + + return ( +
+

{label}

+

{content || '...'}

+
+ ); +} +``` + +**Step 2: 创建 CanvasPanel** + +```typescript +// frontend/src/components/ToolPanel/CanvasPanel.tsx +import { useState } from 'react'; +import { DeltaCard } from '@/components/SensemakingCanvas/DeltaCard'; +import { ArrowRight } from 'lucide-react'; + +interface Act3Reconstruction { + before: string; + after: string; + delta: string; + one_change: string; +} + +interface CanvasPanelProps { + paperId: string; + paperTitle: string; +} + +export function CanvasPanel({ paperId, paperTitle }: CanvasPanelProps) { + const [sessionId, setSessionId] = useState(null); + const [act3, setAct3] = useState(null); + const [loading, setLoading] = useState(false); + + // TODO: 从后端加载 session 数据 + const loadSession = async () => { + setLoading(true); + // await fetch(`/api/sensemaking/sessions?paper_id=${paperId}`) + setLoading(false); + }; + + return ( +
+
+

{paperTitle}

+

认知重构工作台

+
+ + {!act3 ? ( +
+

开始你的认知重构之旅

+ +
+ ) : ( +
+ {/* Before */} + + + {/* Arrow */} +
+ +
+ + {/* After */} + + + {/* Delta */} + + + {/* One Change */} +
+

我的承诺

+

{act3.one_change}

+
+
+ )} +
+ ); +} +``` + +--- + +### Task 6: Sensemaking API (Act 1/2/3 流程) + +**Files:** +- Modify: `apps/api/routes/sensemaking.py` +- Create: `apps/api/services/sensemaking.py` +- Test: `tests/test_sensemaking_api.py` + +**Step 1: 实现 SensemakingService** + +```python +# apps/api/services/sensemaking.py +from apps.api.models.sensemaking import SensemakingSession, UserSchema +from apps.api.db import get_db +from sqlalchemy.orm import Session +from typing import Optional +import json + +class SensemakingService: + def __init__(self, db: Session): + self.db = db + + def create_session(self, paper_id: str, user_schema_id: str) -> SensemakingSession: + session = SensemakingSession( + paper_id=paper_id, + user_schema_id=user_schema_id, + status="in_progress" + ) + self.db.add(session) + self.db.commit() + self.db.refresh(session) + return session + + def update_act1(self, session_id: str, act1_data: dict) -> SensemakingSession: + session = self.db.query(SensemakingSession).filter_by(id=session_id).first() + session.act1_comprehension = act1_data + self.db.commit() + self.db.refresh(session) + return session + + def update_act2(self, session_id: str, act2_data: dict) -> SensemakingSession: + session = self.db.query(SensemakingSession).filter_by(id=session_id).first() + session.act2_collision = act2_data + self.db.commit() + self.db.refresh(session) + return session + + def complete_act3(self, session_id: str, act3_data: dict) -> SensemakingSession: + session = self.db.query(SensemakingSession).filter_by(id=session_id).first() + session.act3_reconstruction = act3_data + session.status = "completed" + from datetime import datetime + session.completed_at = datetime.utcnow() + self.db.commit() + self.db.refresh(session) + return session + + def get_sessions_by_paper(self, paper_id: str) -> list[SensemakingSession]: + return self.db.query(SensemakingSession).filter_by(paper_id=paper_id).all() +``` + +--- + +## Phase 3: 全文对照翻译 (MVP) + +--- + +### Task 7: 段落对照翻译 API + +**Files:** +- Create: `apps/api/routes/translate.py` +- Create: `apps/api/services/translate.py` +- Modify: `frontend/src/components/ToolPanel/TranslationPanel.tsx` +- Test: `tests/test_translate_api.py` + +**Step 1: 创建翻译服务** + +```python +# apps/api/services/translate.py +from apps.api.services.llm import LLMService + +class TranslateService: + def __init__(self): + self.llm = LLMService() + + async def translate_paragraph(self, text: str, target_lang: str = "zh") -> str: + prompt = f"""Translate the following academic text to {target_lang}. + Maintain the academic tone and technical terminology. + + Text: + {text} + + Translation:""" + + result = await self.llm.generate(prompt) + return result + + async def translate_full_paper_segments(self, segments: list[dict], target_lang: str = "zh") -> list[dict]: + """翻译论文分段 + + Args: + segments: [{"id": "1", "type": "paragraph", "content": "..."}, ...] + target_lang: 目标语言 + + Returns: + [{"id": "1", "type": "paragraph", "content": "...", "translation": "..."}, ...] + """ + results = [] + for seg in segments: + translation = await self.translate_paragraph(seg["content"], target_lang) + results.append({ + **seg, + "translation": translation + }) + return results +``` + +**Step 2: 创建翻译路由** + +```python +# apps/api/routes/translate.py +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from typing import Optional + +router = APIRouter(prefix="/translate", tags=["translate"]) + +class TranslateRequest(BaseModel): + text: str + target_lang: str = "zh" + +class TranslateResponse(BaseModel): + original: str + translation: str + +@router.post("/selection", response_model=TranslateResponse) +async def translate_selection(req: TranslateRequest): + """翻译选中文字""" + # TODO: 实现 + pass + +@router.post("/paragraph") +async def translate_paragraph(text: str, target_lang: str = "zh"): + """翻译段落""" + # TODO: 实现 + pass + +@router.post("/segments") +async def translate_segments(segments: list[dict], target_lang: str = "zh"): + """翻译论文分段(返回对照翻译)""" + # TODO: 实现 + pass +``` + +--- + +### Task 8: 段落对照翻译前端 + +**Files:** +- Modify: `frontend/src/components/ToolPanel/TranslationPanel.tsx` + +**Step 1: 添加段落对照翻译模式** + +```typescript +// frontend/src/components/ToolPanel/TranslationPanel.tsx + +interface TranslationPanelProps { + selectedText: string; + paperId: string; +} + +export function TranslationPanel({ selectedText, paperId }: TranslationPanelProps) { + const [mode, setMode] = useState<'selection' | 'paragraph'>('selection'); + // ... existing state + + // 段落对照翻译 + const [segments, setSegments] = useState>([]); + + const handleTranslateFull = async () => { + // TODO: 调用 /api/translate/segments 获取分段翻译 + const res = await fetch(`/api/papers/${paperId}/segments`); + const data = await res.json(); + setSegments(data.segments); + setMode('paragraph'); + }; + + return ( +
+ {/* 模式切换 */} +
+ + +
+ + {/* 模式内容 */} + {mode === 'selection' ? ( + // 现有划词翻译 UI + + ) : ( + // 段落对照翻译 UI + + )} +
+ ); +} +``` + +--- + +## 执行选项 + +**Plan complete and saved to `docs/plans/2026-03-23-paper-sense-making-integration-plan.md`.** + +**Two execution options:** + +**1. Subagent-Driven (this session)** - I dispatch fresh subagent per task, review between tasks, fast iteration + +**2. Parallel Session (separate)** - Open new session with executing-plans, batch execution with checkpoints + +**Which approach?** diff --git a/docs/plans/2026-03-23-paper-sense-making-integration.md b/docs/plans/2026-03-23-paper-sense-making-integration.md new file mode 100644 index 0000000..b13d0b4 --- /dev/null +++ b/docs/plans/2026-03-23-paper-sense-making-integration.md @@ -0,0 +1,420 @@ +# PaperSenseMaking 整合设计文档 + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 将 paper-sense-making 的认知重构理念融入 PaperMind,打造"阅读→理解→重构"的完整论文工作流。 + +**Architecture:** +- **Phase 1**: User Schema 数据模型 + ToolPanel Tab 化改造 +- **Phase 2**: SensemakingSession 流程(Act 1/2/3)+ Canvas 可视化 +- **Phase 3**: PDF 双栏对照翻译(全文模式) + +**Tech Stack:** React 18, FastAPI, SQLite, react-pdf, LLM (GLM-4), Canvas (D3.js/two.js) + +--- + +## 1. 概念映射 + +### paper-sense-making → PaperMind + +| paper-sense-making 概念 | PaperMind 实现 | 位置 | +|------------------------|---------------|------| +| Module 2: PaperSensemaking | PDF 阅读器 + ToolPanel (Sensemaking Tab) | PdfReader.tsx 改造 | +| Module 4: User Schema | UserSchema 数据模型 | 新建 models | +| Module 5: SensemakingCanvas | 右侧面板 Canvas 可视化 | 新建组件 | +| Act 1: Comprehension | Paper 阅读 + 核心观点提取 | ToolPanel Tab | +| Act 2: Collision | 用户 Schema vs Paper 挑战 | ToolPanel Tab | +| Act 3: Reconstruction | Before/After Delta + Canvas | ToolPanel Tab + Canvas | + +--- + +## 2. 数据模型设计 + +### 2.1 UserSchema (新建) + +```python +# apps/api/models/sensemaking.py + +class UserSchema(Base): + """用户认知 Schema - 存储用户的学术背景和关注领域""" + __tablename__ = "user_schemas" + + id = Column(String, primary_key=True) + user_id = Column(String, nullable=False, index=True) # 预留多用户支持 + name = Column(String, nullable=False) # Schema 名称,如"深度学习研究" + + # 核心关注点 - 研究方向 + research_topics = Column(JSON, default=list) # ["深度学习", "强化学习", "NLP"] + + # 学术背景 - 研究层次 + academic_level = Column(String, nullable=True) # "PhD", "Master", "Industry" + + # 研究阶段 - 当前正在攻克的难题 + current_challenges = Column(JSON, default=list) # ["如何提升模型泛化性"] + + # 信仰/立场 - 对某些技术路线的看法 + beliefs = Column(JSON, default=list) # ["Transformer 比 CNN 更好", "LLM 需要符号推理"] + + # 知识缺口 - 想要补充的方向 + knowledge_gaps = Column(JSON, default=list) # ["因果推理", "神经符号"] + + # Schema 版本,用于追踪变化 + version = Column(Integer, default=1) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) +``` + +### 2.2 SensemakingSession (新建) + +```python +class SensemakingSession(Base): + """论文认知重构会话 - 存储完整的认知重构过程""" + __tablename__ = "sensemaking_sessions" + + id = Column(String, primary_key=True) + paper_id = Column(String, nullable=False, index=True) + user_schema_id = Column(String, ForeignKey("user_schemas.id"), nullable=False) + + # 三幕结构数据 (Act 1/2/3) + act1_comprehension = Column(JSON, nullable=True) + act2_collision = Column(JSON, nullable=True) + act3_reconstruction = Column(JSON, nullable=True) + + # 元数据 + status = Column(String, default="in_progress") # in_progress, completed, abandoned + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + completed_at = Column(DateTime, nullable=True) + + # 关联的 AI 对话历史 (可选) + conversation_history = Column(JSON, default=list) +``` + +### 2.3 Schema-M-Paper (关联表) + +```python +class SchemaPaperInteraction(Base): + """用户 Schema 与论文的交互记录""" + __tablename__ = "schema_paper_interactions" + + id = Column(String, primary_key=True) + user_schema_id = Column(String, ForeignKey("user_schemas.id"), nullable=False) + paper_id = Column(String, nullable=False, index=True) + + # 交互类型 + interaction_type = Column(String, nullable=False) # "viewed", "sensemaking_completed", "challenged" + + # 用户对这篇论文的认知变化 + cognitive_delta = Column(JSON, nullable=True) # {"before": "...", "after": "...", "change": "..."} + + created_at = Column(DateTime, default=datetime.utcnow) +``` + +--- + +## 3. 前端架构设计 + +### 3.1 PDF 阅读器改造 + +**文件:** `frontend/src/components/PdfReader.tsx` (改造) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Toolbar: 标题 | 页码 | 缩放 | 全屏 [选中文字] [工具按钮] │ +├─────────────────────────────┬─────────────────────────────────┤ +│ │ ToolPanel (可折叠/展开) │ +│ PDF 阅读区 │ ┌─────────────────────────────┐ │ +│ (连续滚动模式) │ │ [翻译] [AI聚合] [Canvas] │ │ +│ │ ├─────────────────────────────┤ │ +│ │ │ │ │ +│ │ │ 活动 Tab 内容区 │ │ +│ │ │ (支持滚动) │ │ +│ │ │ │ │ +│ │ └─────────────────────────────┘ │ +└─────────────────────────────┴─────────────────────────────────┘ +``` + +**ToolPanel Tab 结构:** + +| Tab | 功能 | 状态 | +|-----|------|------| +| 翻译 | 划词翻译 / 全文对照翻译 | 改造 (现有 AI Panel) | +| AI聚合 | 多源搜索 / 论文摘要 | 集成 feat/ieee | +| Canvas | Sensemaking 可视化 | 新开发 | + +**关键改造点:** + +1. **右侧 AI Panel → TabPanel** + - 固定宽度 384px → 可拖拽调整 + - 支持 Tab 切换 + - Tab 内容区域独立滚动 + +2. **选中文字 → 全局状态** + - 新增 `useSelectedText` hook + - 选中文字后自动同步到 ToolPanel + - ToolPanel 各 Tab 都能访问选中文字 + +3. **PdfReaderProps 扩展** + ```typescript + interface PdfReaderProps { + // ... 现有 props + userSchemaId?: string; // 当前用户的 Schema ID + onSensemakingStart?: (paperId: string) => void; // 开始认知重构 + } + ``` + +### 3.2 新建组件 + +**1. ToolPanel.tsx** (新建) +``` +frontend/src/components/ToolPanel/ +├── ToolPanel.tsx # 主容器,Tab 管理 +├── ToolPanelTab.tsx # Tab 按钮 +├── TranslationPanel.tsx # 翻译 Tab 内容 +├── AggregationPanel.tsx # AI聚合 Tab 内容 (feat/ieee 迁移) +└── CanvasPanel.tsx # Canvas Tab 内容 +``` + +**2. SensemakingCanvas.tsx** (新建) +``` +frontend/src/components/SensemakingCanvas/ +├── SensemakingCanvas.tsx # Canvas 主组件 +├── DeltaVisualization.tsx # Before/After Delta 可视化 +└── CognitiveGraph.tsx # 认知图谱 (D3.js) +``` + +**3. UserSchemaEditor.tsx** (新建) +- 用于创建/编辑 User Schema +- 位置: `/settings/schema` 或 `/profile/schema` + +--- + +## 4. 全文对照翻译方案 + +### 4.1 需求分析 + +大白想要的"全文对照翻译": +- 保持原文的排版(段落、标题层级) +- 保持原文的图片、公式、表格位置 +- 左边原文,右边翻译,同步滚动 + +### 4.2 技术挑战 + +1. **PDF 内容提取** + - react-pdf 的 Document 渲染后是 Canvas,无法直接获取文本坐标 + - 需要额外解析:pdf.js 的 TextContent API 或 pdfminer + +2. **双栏同步滚动** + - 原文和翻译的段落需要一一对应 + - 用户滚动一边,另一边自动同步 + - 鼠标悬停高亮对应段落 + +3. **图片/公式处理** + - 公式通常是图片或 MathML + - 扫描版 PDF 整个页面都是图片 + +### 4.3 实现方案 (MVP → Full) + +**MVP 方案: 段落对照模式** + +``` +┌────────────────────┬────────────────────┐ +│ Section 1 │ 第一部分 │ +│ (原文) │ (翻译) │ +│ │ │ +│ This is a para... │ 这是一段文字... │ +├────────────────────┼────────────────────┤ +│ [Figure 1] │ [Figure 1] │ +│ (原图) │ (原图) │ +├────────────────────┼────────────────────┤ +│ Section 2 │ 第二部分 │ +│ (原文) │ (翻译) │ +└────────────────────┴────────────────────┘ +``` + +**实现步骤:** + +1. **Phase 1 (MVP)**: 划词翻译 + - 选中文字 → 调用翻译 API → 显示浮层翻译 + - 不改变 PDF 阅读体验 + +2. **Phase 2**: 段级对照翻译 + - 调用 LLM 翻译文档,返回分段翻译 + - 双栏显示,段落一一对应 + - PDF 渲染为单栏,翻译面板在右侧 + +3. **Phase 3**: 全文对照翻译 + - 完整保留 PDF 排版 + - 左右分栏同步滚动 + - 图片/公式位置对应 + +### 4.4 API 设计 + +```python +# 翻译 API (新增) +@router.post("/papers/{paper_id}/translate") +async def translate_paper( + paper_id: str, + mode: str = "selection", # "selection" | "paragraph" | "full" + text: str = None, + target_lang: str = "zh" +): + """翻译论文内容""" + # mode=selection: 翻译选中文字 + # mode=paragraph: 翻译整个段落 + # mode=full: 翻译整篇论文 (返回分段翻译结果) +``` + +--- + +## 5. Sensemaking 流程设计 + +### 5.1 Act 1: Comprehension (理解) + +**目标:** 提取论文的核心观点,理解论文在说什么 + +**交互流程:** +1. 用户在 PDF 阅读器中点击 "Canvas" Tab +2. 系统展示论文基本信息 (标题、作者、摘要) +3. 用户选择 "开始阅读" → 进入 Comprehension 模式 +4. 系统引导用户: + - "这篇论文的核心观点是什么?" + - "作者是如何论证的?" + - "你从中学习到了什么?" +5. 用户输入,系统记录到 `act1_comprehension` + +**数据结构:** +```json +{ + "core_claim": "Transformers are better than CNNs for NLP tasks", + "learning_mechanism": "Through self-attention mechanism", + "user_perspective": "I agree because...", + "notes": ["key point 1", "key point 2"] +} +``` + +### 5.2 Act 2: Collision (碰撞) + +**目标:** 识别论文观点与用户已有认知的冲突/一致 + +**触发条件:** 需要用户 Schema 信息 + +**交互流程:** +1. 系统根据 User Schema 推断用户的现有观点 +2. 系统展示论文观点 vs 用户观点的对比 +3. 用户识别摩擦点 (Friction Points): + - "论文说 A,我认为 B" + - "论文论证方法有漏洞" + - "我的实验数据和论文结论矛盾" +4. 用户表明立场 (Stance): 支持/反对/中立 +5. 记录到 `act2_collision` + +**数据结构:** +```json +{ + "user_schema_before": "I believe transformers are not suitable for small datasets", + "paper_challenge": "This paper shows transformers can work well with small data via pre-training", + "friction_points": [ + "Data efficiency assumption vs my experience" + ], + "user_stance": "neutral", + "user_reasoning": "The pre-training approach is interesting but requires extra resources", + "probe_exchange": [ + {"role": "user", "content": "What about computational cost?"}, + {"role": "assistant", "content": "..."} + ] +} +``` + +### 5.3 Act 3: Reconstruction (重构) + +**目标:** 形成新的认知,理解论文如何改变了你对这个领域的理解 + +**交互流程:** +1. 系统展示 Before State (用户原来的认知) +2. 用户描述 After State (阅读后的新认知) +3. 系统计算 Delta (变化) +4. 用户承诺一个改变 (One Change): "我要在下次实验中尝试..." +5. 保存到 `act3_reconstruction` + +**数据结构:** +```json +{ + "before": "Transformers need large datasets to perform well", + "after": "Transformers can work on small datasets with proper pre-training", + "delta": "Pre-training democratizes transformer usage", + "one_change": "I will pre-train on domain-specific corpus before fine-tuning" +} +``` + +### 5.4 Canvas 可视化 + +**Delta 可视化:** + +``` + Before Delta After +┌─────────┐ ┌─────────┐ ┌─────────┐ +│ ██████ │ → │ ████ │ → │ ████████│ +│ 旧认知 │ │ 变化点 │ │ 新认知 │ +└─────────┘ └─────────┘ └─────────┘ +``` + +**认知图谱:** + +- 节点: 论文观点、用户信念、冲突点 +- 边: 支撑/反对/转化关系 +- 颜色: 论文观点(蓝) vs 用户信念(橙) vs 新认知(绿) + +--- + +## 6. 实施计划 + +### Phase 1: 基础层 (User Schema + ToolPanel Tab 化) + +| 任务 | 文件 | 描述 | +|------|------|------| +| T1.1 | `apps/api/models/sensemaking.py` | 新建 UserSchema, SensemakingSession 模型 | +| T1.2 | `apps/api/routes/sensemaking.py` | 新建 CRUD API | +| T1.3 | `frontend/src/hooks/useSelectedText.ts` | 新建选中文字 Hook | +| T1.4 | `frontend/src/components/ToolPanel/` | 新建 ToolPanel 组件 (Tab 化) | +| T1.5 | `frontend/src/components/PdfReader.tsx` | 改造: 集成 ToolPanel | + +### Phase 2: 核心功能 (Sensemaking 流程 + Canvas) + +| 任务 | 文件 | 描述 | +|------|------|------| +| T2.1 | `frontend/src/components/ToolPanel/CanvasPanel.tsx` | Canvas Tab 内容 | +| T2.2 | `frontend/src/components/SensemakingCanvas/` | Canvas 可视化组件 | +| T2.3 | `apps/api/routes/sensemaking.py` | Act 1/2/3 API 逻辑 | +| T2.4 | `frontend/src/pages/Settings/` | UserSchema Editor 页面 | + +### Phase 3: 翻译增强 (全文对照) + +| 任务 | 文件 | 描述 | +|------|------|------| +| T3.1 | `apps/api/routes/translate.py` | 翻译 API (段级/全文) | +| T3.2 | `frontend/src/components/ToolPanel/TranslationPanel.tsx` | 翻译 Tab 升级 | +| T3.3 | `frontend/src/components/DualColumnPDF.tsx` | 双栏对照 PDF 组件 | + +--- + +## 7. 优先级建议 + +1. **T1.1-T1.3**: User Schema 模型 + API (半天) +2. **T1.4-T1.5**: ToolPanel Tab 化 (1天) +3. **T2.1-T2.3**: Sensemaking 流程 + Canvas (2天) +4. **T2.4**: UserSchema Editor (0.5天) +5. **T3.1-T3.3**: 全文对照翻译 (2天) + +**预计总工期: 5-6 天** + +--- + +## 8. 确认决策 + +- [x] **全文对照翻译**: 分期实现 + - **Phase 1 (MVP)**: A - 段落对照模式 + - **Phase 2 (必须实现)**: B - PDF 重渲染模式(完整保留 PDF 排版和图片) +- [x] **Canvas 可视化**: 分期实现 + - **Phase 1 (MVP)**: A - Before/After 简单对比视图 + - **Phase 2 (必须实现)**: B - D3.js 认知图谱(节点+边+动画,可交互) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8d450f2..0bdd6f4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -27,6 +27,7 @@ const Operations = lazy(() => import("@/pages/Operations")); const EmailSettings = lazy(() => import("@/pages/EmailSettings")); const Writing = lazy(() => import("@/pages/Writing")); const Statistics = lazy(() => import("@/pages/Statistics")); +const Settings = lazy(() => import("@/pages/Settings")); import LoginPage from "@/pages/Login"; import { isAuthenticated as checkAuth, clearAuth } from "@/services/api"; @@ -117,7 +118,7 @@ export default function App() { {/* 常见拼写重定向 */} } /> - } /> + }>} /> {/* 404 */} } /> diff --git a/frontend/src/components/PdfReader.tsx b/frontend/src/components/PdfReader.tsx index b6a9eb7..e6caeaf 100644 --- a/frontend/src/components/PdfReader.tsx +++ b/frontend/src/components/PdfReader.tsx @@ -7,7 +7,10 @@ import { Document, Page, pdfjs } from "react-pdf"; import "react-pdf/dist/Page/AnnotationLayer.css"; import "react-pdf/dist/Page/TextLayer.css"; import { paperApi } from "@/services/api"; -import Markdown from "@/components/Markdown"; +import { ToolPanel } from "./ToolPanel/ToolPanel"; +import { TranslationPanel } from "./ToolPanel/TranslationPanel"; +import { AggregationPanel } from "./ToolPanel/AggregationPanel"; +import { CanvasPanel } from "./ToolPanel/CanvasPanel"; import { X, ZoomIn, @@ -15,35 +18,23 @@ import { Maximize2, Minimize2, BookOpen, - Languages, - Lightbulb, - FileText, Loader2, - RotateCw, - MessageSquareText, - Sparkles, - Copy, - Check, } from "lucide-react"; -pdfjs.GlobalWorkerOptions.workerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjs.version}/pdf.worker.min.mjs`; +pdfjs.GlobalWorkerOptions.workerSrc = new URL( + "pdfjs-dist/build/pdf.worker.min.mjs", + import.meta.url +).href; interface PdfReaderProps { paperId: string; paperTitle: string; paperArxivId?: string; // arXiv ID(用于在线链接) + paperPdfPath?: string | null; // 本地 PDF 路径 onClose: () => void; } -type AiAction = "explain" | "translate" | "summarize"; - -interface AiResult { - action: AiAction; - text: string; - result: string; -} - -export default function PdfReader({ paperId, paperTitle, paperArxivId, onClose }: PdfReaderProps) { +export default function PdfReader({ paperId, paperTitle, paperArxivId, paperPdfPath, onClose }: PdfReaderProps) { const [numPages, setNumPages] = useState(0); const [currentPage, setCurrentPage] = useState(1); const [scale, setScale] = useState(1.2); @@ -51,11 +42,7 @@ export default function PdfReader({ paperId, paperTitle, paperArxivId, onClose } const [loadError, setLoadError] = useState(null); /* AI 侧栏 */ - const [aiPanelOpen, setAiPanelOpen] = useState(false); const [selectedText, setSelectedText] = useState(""); - const [aiLoading, setAiLoading] = useState(false); - const [aiResults, setAiResults] = useState([]); - const [copiedIdx, setCopiedIdx] = useState(null); /* 页面输入 */ const [pageInput, setPageInput] = useState(""); @@ -63,11 +50,23 @@ export default function PdfReader({ paperId, paperTitle, paperArxivId, onClose } const scrollRef = useRef(null); const pageRefs = useRef>(new Map()); - // 混合加载:优先本地 PDF,没有则用后端代理访问 arXiv(解决 CORS 问题) + // 优先本地 PDF,没有则用 arXiv 在线代理 const pdfUrl = useMemo(() => { - // 先尝试本地 PDF(如果有) - return paperApi.pdfUrl(paperId, paperArxivId); - }, [paperId, paperArxivId]); + const token = localStorage.getItem('auth_token') || ''; + const tokenParam = token ? `?token=${encodeURIComponent(token)}` : ''; + const base = import.meta.env.VITE_API_BASE || 'http://localhost:8000'; + + // 有本地 PDF 优先使用 + if (paperPdfPath) { + return `${base}/papers/${paperId}/pdf${tokenParam}`; + } + // 没有本地 PDF 但有 arXiv ID,用在线代理 + if (paperArxivId && !paperArxivId.startsWith('ss-')) { + return `${base}/papers/proxy-arxiv-pdf/${paperArxivId}${tokenParam}`; + } + // 最后尝试本地 PDF 端点 + return `${base}/papers/${paperId}/pdf${tokenParam}`; + }, [paperId, paperArxivId, paperPdfPath]); /** * PDF 加载成功 @@ -176,30 +175,6 @@ export default function PdfReader({ paperId, paperTitle, paperArxivId, onClose } return () => document.removeEventListener("mouseup", handler); }, []); - /* AI 操作 */ - const handleAiAction = useCallback(async (action: AiAction, text?: string) => { - const t = text || selectedText; - if (!t) return; - setAiPanelOpen(true); - setAiLoading(true); - try { - const res = await paperApi.aiExplain(paperId, t, action); - setAiResults((prev) => [{ action, text: t.slice(0, 100), result: res.result }, ...prev]); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - setAiResults((prev) => [{ action, text: t.slice(0, 100), result: `错误: ${msg}` }, ...prev]); - } finally { - setAiLoading(false); - } - }, [paperId, selectedText]); - - /* 复制 AI 结果 */ - const handleCopy = useCallback((idx: number, text: string) => { - navigator.clipboard.writeText(text); - setCopiedIdx(idx); - setTimeout(() => setCopiedIdx(null), 2000); - }, []); - /** * 注册页面 ref */ @@ -211,12 +186,6 @@ export default function PdfReader({ paperId, paperTitle, paperArxivId, onClose } } }, []); - const actionLabels: Record = { - explain: { label: "AI 解释", icon: , color: "text-amber-600 bg-amber-50 dark:bg-amber-900/20" }, - translate: { label: "翻译", icon: , color: "text-blue-600 bg-blue-50 dark:bg-blue-900/20" }, - summarize: { label: "总结", icon: , color: "text-emerald-600 bg-emerald-50 dark:bg-emerald-900/20" }, - }; - /* 生成页码数组 */ const pages = useMemo(() => Array.from({ length: numPages }, (_, i) => i + 1), [numPages]); @@ -269,39 +238,18 @@ export default function PdfReader({ paperId, paperTitle, paperArxivId, onClose } - {/* 右侧: AI 功能 & 关闭 */} + {/* 右侧: 关闭 */}
- {selectedText && ( -
- {selectedText} - - - -
- )} -
- {/* PDF 主体 - 连续滚动 */} + {/* PDF 主体 - 连续滚动 - 左半屏 */}
{loadError ? (
@@ -374,7 +322,7 @@ export default function PdfReader({ paperId, paperTitle, paperArxivId, onClose } {numPages > 0 && (
)} - {/* AI 侧边栏 */} -
-
- {/* AI 面板头部 */} -
-
- - AI 阅读助手 -
- -
- - {/* 快捷 AI 操作 */} - {selectedText && ( -
-

选中文本

-

{selectedText}

-
- - - -
-
- )} - - {/* AI 结果列表 */} -
- {aiLoading && ( -
- - AI 分析中... -
- )} - - {aiResults.length === 0 && !aiLoading && ( -
- -
-

选中论文文本

-

即可使用 AI 解释、翻译、总结

-
-
-

快捷键:

-

Ctrl +/- 缩放   Ctrl+0 重置

-

Home/End 首/末页   Esc 关闭

-

鼠标滚轮自由滚动阅读

-
-
- )} - - {aiResults.map((r, i) => { - const cfg = actionLabels[r.action]; - return ( -
- {/* 卡片头部 */} -
- - {cfg.icon} {cfg.label} - - -
- {/* 原文引用 */} -
-

- {r.text} -

-
- {/* AI 输出 - Markdown 渲染 */} -
- {r.result} -
-
- ); - })} -
-
+ {/* 右侧面板 - 右半屏 */} +
+ + {{ + translation: , + aggregation: , + canvas: , + }} +
); diff --git a/frontend/src/components/SensemakingCanvas/DeltaCard.tsx b/frontend/src/components/SensemakingCanvas/DeltaCard.tsx new file mode 100644 index 0000000..68846ea --- /dev/null +++ b/frontend/src/components/SensemakingCanvas/DeltaCard.tsx @@ -0,0 +1,26 @@ +interface DeltaCardProps { + label: string; + content: string; + variant: 'before' | 'after' | 'delta'; +} + +export function DeltaCard({ label, content, variant }: DeltaCardProps) { + const colors = { + before: 'border-white/20 bg-white/5', + after: 'border-primary/30 bg-primary/5', + delta: 'border-amber-500/30 bg-amber-500/5', + }; + + const labelColors = { + before: 'text-white/40', + after: 'text-primary', + delta: 'text-amber-400', + }; + + return ( +
+

{label}

+

{content || '...'}

+
+ ); +} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index e4e05a3..ad5b038 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -2,7 +2,7 @@ * 侧边栏 - AI 应用风格:图标网格 + 对话历史 + 设置弹窗 * @author Color2333 */ -import { useState, useEffect, useMemo, useCallback, lazy, Suspense } from "react"; +import { useState, useEffect, useMemo, useCallback } from "react"; import { NavLink, useNavigate, useLocation } from "react-router-dom"; import { cn } from "@/lib/utils"; import { useConversationCtx } from "@/contexts/ConversationContext"; @@ -10,9 +10,6 @@ import { useGlobalTasks } from "@/contexts/GlobalTaskContext"; import { groupByDate } from "@/hooks/useConversations"; import ConfirmDialog from "@/components/ConfirmDialog"; import LogoIcon from "@/assets/logo-icon.svg?react"; - -// 1550 行的设置弹窗,只在用户点击设置按钮时才加载 -const SettingsDialog = lazy(() => import("./SettingsDialog").then(m => ({ default: m.SettingsDialog }))); import { FileText, Network, @@ -67,7 +64,6 @@ function useDarkMode() { export default function Sidebar() { const [dark, toggleDark] = useDarkMode(); - const [showSettings, setShowSettings] = useState(false); const [mobileOpen, setMobileOpen] = useState(false); const [deleteId, setDeleteId] = useState(null); const [unreadCount, setUnreadCount] = useState(0); @@ -263,7 +259,7 @@ export default function Sidebar() {
- {/* 设置弹窗 - 懒加载,只在用户点击时才拉取 chunk */} - {showSettings && ( - - setShowSettings(false)} /> - - )} - ([]); + const [loading, setLoading] = useState(false); + + const handleSearch = useCallback(async (query: string, channels: string[]) => { + setLoading(true); + try { + const res = await paperApi.multiSourceSearch(query, channels); + setResults(res.results || []); + } catch (err) { + console.error('Search failed:', err); + } finally { + setLoading(false); + } + }, []); + + return ( +
+
+
+ + AI 聚合搜索 +
+ +
+ +
+ {loading && ( +
+ +
+ )} + + {!loading && results.length === 0 && ( +
+ +

输入关键词搜索

+

AI 将从多源聚合相关论文

+
+ )} + + {results.map((result, idx) => { + const primarySource = result.sources?.[0]?.channel; + return ( +
+

{result.title}

+ {result.authors && result.authors.length > 0 && ( +

{result.authors.slice(0, 3).join(', ')}

+ )} + {primarySource && ( + + {primarySource} + + )} +
+ ); + })} +
+
+ ); +} diff --git a/frontend/src/components/ToolPanel/CanvasPanel.tsx b/frontend/src/components/ToolPanel/CanvasPanel.tsx new file mode 100644 index 0000000..16de33e --- /dev/null +++ b/frontend/src/components/ToolPanel/CanvasPanel.tsx @@ -0,0 +1,480 @@ +import { useState, useEffect, useCallback } from 'react'; +import { ArrowRight, BookOpen, Zap, Brain, Loader2 } from 'lucide-react'; +import { DeltaCard } from '@/components/SensemakingCanvas/DeltaCard'; + +interface Act1Data { + summary: string; + key_findings: string[]; +} + +interface Act2Data { + conflicts: string[]; + questions: string[]; +} + +interface Act3Data { + before: string; + after: string; + delta: string; + one_change: string; +} + +interface SessionData { + id: string; + act1_comprehension: { comprehension?: Act1Data } | null; + act2_collision: { collision?: Act2Data } | null; + act3_reconstruction: Act3Data | null; + status: string; +} + +interface CanvasPanelProps { + paperId: string; + paperTitle: string; +} + +type Stage = 'list' | 'act1' | 'act2' | 'act3' | 'result'; + +export function CanvasPanel({ paperId, paperTitle }: CanvasPanelProps) { + const [stage, setStage] = useState('list'); + const [sessions, setSessions] = useState([]); + const [currentSession, setCurrentSession] = useState(null); + const [loading, setLoading] = useState(false); + + const [act1Form, setAct1Form] = useState({ summary: '', key_findings: [''] }); + const [act2Form, setAct2Form] = useState({ conflicts: [''], questions: [''] }); + const [act3Form, setAct3Form] = useState({ before: '', after: '', delta: '', one_change: '' }); + + const loadSessions = useCallback(async () => { + setLoading(true); + try { + const token = localStorage.getItem('auth_token') || ''; + const base = import.meta.env.VITE_API_BASE || 'http://localhost:8000'; + const res = await fetch(`${base}/sensemaking/sessions?paper_id=${paperId}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.ok) { + const data = await res.json(); + setSessions(data); + if (data.length > 0) { + setCurrentSession(data[data.length - 1]); + } + } + } catch (err) { + console.error('Failed to load sessions:', err); + } finally { + setLoading(false); + } + }, [paperId]); + + const startNewSession = async () => { + setLoading(true); + try { + const token = localStorage.getItem('auth_token') || ''; + const base = import.meta.env.VITE_API_BASE || 'http://localhost:8000'; + const schemaRes = await fetch(`${base}/sensemaking/schemas`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!schemaRes.ok) return; + const schemas = await schemaRes.json(); + const defaultSchema = schemas.find((s: { user_id: string }) => s.user_id === 'default') || schemas[0]; + if (!defaultSchema) return; + + const createRes = await fetch(`${base}/sensemaking/sessions`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ paper_id: paperId, user_schema_id: defaultSchema.id }), + }); + if (createRes.ok) { + const session = await createRes.json(); + setCurrentSession(session); + setStage('act1'); + } + } catch (err) { + console.error('Failed to start session:', err); + } finally { + setLoading(false); + } + }; + + const submitAct1 = async () => { + if (!currentSession) return; + setLoading(true); + try { + const token = localStorage.getItem('auth_token') || ''; + const base = import.meta.env.VITE_API_BASE || 'http://localhost:8000'; + const res = await fetch(`${base}/sensemaking/sessions/${currentSession.id}/act1`, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ comprehension: act1Form }), + }); + if (res.ok) { + const updated = await res.json(); + setCurrentSession(updated); + setStage('act2'); + } + } catch (err) { + console.error('Failed to submit act1:', err); + } finally { + setLoading(false); + } + }; + + const submitAct2 = async () => { + if (!currentSession) return; + setLoading(true); + try { + const token = localStorage.getItem('auth_token') || ''; + const base = import.meta.env.VITE_API_BASE || 'http://localhost:8000'; + const res = await fetch(`${base}/sensemaking/sessions/${currentSession.id}/act2`, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ collision: act2Form }), + }); + if (res.ok) { + const updated = await res.json(); + setCurrentSession(updated); + setStage('act3'); + } + } catch (err) { + console.error('Failed to submit act2:', err); + } finally { + setLoading(false); + } + }; + + const submitAct3 = async () => { + if (!currentSession) return; + setLoading(true); + try { + const token = localStorage.getItem('auth_token') || ''; + const base = import.meta.env.VITE_API_BASE || 'http://localhost:8000'; + const res = await fetch(`${base}/sensemaking/sessions/${currentSession.id}/act3`, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(act3Form), + }); + if (res.ok) { + const updated = await res.json(); + setCurrentSession(updated); + setStage('result'); + } + } catch (err) { + console.error('Failed to submit act3:', err); + } finally { + setLoading(false); + } + }; + + const renderList = () => ( +
+
+

{paperTitle}

+

认知重构工作台

+
+ + {loading ? ( +
+ +
+ ) : sessions.length === 0 ? ( +
+ +

开始你的认知重构之旅

+ +
+ ) : ( +
+

历史会话 ({sessions.length})

+ {sessions.map((s) => ( + + ))} + +
+ )} +
+ ); + + const renderAct1 = () => ( +
+
+ +
+ + Act 1: 理解 +
+
+ +
+

论文摘要

+