diff --git a/.env.example b/.env.example index 367af2f..8f74aab 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,11 @@ # ========================================== # PaperMind 环境配置示例 # ========================================== -# +# # 使用说明: # - 本地开发:复制为 .env(根目录) # - Docker部署:复制为 .env(根目录) -# +# # 必须配置项:至少填写一个 LLM API Key # ========================================== diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index b767381..a60216c 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -30,4 +30,4 @@ jobs: env: ALIBABA_CODING_PLAN_API_KEY: ${{ secrets.ALIBABA_CODING_PLAN_API_KEY }} with: - model: alibaba-coding-plan-cn/qwen3.5-plus \ No newline at end of file + model: alibaba-coding-plan-cn/qwen3.5-plus diff --git a/.github/workflows/pr-review-gate.yml b/.github/workflows/pr-review-gate.yml index 32cd330..1ef74c1 100644 --- a/.github/workflows/pr-review-gate.yml +++ b/.github/workflows/pr-review-gate.yml @@ -35,7 +35,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | PR_NUM="${{ steps.pr.outputs.pr_number }}" - + BODY='## 🔍 OpenCode PR Review Required 这是一个受保护的分支,merge 前需要进行 code review。 @@ -50,7 +50,7 @@ jobs: --- *This is an automated reminder from PR Review Gate.*' - + gh pr comment $PR_NUM --body "$BODY" - name: Set status check to success diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 12f0893..362691b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,13 +11,8 @@ repos: args: [--fix, --exit-non-zero-on-fix] - id: ruff-format - # TypeScript/JavaScript - Prettier - - repo: https://github.com/pre-commit/mirrors-prettier - rev: v3.4.2 - hooks: - - id: prettier - files: \.(ts|tsx|js|jsx|json|css|md)$ - exclude: ^frontend/node_modules/ + # TypeScript/JavaScript - 通过 npm scripts 格式化 (prettier 在 node_modules 中) + # 运行 npm run format 格式化前端代码 # 通用检查 - repo: https://github.com/pre-commit/pre-commit-hooks diff --git a/README.md b/README.md index dbb5d0d..614d931 100644 --- a/README.md +++ b/README.md @@ -452,4 +452,4 @@ alembic upgrade head [![Star](https://img.shields.io/github/stars/Color2333/PaperMind?style=social)](https://github.com/Color2333/PaperMind/stargazers) - \ No newline at end of file + diff --git a/apps/api/routers/pipelines.py b/apps/api/routers/pipelines.py index 62922ce..93ad9da 100644 --- a/apps/api/routers/pipelines.py +++ b/apps/api/routers/pipelines.py @@ -24,7 +24,6 @@ @router.post("/pipelines/skim/{paper_id}") def run_skim(paper_id: UUID) -> dict: - tid = f"skim_{paper_id.hex[:8]}" title = get_paper_title(paper_id) or str(paper_id)[:8] global_tracker.start(tid, "skim", f"粗读: {title[:30]}", total=1) @@ -39,7 +38,6 @@ def run_skim(paper_id: UUID) -> dict: @router.post("/pipelines/deep/{paper_id}") def run_deep(paper_id: UUID) -> dict: - tid = f"deep_{paper_id.hex[:8]}" title = get_paper_title(paper_id) or str(paper_id)[:8] global_tracker.start(tid, "deep_read", f"精读: {title[:30]}", total=1) @@ -54,7 +52,6 @@ def run_deep(paper_id: UUID) -> dict: @router.post("/pipelines/embed/{paper_id}") def run_embed(paper_id: UUID) -> dict: - tid = f"embed_{paper_id.hex[:8]}" title = get_paper_title(paper_id) or str(paper_id)[:8] global_tracker.start(tid, "embed", f"嵌入: {title[:30]}", total=1) diff --git a/apps/desktop/__init__.py b/apps/desktop/__init__.py index 8b13789..e69de29 100644 --- a/apps/desktop/__init__.py +++ b/apps/desktop/__init__.py @@ -1 +0,0 @@ - diff --git a/docker-compose.yml b/docker-compose.yml index a7d659c..40b87ac 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -120,4 +120,3 @@ volumes: pm_logs: name: papermind_logs pm_pip_cache: - diff --git a/docs/PaperMind.md b/docs/PaperMind.md index a6ea1c9..e81330b 100644 --- a/docs/PaperMind.md +++ b/docs/PaperMind.md @@ -1,8 +1,8 @@ # PaperMind - AI 研究工作流平台 -**版本:** 2.8 -**日期:** 2026-02-19 -**作者:** color2333 +**版本:** 2.8 +**日期:** 2026-02-19 +**作者:** color2333 **核心愿景:** 从"搜索论文"进化为"理解领域"。通过自动化 Agent 和 LLM,将海量文献转化为结构化的知识图谱,辅助研究者完成从"每日追踪"到"深度调研"的全过程。 --- diff --git a/docs/deployment/DOCKER_DEPLOYMENT.md b/docs/deployment/DOCKER_DEPLOYMENT.md index d90381c..8a48326 100644 --- a/docs/deployment/DOCKER_DEPLOYMENT.md +++ b/docs/deployment/DOCKER_DEPLOYMENT.md @@ -1,7 +1,7 @@ # PaperMind Docker 部署指南 - 端口预留版 > 适用于已有项目占用 3001 和 8001 端口的场景 -> +> > **端口规划**: > - 现有项目:3001(前端) + 8001(后端) > - PaperMind:**3002(前端) + 8002(后端)** @@ -346,14 +346,14 @@ ufw allow from 192.168.1.0/24 to any port 8002 server { listen 443 ssl; server_name your-domain.com; - + ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem; - + location / { proxy_pass http://localhost:3002; } - + location /api/ { proxy_pass http://localhost:8002; } @@ -414,7 +414,7 @@ services: image: prom/prometheus ports: - "9090:9090" - + grafana: image: grafana/grafana ports: diff --git a/docs/deployment/DOCKER_FIXES.md b/docs/deployment/DOCKER_FIXES.md index 7766304..0ab248b 100644 --- a/docs/deployment/DOCKER_FIXES.md +++ b/docs/deployment/DOCKER_FIXES.md @@ -1,7 +1,7 @@ # PaperMind Docker 部署问题修复报告 > 修复日期:2026-02-26 -> +> > 修复目标:确保 Docker 部署顺利,解决前后端配置问题 --- @@ -23,11 +23,11 @@ export function resolveApiBase(): string { if (!isTauri()) { if (import.meta.env.VITE_API_BASE) return import.meta.env.VITE_API_BASE; - + if (import.meta.env.DEV) { return "http://localhost:8000"; } - + // Docker 生产环境使用相对路径 return "/api"; } @@ -66,19 +66,19 @@ cors_allow_origins: str = ( location /api/ { # 去掉 /api 前缀,转发到后端 rewrite ^/api/(.*) /$1 break; - + # 后端服务地址(Docker 内部网络) proxy_pass http://backend:8000; - + # WebSocket/SSE 支持 proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; - + # 关闭缓冲(SSE 需要) proxy_buffering off; proxy_cache off; - + # 超时设置 proxy_read_timeout 120s; } @@ -197,7 +197,7 @@ src/pages/Agent.tsx(346,42): error TS2552 ... ``` -**影响**: +**影响**: - ❌ 不影响 Docker 构建(Dockerfile 使用 `npm run build` 会跳过类型检查) - ⚠️ 建议后续修复这些类型错误 @@ -221,7 +221,7 @@ result.get("saved_path", "N/A") # Pylance 报错,但运行时正确 **原因**: `result` 可能为 `None`,但实际逻辑中不会为 `None` -**影响**: +**影响**: - ✅ 不影响运行 - ✅ 不影响 Docker 构建 diff --git a/docs/deployment/WIKI_DEPLOYMENT.md b/docs/deployment/WIKI_DEPLOYMENT.md index cf89504..a57febd 100644 --- a/docs/deployment/WIKI_DEPLOYMENT.md +++ b/docs/deployment/WIKI_DEPLOYMENT.md @@ -1,6 +1,6 @@ # PaperMind 部署 Wiki -> 📚 **完整的部署指南** - 从本地开发到生产环境,一站式搞定! +> 📚 **完整的部署指南** - 从本地开发到生产环境,一站式搞定! > 📅 最后更新:2026-03-02 | 版本:v3.1 --- @@ -216,7 +216,7 @@ docker compose ps # 预期输出: # NAME STATUS PORTS # papermind-backend Up (healthy) 0.0.0.0:8002->8000/tcp -# papermind-worker Up (healthy) +# papermind-worker Up (healthy) # papermind-frontend Up (healthy) 0.0.0.0:3002->80/tcp # 检查服务健康状态 @@ -255,14 +255,14 @@ services: reservations: cpus: '0.5' # 预留 0.5 核 memory: 512M # 预留 512MB - + worker: deploy: resources: limits: cpus: '2.0' memory: 2G - + frontend: deploy: resources: @@ -819,14 +819,14 @@ ufw allow from 192.168.1.0/24 to any port 8002 server { listen 443 ssl; server_name your-domain.com; - + ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem; - + location / { proxy_pass http://localhost:3002; } - + location /api/ { proxy_pass http://localhost:8002; } @@ -893,7 +893,7 @@ services: - "9090:9090" volumes: - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml - + grafana: image: grafana/grafana ports: diff --git a/frontend/index.html b/frontend/index.html index 5274873..c746dcf 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,20 +5,20 @@ - + - + - + - + diff --git a/frontend/package.json b/frontend/package.json index bc64529..392a7c5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "lint:fix": "eslint \"src/**/*.{ts,tsx}\" --fix" }, "dependencies": { + "@tanstack/react-virtual": "^3.13.23", "@tauri-apps/api": "^2.10.1", "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-fs": "^2.4.5", diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json index e3fa586..3981924 100644 --- a/frontend/public/manifest.json +++ b/frontend/public/manifest.json @@ -14,4 +14,4 @@ "purpose": "any maskable" } ] -} \ No newline at end of file +} diff --git a/frontend/public/papermind-icon.svg b/frontend/public/papermind-icon.svg index bfaff16..eba0632 100644 --- a/frontend/public/papermind-icon.svg +++ b/frontend/public/papermind-icon.svg @@ -10,16 +10,16 @@ } - + - + - + - + - + diff --git a/frontend/src/assets/logo-icon.svg b/frontend/src/assets/logo-icon.svg index bfaff16..eba0632 100644 --- a/frontend/src/assets/logo-icon.svg +++ b/frontend/src/assets/logo-icon.svg @@ -10,16 +10,16 @@ } - + - + - + - + - + diff --git a/frontend/src/contexts/AgentSessionContext.tsx b/frontend/src/contexts/AgentSessionContext.tsx index 3bf6056..0269da3 100644 --- a/frontend/src/contexts/AgentSessionContext.tsx +++ b/frontend/src/contexts/AgentSessionContext.tsx @@ -2,7 +2,15 @@ * Agent 会话全局上下文 - SSE 流和对话状态在页面切换时保持存活 * @author Color2333 */ -import { createContext, useContext, useState, useRef, useEffect, useCallback, useMemo } from "react"; +import { + createContext, + useContext, + useState, + useRef, + useEffect, + useCallback, + useMemo, +} from "react"; import { agentApi } from "@/services/api"; import type { AgentMessage, SSEEvent, SSEEventType } from "@/types"; import { parseSSEStream } from "@/types"; @@ -119,7 +127,13 @@ export function AgentSessionProvider({ children }: { children: React.ReactNode } } return [ ...prev, - { id: `asst_${uid()}`, type: "assistant" as const, content: text, streaming: true, timestamp: new Date() }, + { + id: `asst_${uid()}`, + type: "assistant" as const, + content: text, + streaming: true, + timestamp: new Date(), + }, ]; }); }, []); @@ -140,21 +154,25 @@ export function AgentSessionProvider({ children }: { children: React.ReactNode } } const conv = activeId ? loadConversation(activeId) : null; if (conv && conv.messages.length > 0) { - setItems(conv.messages.map((m): ChatItem => ({ - id: m.id, - type: m.type, - content: m.content, - timestamp: new Date(m.timestamp), - streaming: false, - steps: m.steps, - actionId: m.actionId, - actionDescription: m.actionDescription, - actionTool: m.actionTool, - toolArgs: m.toolArgs, - artifactTitle: m.artifactTitle, - artifactContent: m.artifactContent, - artifactIsHtml: m.artifactIsHtml, - }))); + setItems( + conv.messages.map( + (m): ChatItem => ({ + id: m.id, + type: m.type, + content: m.content, + timestamp: new Date(m.timestamp), + streaming: false, + steps: m.steps, + actionId: m.actionId, + actionDescription: m.actionDescription, + actionTool: m.actionTool, + toolArgs: m.toolArgs, + artifactTitle: m.artifactTitle, + artifactContent: m.artifactContent, + artifactIsHtml: m.artifactIsHtml, + }) + ) + ); } else { setItems([]); } @@ -164,28 +182,27 @@ export function AgentSessionProvider({ children }: { children: React.ReactNode } /* ---- 保存对话到 localStorage ---- */ const buildSavePayload = useCallback((snapshot: ChatItem[]): ConversationMessage[] => { - return snapshot - .map((it) => { - const base: ConversationMessage = { - id: it.id, - type: it.type, - content: it.streaming ? it.content + streamBufRef.current : it.content, - timestamp: it.timestamp.toISOString(), - }; - if (it.type === "step_group" && it.steps) base.steps = it.steps; - if (it.type === "action_confirm") { - base.actionId = it.actionId; - base.actionDescription = it.actionDescription; - base.actionTool = it.actionTool; - base.toolArgs = it.toolArgs; - } - if (it.type === "artifact") { - base.artifactTitle = it.artifactTitle; - base.artifactContent = it.artifactContent; - base.artifactIsHtml = it.artifactIsHtml; - } - return base; - }); + return snapshot.map((it) => { + const base: ConversationMessage = { + id: it.id, + type: it.type, + content: it.streaming ? it.content + streamBufRef.current : it.content, + timestamp: it.timestamp.toISOString(), + }; + if (it.type === "step_group" && it.steps) base.steps = it.steps; + if (it.type === "action_confirm") { + base.actionId = it.actionId; + base.actionDescription = it.actionDescription; + base.actionTool = it.actionTool; + base.toolArgs = it.toolArgs; + } + if (it.type === "artifact") { + base.artifactTitle = it.artifactTitle; + base.artifactContent = it.artifactContent; + base.artifactIsHtml = it.artifactIsHtml; + } + return base; + }); }, []); /* 防抖保存 */ @@ -214,7 +231,13 @@ export function AgentSessionProvider({ children }: { children: React.ReactNode } const lastIdx = copy.length - 1; if (lastIdx < 0) { if (pendingText) { - copy.push({ id: `asst_${uid()}`, type: "assistant" as const, content: pendingText, streaming: false, timestamp: new Date() }); + copy.push({ + id: `asst_${uid()}`, + type: "assistant" as const, + content: pendingText, + streaming: false, + timestamp: new Date(), + }); } return; } @@ -222,7 +245,13 @@ export function AgentSessionProvider({ children }: { children: React.ReactNode } if (last.type === "assistant" && last.streaming) { copy[lastIdx] = { ...last, content: last.content + pendingText, streaming: false }; } else if (pendingText) { - copy.push({ id: `asst_${uid()}`, type: "assistant" as const, content: pendingText, streaming: false, timestamp: new Date() }); + copy.push({ + id: `asst_${uid()}`, + type: "assistant" as const, + content: pendingText, + streaming: false, + timestamp: new Date(), + }); } }; @@ -253,7 +282,16 @@ export function AgentSessionProvider({ children }: { children: React.ReactNode } copy[copy.length - 1] = { ...last, steps }; return copy; } - return [...copy, { id, type: "step_group" as const, content: "", steps: [{ id: stepId, status: "running", toolName, toolArgs }], timestamp: new Date() }]; + return [ + ...copy, + { + id, + type: "step_group" as const, + content: "", + steps: [{ id: stepId, status: "running", toolName, toolArgs }], + timestamp: new Date(), + }, + ]; }); break; } @@ -271,7 +309,12 @@ export function AgentSessionProvider({ children }: { children: React.ReactNode } ? steps.findIndex((s) => s.id === progId) : steps.findIndex((s) => s.status === "running"); if (idx >= 0) { - steps[idx] = { ...steps[idx], progressMessage: progMsg, progressCurrent: progCur, progressTotal: progTotal }; + steps[idx] = { + ...steps[idx], + progressMessage: progMsg, + progressCurrent: progCur, + progressTotal: progTotal, + }; copy[i] = { ...copy[i], steps }; return copy; } @@ -293,7 +336,13 @@ export function AgentSessionProvider({ children }: { children: React.ReactNode } ? steps.findIndex((s) => s.id === toolId) : steps.findIndex((s) => s.toolName === toolName && s.status === "running"); if (idx >= 0) { - steps[idx] = { ...steps[idx], status: (data.success as boolean) ? "done" : "error", success: data.success as boolean, summary: data.summary as string, data: data.data as Record }; + steps[idx] = { + ...steps[idx], + status: (data.success as boolean) ? "done" : "error", + success: data.success as boolean, + summary: data.summary as string, + data: data.data as Record, + }; copy[i] = { ...copy[i], steps }; return copy; } @@ -307,21 +356,35 @@ export function AgentSessionProvider({ children }: { children: React.ReactNode } // HTML 类 artifact(简报等) const artTitle = String(d.title || "Daily Brief"); const artContent = String(d.html); - setItems((prev) => [...prev, { - id: `art_${uid()}`, type: "artifact" as const, content: "", - artifactTitle: artTitle, artifactContent: artContent, artifactIsHtml: true, - timestamp: new Date(), - }]); + setItems((prev) => [ + ...prev, + { + id: `art_${uid()}`, + type: "artifact" as const, + content: "", + artifactTitle: artTitle, + artifactContent: artContent, + artifactIsHtml: true, + timestamp: new Date(), + }, + ]); setCanvas({ title: artTitle, markdown: artContent, isHtml: true }); } else if (d.markdown) { // Markdown 类 artifact(Wiki、RAG 问答报告等) const artTitle = String(d.title || "报告"); const artContent = String(d.markdown); - setItems((prev) => [...prev, { - id: `art_${uid()}`, type: "artifact" as const, content: "", - artifactTitle: artTitle, artifactContent: artContent, artifactIsHtml: false, - timestamp: new Date(), - }]); + setItems((prev) => [ + ...prev, + { + id: `art_${uid()}`, + type: "artifact" as const, + content: "", + artifactTitle: artTitle, + artifactContent: artContent, + artifactIsHtml: false, + timestamp: new Date(), + }, + ]); setCanvas({ title: artTitle, markdown: artContent }); } } @@ -334,7 +397,19 @@ export function AgentSessionProvider({ children }: { children: React.ReactNode } setItems((prev) => { const copy = [...prev]; applyPendingText(copy, pending); - return [...copy, { id, type: "action_confirm" as const, content: "", actionId, actionDescription: data.description as string, actionTool: data.tool as string, toolArgs: data.args as Record, timestamp: new Date() }]; + return [ + ...copy, + { + id, + type: "action_confirm" as const, + content: "", + actionId, + actionDescription: data.description as string, + actionTool: data.tool as string, + toolArgs: data.args as Record, + timestamp: new Date(), + }, + ]; }); setLoading(false); break; @@ -348,7 +423,13 @@ export function AgentSessionProvider({ children }: { children: React.ReactNode } const steps = [...copy[i].steps!]; const running = steps.findIndex((s) => s.status === "running"); if (running >= 0) { - steps[running] = { ...steps[running], status: (data.success as boolean) ? "done" : "error", success: data.success as boolean, summary: data.summary as string, data: data.data as Record }; + steps[running] = { + ...steps[running], + status: (data.success as boolean) ? "done" : "error", + success: data.success as boolean, + summary: data.summary as string, + data: data.data as Record, + }; copy[i] = { ...copy[i], steps }; return copy; } @@ -357,31 +438,70 @@ export function AgentSessionProvider({ children }: { children: React.ReactNode } const last = copy[copy.length - 1]; if (last && last.type === "step_group") { const steps = [...(last.steps || [])]; - steps.push({ id: arId, status: ((data.success as boolean) ? "done" : "error") as "done" | "error", toolName: "操作执行", success: data.success as boolean, summary: data.summary as string, data: data.data as Record }); + steps.push({ + id: arId, + status: ((data.success as boolean) ? "done" : "error") as "done" | "error", + toolName: "操作执行", + success: data.success as boolean, + summary: data.summary as string, + data: data.data as Record, + }); copy[copy.length - 1] = { ...last, steps }; return copy; } - return [...prev, { id: `sg_${uid()}`, type: "step_group" as const, content: "", steps: [{ id: arId, status: ((data.success as boolean) ? "done" : "error") as "done" | "error", toolName: "操作执行", success: data.success as boolean, summary: data.summary as string, data: data.data as Record }], timestamp: new Date() }]; + return [ + ...prev, + { + id: `sg_${uid()}`, + type: "step_group" as const, + content: "", + steps: [ + { + id: arId, + status: ((data.success as boolean) ? "done" : "error") as "done" | "error", + toolName: "操作执行", + success: data.success as boolean, + summary: data.summary as string, + data: data.data as Record, + }, + ], + timestamp: new Date(), + }, + ]; }); if (data.success && data.data) { const d = data.data as Record; if (d.markdown) { const artTitle = String(d.title || "Wiki"); const artContent = String(d.markdown); - setItems((prev) => [...prev, { - id: `art_${uid()}`, type: "artifact" as const, content: "", - artifactTitle: artTitle, artifactContent: artContent, artifactIsHtml: false, - timestamp: new Date(), - }]); + setItems((prev) => [ + ...prev, + { + id: `art_${uid()}`, + type: "artifact" as const, + content: "", + artifactTitle: artTitle, + artifactContent: artContent, + artifactIsHtml: false, + timestamp: new Date(), + }, + ]); setCanvas({ title: artTitle, markdown: artContent }); } else if (d.html) { const artTitle = String(d.title || "Daily Brief"); const artContent = String(d.html); - setItems((prev) => [...prev, { - id: `art_${uid()}`, type: "artifact" as const, content: "", - artifactTitle: artTitle, artifactContent: artContent, artifactIsHtml: true, - timestamp: new Date(), - }]); + setItems((prev) => [ + ...prev, + { + id: `art_${uid()}`, + type: "artifact" as const, + content: "", + artifactTitle: artTitle, + artifactContent: artContent, + artifactIsHtml: true, + timestamp: new Date(), + }, + ]); setCanvas({ title: artTitle, markdown: artContent, isHtml: true }); } } @@ -392,7 +512,15 @@ export function AgentSessionProvider({ children }: { children: React.ReactNode } setItems((prev) => { const copy = [...prev]; applyPendingText(copy, pending); - return [...copy, { id, type: "error" as const, content: (data.message as string) || "未知错误", timestamp: new Date() }]; + return [ + ...copy, + { + id, + type: "error" as const, + content: (data.message as string) || "未知错误", + timestamp: new Date(), + }, + ]; }); break; } @@ -404,11 +532,20 @@ export function AgentSessionProvider({ children }: { children: React.ReactNode } return prev.map((item) => item.type === "assistant" && item.streaming ? { ...item, content: item.content + pending, streaming: false } - : item, + : item ); } if (pending) { - return [...prev, { id: `asst_done_${uid()}`, type: "assistant" as const, content: pending, streaming: false, timestamp: new Date() }]; + return [ + ...prev, + { + id: `asst_done_${uid()}`, + type: "assistant" as const, + content: pending, + streaming: false, + timestamp: new Date(), + }, + ]; } return prev; }); @@ -417,7 +554,7 @@ export function AgentSessionProvider({ children }: { children: React.ReactNode } } } }, - [scheduleFlush, drainBuffer], + [scheduleFlush, drainBuffer] ); /** @@ -437,10 +574,20 @@ export function AgentSessionProvider({ children }: { children: React.ReactNode } return prev.map((item) => item.type === "assistant" && item.streaming ? { ...item, content: item.content + pending, streaming: false } - : item, + : item ); } - if (pending) return [...prev, { id: `asst_fallback_${uid()}`, type: "assistant" as const, content: pending, streaming: false, timestamp: new Date() }]; + if (pending) + return [ + ...prev, + { + id: `asst_fallback_${uid()}`, + type: "assistant" as const, + content: pending, + streaming: false, + timestamp: new Date(), + }, + ]; return prev; }); } @@ -455,7 +602,7 @@ export function AgentSessionProvider({ children }: { children: React.ReactNode } }); } }, - [processSSE, drainBuffer], + [processSSE, drainBuffer] ); /* ---- 发送消息 ---- */ @@ -474,7 +621,10 @@ export function AgentSessionProvider({ children }: { children: React.ReactNode } convId = createConversation(); // 返回新创建的 ID } setLoading(true); - setItems((prev) => [...prev, { id: `user_${uid()}`, type: "user" as const, content: text.trim(), timestamp: new Date() }]); + setItems((prev) => [ + ...prev, + { id: `user_${uid()}`, type: "user" as const, content: text.trim(), timestamp: new Date() }, + ]); // 使用 ref 获取最新 items,避免闭包过时 const currentItems = itemsRef.current; const msgs: AgentMessage[] = []; @@ -492,9 +642,15 @@ export function AgentSessionProvider({ children }: { children: React.ReactNode } msgs.push({ role: "assistant", content: `执行了以下操作:\n${summaries}` }); } } else if (it.type === "action_confirm") { - msgs.push({ role: "assistant", content: `[等待确认] ${it.actionDescription || it.actionTool || ""}` }); + msgs.push({ + role: "assistant", + content: `[等待确认] ${it.actionDescription || it.actionTool || ""}`, + }); } else if (it.type === "artifact") { - msgs.push({ role: "assistant", content: `[已生成内容: ${it.artifactTitle || "未命名"}]\n${(it.artifactContent || "").slice(0, 500)}` }); + msgs.push({ + role: "assistant", + content: `[已生成内容: ${it.artifactTitle || "未命名"}]\n${it.artifactContent || ""}`, + }); } else if (it.type === "error") { msgs.push({ role: "assistant", content: `[错误: ${it.content}]` }); } @@ -506,24 +662,44 @@ export function AgentSessionProvider({ children }: { children: React.ReactNode } // 传递 conversationId 给后端 const resp = await agentApi.chat(msgs, convId); if (!resp.body) { - setItems((p) => [...p, { id: `e_${uid()}`, type: "error" as const, content: "无响应流", timestamp: new Date() }]); + setItems((p) => [ + ...p, + { + id: `e_${uid()}`, + type: "error" as const, + content: "无响应流", + timestamp: new Date(), + }, + ]); setLoading(false); return; } startStream(resp.body.getReader(), ac.signal); } catch (err) { - setItems((p) => [...p, { id: `e_${uid()}`, type: "error" as const, content: err instanceof Error ? err.message : "请求失败", timestamp: new Date() }]); + setItems((p) => [ + ...p, + { + id: `e_${uid()}`, + type: "error" as const, + content: err instanceof Error ? err.message : "请求失败", + timestamp: new Date(), + }, + ]); setLoading(false); } }, - [loading, pendingActions, cancelStream, createConversation, startStream], + [loading, pendingActions, cancelStream, createConversation, startStream] ); /* ---- 确认/拒绝操作 ---- */ const handleConfirm = useCallback( async (actionId: string) => { setConfirmingActions((prev) => new Set(prev).add(actionId)); - setPendingActions((prev) => { const n = new Set(prev); n.delete(actionId); return n; }); + setPendingActions((prev) => { + const n = new Set(prev); + n.delete(actionId); + return n; + }); cancelStream(); setLoading(true); try { @@ -533,18 +709,34 @@ export function AgentSessionProvider({ children }: { children: React.ReactNode } if (resp.body) startStream(resp.body.getReader(), ac.signal); else setLoading(false); } catch (err) { - setItems((p) => [...p, { id: `e_${uid()}`, type: "error" as const, content: err instanceof Error ? err.message : "确认失败", timestamp: new Date() }]); + setItems((p) => [ + ...p, + { + id: `e_${uid()}`, + type: "error" as const, + content: err instanceof Error ? err.message : "确认失败", + timestamp: new Date(), + }, + ]); setLoading(false); } finally { - setConfirmingActions((prev) => { const n = new Set(prev); n.delete(actionId); return n; }); + setConfirmingActions((prev) => { + const n = new Set(prev); + n.delete(actionId); + return n; + }); } }, - [startStream, cancelStream], + [startStream, cancelStream] ); const handleReject = useCallback( async (actionId: string) => { - setPendingActions((prev) => { const n = new Set(prev); n.delete(actionId); return n; }); + setPendingActions((prev) => { + const n = new Set(prev); + n.delete(actionId); + return n; + }); cancelStream(); setLoading(true); try { @@ -557,11 +749,19 @@ export function AgentSessionProvider({ children }: { children: React.ReactNode } setLoading(false); } } catch (err) { - setItems((p) => [...p, { id: `e_${uid()}`, type: "error" as const, content: err instanceof Error ? err.message : "拒绝操作失败", timestamp: new Date() }]); + setItems((p) => [ + ...p, + { + id: `e_${uid()}`, + type: "error" as const, + content: err instanceof Error ? err.message : "拒绝操作失败", + timestamp: new Date(), + }, + ]); setLoading(false); } }, - [startStream, cancelStream], + [startStream, cancelStream] ); const hasPendingConfirm = pendingActions.size > 0; @@ -574,16 +774,39 @@ export function AgentSessionProvider({ children }: { children: React.ReactNode } prev.map((item) => item.type === "assistant" && item.streaming ? { ...item, content: item.content + pending, streaming: false } - : item, - ), + : item + ) ); }, [cancelStream, drainBuffer]); - const value: AgentSessionCtx = useMemo(() => ({ - items, loading, pendingActions, confirmingActions, canvas, hasPendingConfirm, - setCanvas, sendMessage, handleConfirm, handleReject, stopGeneration, - }), [items, loading, pendingActions, confirmingActions, canvas, hasPendingConfirm, - setCanvas, sendMessage, handleConfirm, handleReject, stopGeneration]); + const value: AgentSessionCtx = useMemo( + () => ({ + items, + loading, + pendingActions, + confirmingActions, + canvas, + hasPendingConfirm, + setCanvas, + sendMessage, + handleConfirm, + handleReject, + stopGeneration, + }), + [ + items, + loading, + pendingActions, + confirmingActions, + canvas, + hasPendingConfirm, + setCanvas, + sendMessage, + handleConfirm, + handleReject, + stopGeneration, + ] + ); return {children}; } diff --git a/frontend/src/hooks/useMessageHistory.ts b/frontend/src/hooks/useMessageHistory.ts index fb8507e..cdfa72a 100644 --- a/frontend/src/hooks/useMessageHistory.ts +++ b/frontend/src/hooks/useMessageHistory.ts @@ -31,8 +31,7 @@ export function useMessageHistory() { const summaries = item.steps .filter((s) => s.status === "done" || s.status === "error") .map( - (s) => - `[工具: ${s.toolName}] ${s.success ? "成功" : "失败"}: ${s.summary || ""}`, + (s) => `[工具: ${s.toolName}] ${s.success ? "成功" : "失败"}: ${s.summary || ""}` ) .join("\n"); if (summaries) { @@ -54,7 +53,7 @@ export function useMessageHistory() { case "artifact": messages.push({ role: "assistant", - content: `[已生成内容: ${item.artifactTitle || "未命名"}]\n${(item.artifactContent || "").slice(0, 500)}`, + content: `[已生成内容: ${item.artifactTitle || "未命名"}]\n${item.artifactContent || ""}`, }); break; diff --git a/frontend/src/lib/tauri.ts b/frontend/src/lib/tauri.ts index d984600..5395763 100644 --- a/frontend/src/lib/tauri.ts +++ b/frontend/src/lib/tauri.ts @@ -104,18 +104,18 @@ export function resolveApiBase(): string { if (!isTauri()) { // 优先级:VITE_API_BASE > 环境变量推断 > 默认值 if (import.meta.env.VITE_API_BASE) return import.meta.env.VITE_API_BASE; - + // Docker 环境:使用相对路径(Nginx 反向代理) if (import.meta.env.DEV) { // 开发环境:localhost return "http://localhost:8000"; } - + // 生产环境:使用相对路径,由 Nginx 代理 // Docker 中前端访问后端不需要完整 URL return "/api"; } - + // Tauri 桌面环境 if (_resolvedPort) { return `http://127.0.0.1:${_resolvedPort}`; diff --git a/frontend/src/pages/Agent.tsx b/frontend/src/pages/Agent.tsx index 41479e5..462ca40 100644 --- a/frontend/src/pages/Agent.tsx +++ b/frontend/src/pages/Agent.tsx @@ -40,6 +40,9 @@ import { import { useAgentSession, type ChatItem, type StepItem } from "@/contexts/AgentSessionContext"; import { todayApi } from "@/services/api"; import type { TodaySummary } from "@/types"; +import { ActionConfirmCard } from "./AgentSteps"; +import { UserMessage, AssistantMessage, StepGroupCard, StepRow } from "./AgentMessages"; +import { ChatNavBar } from "./ChatNavBar"; /* ========== 能力芯片(输入框上方始终显示) ========== */ @@ -313,6 +316,9 @@ export default function Agent() { )} + {/* 对话导航条 - 固定在右侧 */} + + {/* 输入区域 */}
@@ -587,7 +593,7 @@ const ChatBlock = memo(function ChatBlock({ }) { switch (item.type) { case "user": - return ; + return ; case "assistant": return ; case "step_group": @@ -627,223 +633,6 @@ const ChatBlock = memo(function ChatBlock({ } }); -/** - * 用户消息 - Claude 风格:无头像,右对齐浅色气泡 - */ -const UserMessage = memo(function UserMessage({ content }: { content: string }) { - return ( -
-
- {content} -
-
- ); -}); - -/** - * Assistant 消息 - Claude 风格:无头像,无气泡背景,纯文字流 - */ -const AssistantMessage = memo(function AssistantMessage({ - content, - streaming, -}: { - content: string; - streaming: boolean; -}) { - const [copied, setCopied] = useState(false); - - const handleCopy = useCallback(() => { - navigator.clipboard.writeText(content).then(() => { - setCopied(true); - setTimeout(() => setCopied(false), 1500); - }); - }, [content]); - - return ( -
- {streaming ? ( -

- {content} - -

- ) : ( - <> -
- }> - {content} - -
-
- -
- - )} -
- ); -}); - -/* ========== 步骤组 ========== */ - -const StepGroupCard = memo(function StepGroupCard({ steps }: { steps: StepItem[] }) { - return ( -
-
-
- - 执行步骤 - - {steps.filter((s) => s.status === "done").length}/{steps.length} - -
-
- {steps.map((step, idx) => ( - - ))} -
-
-
- ); -}); - -function StepRow({ step }: { step: StepItem }) { - const isIngest = step.toolName === "ingest_arxiv"; - const autoExpand = isIngest && step.status === "running"; - const [expanded, setExpanded] = useState(false); - const meta = getToolMeta(step.toolName); - const Icon = meta.icon; - const hasData = step.data && Object.keys(step.data).length > 0; - const hasProgress = step.status === "running" && step.progressTotal && step.progressTotal > 0; - const progressPct = hasProgress - ? Math.round(((step.progressCurrent || 0) / step.progressTotal!) * 100) - : 0; - const showExpanded = expanded || autoExpand; - - const statusIcon = - step.status === "running" ? ( - - ) : step.status === "done" ? ( - - ) : ( - - ); - - return ( -
- - - {/* 入库进度面板 - 独立的可视化区域 */} - {isIngest && hasProgress && ( -
-
-
- - - - - - {progressPct}% - -
-
-

{step.progressMessage}

-

- {step.progressCurrent ?? 0} / {step.progressTotal ?? 0} 篇 -

-
- -
-
-
-
-
- )} - - {/* 非入库工具的简单进度条 */} - {!isIngest && hasProgress && ( -
-
-
- )} - - {showExpanded && step.data && ( -
- -
- )} -
- ); -} - /** * 论文列表卡片(search_papers / search_arxiv 共用) */ @@ -1004,636 +793,6 @@ const IngestResultView = memo(function IngestResultView({ ); }); -/* ========== arXiv 候选论文选择器 ========== */ - -const QUERY_TO_CATEGORIES: Record = { - graphics: ["cs.GR"], - rendering: ["cs.GR", "cs.CV"], - vision: ["cs.CV"], - nlp: ["cs.CL"], - language: ["cs.CL"], - robot: ["cs.RO"], - learning: ["cs.LG", "cs.AI"], - neural: ["cs.LG", "cs.CV", "cs.AI"], - "3d": ["cs.GR", "cs.CV"], - image: ["cs.CV"], - audio: ["cs.SD", "eess.AS"], - speech: ["cs.CL", "cs.SD"], - security: ["cs.CR"], - network: ["cs.NI"], - database: ["cs.DB"], - attention: ["cs.LG", "cs.CL"], - transformer: ["cs.LG", "cs.CL"], - diffusion: ["cs.CV", "cs.LG"], - gaussian: ["cs.GR", "cs.CV"], - nerf: ["cs.GR", "cs.CV"], - reconstruction: ["cs.GR", "cs.CV"], - detection: ["cs.CV"], - segmentation: ["cs.CV"], - generation: ["cs.CV", "cs.LG"], - llm: ["cs.CL", "cs.AI"], - agent: ["cs.AI", "cs.CL"], - rl: ["cs.LG", "cs.AI"], - reinforcement: ["cs.LG", "cs.AI"], - optimization: ["math.OC", "cs.LG"], -}; - -function inferRelevantCategories(query: string): Set { - const qLower = query.toLowerCase(); - const cats = new Set(); - for (const [kw, kwCats] of Object.entries(QUERY_TO_CATEGORIES)) { - if (qLower.includes(kw)) kwCats.forEach((c) => cats.add(c)); - } - return cats; -} - -function isRelevantCandidate(cats: string[], relevantCats: Set): boolean { - if (relevantCats.size === 0) return true; - return cats.some((c) => relevantCats.has(c)); -} - -function ArxivCandidateSelector({ - candidates, - query, -}: { - candidates: Array>; - query: string; -}) { - const { sendMessage, loading } = useAgentSession(); - const relevantCats = inferRelevantCategories(query); - - const [selected, setSelected] = useState>(() => { - if (relevantCats.size === 0) return new Set(candidates.map((c) => String(c.arxiv_id ?? ""))); - const relevant = new Set(); - for (const c of candidates) { - const cats = Array.isArray(c.categories) ? (c.categories as string[]) : []; - if (isRelevantCandidate(cats, relevantCats)) relevant.add(String(c.arxiv_id ?? "")); - } - return relevant.size > 0 ? relevant : new Set(candidates.map((c) => String(c.arxiv_id ?? ""))); - }); - const [submitted, setSubmitted] = useState(false); - const allSelected = selected.size === candidates.length; - const relevantCount = - relevantCats.size > 0 - ? candidates.filter((c) => - isRelevantCandidate( - Array.isArray(c.categories) ? (c.categories as string[]) : [], - relevantCats - ) - ).length - : candidates.length; - - const toggle = (id: string) => { - setSelected((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); - }; - - const selectRelevant = () => { - const relevant = new Set(); - for (const c of candidates) { - const cats = Array.isArray(c.categories) ? (c.categories as string[]) : []; - if (isRelevantCandidate(cats, relevantCats)) relevant.add(String(c.arxiv_id ?? "")); - } - setSelected(relevant); - }; - - const handleSubmit = () => { - if (selected.size === 0 || submitted) return; - setSubmitted(true); - const ids = Array.from(selected).join(", "); - sendMessage(`请将以下论文入库:${ids}`).catch(() => { - setSubmitted(false); - }); - }; - - return ( -
-
-

- {candidates.length} 篇候选论文 - {relevantCats.size > 0 && relevantCount < candidates.length && ( - ({relevantCount} 篇高相关) - )} -

-
- {relevantCats.size > 0 && relevantCount < candidates.length && ( - - )} - - - 已选 {selected.size}/{candidates.length} - -
-
-
- {candidates.map((p, i) => { - const aid = String(p.arxiv_id ?? ""); - const isChecked = selected.has(aid); - const cats = Array.isArray(p.categories) ? (p.categories as string[]) : []; - const isRelevant = isRelevantCandidate(cats, relevantCats); - return ( - - ); - })} -
- {!submitted ? ( - - ) : ( -
- - 已发送请求,等待确认后开始入库… -
- )} -
- ); -} - -const StepDataView = memo(function StepDataView({ - data, - toolName, -}: { - data: Record; - toolName: string; -}) { - const navigate = useNavigate(); - - if (toolName === "search_papers" && Array.isArray(data.papers)) { - return ( - >} - label={`找到 ${(data.papers as unknown[]).length} 篇论文`} - /> - ); - } - if (toolName === "search_arxiv" && Array.isArray(data.candidates)) { - return ( - >} - query={String(data.query ?? "")} - /> - ); - } - if (toolName === "ingest_arxiv" && data.total !== undefined) { - return ; - } - if (toolName === "get_system_status") { - return ( -
- {[ - { label: "论文", value: data.paper_count, color: "text-primary" }, - { label: "已向量化", value: data.embedded_count, color: "text-success" }, - { label: "主题", value: data.topic_count, color: "text-blue-600 dark:text-blue-400" }, - ].map((s) => ( -
- {String(s.value ?? 0)} - {s.label} -
- ))} -
- ); - } - /* ask_knowledge_base — Markdown 答案 + 引用论文 */ - if (toolName === "ask_knowledge_base" && data.markdown) { - const evidence = Array.isArray(data.evidence) - ? (data.evidence as Array>) - : []; - const rounds = data.rounds as number | undefined; - return ( -
- {rounds && rounds > 1 && ( -
- - 经过 {rounds} 轮迭代检索优化 -
- )} -
- }> - {String(data.markdown)} - -
- {evidence.length > 0 && ( -
-

- 引用 {evidence.length} 篇论文 -

-
- {evidence.slice(0, 8).map((e, i) => ( - - ))} -
-
- )} -
- ); - } - /* list_topics — 主题列表 */ - if (toolName === "list_topics" && Array.isArray(data.topics)) { - const topics = data.topics as Array>; - return ( -
- {topics.map((t, i) => ( -
- - {String(t.name ?? "")} - {t.paper_count !== undefined && ( - {String(t.paper_count)} 篇 - )} - {t.enabled !== undefined && ( - - {t.enabled ? "已订阅" : "未订阅"} - - )} -
- ))} -
- ); - } - /* get_timeline — 时间线 */ - if (toolName === "get_timeline" && Array.isArray(data.timeline)) { - const items = data.timeline as Array>; - return ( -
- {items.map((p, i) => ( - - ))} -
- ); - } - /* get_similar_papers — 相似论文 */ - if (toolName === "get_similar_papers") { - const items = Array.isArray(data.items) ? (data.items as Array>) : []; - const ids = Array.isArray(data.similar_ids) ? (data.similar_ids as string[]) : []; - if (items.length > 0) { - return ( -
- {items.map((p, i) => ( - - ))} -
- ); - } - if (ids.length > 0) { - return

找到 {ids.length} 篇相似论文

; - } - } - /* get_citation_tree — 引用树统计 */ - if (toolName === "get_citation_tree" && data.nodes) { - const nodes = Array.isArray(data.nodes) ? data.nodes.length : 0; - const edges = Array.isArray(data.edges) ? data.edges.length : 0; - return ( -
- {nodes} 个节点 - {edges} 条引用关系 -
- ); - } - /* suggest_keywords — 关键词建议 */ - if (toolName === "suggest_keywords" && Array.isArray(data.suggestions)) { - const suggestions = data.suggestions as Array>; - return ( -
- {suggestions.map((s, i) => ( -
-

{String(s.name ?? "")}

-

{String(s.query ?? "")}

- {s.reason !== undefined && ( -

{String(s.reason)}

- )} -
- ))} -
- ); - } - /* skim_paper / deep_read_paper — 报告摘要 */ - if ((toolName === "skim_paper" || toolName === "deep_read_paper") && data.one_liner) { - return ( -
-

{String(data.one_liner)}

- {data.novelty !== undefined && ( -

- 创新点: {String(data.novelty)} -

- )} - {data.methodology !== undefined && ( -

- 方法: {String(data.methodology)} -

- )} -
- ); - } - /* reasoning_analysis — 推理链 */ - if (toolName === "reasoning_analysis" && data.reasoning_steps) { - const steps = Array.isArray(data.reasoning_steps) - ? (data.reasoning_steps as Array>) - : []; - return ( -
- {steps.slice(0, 6).map((s, i) => ( -
-

- {String(s.step_name ?? s.claim ?? `步骤 ${i + 1}`)} -

- {s.evidence !== undefined && ( -

{String(s.evidence)}

- )} -
- ))} -
- ); - } - /* analyze_figures — 图表列表 */ - if (toolName === "analyze_figures" && Array.isArray(data.figures)) { - const figs = data.figures as Array>; - return ( -
- {figs.map((f, i) => ( -
-

- {String(f.figure_type ?? "图表")} — p.{String(f.page ?? "?")} -

-

- {String(f.description ?? f.analysis ?? "")} -

-
- ))} -
- ); - } - /* identify_research_gaps — 研究空白 */ - if (toolName === "identify_research_gaps" && data.analysis) { - const analysis = data.analysis as Record; - const gaps = Array.isArray(analysis.research_gaps) - ? (analysis.research_gaps as Array>) - : []; - return ( -
- {gaps.slice(0, 5).map((g, i) => ( -
-

- {String(g.gap_title ?? g.title ?? `空白 ${i + 1}`)} -

-

- {String(g.description ?? g.evidence ?? "")} -

-
- ))} -
- ); - } - /* get_paper_detail — 论文详情卡片 */ - if (toolName === "get_paper_detail" && data.title) { - return ( -
- - {data.abstract_zh !== undefined && ( -

{String(data.abstract_zh)}

- )} -
- {data.arxiv_id ? {String(data.arxiv_id)} : null} - {data.read_status ? {String(data.read_status)} : null} -
-
- ); - } - /* writing_assist — 写作助手结果 */ - if (toolName === "writing_assist" && data.content) { - return ( -
- }> - {String(data.content)} - -
- ); - } - /* 兜底:原始 JSON */ - return ( -
-      {JSON.stringify(data, null, 2)}
-    
- ); -}); - -/* ========== 确认卡片 ========== */ - -const ActionConfirmCard = memo(function ActionConfirmCard({ - actionId, - description, - tool, - args, - isPending, - isConfirming, - onConfirm, - onReject, -}: { - actionId: string; - description: string; - tool: string; - args?: Record; - isPending: boolean; - isConfirming: boolean; - onConfirm: (id: string) => void; - onReject: (id: string) => void; -}) { - const meta = getToolMeta(tool); - const Icon = meta.icon; - return ( -
-
-
- - - {isPending ? "⚠️ 需要你的确认" : "已处理"} - -
-
-
-
- -
-
-

{description}

- {args && Object.keys(args).length > 0 && ( -
- {Object.entries(args).map(([k, v]) => ( -
- {k}: - - {typeof v === "string" ? v : JSON.stringify(v)} - -
- ))} -
- )} -
-
- {isPending && ( -
- - -
- )} - {!isPending && ( -
- - 已处理 -
- )} -
-
-
- ); -}); - const ErrorCard = memo(function ErrorCard({ content, onRetry, diff --git a/frontend/src/pages/AgentMessages.tsx b/frontend/src/pages/AgentMessages.tsx new file mode 100644 index 0000000..e1464ce --- /dev/null +++ b/frontend/src/pages/AgentMessages.tsx @@ -0,0 +1,231 @@ +import { memo, useState, useCallback, useRef, useEffect, useMemo } from "react"; +import { useNavigate } from "react-router-dom"; +import { cn } from "@/lib/utils"; +import { CheckCircle2, XCircle, Loader2, Play, ChevronDown, ChevronRight } from "lucide-react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import remarkMath from "remark-math"; +import rehypeKatex from "rehype-katex"; +import "katex/dist/katex.min.css"; +import { type StepItem } from "@/contexts/AgentSessionContext"; +import { getToolMeta, StepDataView } from "./AgentSteps"; + +const UserMessage = memo(function UserMessage({ + content, + messageId, +}: { + content: string; + messageId?: string; +}) { + return ( +
+
+ {content} +
+
+ ); +}); + +const AssistantMessage = memo(function AssistantMessage({ + content, + streaming, +}: { + content: string; + streaming: boolean; +}) { + const [copied, setCopied] = useState(false); + const contentRef = useRef(content); + + useEffect(() => { + contentRef.current = content; + }, [content]); + + const handleCopy = useCallback(() => { + navigator.clipboard.writeText(contentRef.current).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }); + }, []); + + const markdownKey = streaming ? content : `static_${content}`; + const markdownContent = useMemo(() => content, [markdownKey]); + + return ( +
+
+ + {markdownContent} + +
+ {streaming && ( + + )} + {!streaming && ( +
+ +
+ )} +
+ ); +}); + +const StepGroupCard = memo(function StepGroupCard({ steps }: { steps: StepItem[] }) { + return ( +
+
+
+ + 执行步骤 + + {steps.filter((s) => s.status === "done").length}/{steps.length} + +
+
+ {steps.map((step, idx) => ( + + ))} +
+
+
+ ); +}); + +function StepRow({ step }: { step: StepItem }) { + const isIngest = step.toolName === "ingest_arxiv"; + const autoExpand = isIngest && step.status === "running"; + const [expanded, setExpanded] = useState(false); + const meta = getToolMeta(step.toolName); + const Icon = meta.icon; + const hasData = step.data && Object.keys(step.data).length > 0; + const hasProgress = step.status === "running" && step.progressTotal && step.progressTotal > 0; + const progressPct = hasProgress + ? Math.round(((step.progressCurrent || 0) / step.progressTotal!) * 100) + : 0; + const showExpanded = expanded || autoExpand; + + const statusIcon = + step.status === "running" ? ( + + ) : step.status === "done" ? ( + + ) : ( + + ); + + return ( +
+ + + {isIngest && hasProgress && ( +
+
+
+ + + + + + {progressPct}% + +
+
+

{step.progressMessage}

+

+ {step.progressCurrent ?? 0} / {step.progressTotal ?? 0} 篇 +

+
+ +
+
+
+
+
+ )} + + {!isIngest && hasProgress && ( +
+
+
+ )} + + {showExpanded && step.data && ( +
+ +
+ )} +
+ ); +} + +export { UserMessage, AssistantMessage, StepGroupCard, StepRow }; diff --git a/frontend/src/pages/AgentSteps.tsx b/frontend/src/pages/AgentSteps.tsx new file mode 100644 index 0000000..3b33b85 --- /dev/null +++ b/frontend/src/pages/AgentSteps.tsx @@ -0,0 +1,594 @@ +import { memo, useState, Suspense } from "react"; +import { useNavigate } from "react-router-dom"; +import { cn } from "@/lib/utils"; +import { + CheckCircle2, + XCircle, + Loader2, + Download, + Search, + BookOpen, + FileText, + Brain, + Newspaper, + Star, + Hash, + TrendingUp, + AlertTriangle, +} from "lucide-react"; +import { useAgentSession, type StepItem } from "@/contexts/AgentSessionContext"; +import { lazy } from "react"; +const Markdown = lazy(() => import("@/components/Markdown")); + +interface ToolMeta { + icon: typeof Search; + label: string; +} + +function getToolMeta(name: string): ToolMeta { + const META: Record = { + search_arxiv: { icon: Search, label: "搜索 arXiv" }, + search_papers: { icon: Search, label: "搜索论文库" }, + ingest_arxiv: { icon: Download, label: "下载入库" }, + get_system_status: { icon: Brain, label: "系统状态" }, + list_topics: { icon: Hash, label: "主题列表" }, + get_timeline: { icon: TrendingUp, label: "时间线" }, + get_similar_papers: { icon: Star, label: "相似论文" }, + get_citation_tree: { icon: BookOpen, label: "引用树" }, + suggest_keywords: { icon: Hash, label: "关键词建议" }, + ask_knowledge_base: { icon: Brain, label: "知识库问答" }, + skim_paper: { icon: FileText, label: "粗读论文" }, + deep_read_paper: { icon: BookOpen, label: "精读论文" }, + reasoning_analysis: { icon: Brain, label: "推理链分析" }, + analyze_figures: { icon: FileText, label: "图表解读" }, + identify_research_gaps: { icon: Search, label: "研究空白" }, + get_paper_detail: { icon: FileText, label: "论文详情" }, + writing_assist: { icon: Newspaper, label: "写作助手" }, + }; + return META[name] || { icon: FileText, label: name }; +} + +function PaperListView({ papers, label }: { papers: Array>; label: string }) { + const navigate = useNavigate(); + return ( +
+

{label}

+ {papers.slice(0, 5).map((p, i) => ( + + ))} +
+ ); +} + +function IngestResultView({ data }: { data: Record }) { + const navigate = useNavigate(); + return ( +
+
+ 已入库 {String(data.total ?? 0)} 篇 + {data.skipped !== undefined && ({String(data.skipped)} 篇跳过)} +
+ {Array.isArray(data.papers) && (data.papers as Array>).length > 0 && ( +
+ {(data.papers as Array>).slice(0, 3).map((p, i) => ( + + ))} +
+ )} +
+ ); +} + +function inferRelevantCategories(query: string): Set { + const cats: string[] = []; + const LOWER = query.toLowerCase(); + const ALIASES: Record = { + "cs.AI": ["artificial intelligence", "ai", "machine learning", "ml", "deep learning", "neural", "gpt", "llm", "transformer", "reinforcement learning", "nlp", "natural language"], + "cs.CV": ["computer vision", "cv", "image", "video", "object detection", "segmentation", "recognition", "gan", "diffusion", "stable diffusion", "dino"], + "cs.LG": ["machine learning", "ml", "deep learning", "neural network", "representation", "self-supervised", "contrastive"], + "cs.CL": ["computational linguistics", "nlp", "natural language", "text", "language model", "translation", "parsing", "word2vec", "bert", "embedding"], + "cs.IR": ["information retrieval", "search", "ranking", "recommender", "recommendation"], + "cs.KR": ["knowledge representation", "reasoning", "logic", "knowledge graph"], + "cs.Robotics": ["robotics", "robot", "autonomous", "manipulation", "navigation"], + "cs.NE": ["neural evolution", "evolutionary", "genetic algorithm"], + "cs.SE": ["software engineering", "program synthesis", "code generation"], + "cs.CR": ["cryptography", "security", "privacy"], + "cs.CY": ["cybersecurity", "security", "privacy", "attack", "defense"], + "cs.DC": ["distributed computing", "parallel", "grid", "cluster"], + "cs.DS": ["data science", "analytics", "data mining", "big data"], + "cs.DB": ["database", "sql", "nosql", "query"], + "cs.PL": ["programming language", "compiler", "type system", "parser"], + "cs.HCI": ["human computer interaction", "ux", "ui", "interface", "vr", "ar", "virtual reality", "augmented reality"], + "cs.GR": ["graphics", "rendering", "3d", "geometry", "animation"], + "cs.MM": ["multimedia", "video", "audio", "speech"], + "cs.SD": ["sound", "audio", "speech", "music"], + "cs.LO": ["logic", "formal", "theorem proving", "coq", "lean"], + "cs.MA": ["mathematics", "algebra", "geometry", "calculus", "optimization"], + "cs.RO": ["optimization", "operations research", "linear programming", "integer programming"], + "cs.ET": ["emerging technology", "blockchain", "web3", "metaverse"], + "cs.CG": ["computational geometry", "geometry", "mesh", "voronoi"], + "cs.AP": ["applied computing", "e-commerce", "finance", "health", "medicine", "biology", "bioinformatics"], + }; + for (const [cat, kwList] of Object.entries(ALIASES)) { + if (kwList.some((kw) => LOWER.includes(kw))) { + cats.push(cat); + } + } + return new Set(cats); +} + +function isRelevantCandidate(cats: string[], relevantCats: Set): boolean { + if (relevantCats.size === 0) return true; + return cats.some((c) => relevantCats.has(c)); +} + +const ArxivCandidateSelector = memo(function ArxivCandidateSelector({ + candidates, + query, +}: { + candidates: Array>; + query: string; +}) { + const { sendMessage, loading } = useAgentSession(); + const relevantCats = inferRelevantCategories(query); + + const [selected, setSelected] = useState>(() => { + if (relevantCats.size === 0) return new Set(candidates.map((c) => String(c.arxiv_id ?? ""))); + const relevant = new Set(); + for (const c of candidates) { + const cats = Array.isArray(c.categories) ? (c.categories as string[]) : []; + if (isRelevantCandidate(cats, relevantCats)) relevant.add(String(c.arxiv_id ?? "")); + } + return relevant.size > 0 ? relevant : new Set(candidates.map((c) => String(c.arxiv_id ?? ""))); + }); + const [submitted, setSubmitted] = useState(false); + const allSelected = selected.size === candidates.length; + const relevantCount = + relevantCats.size > 0 + ? candidates.filter((c) => isRelevantCandidate(Array.isArray(c.categories) ? (c.categories as string[]) : [], relevantCats)).length + : candidates.length; + + const toggle = (id: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const selectRelevant = () => { + const relevant = new Set(); + for (const c of candidates) { + const cats = Array.isArray(c.categories) ? (c.categories as string[]) : []; + if (isRelevantCandidate(cats, relevantCats)) relevant.add(String(c.arxiv_id ?? "")); + } + setSelected(relevant); + }; + + const handleSubmit = () => { + if (selected.size === 0 || submitted) return; + setSubmitted(true); + const ids = Array.from(selected).join(", "); + sendMessage(`请将以下论文入库:${ids}`).catch(() => { + setSubmitted(false); + }); + }; + + return ( +
+
+

+ {candidates.length} 篇候选论文 + {relevantCats.size > 0 && relevantCount < candidates.length && ( + ({relevantCount} 篇高相关) + )} +

+
+ {relevantCats.size > 0 && relevantCount < candidates.length && ( + + )} + + 已选 {selected.size}/{candidates.length} +
+
+
+ {candidates.map((p, i) => { + const aid = String(p.arxiv_id ?? ""); + const isChecked = selected.has(aid); + const cats = Array.isArray(p.categories) ? (p.categories as string[]) : []; + const isRelevant = isRelevantCandidate(cats, relevantCats); + return ( + + ); + })} +
+ {!submitted ? ( + + ) : ( +
+ + 已发送请求,等待确认后开始入库… +
+ )} +
+ ); +}); + +const StepDataView = memo(function StepDataView({ data, toolName }: { data: Record; toolName: string }) { + const navigate = useNavigate(); + + if (toolName === "search_papers" && Array.isArray(data.papers)) { + return >} label={`找到 ${(data.papers as unknown[]).length} 篇论文`} />; + } + if (toolName === "search_arxiv" && Array.isArray(data.candidates)) { + return >} query={String(data.query ?? "")} />; + } + if (toolName === "ingest_arxiv" && data.total !== undefined) { + return ; + } + if (toolName === "get_system_status") { + return ( +
+ {[ + { label: "论文", value: data.paper_count, color: "text-primary" }, + { label: "已向量化", value: data.embedded_count, color: "text-success" }, + { label: "主题", value: data.topic_count, color: "text-blue-600 dark:text-blue-400" }, + ].map((s) => ( +
+ {String(s.value ?? 0)} + {s.label} +
+ ))} +
+ ); + } + if (toolName === "ask_knowledge_base" && data.markdown) { + const evidence = Array.isArray(data.evidence) ? (data.evidence as Array>) : []; + const rounds = data.rounds as number | undefined; + return ( +
+ {rounds && rounds > 1 && ( +
+ + 经过 {rounds} 轮迭代检索优化 +
+ )} +
+ }>{String(data.markdown)} +
+ {evidence.length > 0 && ( +
+

引用 {evidence.length} 篇论文

+
+ {evidence.slice(0, 8).map((e, i) => ( + + ))} +
+
+ )} +
+ ); + } + if (toolName === "list_topics" && Array.isArray(data.topics)) { + const topics = data.topics as Array>; + return ( +
+ {topics.map((t, i) => ( +
+ + {String(t.name ?? "")} + {t.paper_count !== undefined && {String(t.paper_count)} 篇} + {t.enabled !== undefined && ( + + {t.enabled ? "已订阅" : "未订阅"} + + )} +
+ ))} +
+ ); + } + if (toolName === "get_timeline" && Array.isArray(data.timeline)) { + const items = data.timeline as Array>; + return ( +
+ {items.map((p, i) => ( + + ))} +
+ ); + } + if (toolName === "get_similar_papers") { + const items = Array.isArray(data.items) ? (data.items as Array>) : []; + const ids = Array.isArray(data.similar_ids) ? (data.similar_ids as string[]) : []; + if (items.length > 0) { + return ( +
+ {items.map((p, i) => ( + + ))} +
+ ); + } + if (ids.length > 0) return

找到 {ids.length} 篇相似论文

; + } + if (toolName === "get_citation_tree" && data.nodes) { + const nodes = Array.isArray(data.nodes) ? data.nodes.length : 0; + const edges = Array.isArray(data.edges) ? data.edges.length : 0; + return ( +
+ {nodes} 个节点 + {edges} 条引用关系 +
+ ); + } + if (toolName === "suggest_keywords" && Array.isArray(data.suggestions)) { + const suggestions = data.suggestions as Array>; + return ( +
+ {suggestions.map((s, i) => ( +
+

{String(s.name ?? "")}

+

{String(s.query ?? "")}

+ {s.reason !== undefined &&

{String(s.reason)}

} +
+ ))} +
+ ); + } + if ((toolName === "skim_paper" || toolName === "deep_read_paper") && data.one_liner) { + return ( +
+

{String(data.one_liner)}

+ {data.novelty !== undefined &&

创新点: {String(data.novelty)}

} + {data.methodology !== undefined &&

方法: {String(data.methodology)}

} +
+ ); + } + if (toolName === "reasoning_analysis" && data.reasoning_steps) { + const steps = Array.isArray(data.reasoning_steps) ? (data.reasoning_steps as Array>) : []; + return ( +
+ {steps.slice(0, 6).map((s, i) => ( +
+

{String(s.step_name ?? s.claim ?? `步骤 ${i + 1}`)}

+ {s.evidence !== undefined &&

{String(s.evidence)}

} +
+ ))} +
+ ); + } + if (toolName === "analyze_figures" && Array.isArray(data.figures)) { + const figs = data.figures as Array>; + return ( +
+ {figs.map((f, i) => ( +
+

{String(f.figure_type ?? "图表")} — p.{String(f.page ?? "?")}

+

{String(f.description ?? f.analysis ?? "")}

+
+ ))} +
+ ); + } + if (toolName === "identify_research_gaps" && data.analysis) { + const analysis = data.analysis as Record; + const gaps = Array.isArray(analysis.research_gaps) ? (analysis.research_gaps as Array>) : []; + return ( +
+ {gaps.slice(0, 5).map((g, i) => ( +
+

{String(g.gap_title ?? g.title ?? `空白 ${i + 1}`)}

+

{String(g.description ?? g.evidence ?? "")}

+
+ ))} +
+ ); + } + if (toolName === "get_paper_detail" && data.title) { + return ( +
+ + {data.abstract_zh !== undefined &&

{String(data.abstract_zh)}

} +
+ {data.arxiv_id ? {String(data.arxiv_id)} : null} + {data.read_status ? {String(data.read_status)} : null} +
+
+ ); + } + if (toolName === "writing_assist" && data.content) { + return ( +
+ }>{String(data.content)} +
+ ); + } + return
{JSON.stringify(data, null, 2)}
; +}); + +export { getToolMeta, ArxivCandidateSelector, StepDataView, ActionConfirmCard }; + +const ActionConfirmCard = memo(function ActionConfirmCard({ + actionId, + description, + tool, + args, + isPending, + isConfirming, + onConfirm, + onReject, +}: { + actionId: string; + description: string; + tool: string; + args?: Record; + isPending: boolean; + isConfirming: boolean; + onConfirm: (id: string) => void; + onReject: (id: string) => void; +}) { + const meta = getToolMeta(tool); + const Icon = meta.icon; + return ( +
+
+
+ + + {isPending ? "⚠️ 需要你的确认" : "已处理"} + +
+
+
+
+ +
+
+

{description}

+ {args && Object.keys(args).length > 0 && ( +
+ {Object.entries(args).map(([k, v]) => ( +
+ {k}: + + {typeof v === "string" ? v : JSON.stringify(v)} + +
+ ))} +
+ )} +
+
+ {isPending && ( +
+ + +
+ )} + {!isPending && ( +
+ + 已处理 +
+ )} +
+
+
+ ); +}); diff --git a/frontend/src/pages/Chat.tsx b/frontend/src/pages/Chat.tsx deleted file mode 100644 index 8b4cbc9..0000000 --- a/frontend/src/pages/Chat.tsx +++ /dev/null @@ -1,254 +0,0 @@ -/** - * AI Chat - RAG 问答 (Claude 对话风格) - * 覆盖 API: POST /rag/ask - * @author Color2333 - */ -import { useState, useRef, useEffect } from "react"; -import { Card, Button } from "@/components/ui"; -import { ragApi } from "@/services/api"; -import type { ChatMessage } from "@/types"; -import { uid } from "@/lib/utils"; -import { Send, Sparkles, User, BookOpen, Trash2 } from "lucide-react"; - -export default function Chat() { - const [messages, setMessages] = useState([]); - const [input, setInput] = useState(""); - const [loading, setLoading] = useState(false); - const scrollRef = useRef(null); - const inputRef = useRef(null); - - useEffect(() => { - if (scrollRef.current) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; - } - }, [messages]); - - const handleSend = async () => { - const question = input.trim(); - if (!question || loading) return; - - const userMsg: ChatMessage = { - id: uid(), - role: "user", - content: question, - timestamp: new Date(), - }; - setMessages((prev) => [...prev, userMsg]); - setInput(""); - setLoading(true); - - try { - const res = await ragApi.ask({ question, top_k: 5 }); - const botMsg: ChatMessage = { - id: uid(), - role: "assistant", - content: res.answer, - cited_paper_ids: res.cited_paper_ids, - evidence: res.evidence, - timestamp: new Date(), - }; - setMessages((prev) => [...prev, botMsg]); - } catch (err) { - const errorMsg: ChatMessage = { - id: uid(), - role: "assistant", - content: `抱歉,查询时出现错误: ${err instanceof Error ? err.message : "未知错误"}`, - timestamp: new Date(), - }; - setMessages((prev) => [...prev, errorMsg]); - } finally { - setLoading(false); - inputRef.current?.focus(); - } - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - handleSend(); - } - }; - - const clearChat = () => { - setMessages([]); - }; - - return ( -
- {/* 标题栏 */} -
-
-

AI Chat

-

基于 RAG 的跨论文智能问答

-
- {messages.length > 0 && ( - - )} -
- - {/* 消息区域 */} -
- {messages.length === 0 ? ( -
-
- -
-

PaperMind AI

-

- 基于你收录的论文进行智能问答。 支持跨文档检索,自动引用来源论文。 -

-
- {[ - "这些论文中关于 Transformer 的主要创新是什么?", - "有哪些论文讨论了模型压缩的方法?", - "总结一下近期在多模态学习方面的进展", - ].map((q) => ( - - ))} -
-
- ) : ( - messages.map((msg) => ( -
- {msg.role === "assistant" && ( -
- -
- )} -
- {msg.role === "user" ? ( -

{msg.content}

- ) : ( - <> -
-

- {msg.content} -

-
- {/* 引用论文 */} - {msg.cited_paper_ids && msg.cited_paper_ids.length > 0 && ( -
- {msg.cited_paper_ids.map((cid) => ( - - - {cid.slice(0, 8)}... - - ))} -
- )} - {/* Evidence */} - {msg.evidence && msg.evidence.length > 0 && ( -
- - 查看 {msg.evidence.length} 条证据 - -
- {msg.evidence.map((ev, i) => ( -
- {JSON.stringify(ev, null, 2)} -
- ))} -
-
- )} - - )} -
- {msg.role === "user" && ( -
- -
- )} -
- )) - )} - - {/* 加载中提示 */} - {loading && ( -
-
- -
-
-
-
- - - -
- 正在思考... -
-
-
- )} -
- - {/* 输入区域 */} -
-
-