-
-
+ {/* Tab 切换 + 模式选择 */}
+
+
+
+
+
+
+ {/* 翻译模式选择 */}
+ {viewMode === 'bilingual' && (
+
+ )}
{/* 划词翻译模式 */}
From b301dfd133f40612fe5407429ff892ec02d1ac9d Mon Sep 17 00:00:00 2001
From: Color2333 <1552429809@qq.com>
Date: Mon, 23 Mar 2026 18:43:35 +0800
Subject: [PATCH 26/39] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=20README=20?=
=?UTF-8?q?=E7=BF=BB=E8=AF=91=E5=8A=9F=E8=83=BD=E8=AF=B4=E6=98=8E?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 添加混合翻译模式说明
- 快速翻译:PyMuPDF 分段 + 并发(1-2 分钟)
- 布局保留:PDFMathTranslate 完整排版(3-5 分钟)
---
README.md | 10 ++++++----
1 file changed, 6 insertions(+), 4 deletions(-)
diff --git a/README.md b/README.md
index 1f6d0d8..5fcfab4 100644
--- a/README.md
+++ b/README.md
@@ -92,10 +92,12 @@ PaperMind 是一个面向科研工作者的 AI 增强平台,帮你从「搜索
「阅读→理解→重构」的完整论文工作流:
-- 📝 **Act 1 理解** —— 摘要+关键发现,厘清论文核心
-- ⚡ **Act 2 碰撞** —— 冲突+疑问,与已有知识对话
-- 🔄 **Act 3 重构** —— 前后对比+认知变化,形成新认知
-- 📖 **全文对照翻译** —— 段落级中英对照
+- 📝 **Act 1 理解** —— 摘要 + 关键发现,厘清论文核心
+- ⚡ **Act 2 碰撞** —— 冲突 + 疑问,与已有知识对话
+- 🔄 **Act 3 重构** —— 前后对比 + 认知变化,形成新认知
+- 📖 **全文对照翻译** —— 段落级中英对照,支持两种模式
+ - ⚡ **快速翻译**:1-2 分钟,PyMuPDF 分段 + 并发翻译
+ - 📐 **布局保留**:3-5 分钟,PDFMathTranslate 完整排版(公式/图表保留)
### 🤖 AI Agent 对话
From 82d8de57726645761165fcc4b3fb4dedf7a47793 Mon Sep 17 00:00:00 2001
From: Color2333 <1552429809@qq.com>
Date: Mon, 23 Mar 2026 18:53:32 +0800
Subject: [PATCH 27/39] =?UTF-8?q?feat(frontend):=20LLM=20=E6=A8=A1?=
=?UTF-8?q?=E5=9E=8B=E9=85=8D=E7=BD=AE=E7=AE=A1=E7=90=86=E7=95=8C=E9=9D=A2?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
apps/api/main.py | 6 +-
apps/api/routers/llm_configs.py | 222 +++++++++++++
apps/api/services/translate.py | 4 +
frontend/src/pages/LLMSettings.tsx | 454 ++++++++++++++++++++++++++
frontend/src/services/llmConfigApi.ts | 115 +++++++
packages/domain/model_tier.py | 77 +++++
6 files changed, 875 insertions(+), 3 deletions(-)
create mode 100644 apps/api/routers/llm_configs.py
create mode 100644 frontend/src/pages/LLMSettings.tsx
create mode 100644 frontend/src/services/llmConfigApi.ts
create mode 100644 packages/domain/model_tier.py
diff --git a/apps/api/main.py b/apps/api/main.py
index 712109a..c12a724 100644
--- a/apps/api/main.py
+++ b/apps/api/main.py
@@ -155,17 +155,16 @@ async def app_error_handler(_request: Request, exc: AppError):
cs_feeds,
graph,
jobs,
+ llm_configs,
papers,
pipelines,
sensemaking,
+ settings as settings_router,
system,
topics,
translate,
writing,
)
-from apps.api.routers import (
- settings as settings_router,
-)
app.include_router(system.router)
app.include_router(papers.router)
@@ -181,3 +180,4 @@ async def app_error_handler(_request: Request, exc: AppError):
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/services/translate.py b/apps/api/services/translate.py
index 06f6062..1bb9947 100644
--- a/apps/api/services/translate.py
+++ b/apps/api/services/translate.py
@@ -23,6 +23,10 @@ def translate_text(text: str, target_lang: str = "zh") -> str:
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
diff --git a/frontend/src/pages/LLMSettings.tsx b/frontend/src/pages/LLMSettings.tsx
new file mode 100644
index 0000000..911e032
--- /dev/null
+++ b/frontend/src/pages/LLMSettings.tsx
@@ -0,0 +1,454 @@
+/**
+ * LLM 模型配置管理页面
+ */
+import { useState, useCallback, useEffect } from "react";
+import { Plus, Trash2, Edit, Save, X, Zap, Settings, Check } from "lucide-react";
+import { llmConfigApi, type LLMConfigItem, type LLMConfigCreate } from "@/services/llmConfigApi";
+
+// 预设配置模板
+const PRESET_CONFIGS = [
+ {
+ provider: "zhipu",
+ name: "智谱 AI - 经济型",
+ model_skim: "glm-4-flash",
+ model_deep: "glm-4.7",
+ model_vision: "glm-4v-flash",
+ model_embedding: "embedding-3",
+ model_fallback: "glm-4-flash",
+ },
+ {
+ provider: "zhipu",
+ name: "智谱 AI - 高级型",
+ model_skim: "glm-4.7",
+ model_deep: "glm-4-air",
+ model_vision: "glm-4v-flash",
+ model_embedding: "embedding-3",
+ model_fallback: "glm-4.7",
+ },
+ {
+ provider: "siliconflow",
+ name: "硅基流动 - 性价比",
+ model_skim: "Qwen/Qwen2.5-7B-Instruct",
+ model_deep: "deepseek-ai/DeepSeek-V3",
+ model_vision: "Pro/Qwen/Qwen2.5-VL-72B-Instruct",
+ model_embedding: "BAAI/bge-m3",
+ model_fallback: "Qwen/Qwen2.5-7B-Instruct",
+ },
+];
+
+export default function LLMSettings() {
+ const [configs, setConfigs] = useState
([]);
+ const [activeId, setActiveId] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [showCreateForm, setShowCreateForm] = useState(false);
+ const [editingId, setEditingId] = useState(null);
+ const [formData, setFormData] = useState({
+ name: "",
+ provider: "zhipu",
+ api_key: "",
+ api_base_url: "",
+ model_skim: "",
+ model_deep: "",
+ model_vision: "",
+ model_embedding: "",
+ model_fallback: "",
+ });
+
+ const loadConfigs = useCallback(async () => {
+ setLoading(true);
+ try {
+ const result = await llmConfigApi.list();
+ setConfigs(result.configs);
+ setActiveId(result.active_id);
+ } catch (err) {
+ console.error("Failed to load configs:", err);
+ alert("加载配置失败:" + (err as Error).message);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ loadConfigs();
+ }, [loadConfigs]);
+
+ const resetForm = useCallback(() => {
+ setFormData({
+ name: "",
+ provider: "zhipu",
+ api_key: "",
+ api_base_url: "",
+ model_skim: "",
+ model_deep: "",
+ model_vision: "",
+ model_embedding: "",
+ model_fallback: "",
+ });
+ }, []);
+
+ const usePreset = useCallback((preset: typeof PRESET_CONFIGS[0]) => {
+ setFormData({
+ ...preset,
+ api_key: "",
+ api_base_url: "",
+ name: preset.name,
+ provider: preset.provider,
+ });
+ setShowCreateForm(true);
+ }, []);
+
+ const handleCreate = useCallback(async () => {
+ try {
+ await llmConfigApi.create(formData);
+ setShowCreateForm(false);
+ loadConfigs();
+ resetForm();
+ } catch (err) {
+ alert("创建失败:" + (err as Error).message);
+ }
+ }, [formData, loadConfigs, resetForm]);
+
+ const handleActivate = useCallback(async (configId: string) => {
+ try {
+ await llmConfigApi.activate(configId);
+ loadConfigs();
+ } catch (err) {
+ alert("激活失败:" + (err as Error).message);
+ }
+ }, [loadConfigs]);
+
+ const handleDelete = useCallback(async (configId: string) => {
+ if (!confirm("确定要删除此配置吗?")) return;
+ try {
+ await llmConfigApi.delete(configId);
+ loadConfigs();
+ } catch (err) {
+ alert("删除失败:" + (err as Error).message);
+ }
+ }, [loadConfigs]);
+
+ const startEdit = useCallback((config: LLMConfigItem) => {
+ setEditingId(config.id);
+ setFormData({
+ name: config.name,
+ provider: config.provider,
+ api_key: "",
+ api_base_url: config.api_base_url || "",
+ model_skim: config.model_skim,
+ model_deep: config.model_deep,
+ model_vision: config.model_vision || "",
+ model_embedding: config.model_embedding,
+ model_fallback: config.model_fallback,
+ });
+ setShowCreateForm(false);
+ }, []);
+
+ const handleUpdate = useCallback(async (configId: string) => {
+ try {
+ await llmConfigApi.update(configId, formData);
+ setEditingId(null);
+ loadConfigs();
+ resetForm();
+ } catch (err) {
+ alert("更新失败:" + (err as Error).message);
+ }
+ }, [formData, loadConfigs, resetForm]);
+
+ return (
+
+ {/* 顶部导航 */}
+
+
+
+
+
LLM 模型配置
+
+
+
+
+
+
+ {/* 预设配置推荐 */}
+ {!showCreateForm && configs.length === 0 && (
+
+
+
+ 快速开始 - 选择预设配置
+
+
+ {PRESET_CONFIGS.map((preset) => (
+
+ ))}
+
+
+ )}
+
+ {/* 创建/编辑表单 */}
+ {showCreateForm && (
+
+
+
+ {editingId ? "编辑配置" : "创建新配置"}
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {/* 配置列表 */}
+ {loading ? (
+
+ ) : (
+
+ {configs.map((config) => (
+
+
+
+
+
{config.name}
+ {config.is_active && (
+
+
+ 使用中
+
+ )}
+
+ {config.provider}
+
+
+
+
+
+ 粗读:
+ {config.model_skim}
+
+
+ 精读:
+ {config.model_deep}
+
+
+ 视觉:
+ {config.model_vision || "未设置"}
+
+
+
+
+
+ {!config.is_active && (
+
+ )}
+
+
+
+
+
+ ))}
+
+ {configs.length === 0 && !showCreateForm && (
+
+
+
暂无配置
+
点击上方"新建配置"或选择预设配置开始
+
+ )}
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/services/llmConfigApi.ts b/frontend/src/services/llmConfigApi.ts
new file mode 100644
index 0000000..61225ce
--- /dev/null
+++ b/frontend/src/services/llmConfigApi.ts
@@ -0,0 +1,115 @@
+/**
+ * LLM 配置管理 API 服务
+ */
+
+export interface LLMConfigItem {
+ id: string;
+ name: string;
+ provider: string;
+ api_base_url: string | null;
+ model_skim: string;
+ model_deep: string;
+ model_vision: string | null;
+ model_embedding: string;
+ model_fallback: string;
+ is_active: boolean;
+}
+
+export interface LLMConfigCreate {
+ name: string;
+ provider: string;
+ api_key: string;
+ api_base_url?: string | null;
+ model_skim: string;
+ model_deep: string;
+ model_vision?: string | null;
+ model_embedding: string;
+ model_fallback: string;
+}
+
+export interface LLMConfigUpdate {
+ name?: string;
+ provider?: string;
+ api_key?: string;
+ api_base_url?: string | null;
+ model_skim?: string;
+ model_deep?: string;
+ model_vision?: string | null;
+ model_embedding?: string;
+ model_fallback?: string;
+}
+
+export interface LLMConfigList {
+ configs: LLMConfigItem[];
+ active_id: string | null;
+}
+
+async function request(path: string, options: RequestInit = {}): Promise {
+ const token = localStorage.getItem("auth_token");
+ const headers: HeadersInit = {
+ "Content-Type": "application/json",
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ };
+
+ const url = `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${path}`;
+ const resp = await fetch(url, {
+ ...options,
+ headers: {
+ ...headers,
+ ...(options.headers as HeadersInit),
+ },
+ });
+
+ if (!resp.ok) {
+ const error = await resp.json().catch(() => ({ detail: "请求失败" }));
+ throw new Error(error.detail || "请求失败");
+ }
+
+ return resp.json();
+}
+
+export const llmConfigApi = {
+ /** 获取所有配置 */
+ async list(): Promise {
+ return request("/llm-configs");
+ },
+
+ /** 获取单个配置 */
+ async get(configId: string): Promise<{ config: LLMConfigItem }> {
+ return request(`/llm-configs/${configId}`);
+ },
+
+ /** 创建配置 */
+ async create(data: LLMConfigCreate): Promise<{ config: LLMConfigItem }> {
+ return request("/llm-configs", {
+ method: "POST",
+ body: JSON.stringify(data),
+ });
+ },
+
+ /** 更新配置 */
+ async update(
+ configId: string,
+ data: LLMConfigUpdate
+ ): Promise<{ config: LLMConfigItem }> {
+ return request(`/llm-configs/${configId}`, {
+ method: "PATCH",
+ body: JSON.stringify(data),
+ });
+ },
+
+ /** 删除配置 */
+ async delete(configId: string): Promise<{ message: string }> {
+ return request(`/llm-configs/${configId}`, {
+ method: "DELETE",
+ });
+ },
+
+ /** 激活配置 */
+ async activate(configId: string): Promise<{ config: LLMConfigItem }> {
+ return request("/llm-configs/activate", {
+ method: "POST",
+ body: JSON.stringify({ config_id: configId }),
+ });
+ },
+};
diff --git a/packages/domain/model_tier.py b/packages/domain/model_tier.py
new file mode 100644
index 0000000..9c1409e
--- /dev/null
+++ b/packages/domain/model_tier.py
@@ -0,0 +1,77 @@
+"""
+场景化模型配置 - 按使用场景分配不同成本的模型
+"""
+
+from enum import Enum
+
+
+class ModelTier(str, Enum):
+ """模型成本分层"""
+
+ ECONOMY = "economy" # 经济型:最便宜,适合简单任务(摘要、分类、关键词提取)
+ STANDARD = "standard" # 标准型:性价比,适合一般任务(RAG、对话、翻译)
+ PREMIUM = "premium" # 高级型:较贵,适合复杂任务(深度分析、写作、推理)
+ VISION = "vision" # 视觉型:图像/图表理解
+
+
+MODEL_TIER_SCENARIOS = {
+ # Economy 场景 - 快速 + 便宜
+ ModelTier.ECONOMY: [
+ "skim", # 论文粗读
+ "keyword", # 关键词提取
+ "classify", # 分类/标签
+ "embedding", # 向量化
+ "summarize_short", # 短摘要
+ ],
+ # Standard 场景 - 性价比
+ ModelTier.STANDARD: [
+ "translate", # 翻译
+ "rag", # RAG 问答
+ "chat", # Agent 对话
+ "explain", # 概念解释
+ "summarize_medium", # 中等摘要
+ ],
+ # Premium 场景 - 高质量
+ ModelTier.PREMIUM: [
+ "deep", # 论文精读
+ "writing", # 学术写作
+ "reasoning", # 逻辑推理
+ "figure_analysis", # 图表分析
+ "wiki", # Wiki 生成
+ "summarize_long", # 长文档摘要
+ ],
+ # Vision 场景
+ ModelTier.VISION: [
+ "vision", # 视觉理解
+ "ocr", # OCR 识别
+ ],
+}
+
+
+# 预设模型配置模板(常见服务商)
+PRESET_MODEL_CONFIGS = {
+ "zhipu": {
+ ModelTier.ECONOMY: "glm-4-flash", # ~¥0.001/1k tokens
+ ModelTier.STANDARD: "glm-4.7", # ~¥0.005/1k tokens
+ ModelTier.PREMIUM: "glm-4-air", # ~¥0.01/1k tokens
+ ModelTier.VISION: "glm-4v-flash", # 视觉
+ },
+ "openai": {
+ ModelTier.ECONOMY: "gpt-4o-mini", # $0.00015/1k
+ ModelTier.STANDARD: "gpt-4o", # $0.005/1k
+ ModelTier.PREMIUM: "gpt-4.5-preview", # $0.075/1k
+ ModelTier.VISION: "gpt-4o",
+ },
+ "anthropic": {
+ ModelTier.ECONOMY: "claude-3-haiku", # $0.00025/1k
+ ModelTier.STANDARD: "claude-3.5-sonnet", # $0.003/1k
+ ModelTier.PREMIUM: "claude-3.5-opus", # $0.015/1k
+ ModelTier.VISION: "claude-3.5-sonnet",
+ },
+ "siliconflow": {
+ ModelTier.ECONOMY: "Qwen/Qwen2.5-7B-Instruct", # 免费/极便宜
+ ModelTier.STANDARD: "Qwen/Qwen2.5-72B-Instruct", # 便宜
+ ModelTier.PREMIUM: "deepseek-ai/DeepSeek-V3", # 性价比高
+ ModelTier.VISION: "Pro/Qwen/Qwen2.5-VL-72B-Instruct",
+ },
+}
From c44e00d75504c59c021b10e551f640271bbd654f Mon Sep 17 00:00:00 2001
From: Color2333 <1552429809@qq.com>
Date: Mon, 23 Mar 2026 18:54:19 +0800
Subject: [PATCH 28/39] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=20README=20?=
=?UTF-8?q?=E6=B7=BB=E5=8A=A0=20LLM=20=E6=A8=A1=E5=9E=8B=E7=AE=A1=E7=90=86?=
=?UTF-8?q?=E8=AF=B4=E6=98=8E?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 15 +++++++++++++++
apps/api/main.py | 4 +++-
2 files changed, 18 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 5fcfab4..1f71739 100644
--- a/README.md
+++ b/README.md
@@ -173,6 +173,21 @@ PaperMind 是一个面向科研工作者的 AI 增强平台,帮你从「搜索
- 🎫 **JWT Token** —— 7 天有效期,自动续期
- 🛡️ **全站保护** —— 所有 API 都需要认证
+### ⚙️ LLM 模型管理
+
+灵活控制成本,按场景分配模型:
+
+- 📊 **成本分层** —— Economy/Standard/Premium/Vision 四层
+- 🔄 **一键切换** —— 在设置页面随时切换配置
+- 🎯 **场景映射** —— 粗读/精读/翻译/写作自动匹配对应模型
+- 💰 **成本优化** —— 简单任务用便宜模型,复杂推理用高级模型
+- 📈 **Token 追踪** —— 所有 API 调用自动记录成本和用量
+
+**预设配置模板**:
+- 智谱 AI 经济型(glm-4-flash)— 日常使用,极低成本
+- 智谱 AI 高级型(glm-4-air)— 复杂推理,高质量输出
+- 硅基流动性价比(DeepSeek-V3)— 高性能,低价格
+
---
## 🏗️ 架构总览
diff --git a/apps/api/main.py b/apps/api/main.py
index c12a724..efe91f0 100644
--- a/apps/api/main.py
+++ b/apps/api/main.py
@@ -159,12 +159,14 @@ async def app_error_handler(_request: Request, exc: AppError):
papers,
pipelines,
sensemaking,
- settings as settings_router,
system,
topics,
translate,
writing,
)
+from apps.api.routers import (
+ settings as settings_router,
+)
app.include_router(system.router)
app.include_router(papers.router)
From 87106819da8b1cc86e1c7fc84df11a883b08ebd5 Mon Sep 17 00:00:00 2001
From: Color2333 <1552429809@qq.com>
Date: Mon, 23 Mar 2026 19:40:45 +0800
Subject: [PATCH 29/39] =?UTF-8?q?chore:=20=E7=BB=9F=E4=B8=80=E6=89=80?=
=?UTF-8?q?=E6=9C=89=E6=A8=A1=E5=9E=8B=E9=85=8D=E7=BD=AE=E4=B8=BA=20GLM-4.?=
=?UTF-8?q?7=20=E5=92=8C=20GLM-4.6V?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
模型配置更新:
- config.py: 默认模型统一为 glm-4.7 / glm-4.6v
- model_tier.py: 预设模板只保留智谱 GLM-4.7 系列
- LLMSettings.tsx: 前端预设只保留统一配置
配置说明:
- 粗读/简单任务:glm-4.7
- 精读/复杂任务:glm-4.7
- 视觉任务:glm-4.6v
- 降级备用:glm-4.7
简化模型管理,避免配置混乱。
---
frontend/src/pages/LLMSettings.tsx | 24 +++---------------------
packages/domain/model_tier.py | 26 ++++----------------------
2 files changed, 7 insertions(+), 43 deletions(-)
diff --git a/frontend/src/pages/LLMSettings.tsx b/frontend/src/pages/LLMSettings.tsx
index 911e032..544c775 100644
--- a/frontend/src/pages/LLMSettings.tsx
+++ b/frontend/src/pages/LLMSettings.tsx
@@ -9,31 +9,13 @@ import { llmConfigApi, type LLMConfigItem, type LLMConfigCreate } from "@/servic
const PRESET_CONFIGS = [
{
provider: "zhipu",
- name: "智谱 AI - 经济型",
- model_skim: "glm-4-flash",
- model_deep: "glm-4.7",
- model_vision: "glm-4v-flash",
- model_embedding: "embedding-3",
- model_fallback: "glm-4-flash",
- },
- {
- provider: "zhipu",
- name: "智谱 AI - 高级型",
+ name: "智谱 AI - GLM-4.7 统一配置",
model_skim: "glm-4.7",
- model_deep: "glm-4-air",
- model_vision: "glm-4v-flash",
+ model_deep: "glm-4.7",
+ model_vision: "glm-4.6v",
model_embedding: "embedding-3",
model_fallback: "glm-4.7",
},
- {
- provider: "siliconflow",
- name: "硅基流动 - 性价比",
- model_skim: "Qwen/Qwen2.5-7B-Instruct",
- model_deep: "deepseek-ai/DeepSeek-V3",
- model_vision: "Pro/Qwen/Qwen2.5-VL-72B-Instruct",
- model_embedding: "BAAI/bge-m3",
- model_fallback: "Qwen/Qwen2.5-7B-Instruct",
- },
];
export default function LLMSettings() {
diff --git a/packages/domain/model_tier.py b/packages/domain/model_tier.py
index 9c1409e..80cbc2c 100644
--- a/packages/domain/model_tier.py
+++ b/packages/domain/model_tier.py
@@ -51,27 +51,9 @@ class ModelTier(str, Enum):
# 预设模型配置模板(常见服务商)
PRESET_MODEL_CONFIGS = {
"zhipu": {
- ModelTier.ECONOMY: "glm-4-flash", # ~¥0.001/1k tokens
- ModelTier.STANDARD: "glm-4.7", # ~¥0.005/1k tokens
- ModelTier.PREMIUM: "glm-4-air", # ~¥0.01/1k tokens
- ModelTier.VISION: "glm-4v-flash", # 视觉
- },
- "openai": {
- ModelTier.ECONOMY: "gpt-4o-mini", # $0.00015/1k
- ModelTier.STANDARD: "gpt-4o", # $0.005/1k
- ModelTier.PREMIUM: "gpt-4.5-preview", # $0.075/1k
- ModelTier.VISION: "gpt-4o",
- },
- "anthropic": {
- ModelTier.ECONOMY: "claude-3-haiku", # $0.00025/1k
- ModelTier.STANDARD: "claude-3.5-sonnet", # $0.003/1k
- ModelTier.PREMIUM: "claude-3.5-opus", # $0.015/1k
- ModelTier.VISION: "claude-3.5-sonnet",
- },
- "siliconflow": {
- ModelTier.ECONOMY: "Qwen/Qwen2.5-7B-Instruct", # 免费/极便宜
- ModelTier.STANDARD: "Qwen/Qwen2.5-72B-Instruct", # 便宜
- ModelTier.PREMIUM: "deepseek-ai/DeepSeek-V3", # 性价比高
- ModelTier.VISION: "Pro/Qwen/Qwen2.5-VL-72B-Instruct",
+ ModelTier.ECONOMY: "glm-4.7", # 统一使用 GLM-4.7
+ ModelTier.STANDARD: "glm-4.7", # 统一使用 GLM-4.7
+ ModelTier.PREMIUM: "glm-4.7", # 统一使用 GLM-4.7
+ ModelTier.VISION: "glm-4.6v", # 视觉专用
},
}
From be38276d7ce250357d8810d1279413c93ffb6b68 Mon Sep 17 00:00:00 2001
From: Color2333 <1552429809@qq.com>
Date: Mon, 23 Mar 2026 19:41:00 +0800
Subject: [PATCH 30/39] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=20README=20?=
=?UTF-8?q?=E8=AF=B4=E6=98=8E=E7=BB=9F=E4=B8=80=E6=A8=A1=E5=9E=8B=E9=85=8D?=
=?UTF-8?q?=E7=BD=AE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 14 +++++++-------
1 file changed, 7 insertions(+), 7 deletions(-)
diff --git a/README.md b/README.md
index 1f71739..f40d462 100644
--- a/README.md
+++ b/README.md
@@ -177,16 +177,16 @@ PaperMind 是一个面向科研工作者的 AI 增强平台,帮你从「搜索
灵活控制成本,按场景分配模型:
-- 📊 **成本分层** —— Economy/Standard/Premium/Vision 四层
+- 📊 **统一配置** —— 默认使用 GLM-4.7(文本)+ GLM-4.6V(视觉)
- 🔄 **一键切换** —— 在设置页面随时切换配置
-- 🎯 **场景映射** —— 粗读/精读/翻译/写作自动匹配对应模型
-- 💰 **成本优化** —— 简单任务用便宜模型,复杂推理用高级模型
+- 🎯 **场景映射** —— 所有文本任务自动使用 GLM-4.7
+- 💰 **成本优化** —— 单一模型配置,避免管理复杂度
- 📈 **Token 追踪** —— 所有 API 调用自动记录成本和用量
-**预设配置模板**:
-- 智谱 AI 经济型(glm-4-flash)— 日常使用,极低成本
-- 智谱 AI 高级型(glm-4-air)— 复杂推理,高质量输出
-- 硅基流动性价比(DeepSeek-V3)— 高性能,低价格
+**默认模型配置**:
+- 文本任务(粗读/精读/翻译/写作):GLM-4.7
+- 视觉任务(图表分析/OCR):GLM-4.6V
+- 降级备用:GLM-4.7
---
From 867595b0f03ee2b9483ffaf0b0e65975285581c3 Mon Sep 17 00:00:00 2001
From: Color2333 <1552429809@qq.com>
Date: Tue, 24 Mar 2026 14:40:21 +0800
Subject: [PATCH 31/39] =?UTF-8?q?ui:=20=E4=BC=98=E5=8C=96=20LLM=20?=
=?UTF-8?q?=E9=85=8D=E7=BD=AE=E9=A1=B5=E9=9D=A2=E6=98=BE=E7=A4=BA?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
界面改进:
- 顶部添加当前激活配置概览卡片,一目了然
- 配置列表卡片样式优化,图标 + 模型标签更直观
- 表单分组展示(基础信息 / 模型配置),层次清晰
- 预设配置卡片带图标和描述,更有引导性
- 整体布局更紧凑、更专业
交互优化:
- 文本模型输入联动(修改一个自动填其他)
- 悬停效果和过渡动画
- 删除按钮仅在非激活状态可点击
---
frontend/src/pages/LLMSettings.tsx | 478 +++++++++++++++++------------
1 file changed, 277 insertions(+), 201 deletions(-)
diff --git a/frontend/src/pages/LLMSettings.tsx b/frontend/src/pages/LLMSettings.tsx
index 544c775..007145c 100644
--- a/frontend/src/pages/LLMSettings.tsx
+++ b/frontend/src/pages/LLMSettings.tsx
@@ -2,14 +2,15 @@
* LLM 模型配置管理页面
*/
import { useState, useCallback, useEffect } from "react";
-import { Plus, Trash2, Edit, Save, X, Zap, Settings, Check } from "lucide-react";
+import { Plus, Trash2, Edit, Save, X, Zap, Settings, Check, Brain, Eye, Layers, Key } from "lucide-react";
import { llmConfigApi, type LLMConfigItem, type LLMConfigCreate } from "@/services/llmConfigApi";
// 预设配置模板
const PRESET_CONFIGS = [
{
provider: "zhipu",
- name: "智谱 AI - GLM-4.7 统一配置",
+ name: "智谱 AI - GLM-4.7",
+ description: "统一配置,文本任务使用 GLM-4.7,视觉任务使用 GLM-4.6V",
model_skim: "glm-4.7",
model_deep: "glm-4.7",
model_vision: "glm-4.6v",
@@ -136,19 +137,26 @@ export default function LLMSettings() {
}
}, [formData, loadConfigs, resetForm]);
+ const activeConfig = configs.find((c) => c.id === activeId);
+
return (
{/* 顶部导航 */}
-
+
-
-
LLM 模型配置
+
+
+
+
+
LLM 模型配置
+
管理 AI 模型配置,控制成本
+
-
+
+ {/* 当前激活配置概览 */}
+ {activeConfig && !showCreateForm && (
+
+
+
+
+
+
+
+
当前使用
+
{activeConfig.name}
+
+
+
+ {activeConfig.model_skim}
+
+
+
+ {activeConfig.model_vision || "未设置"}
+
+
+
+
+
+
+
+ )}
+
{/* 预设配置推荐 */}
{!showCreateForm && configs.length === 0 && (
-
+
- 快速开始 - 选择预设配置
+ 快速开始
-
+
{PRESET_CONFIGS.map((preset) => (
))}
@@ -186,8 +241,8 @@ export default function LLMSettings() {
{/* 创建/编辑表单 */}
{showCreateForm && (
-
-
+
+
{editingId ? "编辑配置" : "创建新配置"}
@@ -198,122 +253,134 @@ export default function LLMSettings() {
setEditingId(null);
resetForm();
}}
- className="rounded-lg p-2 text-white/40 hover:bg-white/10"
+ className="rounded-lg p-2 text-white/40 hover:bg-white/10 hover:text-white"
>
-
-
-
- setFormData({ ...formData, name: e.target.value })}
- className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white outline-none focus:border-primary/50"
- placeholder="例如:智谱 AI-经济型"
- />
-
-
-
-
-
-
-
-
-
- setFormData({ ...formData, api_key: e.target.value })}
- className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white outline-none focus:border-primary/50"
- placeholder="sk-..."
- />
-
+ {/* 基础信息 */}
+
+
+
+ 基础信息
+
+
+
+
+ setFormData({ ...formData, name: e.target.value })}
+ className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2.5 text-sm text-white outline-none focus:border-primary/50"
+ placeholder="例如:智谱 AI 配置"
+ />
+
-
-
- setFormData({ ...formData, api_base_url: e.target.value })}
- className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white outline-none focus:border-primary/50"
- placeholder="https://..."
- />
-
+
+
+
+
-
+
-
-
- setFormData({ ...formData, model_deep: e.target.value })}
- className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white outline-none focus:border-primary/50"
- placeholder="glm-4.7"
- />
-
+ {/* 模型配置 */}
+
+
+
+ 模型配置
+
+
@@ -321,10 +388,10 @@ export default function LLMSettings() {
@@ -342,92 +409,101 @@ export default function LLMSettings() {
)}
{/* 配置列表 */}
- {loading ? (
-
- ) : (
-
- {configs.map((config) => (
-
-
-
-
-
{config.name}
- {config.is_active && (
-
-
- 使用中
-
- )}
-
- {config.provider}
-
-
-
-
-
- 粗读:
- {config.model_skim}
-
-
-
精读:
-
{config.model_deep}
+ {!showCreateForm && configs.length > 0 && (
+
+
+
+ 所有配置 ({configs.length})
+
+
+ {configs.map((config) => (
+
+
+
+
+
-
-
视觉:
-
{config.model_vision || "未设置"}
+
+
+
{config.name}
+ {config.is_active && (
+
+
+ 使用中
+
+ )}
+
+
{config.provider}
-
-
- {!config.is_active && (
+
+ {!config.is_active && (
+
+ )}
+
- )}
-
-
+
+
+
+
+
+
+ {config.model_skim}
+
+
+
+ {config.model_vision || "未设置"}
+
+
+
+ {config.model_embedding}
+
-
- ))}
+ ))}
+
+
+ )}
- {configs.length === 0 && !showCreateForm && (
-
-
-
暂无配置
-
点击上方"新建配置"或选择预设配置开始
-
- )}
+ {configs.length === 0 && !showCreateForm && (
+
+
+
+
+
暂无 LLM 配置
+
点击上方"新建配置"开始
)}
From b40910f5535e97ac851580c67c7031367a2f2dcb Mon Sep 17 00:00:00 2001
From: Color2333 <1552429809@qq.com>
Date: Tue, 24 Mar 2026 14:56:12 +0800
Subject: [PATCH 32/39] =?UTF-8?q?feat(settings):=20=E8=BF=81=E7=A7=BB?=
=?UTF-8?q?=E5=88=B0Claude=E9=A3=8E=E6=A0=BC=E8=AE=BE=E7=BD=AE=E9=A1=B5?=
=?UTF-8?q?=E9=9D=A2?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 删除旧的独立LLMSettings对话框页面
- 新Settings页面采用左侧导航+右侧内容布局
- 四个设置标签页:LLM配置/邮箱报告/Pipeline/运维
- 修复form label关联和button type等lint问题
- 路由/settings现在指向新的Settings组件
---
frontend/src/App.tsx | 3 +-
frontend/src/pages/LLMSettings.tsx | 512 --------------------------
frontend/src/pages/Settings.tsx | 557 +++++++++++++++++++++++++++++
3 files changed, 559 insertions(+), 513 deletions(-)
delete mode 100644 frontend/src/pages/LLMSettings.tsx
create mode 100644 frontend/src/pages/Settings.tsx
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/pages/LLMSettings.tsx b/frontend/src/pages/LLMSettings.tsx
deleted file mode 100644
index 007145c..0000000
--- a/frontend/src/pages/LLMSettings.tsx
+++ /dev/null
@@ -1,512 +0,0 @@
-/**
- * LLM 模型配置管理页面
- */
-import { useState, useCallback, useEffect } from "react";
-import { Plus, Trash2, Edit, Save, X, Zap, Settings, Check, Brain, Eye, Layers, Key } from "lucide-react";
-import { llmConfigApi, type LLMConfigItem, type LLMConfigCreate } from "@/services/llmConfigApi";
-
-// 预设配置模板
-const PRESET_CONFIGS = [
- {
- provider: "zhipu",
- name: "智谱 AI - GLM-4.7",
- description: "统一配置,文本任务使用 GLM-4.7,视觉任务使用 GLM-4.6V",
- model_skim: "glm-4.7",
- model_deep: "glm-4.7",
- model_vision: "glm-4.6v",
- model_embedding: "embedding-3",
- model_fallback: "glm-4.7",
- },
-];
-
-export default function LLMSettings() {
- const [configs, setConfigs] = useState
([]);
- const [activeId, setActiveId] = useState(null);
- const [loading, setLoading] = useState(false);
- const [showCreateForm, setShowCreateForm] = useState(false);
- const [editingId, setEditingId] = useState(null);
- const [formData, setFormData] = useState({
- name: "",
- provider: "zhipu",
- api_key: "",
- api_base_url: "",
- model_skim: "",
- model_deep: "",
- model_vision: "",
- model_embedding: "",
- model_fallback: "",
- });
-
- const loadConfigs = useCallback(async () => {
- setLoading(true);
- try {
- const result = await llmConfigApi.list();
- setConfigs(result.configs);
- setActiveId(result.active_id);
- } catch (err) {
- console.error("Failed to load configs:", err);
- alert("加载配置失败:" + (err as Error).message);
- } finally {
- setLoading(false);
- }
- }, []);
-
- useEffect(() => {
- loadConfigs();
- }, [loadConfigs]);
-
- const resetForm = useCallback(() => {
- setFormData({
- name: "",
- provider: "zhipu",
- api_key: "",
- api_base_url: "",
- model_skim: "",
- model_deep: "",
- model_vision: "",
- model_embedding: "",
- model_fallback: "",
- });
- }, []);
-
- const usePreset = useCallback((preset: typeof PRESET_CONFIGS[0]) => {
- setFormData({
- ...preset,
- api_key: "",
- api_base_url: "",
- name: preset.name,
- provider: preset.provider,
- });
- setShowCreateForm(true);
- }, []);
-
- const handleCreate = useCallback(async () => {
- try {
- await llmConfigApi.create(formData);
- setShowCreateForm(false);
- loadConfigs();
- resetForm();
- } catch (err) {
- alert("创建失败:" + (err as Error).message);
- }
- }, [formData, loadConfigs, resetForm]);
-
- const handleActivate = useCallback(async (configId: string) => {
- try {
- await llmConfigApi.activate(configId);
- loadConfigs();
- } catch (err) {
- alert("激活失败:" + (err as Error).message);
- }
- }, [loadConfigs]);
-
- const handleDelete = useCallback(async (configId: string) => {
- if (!confirm("确定要删除此配置吗?")) return;
- try {
- await llmConfigApi.delete(configId);
- loadConfigs();
- } catch (err) {
- alert("删除失败:" + (err as Error).message);
- }
- }, [loadConfigs]);
-
- const startEdit = useCallback((config: LLMConfigItem) => {
- setEditingId(config.id);
- setFormData({
- name: config.name,
- provider: config.provider,
- api_key: "",
- api_base_url: config.api_base_url || "",
- model_skim: config.model_skim,
- model_deep: config.model_deep,
- model_vision: config.model_vision || "",
- model_embedding: config.model_embedding,
- model_fallback: config.model_fallback,
- });
- setShowCreateForm(false);
- }, []);
-
- const handleUpdate = useCallback(async (configId: string) => {
- try {
- await llmConfigApi.update(configId, formData);
- setEditingId(null);
- loadConfigs();
- resetForm();
- } catch (err) {
- alert("更新失败:" + (err as Error).message);
- }
- }, [formData, loadConfigs, resetForm]);
-
- const activeConfig = configs.find((c) => c.id === activeId);
-
- return (
-
- {/* 顶部导航 */}
-
-
-
-
-
-
-
-
LLM 模型配置
-
管理 AI 模型配置,控制成本
-
-
-
-
-
-
-
- {/* 当前激活配置概览 */}
- {activeConfig && !showCreateForm && (
-
-
-
-
-
-
-
-
当前使用
-
{activeConfig.name}
-
-
-
- {activeConfig.model_skim}
-
-
-
- {activeConfig.model_vision || "未设置"}
-
-
-
-
-
-
-
- )}
-
- {/* 预设配置推荐 */}
- {!showCreateForm && configs.length === 0 && (
-
-
-
- 快速开始
-
-
- {PRESET_CONFIGS.map((preset) => (
-
- ))}
-
-
- )}
-
- {/* 创建/编辑表单 */}
- {showCreateForm && (
-
-
-
- {editingId ? "编辑配置" : "创建新配置"}
-
-
-
-
- {/* 基础信息 */}
-
-
-
- 基础信息
-
-
-
-
- setFormData({ ...formData, name: e.target.value })}
- className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2.5 text-sm text-white outline-none focus:border-primary/50"
- placeholder="例如:智谱 AI 配置"
- />
-
-
-
-
-
-
-
-
-
- setFormData({ ...formData, api_key: e.target.value })}
- className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2.5 text-sm text-white outline-none focus:border-primary/50"
- placeholder="输入 API Key"
- />
-
-
-
-
- {/* 模型配置 */}
-
-
-
-
-
-
-
- )}
-
- {/* 配置列表 */}
- {!showCreateForm && configs.length > 0 && (
-
-
-
- 所有配置 ({configs.length})
-
-
- {configs.map((config) => (
-
-
-
-
-
-
-
-
-
{config.name}
- {config.is_active && (
-
-
- 使用中
-
- )}
-
-
{config.provider}
-
-
-
-
- {!config.is_active && (
-
- )}
-
-
-
-
-
-
-
-
- {config.model_skim}
-
-
-
- {config.model_vision || "未设置"}
-
-
-
- {config.model_embedding}
-
-
-
- ))}
-
-
- )}
-
- {configs.length === 0 && !showCreateForm && (
-
-
-
-
-
暂无 LLM 配置
-
点击上方"新建配置"开始
-
- )}
-
-
- );
-}
diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx
new file mode 100644
index 0000000..561e6aa
--- /dev/null
+++ b/frontend/src/pages/Settings.tsx
@@ -0,0 +1,557 @@
+/**
+ * Claude 风格的设置页面 - 左侧导航 + 右侧内容
+ */
+import { useState, useCallback, useEffect } from "react";
+import {
+ Cpu,
+ Mail,
+ GitBranch,
+ Settings,
+ ChevronRight,
+ Plus,
+ Trash2,
+ Pencil,
+ Power,
+ Eye,
+ EyeOff,
+ Server,
+ RefreshCw,
+ Play,
+ Link2,
+ BookOpen,
+ Activity,
+} from "lucide-react";
+import { useToast } from "@/contexts/ToastContext";
+import { Button } from "@/components/ui/Button";
+import { Badge } from "@/components/ui/Badge";
+import { Spinner } from "@/components/ui/Spinner";
+import {
+ llmConfigApi,
+ pipelineApi,
+ citationApi,
+ jobApi,
+ systemApi,
+ emailConfigApi,
+ dailyReportApi,
+} from "@/services/api";
+import { cn } from "@/lib/utils";
+import { formatDuration, timeAgo } from "@/lib/utils";
+
+type SettingsTab = "llm" | "email" | "pipeline" | "ops";
+
+const NAV_ITEMS: { key: SettingsTab; label: string; icon: typeof Cpu }[] = [
+ { key: "llm", label: "LLM 配置", icon: Cpu },
+ { key: "email", label: "邮箱与报告", icon: Mail },
+ { key: "pipeline", label: "Pipeline", icon: GitBranch },
+ { key: "ops", label: "运维", icon: Settings },
+];
+
+const PROVIDER_PRESETS: Record }> = {
+ zhipu: {
+ label: "智谱 AI",
+ base_url: "https://open.bigmodel.cn/api/paas/v4/",
+ models: { model_skim: "glm-4.7", model_deep: "glm-4.7", model_vision: "glm-4.6v", model_embedding: "embedding-3", model_fallback: "glm-4.7" },
+ },
+};
+
+export default function SettingsPage() {
+ const location = useLocation();
+ const [activeTab, setActiveTab] = useState("llm");
+
+ return (
+
+ {/* 左侧边栏 */}
+
+
+ {/* 右侧内容 */}
+
+
+ {activeTab === "llm" &&
}
+ {activeTab === "email" &&
}
+ {activeTab === "pipeline" &&
}
+ {activeTab === "ops" &&
}
+
+
+
+ );
+}
+
+/* ======== LLM 设置 ======== */
+function LLMSettings() {
+ const { toast } = useToast();
+ const [configs, setConfigs] = useState([]);
+ const [activeInfo, setActiveInfo] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [showAdd, setShowAdd] = useState(false);
+ const [editCfg, setEditCfg] = useState(null);
+
+ const load = useCallback(async () => {
+ try {
+ const [listRes, activeRes] = await Promise.all([llmConfigApi.list(), llmConfigApi.active()]);
+ setConfigs(listRes.items || []);
+ setActiveInfo(activeRes);
+ } catch {
+ toast("error", "加载 LLM 配置失败");
+ } finally {
+ setLoading(false);
+ }
+ }, [toast]);
+
+ useEffect(() => { load(); }, [load]);
+
+ if (loading) return
;
+
+ return (
+
+
+
LLM 模型配置
+
配置 AI 模型,管理成本
+
+
+ {/* 当前激活 */}
+ {activeInfo && (
+
+
+
+
+
+
+
+
+ {activeInfo.config?.name || "当前配置"}
+ 使用中
+
+
+ 文本: {activeInfo.config?.model_skim}
+ 视觉: {activeInfo.config?.model_vision || "未设置"}
+
+
+
+
+
+
+ )}
+
+ {/* 配置列表 */}
+
+
+
所有配置
+
+
+
+ {configs.length === 0 ? (
+
+ ) : (
+
+ {configs.map((cfg) => (
+
+
+
+
+
+
+
+ {cfg.name}
+ {cfg.is_active && 激活}
+ {cfg.provider}
+
+
+ {cfg.api_key_masked}
+
+
+
+
+ {!cfg.is_active && (
+
+ )}
+
+
+
+
+ ))}
+
+ )}
+
+
+ {/* 添加/编辑弹窗 */}
+ {(showAdd || editCfg) && (
+
{ setShowAdd(false); setEditCfg(null); }}
+ onSaved={() => { setShowAdd(false); setEditCfg(null); load(); }}
+ />
+ )}
+
+ );
+}
+
+function ConfigModal({ config, onClose, onSaved }: { config?: any; onClose: () => void; onSaved: () => void }) {
+ const [form, setForm] = useState({
+ name: config?.name || "",
+ provider: config?.provider || "zhipu",
+ api_key: "",
+ api_base_url: config?.api_base_url || PROVIDER_PRESETS.zhipu.base_url,
+ model_skim: config?.model_skim || "glm-4.7",
+ model_deep: config?.model_deep || "glm-4.7",
+ model_vision: config?.model_vision || "glm-4.6v",
+ model_embedding: config?.model_embedding || "embedding-3",
+ model_fallback: config?.model_fallback || "glm-4.7",
+ });
+ const [showKey, setShowKey] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState("");
+
+ const setField = (k: string, v: string) => setForm((p) => ({ ...p, [k]: v }));
+
+ const handleProviderChange = (provider: string) => {
+ const preset = PROVIDER_PRESETS[provider];
+ if (preset) {
+ setForm((p) => ({ ...p, provider, api_base_url: preset.base_url, ...preset.models }));
+ }
+ };
+
+ const handleSubmit = async () => {
+ if (!form.name.trim()) { setError("请输入配置名称"); return; }
+ if (!form.api_key.trim() && !config) { setError("请输入 API Key"); return; }
+ setSubmitting(true);
+ setError("");
+ try {
+ if (config) {
+ const payload: any = { name: form.name, provider: form.provider, api_base_url: form.api_base_url, model_skim: form.model_skim, model_deep: form.model_deep, model_vision: form.model_vision, model_embedding: form.model_embedding, model_fallback: form.model_fallback };
+ if (form.api_key) payload.api_key = form.api_key;
+ await llmConfigApi.update(config.id, payload);
+ } else {
+ await llmConfigApi.create(form);
+ }
+ onSaved();
+ } catch (err: any) {
+ setError(err.message || "操作失败");
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
{config ? "编辑配置" : "添加配置"}
+ {error &&
{error}
}
+
+
+
+
+ setField("name", e.target.value)} className="w-full rounded-lg border border-border bg-page px-3 py-2 text-sm text-ink outline-none focus:border-primary" placeholder="如:智谱 AI" />
+
+
+
+
+
+
+
+
+
+ setField("api_key", e.target.value)} className="w-full rounded-lg border border-border bg-page px-3 py-2 pr-10 text-sm text-ink outline-none focus:border-primary" placeholder={config ? "留空保持不变" : "输入 API Key"} />
+
+
+
+
+
+ setField("api_base_url", e.target.value)} className="w-full rounded-lg border border-border bg-page px-3 py-2 text-sm text-ink outline-none focus:border-primary" placeholder="留空使用默认" />
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/* ======== 邮箱设置 ======== */
+function EmailSettings() {
+ const { toast } = useToast();
+ const [emailConfigs, setEmailConfigs] = useState([]);
+ const [dailyReport, setDailyReport] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ const loadEmails = useCallback(async () => {
+ try { setEmailConfigs(await emailConfigApi.list() || []); } catch { toast("error", "加载邮箱配置失败"); }
+ }, [toast]);
+
+ const loadDaily = useCallback(async () => {
+ try { setDailyReport(await dailyReportApi.getConfig()); } catch { toast("error", "加载报告配置失败"); }
+ }, [toast]);
+
+ useEffect(() => { Promise.all([loadEmails(), loadDaily()]).finally(() => setLoading(false)); }, [loadEmails, loadDaily]);
+
+ if (loading) return
;
+
+ return (
+
+
+
+ {/* 邮箱配置 */}
+
+
+
邮箱配置
+
+
+ {emailConfigs.length === 0 ? (
+
+ ) : (
+ emailConfigs.map((cfg) => (
+
+
+
+
+
+
+
+ {cfg.name}
+ {cfg.is_active && 激活}
+
+
{cfg.sender_email}
+
+
+
+ {!cfg.is_active &&
}
+
+
+
+ ))
+ )}
+
+
+ {/* 每日报告 */}
+ {dailyReport && (
+
+
每日报告
+
+
+
+
+
+
每日报告
+
{dailyReport.enabled ? "已启用" : "已禁用"}
+
+
+
+
+
+
+ )}
+
+ );
+}
+
+/* ======== Pipeline 设置 ======== */
+function PipelineSettings() {
+ const [runs, setRuns] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [filter, setFilter] = useState<"all" | "succeeded" | "failed">("all");
+
+ const loadRuns = useCallback(async () => {
+ try { setRuns((await pipelineApi.runs(50)).items || []); } catch { /* quiet */ } finally { setLoading(false); }
+ }, []);
+
+ useEffect(() => { loadRuns(); }, [loadRuns]);
+
+ if (loading) return
;
+
+ const filtered = filter === "all" ? runs : runs.filter((r) => r.status === filter);
+ const counts = { all: runs.length, succeeded: runs.filter((r) => r.status === "succeeded").length, failed: runs.filter((r) => r.status === "failed").length };
+
+ return (
+
+
+
Pipeline 运行记录
+
查看和管理 Pipeline 执行历史
+
+
+
+ {(["all", "succeeded", "failed"] as const).map((f) => (
+
+ ))}
+
+
+
+ {filtered.length === 0 ? (
+
+ ) : (
+
+ {filtered.map((run) => (
+
+
+ {run.pipeline_name}
+ {run.elapsed_ms != null ? formatDuration(run.elapsed_ms) : ""}
+ {timeAgo(run.created_at)}
+
+ ))}
+
+ )}
+
+ );
+}
+
+/* ======== 运维设置 ======== */
+function OpsSettings() {
+ const [results, setResults] = useState>({});
+
+ const ops = [
+ { key: "batchProcess", label: "一键嵌入 & 粗读", desc: "对所有未读论文执行向量嵌入 + AI 粗读", icon: BookOpen, action: async () => { const r = await jobApi.batchProcessUnread(50); return r.message; } },
+ { key: "syncIncremental", label: "增量引用同步", desc: "同步论文之间的引用关系", icon: Link2, action: async () => { const r = await citationApi.syncIncremental(); return `处理 ${r.processed_papers ?? 0} 篇,新增 ${r.edges_inserted} 条边`; } },
+ { key: "health", label: "系统健康检查", desc: "查看数据库和统计信息", icon: Activity, action: async () => { const r = await systemApi.status(); return `${r.health.status === "ok" ? "正常" : "异常"} | ${r.counts.topics} 主题 | ${r.counts.papers_latest_200} 论文`; } },
+ ];
+
+ const runOp = async (key: string, fn: () => Promise) => {
+ try {
+ const msg = await fn();
+ setResults((p) => ({ ...p, [key]: { success: true, msg } }));
+ } catch (e: any) {
+ setResults((p) => ({ ...p, [key]: { success: false, msg: e.message || "失败" } }));
+ }
+ };
+
+ return (
+
+
+
+
+ {ops.map((op) => {
+ const Icon = op.icon;
+ const result = results[op.key];
+ return (
+
+
+
+
+
+
+
+
{op.label}
+
{op.desc}
+
+
+
+
+ {result && (
+
+ {result.msg}
+
+ )}
+
+ );
+ })}
+
+
+ );
+}
From eb41f04b72bffde0d3ff5ac84feac6d90953bb87 Mon Sep 17 00:00:00 2001
From: Color2333 <1552429809@qq.com>
Date: Tue, 24 Mar 2026 15:08:13 +0800
Subject: [PATCH 33/39] =?UTF-8?q?fix(settings):=20=E4=BE=A7=E8=BE=B9?=
=?UTF-8?q?=E6=A0=8F=E8=AE=BE=E7=BD=AE=E6=94=B9=E4=B8=BA=E8=B7=AF=E7=94=B1?=
=?UTF-8?q?=E5=AF=BC=E8=88=AA?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 移除 showSettings state 和 SettingsDialog 弹窗
- 点击侧边栏设置改为导航到 /settings 路由
- 删除 SettingsDialog.tsx 组件
- 清理不再使用的 lazy/Suspense 导入
- 修复 Settings.tsx 中 useLocation 未定义错误
---
frontend/src/components/Sidebar.tsx | 15 ++-------------
frontend/src/pages/Settings.tsx | 1 -
2 files changed, 2 insertions(+), 14 deletions(-)
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)} />
-
- )}
-
("llm");
return (
From 1bee4678f638ebbfb24887e5e5ddc4a3a068f341 Mon Sep 17 00:00:00 2001
From: Color2333 <1552429809@qq.com>
Date: Tue, 24 Mar 2026 16:12:40 +0800
Subject: [PATCH 34/39] =?UTF-8?q?feat(settings):=20=E5=AE=8C=E5=96=84?=
=?UTF-8?q?=E6=89=80=E6=9C=89=E8=AE=BE=E7=BD=AE=E5=8A=9F=E8=83=BD=E4=B8=8E?=
=?UTF-8?q?SettingsDialog=E5=AF=B9=E9=BD=90?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
LLMTab:
- 添加ProviderBadge彩色服务商标签
- 添加deactivate切回默认配置功能
- 添加OpenAI/Anthropic预设配置
EmailTab:
- 添加send_email_report开关
- 添加recipient_emails收件人配置
- 添加cron_expression定时任务配置
- 添加include_paper_details/include_graph_insights选项
- 添加AddEmailConfigModal/EditEmailConfigModal完整表单
- 添加QQ邮箱/Gmail/163邮箱快捷预设
- 添加test邮件功能
PipelineTab:
- 添加StatusDot状态指示器
- 添加paper_id显示
- 添加running/pending状态过滤
OpsTab:
- 添加dailyJob每日任务
- 添加weeklyJob每周图维护
- 添加loading状态管理
---
frontend/src/pages/Settings.tsx | 543 +++++++++++++++++++++++++++++---
1 file changed, 494 insertions(+), 49 deletions(-)
diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx
index b918c7b..f93f6c4 100644
--- a/frontend/src/pages/Settings.tsx
+++ b/frontend/src/pages/Settings.tsx
@@ -12,6 +12,7 @@ import {
Trash2,
Pencil,
Power,
+ PowerOff,
Eye,
EyeOff,
Server,
@@ -20,6 +21,14 @@ import {
Link2,
BookOpen,
Activity,
+ Zap,
+ Network,
+ Calendar,
+ CheckCircle2,
+ XCircle,
+ Clock,
+ AlertTriangle,
+ Send,
} from "lucide-react";
import { useToast } from "@/contexts/ToastContext";
import { Button } from "@/components/ui/Button";
@@ -34,6 +43,7 @@ import {
emailConfigApi,
dailyReportApi,
} from "@/services/api";
+import { getErrorMessage } from "@/lib/errorHandler";
import { cn } from "@/lib/utils";
import { formatDuration, timeAgo } from "@/lib/utils";
@@ -52,6 +62,16 @@ const PROVIDER_PRESETS: Record = {
+ zhipu: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300",
+ openai: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300",
+ anthropic: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300",
+ };
+ const labels: Record = {
+ zhipu: "智谱",
+ openai: "OpenAI",
+ anthropic: "Anthropic",
+ };
+ return (
+
+
+ {labels[provider] || provider}
+
+ );
+}
+
function LLMSettings() {
const { toast } = useToast();
const [configs, setConfigs] = useState([]);
@@ -110,6 +149,7 @@ function LLMSettings() {
const [loading, setLoading] = useState(true);
const [showAdd, setShowAdd] = useState(false);
const [editCfg, setEditCfg] = useState(null);
+ const [submitting, setSubmitting] = useState(false);
const load = useCallback(async () => {
try {
@@ -125,6 +165,19 @@ function LLMSettings() {
useEffect(() => { load(); }, [load]);
+ const handleDeactivate = async () => {
+ setSubmitting(true);
+ try {
+ await llmConfigApi.deactivate();
+ await load();
+ toast("success", "已切回默认配置");
+ } catch (err) {
+ toast("error", getErrorMessage(err));
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
if (loading) return
;
return (
@@ -146,17 +199,31 @@ function LLMSettings() {
{activeInfo.config?.name || "当前配置"}
使用中
+
+
+ {activeInfo.source === "database" ? "用户配置" : ".env"}
+
- 文本: {activeInfo.config?.model_skim}
- 视觉: {activeInfo.config?.model_vision || "未设置"}
+ 粗读: {activeInfo.config?.model_skim}
+ 精读: {activeInfo.config?.model_deep}
+ {activeInfo.config?.model_vision && 视觉: {activeInfo.config?.model_vision}}
+ 嵌入: {activeInfo.config?.model_embedding}
-
+
+
+ {activeInfo.source === "database" && (
+
+ )}
+
)}
@@ -194,7 +261,7 @@ function LLMSettings() {
{cfg.name}
{cfg.is_active &&
激活}
-
{cfg.provider}
+
{cfg.api_key_masked}
@@ -232,6 +299,7 @@ function LLMSettings() {
}
function ConfigModal({ config, onClose, onSaved }: { config?: any; onClose: () => void; onSaved: () => void }) {
+ const { toast } = useToast();
const [form, setForm] = useState({
name: config?.name || "",
provider: config?.provider || "zhipu",
@@ -266,12 +334,14 @@ function ConfigModal({ config, onClose, onSaved }: { config?: any; onClose: () =
const payload: any = { name: form.name, provider: form.provider, api_base_url: form.api_base_url, model_skim: form.model_skim, model_deep: form.model_deep, model_vision: form.model_vision, model_embedding: form.model_embedding, model_fallback: form.model_fallback };
if (form.api_key) payload.api_key = form.api_key;
await llmConfigApi.update(config.id, payload);
+ toast("success", "配置已保存");
} else {
await llmConfigApi.create(form);
+ toast("success", "配置已创建");
}
onSaved();
} catch (err: any) {
- setError(err.message || "操作失败");
+ setError(getErrorMessage(err));
} finally {
setSubmitting(false);
}
@@ -341,18 +411,107 @@ function EmailSettings() {
const { toast } = useToast();
const [emailConfigs, setEmailConfigs] = useState
([]);
const [dailyReport, setDailyReport] = useState(null);
+ const [localConfig, setLocalConfig] = useState(null);
const [loading, setLoading] = useState(true);
+ const [submitting, setSubmitting] = useState(false);
+ const [showAddEmail, setShowAddEmail] = useState(false);
+ const [editEmailConfig, setEditEmailConfig] = useState(null);
+ const [testEmailId, setTestEmailId] = useState(null);
const loadEmails = useCallback(async () => {
try { setEmailConfigs(await emailConfigApi.list() || []); } catch { toast("error", "加载邮箱配置失败"); }
}, [toast]);
const loadDaily = useCallback(async () => {
- try { setDailyReport(await dailyReportApi.getConfig()); } catch { toast("error", "加载报告配置失败"); }
+ try {
+ const data = await dailyReportApi.getConfig();
+ setDailyReport(data);
+ setLocalConfig(data);
+ } catch { toast("error", "加载报告配置失败"); }
}, [toast]);
useEffect(() => { Promise.all([loadEmails(), loadDaily()]).finally(() => setLoading(false)); }, [loadEmails, loadDaily]);
+ const handleActivateEmail = async (id: string) => {
+ setSubmitting(true);
+ try {
+ await emailConfigApi.activate(id);
+ await loadEmails();
+ toast("success", "邮箱已激活");
+ } catch (err) {
+ toast("error", getErrorMessage(err));
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const handleDeleteEmail = async (id: string) => {
+ if (!confirm("确定要删除此邮箱配置?")) return;
+ try {
+ await emailConfigApi.delete(id);
+ await loadEmails();
+ toast("success", "邮箱配置已删除");
+ } catch (err) {
+ toast("error", getErrorMessage(err));
+ }
+ };
+
+ const handleTestEmail = async (id: string) => {
+ setTestEmailId(id);
+ try {
+ await emailConfigApi.test(id);
+ toast("success", "测试邮件已发送,请检查邮箱");
+ } catch (err) {
+ toast("error", getErrorMessage(err));
+ } finally {
+ setTestEmailId(null);
+ }
+ };
+
+ const handleUpdateDailyReport = async (updates: any) => {
+ setSubmitting(true);
+ try {
+ const body: Record = { ...updates };
+ if (updates.recipient_emails !== undefined) {
+ body.recipient_emails = Array.isArray(updates.recipient_emails) ? updates.recipient_emails.join(",") : updates.recipient_emails;
+ }
+ const data = await dailyReportApi.updateConfig(body);
+ if (data.config) {
+ setDailyReport(data.config);
+ setLocalConfig(data.config);
+ toast("success", "每日报告配置已更新");
+ }
+ } catch (err) {
+ toast("error", getErrorMessage(err));
+ await loadDaily();
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const handleInputChange = (field: string, value: any) => {
+ setLocalConfig((prev: any) => ({ ...prev, [field]: value }));
+ };
+
+ const handleInputBlur = (field: string) => {
+ if (localConfig && localConfig[field] !== dailyReport[field]) {
+ handleUpdateDailyReport({ [field]: localConfig[field] });
+ }
+ };
+
+ const handleRunDailyWorkflow = async () => {
+ if (!confirm("确定要立即执行每日工作流吗?这将使用AI推荐系统找出高价值论文进行精读,生成每日简报并发送邮件报告。\n\n注意:精读论文需要几分钟时间,任务将在后台执行,请稍后查看结果。")) return;
+ setSubmitting(true);
+ try {
+ await dailyReportApi.runOnce();
+ toast("success", "每日报告工作流已启动,正在后台执行");
+ } catch (err) {
+ toast("error", getErrorMessage(err));
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
if (loading) return
;
return (
@@ -366,19 +525,7 @@ function EmailSettings() {
邮箱配置
-
@@ -403,8 +550,12 @@ function EmailSettings() {
- {!cfg.is_active &&
{ await emailConfigApi.activate(cfg.id); loadEmails(); toast("success", "已激活"); }}>}
-
{ if (confirm("删除此配置?")) { await emailConfigApi.delete(cfg.id); loadEmails(); } }}>
+ {!cfg.is_active &&
handleActivateEmail(cfg.id)} disabled={submitting}>}
+
handleTestEmail(cfg.id)} disabled={testEmailId === cfg.id}>
+ {testEmailId === cfg.id ? : }
+
+
setEditEmailConfig(cfg)}>
+
handleDeleteEmail(cfg.id)} disabled={cfg.is_active}>
))
@@ -415,7 +566,7 @@ function EmailSettings() {
{dailyReport && (
每日报告
-
+
@@ -428,24 +579,317 @@ function EmailSettings() {
{ await dailyReportApi.updateConfig({ enabled: !dailyReport.enabled }); loadDaily(); }}
+ onClick={() => handleUpdateDailyReport({ enabled: !dailyReport.enabled })}
+ disabled={submitting}
className={cn("relative h-6 w-11 rounded-full transition-colors", dailyReport.enabled ? "bg-primary" : "bg-ink-tertiary")}
>
+
+ {dailyReport.enabled && (
+ <>
+
+
+
发送邮件报告
+
handleUpdateDailyReport({ send_email_report: !dailyReport.send_email_report })}
+ disabled={submitting}
+ className={cn("relative h-4 w-8 rounded-full transition-colors", dailyReport.send_email_report ? "bg-primary" : "bg-ink-tertiary")}
+ >
+
+
+
+ {dailyReport.send_email_report && (
+
+
handleInputChange("recipient_emails", e.target.value)}
+ onBlur={() => handleInputBlur("recipient_emails")}
+ disabled={submitting}
+ className="w-full rounded border border-border bg-surface px-2 py-1.5 text-xs text-ink placeholder:text-ink-placeholder"
+ />
+
+
+
handleInputChange("cron_expression", e.target.value)}
+ onBlur={() => handleInputBlur("cron_expression")}
+ disabled={submitting}
+ className="w-full rounded border border-border bg-surface px-2 py-1.5 text-xs font-mono text-ink placeholder:text-ink-placeholder"
+ />
+
+ 默认:0 4 * * *(UTC 4 点 = 北京时间 12 点)
+
+ 格式:分 时 日 月 周
+
+
+
+ )}
+
+
+
+
+
+ {submitting ? <>执行中...> : <>立即执行>}
+
+ >
+ )}
)}
+
+ {/* 添加邮箱弹窗 */}
+ {showAddEmail && (
+
{ setShowAddEmail(false); loadEmails(); }}
+ onCancel={() => setShowAddEmail(false)}
+ />
+ )}
+
+ {/* 编辑邮箱弹窗 */}
+ {editEmailConfig && (
+ { setEditEmailConfig(null); loadEmails(); }}
+ onCancel={() => setEditEmailConfig(null)}
+ />
+ )}
+
+ );
+}
+
+function AddEmailConfigModal({ onCreated, onCancel }: { onCreated: () => void; onCancel: () => void }) {
+ const { toast } = useToast();
+ const [form, setForm] = useState({
+ name: "",
+ smtp_server: "",
+ smtp_port: 587,
+ smtp_use_tls: true,
+ sender_email: "",
+ sender_name: "PaperMind",
+ username: "",
+ password: "",
+ });
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState("");
+
+ const setField = (key: string, value: any) => setForm((prev) => ({ ...prev, [key]: value }));
+
+ const handleSelectPreset = async (provider: string) => {
+ try {
+ const data = await emailConfigApi.smtpPresets();
+ const preset = data[provider];
+ if (!preset) {
+ toast("error", `未找到 ${provider} 邮箱的预设配置`);
+ return;
+ }
+ setForm((prev) => ({
+ ...prev,
+ smtp_server: preset.smtp_server || prev.smtp_server,
+ smtp_port: preset.smtp_port || 587,
+ smtp_use_tls: preset.smtp_use_tls !== false,
+ }));
+ } catch (err) {
+ toast("error", getErrorMessage(err));
+ }
+ };
+
+ const handleSubmit = async () => {
+ if (!form.name.trim() || !form.smtp_server || !form.sender_email || !form.username || !form.password) {
+ setError("请填写所有必填字段");
+ return;
+ }
+ setSubmitting(true);
+ setError("");
+ try {
+ await emailConfigApi.create(form);
+ toast("success", "邮箱配置已添加");
+ onCreated();
+ } catch (err) {
+ setError(getErrorMessage(err));
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
添加邮箱配置
+ {error &&
{error}
}
+
+
+ handleSelectPreset("qq")} className="flex-1">QQ 邮箱
+ handleSelectPreset("gmail")} className="flex-1">Gmail
+ handleSelectPreset("163")} className="flex-1">163 邮箱
+
+
+
+
+
+ setField("username", e.target.value)} className="w-full rounded-lg border border-border bg-page px-2.5 py-1.5 text-xs text-ink outline-none focus:border-primary" placeholder="同发件人邮箱" />
+
+
+
+ setField("password", e.target.value)} className="w-full rounded-lg border border-border bg-page px-2.5 py-1.5 text-xs text-ink outline-none focus:border-primary" placeholder="邮箱授权码" />
+
+
+
+
+ 取消
+ {submitting ? : null}添加
+
+
+
+ );
+}
+
+function EditEmailConfigModal({ config, onSaved, onCancel }: { config: any; onSaved: () => void; onCancel: () => void }) {
+ const [form, setForm] = useState({
+ name: config.name,
+ smtp_server: config.smtp_server,
+ smtp_port: config.smtp_port,
+ smtp_use_tls: config.smtp_use_tls,
+ sender_email: config.sender_email,
+ sender_name: config.sender_name || "PaperMind",
+ username: config.username,
+ password: "",
+ });
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState("");
+
+ const setField = (key: string, value: any) => setForm((prev) => ({ ...prev, [key]: value }));
+
+ const handleSubmit = async () => {
+ setSubmitting(true);
+ setError("");
+ try {
+ const payload = { ...form };
+ if (!form.password) delete (payload as any).password;
+ await emailConfigApi.update(config.id, payload);
+ onSaved();
+ } catch (err) {
+ setError(getErrorMessage(err));
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
编辑邮箱配置
+ {error &&
{error}
}
+
+
+ 取消
+ {submitting ? : null}保存
+
+
);
}
/* ======== Pipeline 设置 ======== */
+function StatusDot({ status }: { status: string }) {
+ const colors: Record
= {
+ succeeded: "bg-success",
+ failed: "bg-error",
+ running: "bg-info animate-pulse",
+ pending: "bg-warning",
+ };
+ return ;
+}
+
function PipelineSettings() {
const [runs, setRuns] = useState([]);
const [loading, setLoading] = useState(true);
- const [filter, setFilter] = useState<"all" | "succeeded" | "failed">("all");
+ const [filter, setFilter] = useState<"all" | "succeeded" | "failed" | "running">("all");
const loadRuns = useCallback(async () => {
try { setRuns((await pipelineApi.runs(50)).items || []); } catch { /* quiet */ } finally { setLoading(false); }
@@ -456,7 +900,7 @@ function PipelineSettings() {
if (loading) return
;
const filtered = filter === "all" ? runs : runs.filter((r) => r.status === filter);
- const counts = { all: runs.length, succeeded: runs.filter((r) => r.status === "succeeded").length, failed: runs.filter((r) => r.status === "failed").length };
+ const counts = { all: runs.length, succeeded: runs.filter((r) => r.status === "succeeded").length, failed: runs.filter((r) => r.status === "failed").length, running: runs.filter((r) => r.status === "running" || r.status === "pending").length };
return (
@@ -466,9 +910,9 @@ function PipelineSettings() {
- {(["all", "succeeded", "failed"] as const).map((f) => (
+ {(["all", "succeeded", "failed", "running"] as const).map((f) => (
setFilter(f)} className={cn("rounded-lg px-3 py-1.5 text-xs font-medium transition-colors", filter === f ? "bg-primary text-white" : "bg-hover text-ink-secondary hover:text-ink")}>
- {f === "all" ? `全部 (${counts.all})` : f === "succeeded" ? `成功 (${counts.succeeded})` : `失败 (${counts.failed})`}
+ {f === "all" ? `全部 (${counts.all})` : f === "succeeded" ? `成功 (${counts.succeeded})` : f === "failed" ? `失败 (${counts.failed})` : `进行中 (${counts.running})`}
))}
@@ -480,11 +924,12 @@ function PipelineSettings() {
暂无运行记录
) : (
-
+
{filtered.map((run) => (
-
+
{run.pipeline_name}
+ {run.paper_id && {run.paper_id.slice(0, 8)}}
{run.elapsed_ms != null ? formatDuration(run.elapsed_ms) : ""}
{timeAgo(run.created_at)}
@@ -496,24 +941,24 @@ function PipelineSettings() {
}
/* ======== 运维设置 ======== */
+interface OpResult { success: boolean; message: string; }
+
function OpsSettings() {
- const [results, setResults] = useState
>({});
+ const { toast } = useToast();
+ const [results, setResults] = useState>({});
+ const [loadings, setLoadings] = useState>({});
+
+ const setL = (k: string, v: boolean) => setLoadings((p) => ({ ...p, [k]: v }));
+ const setR = (k: string, r: OpResult) => setResults((p) => ({ ...p, [k]: r }));
const ops = [
- { key: "batchProcess", label: "一键嵌入 & 粗读", desc: "对所有未读论文执行向量嵌入 + AI 粗读", icon: BookOpen, action: async () => { const r = await jobApi.batchProcessUnread(50); return r.message; } },
- { key: "syncIncremental", label: "增量引用同步", desc: "同步论文之间的引用关系", icon: Link2, action: async () => { const r = await citationApi.syncIncremental(); return `处理 ${r.processed_papers ?? 0} 篇,新增 ${r.edges_inserted} 条边`; } },
- { key: "health", label: "系统健康检查", desc: "查看数据库和统计信息", icon: Activity, action: async () => { const r = await systemApi.status(); return `${r.health.status === "ok" ? "正常" : "异常"} | ${r.counts.topics} 主题 | ${r.counts.papers_latest_200} 论文`; } },
+ { key: "batchProcess", label: "一键嵌入 & 粗读未读论文", desc: "对所有未读论文执行向量嵌入 + AI 粗读(并行处理)", icon: BookOpen, action: async () => { setL("batchProcess", true); try { const r = await jobApi.batchProcessUnread(50); setR("batchProcess", { success: r.failed === 0, message: r.message }); } catch (err) { setR("batchProcess", { success: false, message: err instanceof Error ? err.message : "失败" }); } finally { setL("batchProcess", false); } } },
+ { key: "syncIncremental", label: "增量引用同步", desc: "同步论文之间的引用关系", icon: Link2, action: async () => { setL("syncIncremental", true); try { const r = await citationApi.syncIncremental(); setR("syncIncremental", { success: true, message: `同步完成,处理 ${r.processed_papers ?? 0} 篇,新增 ${r.edges_inserted} 条边` }); } catch (err) { setR("syncIncremental", { success: false, message: err instanceof Error ? err.message : "失败" }); } finally { setL("syncIncremental", false); } } },
+ { key: "dailyJob", label: "执行每日任务", desc: "抓取论文 + 生成简报", icon: Calendar, action: async () => { setL("dailyJob", true); try { await jobApi.dailyRun(); setR("dailyJob", { success: true, message: "每日任务执行完成" }); } catch (err) { setR("dailyJob", { success: false, message: err instanceof Error ? err.message : "失败" }); } finally { setL("dailyJob", false); } } },
+ { key: "weeklyJob", label: "每周图维护", desc: "引用同步 + 图谱维护", icon: Network, action: async () => { setL("weeklyJob", true); try { await jobApi.weeklyGraphRun(); setR("weeklyJob", { success: true, message: "每周维护执行完成" }); } catch (err) { setR("weeklyJob", { success: false, message: err instanceof Error ? err.message : "失败" }); } finally { setL("weeklyJob", false); } } },
+ { key: "health", label: "系统健康检查", desc: "数据库 + 统计信息", icon: Zap, action: async () => { setL("health", true); try { const r = await systemApi.status(); setR("health", { success: r.health.status === "ok", message: `${r.health.status === "ok" ? "正常" : "异常"} | ${r.counts.topics} 主题 | ${r.counts.papers_latest_200} 论文` }); } catch (err) { setR("health", { success: false, message: err instanceof Error ? err.message : "失败" }); } finally { setL("health", false); } } },
];
- const runOp = async (key: string, fn: () => Promise) => {
- try {
- const msg = await fn();
- setResults((p) => ({ ...p, [key]: { success: true, msg } }));
- } catch (e: any) {
- setResults((p) => ({ ...p, [key]: { success: false, msg: e.message || "失败" } }));
- }
- };
-
return (
@@ -525,6 +970,7 @@ function OpsSettings() {
{ops.map((op) => {
const Icon = op.icon;
const result = results[op.key];
+ const loading = loadings[op.key];
return (
@@ -537,14 +983,13 @@ function OpsSettings() {
{op.desc}
-
runOp(op.key, op.action)}>
-
- 执行
+ op.action()} disabled={loading}>
+ {loading ? <>执行中> : <>执行>}
{result && (
- {result.msg}
+ {result.message}
)}
From 7dae78acb5ea096c3fc9fb35a9304ae3ce6074a5 Mon Sep 17 00:00:00 2001
From: Color2333 <1552429809@qq.com>
Date: Tue, 24 Mar 2026 16:18:27 +0800
Subject: [PATCH 35/39] =?UTF-8?q?fix(settings):=20=E8=A1=A5=E5=85=85?=
=?UTF-8?q?=E7=BC=BA=E5=A4=B1=E5=8A=9F=E8=83=BD=E5=92=8C=E4=BF=AE=E5=A4=8D?=
=?UTF-8?q?=E7=8A=B6=E6=80=81=E7=AE=A1=E7=90=86bug?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- EmailTab: 添加 auto_deep_read 自动精读开关
- EmailTab: 添加 deep_read_limit 每日精读上限配置
- LLMTab: 添加 handleActivate/handleDelete 专用处理函数
- LLMTab: activate/delete/编辑按钮在操作中禁用防止竞态
---
frontend/src/pages/Settings.tsx | 65 +++++++++++++++++++++++++++++++--
1 file changed, 62 insertions(+), 3 deletions(-)
diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx
index f93f6c4..bf22094 100644
--- a/frontend/src/pages/Settings.tsx
+++ b/frontend/src/pages/Settings.tsx
@@ -150,6 +150,7 @@ function LLMSettings() {
const [showAdd, setShowAdd] = useState(false);
const [editCfg, setEditCfg] = useState(null);
const [submitting, setSubmitting] = useState(false);
+ const [actionId, setActionId] = useState(null);
const load = useCallback(async () => {
try {
@@ -178,6 +179,33 @@ function LLMSettings() {
}
};
+ const handleActivate = async (id: string) => {
+ setActionId(id);
+ try {
+ await llmConfigApi.activate(id);
+ await load();
+ toast("success", "配置已激活");
+ } catch (err) {
+ toast("error", getErrorMessage(err));
+ } finally {
+ setActionId(null);
+ }
+ };
+
+ const handleDelete = async (id: string) => {
+ if (!confirm("确定要删除此配置?")) return;
+ setActionId(id);
+ try {
+ await llmConfigApi.delete(id);
+ await load();
+ toast("success", "配置已删除");
+ } catch (err) {
+ toast("error", getErrorMessage(err));
+ } finally {
+ setActionId(null);
+ }
+ };
+
if (loading) return
;
return (
@@ -270,13 +298,13 @@ function LLMSettings() {
{!cfg.is_active && (
-
{ await llmConfigApi.activate(cfg.id); load(); }}>
+ handleActivate(cfg.id)} disabled={actionId !== null}>
激活
)}
- setEditCfg(cfg)}>
- { if (confirm("确定删除?")) { await llmConfigApi.delete(cfg.id); load(); } }} disabled={cfg.is_active}>
+ setEditCfg(cfg)} disabled={actionId !== null}>
+ handleDelete(cfg.id)} disabled={cfg.is_active || actionId !== null}>
@@ -634,6 +662,37 @@ function EmailSettings() {
)}
+
+
+
自动精读新论文
+
每日自动精选高价值论文进行深度阅读
+
+
handleUpdateDailyReport({ auto_deep_read: !dailyReport.auto_deep_read })}
+ disabled={submitting}
+ className={cn("relative h-5 w-9 rounded-full transition-colors", dailyReport.auto_deep_read ? "bg-primary" : "bg-ink-tertiary")}
+ >
+
+
+
+ {dailyReport.auto_deep_read && (
+
+ 每日精读上限
+ handleInputChange("deep_read_limit", parseInt(e.target.value) || 10)}
+ onBlur={() => handleInputBlur("deep_read_limit")}
+ disabled={submitting}
+ className="w-20 rounded border border-border bg-page px-2 py-1 text-xs text-ink outline-none focus:border-primary"
+ />
+ 篇
+
+ )}
+
报告内容
From c700abab53d9fbf127157442483cb30bbd69140f Mon Sep 17 00:00:00 2001
From: Color2333 <1552429809@qq.com>
Date: Tue, 24 Mar 2026 16:56:09 +0800
Subject: [PATCH 36/39] =?UTF-8?q?feat(collect):=20=E6=B7=BB=E5=8A=A0?=
=?UTF-8?q?=E5=A4=9A=E6=BA=90=E6=90=9C=E7=B4=A2=20tab=20=E5=85=A5=E5=8F=A3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增"多源搜索"tab,支持 ArXiv/OpenAlex/Semantic Scholar 等多渠道
- 添加 paperApi.multiSourceSearch() 和 suggestChannels() API
- 添加 MultiSourceSearchResult 和 ChannelSuggestion 类型
- 多源搜索区域包含:搜索框、渠道推荐、渠道选择按钮组
- 搜索结果展示论文标题、作者、来源和日期
---
frontend/src/pages/Collect.tsx | 215 ++++++++++++++++++++++++++++++++-
frontend/src/services/api.ts | 11 ++
frontend/src/types/index.ts | 23 ++++
3 files changed, 247 insertions(+), 2 deletions(-)
diff --git a/frontend/src/pages/Collect.tsx b/frontend/src/pages/Collect.tsx
index dbbfcf5..dc0c729 100644
--- a/frontend/src/pages/Collect.tsx
+++ b/frontend/src/pages/Collect.tsx
@@ -33,7 +33,7 @@ import {
Play,
Layers,
} from "lucide-react";
-import { ingestApi, topicApi } from "@/services/api";
+import { ingestApi, topicApi, paperApi } from "@/services/api";
import { useToast } from "@/contexts/ToastContext";
import ConfirmDialog from "@/components/ConfirmDialog";
import type {
@@ -44,11 +44,13 @@ import type {
KeywordSuggestion,
IngestPaper,
TopicFetchResult,
+ MultiSourceSearchResult,
+ ChannelSuggestion,
} from "@/types";
import CSFeeds from "./CSFeeds";
type SortBy = "submittedDate" | "relevance" | "lastUpdatedDate";
-type ActiveTab = "search" | "subscriptions" | "csfeeds";
+type ActiveTab = "search" | "subscriptions" | "csfeeds" | "multi";
interface SearchResult {
ingested: number;
@@ -98,6 +100,15 @@ function relativeTime(iso: string): string {
return d.toLocaleDateString("zh-CN");
}
+const CHANNEL_MAP: Record = {
+ arxiv: { id: "arxiv", name: "ArXiv", isFree: true },
+ openalex: { id: "openalex", name: "OpenAlex", isFree: true },
+ semantic_scholar: { id: "semantic_scholar", name: "Semantic Scholar", isFree: true },
+ dblp: { id: "dblp", name: "DBLP", isFree: true },
+ ieee: { id: "ieee", name: "IEEE", isFree: false },
+ biorxiv: { id: "biorxiv", name: "bioRxiv", isFree: true },
+};
+
export default function Collect() {
const { toast } = useToast();
const navigate = useNavigate();
@@ -113,6 +124,45 @@ export default function Collect() {
const [results, setResults] = useState([]);
const [error, setError] = useState("");
+ // ========== 多源搜索 ==========
+ const [multiQuery, setMultiQuery] = useState("");
+ const [multiChannels, setMultiChannels] = useState(["arxiv"]);
+ const [multiLoading, setMultiLoading] = useState(false);
+ const [multiResults, setMultiResults] = useState([]);
+ const [multiSuggestions, setMultiSuggestions] = useState(null);
+
+ const handleMultiSearch = useCallback(async (q: string, channels: string[]) => {
+ if (!q.trim()) return;
+ setMultiLoading(true);
+ try {
+ const res = await paperApi.multiSourceSearch(q.trim(), channels);
+ setMultiResults(res.results || []);
+ if (res.results && res.results.length > 0) {
+ toast("success", `找到 ${res.results.length} 篇相关论文`);
+ } else {
+ toast("info", "未找到相关论文");
+ }
+ } catch (err) {
+ toast("error", err instanceof Error ? err.message : "搜索失败");
+ } finally {
+ setMultiLoading(false);
+ }
+ }, [toast]);
+
+ const fetchMultiSuggestions = useCallback(async (q: string) => {
+ if (!q.trim()) { setMultiSuggestions(null); return; }
+ try {
+ const res = await paperApi.suggestChannels(q.trim());
+ setMultiSuggestions(res);
+ } catch { /* quiet */ }
+ }, []);
+
+ const applyMultiRecommendation = useCallback(() => {
+ if (multiSuggestions?.recommended) {
+ setMultiChannels(multiSuggestions.recommended);
+ }
+ }, [multiSuggestions]);
+
// ========== 订阅管理 ==========
const [topics, setTopics] = useState([]);
const [loading, setLoading] = useState(true);
@@ -414,6 +464,17 @@ export default function Collect() {
分类订阅
+ setActiveTab("multi")}
+ className={`flex items-center gap-2 rounded-lg px-5 py-2.5 text-sm font-medium transition-all ${
+ activeTab === "multi"
+ ? "bg-primary text-white shadow-sm"
+ : "text-ink-secondary hover:text-ink hover:bg-muted"
+ }`}
+ >
+
+ 多源搜索
+
{/* 错误 */}
@@ -542,6 +603,156 @@ export default function Collect() {
)}
+ {activeTab === "multi" && (
+
+
+
+
+
+
+
多源搜索
+
+ 从 ArXiv、OpenAlex、Semantic Scholar 等多渠道并行搜索
+
+
+
+
+
+
+
+
+ {
+ setMultiQuery(e.target.value);
+ fetchMultiSuggestions(e.target.value);
+ }}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") handleMultiSearch(multiQuery, multiChannels);
+ }}
+ placeholder="输入关键词,如 machine learning transformer"
+ className="border-border bg-page text-ink placeholder:text-ink-placeholder focus:border-primary focus:ring-primary/20 h-11 w-full rounded-xl border pr-4 pl-10 text-sm focus:ring-2 focus:outline-none"
+ />
+
+
handleMultiSearch(multiQuery, multiChannels)}
+ loading={multiLoading}
+ disabled={!multiQuery.trim() || multiChannels.length === 0}
+ icon={}
+ >
+ 搜索
+
+
+
+ {multiSuggestions && multiSuggestions.recommended.length > 0 && (
+
+
+
推荐渠道:
+
+ {multiSuggestions.recommended.map((id) => {
+ const ch = CHANNEL_MAP[id];
+ return ch ? (
+
+ {ch.name}
+
+ ) : null;
+ })}
+
+ {JSON.stringify(multiSuggestions.recommended.sort()) !== JSON.stringify(multiChannels.sort()) && (
+
+ 应用推荐
+
+ )}
+
+ )}
+
+
+ 渠道:
+ {Object.values(CHANNEL_MAP).map((channel) => {
+ const isSelected = multiChannels.includes(channel.id);
+ return (
+ {
+ setMultiChannels((prev) =>
+ prev.includes(channel.id)
+ ? prev.filter((id) => id !== channel.id)
+ : [...prev, channel.id]
+ );
+ }}
+ className={`
+ inline-flex items-center px-3 py-1 rounded-full text-sm border transition-all
+ ${
+ isSelected
+ ? "bg-primary text-white border-primary"
+ : "bg-page text-ink-secondary border-border hover:border-ink-tertiary"
+ }
+ `}
+ >
+ {channel.name}
+ {!channel.isFree && 💰}
+
+ );
+ })}
+
+
+
+ {multiLoading && (
+
+
+
+ )}
+
+ {!multiLoading && multiResults.length === 0 && multiQuery.trim() && (
+
+ )}
+
+ {!multiLoading && multiResults.length > 0 && (
+
+
+
+ 共找到 {multiResults.length} 篇论文
+
+
+ {multiResults.map((paper, idx) => (
+
+
{paper.title}
+ {paper.authors && paper.authors.length > 0 && (
+
+ {paper.authors.slice(0, 3).join(", ")}
+ {paper.authors.length > 3 && " et al."}
+
+ )}
+
+
+ {paper.source}
+
+ {paper.publishedDate && (
+ {paper.publishedDate}
+ )}
+
+
+ ))}
+
+ )}
+
+ )}
+
{/* ================================================================
* 自动订阅管理
* ================================================================ */}
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
index 0c0f863..9f067bd 100644
--- a/frontend/src/services/api.ts
+++ b/frontend/src/services/api.ts
@@ -53,6 +53,8 @@ import type {
ActiveTaskInfo,
LoginResponse,
AuthStatusResponse,
+ MultiSourceSearchResult,
+ ChannelSuggestion,
} from "@/types";
export type {
@@ -275,6 +277,15 @@ export const paperApi = {
},
aiExplain: (id: string, text: string, action: "explain" | "translate" | "summarize") =>
post<{ action: string; result: string }>(`/papers/${id}/ai/explain`, { text, action }),
+ multiSourceSearch: (query: string, channels: string[]) => {
+ const params = new URLSearchParams({
+ query,
+ channels: channels.join(","),
+ });
+ return post(`/papers/multi-source-search?${params}`);
+ },
+ suggestChannels: (query: string) =>
+ get(`/papers/suggest-channels?query=${encodeURIComponent(query)}`),
};
/* ========== 摄入 ========== */
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index 6eea8be..72301c0 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -659,6 +659,29 @@ export interface IngestResult {
papers?: IngestPaper[];
}
+/* ========== 多源搜索 ========== */
+export interface MultiSourcePaper {
+ id: string;
+ title: string;
+ authors?: string[];
+ abstract?: string;
+ source: string;
+ url?: string;
+ publishedDate?: string;
+ externalId?: string;
+}
+
+export interface MultiSourceSearchResult {
+ results: MultiSourcePaper[];
+ channelStats?: Record;
+}
+
+export interface ChannelSuggestion {
+ recommended: string[];
+ alternatives: string[];
+ reasoning: string;
+}
+
/* ========== 聊天消息 ========== */
export interface ChatMessage {
id: string;
From dadcf99665f2a164bfbdce0dfbaf5c4b27bd217c Mon Sep 17 00:00:00 2001
From: Color2333 <1552429809@qq.com>
Date: Thu, 26 Mar 2026 16:33:12 +0800
Subject: [PATCH 37/39] =?UTF-8?q?fix(multi-source):=20=E4=BF=AE=E5=A4=8D?=
=?UTF-8?q?=E5=A4=9A=E6=BA=90=E6=90=9C=E7=B4=A2=E4=B8=A5=E9=87=8D=E9=97=AE?=
=?UTF-8?q?=E9=A2=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
P0 修复:
- api.ts: 端点从 /papers/multi-source-search 改为 /papers/search-multi
- api.ts: 修复响应字段映射 (papers→results, channel_stats→channelStats)
- 类型定义: 更新 MultiSourcePaper 匹配后端返回结构
- arxiv_channel.py: 确保 source/source_id 正确设置
P1 修复:
- arxiv_channel.py: days_back=0 移除默认7天限制
- ChannelContext.tsx: 使用 resolveApiBase() 替代硬编码 /api
- AggregationPanel.tsx: 使用 paperApi.multiSourceSearch() 替代原生 fetch
- MultiSourceSearchBar.tsx: 使用 paperApi.suggestChannels() 替代原生 fetch
---
.../src/components/ToolPanel/AggregationPanel.tsx | 14 +++-----------
.../src/components/search/MultiSourceSearchBar.tsx | 8 ++------
frontend/src/contexts/ChannelContext.tsx | 5 +++--
frontend/src/services/api.ts | 5 ++++-
frontend/src/types/index.ts | 11 +++++------
packages/integrations/arxiv_channel.py | 7 +++----
6 files changed, 20 insertions(+), 30 deletions(-)
diff --git a/frontend/src/components/ToolPanel/AggregationPanel.tsx b/frontend/src/components/ToolPanel/AggregationPanel.tsx
index 96819a6..421bc9c 100644
--- a/frontend/src/components/ToolPanel/AggregationPanel.tsx
+++ b/frontend/src/components/ToolPanel/AggregationPanel.tsx
@@ -1,6 +1,7 @@
import { useState, useCallback } from 'react';
import { Sparkles, Search as SearchIcon, Loader2 } from 'lucide-react';
import { MultiSourceSearchBar } from '@/components/search/MultiSourceSearchBar';
+import { paperApi } from '@/services/api';
interface SearchResult {
id: string;
@@ -24,17 +25,8 @@ export function AggregationPanel({ selectedText, paperId }: AggregationPanelProp
const handleSearch = useCallback(async (query: string, channels: string[]) => {
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}/papers/multi-source-search?query=${encodeURIComponent(query)}&channels=${channels.join(',')}`, {
- headers: {
- Authorization: `Bearer ${token}`,
- },
- });
- if (res.ok) {
- const data = await res.json();
- setResults(data.results || []);
- }
+ const res = await paperApi.multiSourceSearch(query, channels);
+ setResults(res.results || []);
} catch (err) {
console.error('Search failed:', err);
} finally {
diff --git a/frontend/src/components/search/MultiSourceSearchBar.tsx b/frontend/src/components/search/MultiSourceSearchBar.tsx
index f6a9218..f3cff34 100644
--- a/frontend/src/components/search/MultiSourceSearchBar.tsx
+++ b/frontend/src/components/search/MultiSourceSearchBar.tsx
@@ -1,6 +1,7 @@
import React, { useState, useCallback } from 'react';
import { Search, Loader2, Sparkles } from 'lucide-react';
import { useChannels } from '@/contexts/ChannelContext';
+import { paperApi } from '@/services/api';
interface MultiSourceSearchBarProps {
onSearch: (query: string, channels: string[]) => void;
@@ -27,12 +28,7 @@ export const MultiSourceSearchBar: React.FC = ({
setSuggestions(null);
return;
}
- fetch(`/papers/suggest-channels?query=${encodeURIComponent(q)}`, {
- headers: {
- Authorization: `Bearer ${localStorage.getItem('auth_token') || ''}`,
- },
- })
- .then((res) => res.ok && res.json())
+ paperApi.suggestChannels(q)
.then((data) => data && setSuggestions(data))
.catch(() => {});
}, []);
diff --git a/frontend/src/contexts/ChannelContext.tsx b/frontend/src/contexts/ChannelContext.tsx
index eb07c71..cf8d8fc 100644
--- a/frontend/src/contexts/ChannelContext.tsx
+++ b/frontend/src/contexts/ChannelContext.tsx
@@ -6,6 +6,7 @@
*/
import { createContext, useContext, useState, useCallback, ReactNode, useEffect } from 'react';
+import { resolveApiBase } from '@/lib/tauri';
export interface Channel {
id: string;
@@ -40,7 +41,8 @@ export function ChannelProvider({ children }: { children: ReactNode }) {
const fetchChannels = useCallback(async () => {
try {
setLoading(true);
- const response = await fetch('/api/papers/suggest-channels');
+ const base = resolveApiBase();
+ const response = await fetch(`${base.replace(/\/+$/, '')}/papers/suggest-channels`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
@@ -49,7 +51,6 @@ export function ChannelProvider({ children }: { children: ReactNode }) {
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败');
- // 降级:使用默认渠道列表
setChannels(INITIAL_CHANNELS);
} finally {
setLoading(false);
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
index 9f067bd..2e130c1 100644
--- a/frontend/src/services/api.ts
+++ b/frontend/src/services/api.ts
@@ -282,7 +282,10 @@ export const paperApi = {
query,
channels: channels.join(","),
});
- return post(`/papers/multi-source-search?${params}`);
+ return post(`/papers/search-multi?${params}`).then((res) => ({
+ results: res.papers || [],
+ channelStats: res.channel_stats,
+ }));
},
suggestChannels: (query: string) =>
get(`/papers/suggest-channels?query=${encodeURIComponent(query)}`),
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index 72301c0..f70015b 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -665,15 +665,14 @@ export interface MultiSourcePaper {
title: string;
authors?: string[];
abstract?: string;
- source: string;
- url?: string;
- publishedDate?: string;
- externalId?: string;
+ year?: number | null;
+ venue?: string | null;
+ sources: { channel: string; [key: string]: unknown }[];
}
export interface MultiSourceSearchResult {
- results: MultiSourcePaper[];
- channelStats?: Record;
+ papers: MultiSourcePaper[];
+ channel_stats?: Record;
}
export interface ChannelSuggestion {
diff --git a/packages/integrations/arxiv_channel.py b/packages/integrations/arxiv_channel.py
index 83e4519..4407f63 100644
--- a/packages/integrations/arxiv_channel.py
+++ b/packages/integrations/arxiv_channel.py
@@ -5,9 +5,9 @@
@author Color2333
"""
+from packages.domain.schemas import PaperCreate
from packages.integrations.arxiv_client import ArxivClient
from packages.integrations.channel_base import ChannelBase
-from packages.domain.schemas import PaperCreate
class ArxivChannel(ChannelBase):
@@ -44,9 +44,8 @@ def fetch(self, query: str, max_results: int = 20) -> list[PaperCreate]:
Returns:
list[PaperCreate]: 论文列表,source 字段统一设置为 "arxiv"
"""
- papers = self._client.fetch_latest(query, max_results)
+ papers = self._client.fetch_latest(query, max_results, days_back=0)
- # 统一设置 source 字段
for paper in papers:
paper.source = "arxiv"
paper.source_id = paper.arxiv_id
@@ -65,7 +64,7 @@ def download_pdf(self, arxiv_id: str) -> str | None:
"""
try:
return self._client.download_pdf(arxiv_id)
- except Exception as exc:
+ except Exception:
return None
def supports_incremental(self) -> bool:
From 04d6eed871da5b131dff8120349039a642236645 Mon Sep 17 00:00:00 2001
From: Color2333 <1552429809@qq.com>
Date: Fri, 24 Apr 2026 12:11:10 +0800
Subject: [PATCH 38/39] =?UTF-8?q?fix(agent/arxiv):=20=E4=BF=AE=E5=A4=8D=20?=
=?UTF-8?q?arXiv=20=E6=A3=80=E7=B4=A2/=E7=8A=B6=E6=80=81=E6=9C=BA/?=
=?UTF-8?q?=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86=E7=AD=89=E6=A0=B8=E5=BF=83?=
=?UTF-8?q?=E4=BD=93=E9=AA=8C=E9=97=AE=E9=A2=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
从一次真实使用记录定位到全链路多处核心 bug,本次一并修复:
后端 - arXiv 检索:
- arxiv_client.py 补 import time(重试分支原本会 NameError)
- fetch_latest 默认 days_back=0(原 7 天过滤把 OpenShape/Uni3D 等经典论文全筛掉)
- _build_arxiv_query 拆词上限 3→6,新增带引号精确短语搜索
- /ingest/arxiv 路由暴露 days_back 参数(默认 0),前端 ingestApi.arxiv 同步
- search_arxiv Agent 工具暴露 days_back / sort_by(默认 relevance)
后端 - Agent 状态机:
- 新增 _HANDLED_ACTION_CACHE TTL 内存缓存,confirm/reject 幂等保护
- 找不到 action 时区分文案:「已处理过」vs「真的过期」,消除矛盾提示
- SYSTEM_PROMPT 强化:ingest_arxiv 前必须列出候选标题/作者/年份,禁止盲 ID 确认
前端 - 错误处理:
- services/api.ts 新增 friendlyStatusMessage + extractErrorMessage
- request/fetchSSE 响应非 JSON 或以 < /
---
apps/api/routers/topics.py | 16 ++-
.../components/ToolPanel/AggregationPanel.tsx | 45 ++++----
frontend/src/contexts/AgentSessionContext.tsx | 13 ++-
frontend/src/lib/errorHandler.ts | 24 ++++-
frontend/src/pages/AgentSteps.tsx | 101 ++++++++++++++++--
frontend/src/pages/Collect.tsx | 55 +++++-----
frontend/src/pages/PaperDetail.tsx | 5 +-
frontend/src/services/api.ts | 81 +++++++++++---
packages/ai/agent_service.py | 67 +++++++++---
packages/ai/agent_tools.py | 40 ++++++-
packages/integrations/arxiv_client.py | 29 +++--
11 files changed, 364 insertions(+), 112 deletions(-)
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/frontend/src/components/ToolPanel/AggregationPanel.tsx b/frontend/src/components/ToolPanel/AggregationPanel.tsx
index 421bc9c..0e91168 100644
--- a/frontend/src/components/ToolPanel/AggregationPanel.tsx
+++ b/frontend/src/components/ToolPanel/AggregationPanel.tsx
@@ -2,16 +2,7 @@ import { useState, useCallback } from 'react';
import { Sparkles, Search as SearchIcon, Loader2 } from 'lucide-react';
import { MultiSourceSearchBar } from '@/components/search/MultiSourceSearchBar';
import { paperApi } from '@/services/api';
-
-interface SearchResult {
- id: string;
- title: string;
- authors?: string[];
- abstract?: string;
- source: string;
- url?: string;
- publishedDate?: string;
-}
+import type { MultiSourcePaper } from '@/types';
interface AggregationPanelProps {
selectedText: string;
@@ -19,7 +10,10 @@ interface AggregationPanelProps {
}
export function AggregationPanel({ selectedText, paperId }: AggregationPanelProps) {
- const [results, setResults] = useState([]);
+ // 当前面板由上游传入 selectedText / paperId,规划中用于基于选中文本做聚合搜索
+ void selectedText;
+ void paperId;
+ const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const handleSearch = useCallback(async (query: string, channels: string[]) => {
@@ -59,19 +53,22 @@ export function AggregationPanel({ selectedText, paperId }: AggregationPanelProp
)}
- {results.map((result, idx) => (
-
-
{result.title}
- {result.authors && result.authors.length > 0 && (
-
{result.authors.slice(0, 3).join(', ')}
- )}
- {result.source && (
-
- {result.source}
-
- )}
-
- ))}
+ {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/contexts/AgentSessionContext.tsx b/frontend/src/contexts/AgentSessionContext.tsx
index b5a8de9..2fb3c25 100644
--- a/frontend/src/contexts/AgentSessionContext.tsx
+++ b/frontend/src/contexts/AgentSessionContext.tsx
@@ -696,8 +696,16 @@ export function AgentSessionProvider({ children }: { children: React.ReactNode }
);
/* ---- 确认/拒绝操作 ---- */
+ // 已处理(confirm/reject 过)的 actionId,用于防重复提交
+ const handledActionsRef = useRef
>(new Set());
+
const handleConfirm = useCallback(
async (actionId: string) => {
+ // 幂等保护:防 StrictMode 双触发 / 按钮快速双击 / 错误重试等场景
+ if (handledActionsRef.current.has(actionId)) return;
+ if (confirmingActionIds.includes(actionId)) return;
+ handledActionsRef.current.add(actionId);
+
setConfirmingActionIds((prev) => [...prev, actionId]);
setPendingActionIds((prev) => prev.filter((id) => id !== actionId));
cancelStream();
@@ -723,11 +731,14 @@ export function AgentSessionProvider({ children }: { children: React.ReactNode }
setConfirmingActionIds((prev) => prev.filter((id) => id !== actionId));
}
},
- [startStream, cancelStream]
+ [startStream, cancelStream, confirmingActionIds]
);
const handleReject = useCallback(
async (actionId: string) => {
+ if (handledActionsRef.current.has(actionId)) return;
+ handledActionsRef.current.add(actionId);
+
setPendingActionIds((prev) => prev.filter((id) => id !== actionId));
cancelStream();
setLoading(true);
diff --git a/frontend/src/lib/errorHandler.ts b/frontend/src/lib/errorHandler.ts
index cbbb93b..f3d2c82 100644
--- a/frontend/src/lib/errorHandler.ts
+++ b/frontend/src/lib/errorHandler.ts
@@ -84,11 +84,16 @@ export function parseErrorType(error: unknown): ErrorType {
return ErrorType.NOT_FOUND;
}
- // 服务器错误
+ // 服务器错误 / 网关超时
if (
message.includes("500") ||
message.includes("502") ||
- message.includes("503")
+ message.includes("503") ||
+ message.includes("504") ||
+ message.includes("524") ||
+ message.includes("408") ||
+ message.includes("gateway time-out") ||
+ message.includes("gateway timeout")
) {
return ErrorType.SERVER;
}
@@ -131,8 +136,19 @@ export function getErrorMessage(error: unknown): string {
}
}
- // 返回原始错误消息(如果比较短)
- if (message.length < 100) {
+ // HTML 污染兜底:若消息包含 HTML 标签或 DOCTYPE,直接走默认文案
+ const lower = message.toLowerCase();
+ if (
+ message.includes(" import("@/components/Markdown"));
@@ -489,6 +489,44 @@ const StepDataView = memo(function StepDataView({ data, toolName }: { data: Reco
export { getToolMeta, ArxivCandidateSelector, StepDataView, ActionConfirmCard };
+/**
+ * 从对话 items 反查 arxiv_ids 对应的候选论文元信息
+ * 用于在 ingest_arxiv 确认卡里显示标题/作者,避免让用户"盲确认"裸 ID
+ */
+function lookupArxivCandidates(
+ items: ReturnType["items"],
+ arxivIds: string[]
+): Array<{ arxiv_id: string; title?: string; authors?: string[] }> {
+ const want = new Set(arxivIds.map((id) => id.split("v")[0])); // 去掉版本号
+ const found = new Map();
+
+ for (let i = items.length - 1; i >= 0 && found.size < want.size; i--) {
+ const it = items[i];
+ if (it.type !== "step_group" || !it.steps) continue;
+ for (const step of it.steps) {
+ if (step.toolName !== "search_arxiv") continue;
+ const cands = step.data?.candidates;
+ if (!Array.isArray(cands)) continue;
+ for (const c of cands) {
+ const rec = c as Record;
+ const aid = String(rec.arxiv_id ?? "").split("v")[0];
+ if (want.has(aid) && !found.has(aid)) {
+ found.set(aid, {
+ arxiv_id: String(rec.arxiv_id ?? aid),
+ title: rec.title ? String(rec.title) : undefined,
+ authors: Array.isArray(rec.authors) ? (rec.authors as string[]) : undefined,
+ });
+ }
+ }
+ }
+ }
+
+ return arxivIds.map((id) => {
+ const base = id.split("v")[0];
+ return found.get(base) ?? { arxiv_id: id };
+ });
+}
+
const ActionConfirmCard = memo(function ActionConfirmCard({
actionId,
description,
@@ -510,6 +548,15 @@ const ActionConfirmCard = memo(function ActionConfirmCard({
}) {
const meta = getToolMeta(tool);
const Icon = meta.icon;
+ const { items } = useAgentSession();
+
+ // 特化:ingest_arxiv 时把 arxiv_ids 反查成标题/作者卡片
+ const ingestPreview = useMemo(() => {
+ if (tool !== "ingest_arxiv") return null;
+ const ids = args?.arxiv_ids;
+ if (!Array.isArray(ids) || ids.length === 0) return null;
+ return lookupArxivCandidates(items, ids.map(String));
+ }, [tool, args, items]);
return (
-
+
{description}
- {args && Object.keys(args).length > 0 && (
-
- {Object.entries(args).map(([k, v]) => (
-
-
{k}:
-
- {typeof v === "string" ? v : JSON.stringify(v)}
-
+ {ingestPreview && ingestPreview.length > 0 ? (
+
+
+ 即将入库 {ingestPreview.length} 篇论文:
+
+ {ingestPreview.map((p, idx) => (
+
+
+ {idx + 1}.
+ {p.title || 未找到元信息(仅 ID)}
+
+
+ {p.arxiv_id}
+ {p.authors && p.authors.length > 0 && (
+
+ {p.authors.slice(0, 3).join(", ")}
+ {p.authors.length > 3 ? " 等" : ""}
+
+ )}
+
))}
+ {args?.query ? (
+
+ 来源查询:{String(args.query)}
+
+ ) : null}
+ ) : (
+ args &&
+ Object.keys(args).length > 0 && (
+
+ {Object.entries(args).map(([k, v]) => (
+
+ {k}:
+
+ {typeof v === "string" ? v : JSON.stringify(v)}
+
+
+ ))}
+
+ )
)}
diff --git a/frontend/src/pages/Collect.tsx b/frontend/src/pages/Collect.tsx
index dc0c729..e1de47b 100644
--- a/frontend/src/pages/Collect.tsx
+++ b/frontend/src/pages/Collect.tsx
@@ -21,7 +21,6 @@ import {
X,
Rss,
Loader2,
- RefreshCw,
FileText,
ExternalLink,
ChevronDown,
@@ -38,13 +37,11 @@ import { useToast } from "@/contexts/ToastContext";
import ConfirmDialog from "@/components/ConfirmDialog";
import type {
Topic,
- TopicCreate,
- TopicUpdate,
ScheduleFrequency,
KeywordSuggestion,
IngestPaper,
TopicFetchResult,
- MultiSourceSearchResult,
+ MultiSourcePaper,
ChannelSuggestion,
} from "@/types";
import CSFeeds from "./CSFeeds";
@@ -128,7 +125,7 @@ export default function Collect() {
const [multiQuery, setMultiQuery] = useState("");
const [multiChannels, setMultiChannels] = useState
(["arxiv"]);
const [multiLoading, setMultiLoading] = useState(false);
- const [multiResults, setMultiResults] = useState([]);
+ const [multiResults, setMultiResults] = useState([]);
const [multiSuggestions, setMultiSuggestions] = useState(null);
const handleMultiSearch = useCallback(async (q: string, channels: string[]) => {
@@ -726,28 +723,36 @@ export default function Collect() {
共找到 {multiResults.length} 篇论文
- {multiResults.map((paper, idx) => (
-
-
{paper.title}
- {paper.authors && paper.authors.length > 0 && (
-
- {paper.authors.slice(0, 3).join(", ")}
- {paper.authors.length > 3 && " et al."}
-
- )}
-
-
- {paper.source}
-
- {paper.publishedDate && (
-
{paper.publishedDate}
+ {multiResults.map((paper: MultiSourcePaper, idx: number) => {
+ const primarySource = paper.sources?.[0]?.channel;
+ return (
+
+
{paper.title}
+ {paper.authors && paper.authors.length > 0 && (
+
+ {paper.authors.slice(0, 3).join(", ")}
+ {paper.authors.length > 3 && " et al."}
+
)}
+
+ {primarySource && (
+
+ {primarySource}
+
+ )}
+ {paper.year && (
+ {paper.year}
+ )}
+ {paper.venue && (
+ {paper.venue}
+ )}
+
-
- ))}
+ );
+ })}
)}
diff --git a/frontend/src/pages/PaperDetail.tsx b/frontend/src/pages/PaperDetail.tsx
index 51766cd..be18ca5 100644
--- a/frontend/src/pages/PaperDetail.tsx
+++ b/frontend/src/pages/PaperDetail.tsx
@@ -277,7 +277,7 @@ export default function PaperDetail() {
setReportTab("deep");
try {
const report = await pipelineApi.deep(id);
- setSavedDeep(report);
+ setDeepReport(report);
toast("success", "精读完成");
} catch {
toast("error", "精读失败");
@@ -491,9 +491,6 @@ export default function PaperDetail() {
? "done"
: "idle";
- const anyPipelineRunning =
- skimLoading || deepLoading || figuresAnalyzing || reasoningLoading || embedLoading;
-
return (
{/* 页面头 */}
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
index 2e130c1..cbd3db3 100644
--- a/frontend/src/services/api.ts
+++ b/frontend/src/services/api.ts
@@ -94,6 +94,56 @@ export function clearAuth(): void {
localStorage.removeItem("auth_token");
}
+/** 按 HTTP 状态码映射友好文案(HTML/超长响应降级用) */
+function friendlyStatusMessage(status: number, statusText: string): string {
+ if (status === 408 || status === 504 || status === 524) {
+ return "请求超时,服务端处理时间过长,请稍后重试";
+ }
+ if (status === 502 || status === 503) {
+ return "服务暂时不可用,请稍后重试";
+ }
+ if (status === 500) {
+ return "服务器内部错误,请稍后重试或联系管理员";
+ }
+ if (status === 429) {
+ return "请求过于频繁,请稍后再试";
+ }
+ if (status === 404) {
+ return "请求的资源不存在";
+ }
+ return `${status} ${statusText}`.trim() || "请求失败";
+}
+
+/** 从失败响应中安全提取错误消息:JSON 走字段,HTML/超长文本走状态码降级 */
+async function extractErrorMessage(resp: Response): Promise {
+ const fallback = friendlyStatusMessage(resp.status, resp.statusText);
+ const contentType = resp.headers.get("content-type") || "";
+ if (contentType.includes("application/json")) {
+ try {
+ const body = await resp.json();
+ const msg = body?.message || body?.detail || body?.error;
+ if (typeof msg === "string" && msg.trim()) return msg.trim();
+ } catch {
+ // JSON 解析失败,降级到 fallback
+ }
+ return fallback;
+ }
+ // 非 JSON(text/html / text/plain 等):可能是 Cloudflare 504 HTML 页,不能原样抛
+ try {
+ const text = (await resp.text()).trim();
+ if (!text) return fallback;
+ // HTML 直接丢弃
+ if (text.startsWith("<") || text.toLowerCase().includes("(path: string, options: RequestInit = {}): Promise {
const url = `${getApiBase().replace(/\/+$/, "")}${path}`;
let resp: Response;
@@ -110,19 +160,10 @@ async function request(path: string, options: RequestInit = {}): Promise {
throw new Error("网络连接失败,请检查后端服务是否启动");
}
if (!resp.ok) {
- let msg = `${resp.status} ${resp.statusText}`;
- try {
- const body = await resp.json();
- // 兼容后端 AppError 格式: {error, message, detail}
- msg = body.message || body.detail || body.error || msg;
- } catch {
- const text = await resp.text().catch(() => "");
- if (text) msg = text;
- }
+ const msg = await extractErrorMessage(resp);
// 401 未认证,清除 token 并刷新页面跳转登录
if (resp.status === 401) {
clearAuth();
- // 强制刷新页面触发 App 重新渲染登录页
window.location.reload();
}
throw new Error(msg);
@@ -294,8 +335,19 @@ export const paperApi = {
/* ========== 摄入 ========== */
export const ingestApi = {
- arxiv: (query: string, maxResults = 20, topicId?: string, sortBy = "submittedDate") => {
- const params = new URLSearchParams({ query, max_results: String(maxResults), sort_by: sortBy });
+ arxiv: (
+ query: string,
+ maxResults = 20,
+ topicId?: string,
+ sortBy = "submittedDate",
+ daysBack = 0
+ ) => {
+ const params = new URLSearchParams({
+ query,
+ max_results: String(maxResults),
+ sort_by: sortBy,
+ days_back: String(daysBack),
+ });
if (topicId) params.append("topic_id", topicId);
return post(`/ingest/arxiv?${params}`);
},
@@ -489,13 +541,12 @@ async function fetchSSE(url: string, init?: RequestInit): Promise {
},
});
if (!resp.ok) {
- // 401 未认证,清除 token 并刷新页面跳转登录
if (resp.status === 401) {
clearAuth();
window.location.reload();
}
- const text = await resp.text().catch(() => "");
- throw new Error(`请求失败 (${resp.status}): ${text || resp.statusText}`);
+ const msg = await extractErrorMessage(resp);
+ throw new Error(`请求失败 (${resp.status}): ${msg}`);
}
return resp;
}
diff --git a/packages/ai/agent_service.py b/packages/ai/agent_service.py
index 66ea3ca..eeb03bf 100644
--- a/packages/ai/agent_service.py
+++ b/packages/ai/agent_service.py
@@ -7,6 +7,7 @@
import json
import logging
+import time
from typing import TYPE_CHECKING
from packages.agent_core.context_compaction import (
@@ -51,6 +52,9 @@
→ 调 search_arxiv 获取候选
→ **停下来**,等用户在前端界面勾选要入库的论文
→ 用户确认后调 ingest_arxiv(arxiv_ids=[用户选的])
+ → 调用 ingest_arxiv 前,**必须**先在文本消息中逐条列出每篇候选的
+ 「标题 + 第一作者 + 年份 + arXiv ID」,严禁只给出 arxiv_ids 列表让用户盲确认。
+ 用户对"看不见的 ID"没有判断依据,这是硬性规则。
4. **分析论文**("粗读"、"精读"、"分析图表")
→ 先确认目标论文 ID,再调对应工具
@@ -105,12 +109,40 @@
_ACTION_TTL = 1800 # 30 分钟
+# 已处理(确认/拒绝)过的 action_id → 时间戳;用于幂等保护,避免重复 confirm 报"已过期"
+_HANDLED_ACTION_CACHE: dict[str, float] = {}
+_HANDLED_ACTION_TTL = 3600.0 # 1 小时
+
def _make_sse(event: str, data: dict) -> str:
"""格式化 SSE 事件"""
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
+def _mark_action_handled(action_id: str) -> None:
+ """标记 action 已被处理过(确认/拒绝),避免重复触发时报'已过期'"""
+ _HANDLED_ACTION_CACHE[action_id] = time.time()
+ # 控制缓存膨胀
+ if len(_HANDLED_ACTION_CACHE) > 256:
+ now = time.time()
+ expired = [
+ aid for aid, ts in _HANDLED_ACTION_CACHE.items() if now - ts > _HANDLED_ACTION_TTL
+ ]
+ for aid in expired:
+ _HANDLED_ACTION_CACHE.pop(aid, None)
+
+
+def _is_action_handled(action_id: str) -> bool:
+ """判断 action 是否已被处理过(幂等保护)"""
+ ts = _HANDLED_ACTION_CACHE.get(action_id)
+ if ts is None:
+ return False
+ if time.time() - ts > _HANDLED_ACTION_TTL:
+ _HANDLED_ACTION_CACHE.pop(action_id, None)
+ return False
+ return True
+
+
def _record_agent_usage(
provider: str,
model: str,
@@ -282,18 +314,21 @@ def stream_chat(
if confirmed_action_id:
action = _load_pending_action(confirmed_action_id)
if not action:
+ # 幂等保护:已处理过的 action 给中性提示,不再报"已过期"
+ already_handled = _is_action_handled(confirmed_action_id)
+ err_msg = (
+ "该操作已处理过,请继续后续对话。"
+ if already_handled
+ else "该操作已过期(可能因为服务重启或超时)。请重新描述您的需求,Agent 会重新发起操作。"
+ )
def _err_iter():
- yield _make_sse(
- "error",
- {
- "message": "该操作已过期(可能因为服务重启或超时)。请重新描述您的需求,Agent 会重新发起操作。"
- },
- )
+ yield _make_sse("error", {"message": err_msg})
yield _make_sse("done", {})
return _err_iter(), conversation
+ _mark_action_handled(confirmed_action_id)
loop = _create_loop(conversation)
def _confirm_iter():
@@ -318,18 +353,23 @@ def confirm_action(action_id: str) -> tuple[Iterator[str], list[dict]]:
action = _load_pending_action(action_id)
if not action:
+ # 幂等保护:之前确认/拒绝过就明确提示,不再和"真过期"混淆
+ already_handled = _is_action_handled(action_id)
+ err_msg = (
+ "该操作已处理过,请继续后续对话。"
+ if already_handled
+ else "该操作已过期(可能因为服务重启或超时)。请重新描述您的需求,Agent 会重新发起操作。"
+ )
def _err_iter():
- yield _make_sse(
- "error",
- {
- "message": "该操作已过期(可能因为服务重启或超时)。请重新描述您的需求,Agent 会重新发起操作。"
- },
- )
+ yield _make_sse("error", {"message": err_msg})
yield _make_sse("done", {})
return _err_iter(), []
+ # 立即标记已处理,防止用户双击 / 网络重试导致二次执行
+ _mark_action_handled(action_id)
+
# 删除 pending action
try:
with session_scope() as session:
@@ -354,8 +394,9 @@ def reject_action(action_id: str) -> tuple[Iterator[str], list[dict]]:
action = _load_pending_action(action_id)
- # 删除 pending action
+ # 标记已处理 + 删除 pending action
if action:
+ _mark_action_handled(action_id)
try:
with session_scope() as session:
repo = AgentPendingActionRepository(session)
diff --git a/packages/ai/agent_tools.py b/packages/ai/agent_tools.py
index b8f7783..d14bb0b 100644
--- a/packages/ai/agent_tools.py
+++ b/packages/ai/agent_tools.py
@@ -6,8 +6,8 @@
from __future__ import annotations
import logging
-from collections.abc import Iterator
from dataclasses import dataclass, field
+from typing import TYPE_CHECKING
from uuid import UUID, uuid4
from packages.ai.brief_service import DailyBriefService
@@ -21,6 +21,9 @@
TopicRepository,
)
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
logger = logging.getLogger(__name__)
@@ -194,6 +197,19 @@ class ToolDef:
"description": "最大搜索数量",
"default": 20,
},
+ "days_back": {
+ "type": "integer",
+ "description": (
+ "只检索最近 N 天提交的论文(默认 0 = 不限日期,可搜到经典/老论文)。"
+ "需要最新增量时传 7 或 30。"
+ ),
+ "default": 0,
+ },
+ "sort_by": {
+ "type": "string",
+ "description": "排序方式:relevance(相关性,默认)/ submittedDate(最新优先)",
+ "default": "relevance",
+ },
},
"required": ["query"],
},
@@ -648,7 +664,7 @@ def _get_timeline(keyword: str, limit: int = 100) -> ToolResult:
try:
result = GraphService().timeline(keyword=keyword, limit=limit)
tl = result.get("timeline", [])
- years = sorted(set(p.get("year") for p in tl if p.get("year")))
+ years = sorted({p.get("year") for p in tl if p.get("year")})
year_range = (
f"{years[0]}-{years[-1]}" if len(years) >= 2 else (str(years[0]) if years else "无")
)
@@ -741,12 +757,26 @@ def _get_system_status() -> ToolResult:
return ToolResult(success=False, summary=f"获取系统状态失败: {exc!s}")
-def _search_arxiv(query: str, max_results: int = 20) -> ToolResult:
- """搜索 arXiv,返回候选论文列表(不入库)"""
+def _search_arxiv(
+ query: str,
+ max_results: int = 20,
+ days_back: int = 0,
+ sort_by: str = "relevance",
+) -> ToolResult:
+ """搜索 arXiv,返回候选论文列表(不入库)
+
+ days_back=0(默认)不限日期,适合按关键词检索经典/全时间段论文。
+ 想要最新增量时传 days_back=7/30。
+ """
from packages.integrations.arxiv_client import ArxivClient
try:
- papers = ArxivClient().fetch_latest(query=query, max_results=max_results)
+ papers = ArxivClient().fetch_latest(
+ query=query,
+ max_results=max_results,
+ sort_by=sort_by,
+ days_back=days_back,
+ )
except Exception as exc:
logger.exception("ArXiv search failed: %s", exc)
return ToolResult(success=False, summary=f"ArXiv 搜索失败: {exc!s}")
diff --git a/packages/integrations/arxiv_client.py b/packages/integrations/arxiv_client.py
index 6cf3fa9..f87e5c1 100644
--- a/packages/integrations/arxiv_client.py
+++ b/packages/integrations/arxiv_client.py
@@ -2,6 +2,7 @@
import logging
import re
+import time
import xml.etree.ElementTree as ElementTree
from datetime import date, datetime, timedelta
@@ -15,32 +16,38 @@
logger = logging.getLogger(__name__)
-def _build_arxiv_query(raw: str, days_back: int = 7) -> str:
+def _build_arxiv_query(raw: str, days_back: int = 0) -> str:
"""将用户输入转换为 ArXiv API 查询语法
- 已是结构化查询(含 all:/ti: 等)直接返回
- - 否则按空格拆分,取前 3 个关键词用 AND 连接(避免 429)
- - 当 days_back > 0 时自动添加最近 N 天的日期范围过滤
+ - 带引号则识别为精确短语搜索
+ - 否则按空格拆分,取前 6 个关键词用 AND 连接
+ - 当 days_back > 0 时自动添加最近 N 天的日期范围过滤(默认 0 = 不过滤)
"""
raw = raw.strip()
if not raw:
return raw
- # 日期过滤(days_back <= 0 时不添加)
date_filter = ""
if days_back > 0:
from_date = datetime.now() - timedelta(days=days_back)
date_filter = f" AND submittedDate:[{from_date.strftime('%Y%m%d')}000000 TO *]"
if re.search(r"\b(all|ti|au|abs|cat|co|jr|rn|id):", raw):
- # 已经是结构化查询,检查是否已有日期过滤
if "submittedDate:" not in raw:
return raw + date_filter
return raw
- # 拆分词汇,跳过短词(<2 字符),最多取 3 个
+
+ # 整串带引号 → 当作精确短语搜索
+ quoted = re.match(r'^"(.+)"$', raw)
+ if quoted:
+ phrase = quoted.group(1).strip()
+ return f'all:"{phrase}"' + date_filter
+
+ # 拆词:跳过短词(<2 字符),最多取 6 个(原为 3,易把多关键词查询截断)
tokens = [t.strip() for t in raw.split() if len(t.strip()) >= 2]
if not tokens:
return f"all:{raw}"
- tokens = tokens[:3]
+ tokens = tokens[:6]
return " AND ".join(f"all:{t}" for t in tokens) + date_filter
@@ -61,9 +68,13 @@ def fetch_latest(
max_results: int = 20,
sort_by: str = "submittedDate",
start: int = 0,
- days_back: int = 7,
+ days_back: int = 0,
) -> list[PaperCreate]:
- """sort_by: submittedDate(最新) / relevance(相关性) / lastUpdatedDate"""
+ """sort_by: submittedDate(最新) / relevance(相关性) / lastUpdatedDate
+
+ days_back 默认 0 = 不加日期过滤(否则经典老论文如 OpenShape/Uni3D 都会被筛掉)。
+ 订阅/定时任务需要最新增量时,由调用方显式传 days_back。
+ """
# 获取速率限制许可(10 秒超时)
if not acquire_api("arxiv", timeout=10.0):
raise httpx.TimeoutException("ArXiv 速率限制等待超时,请稍后重试")
From fcc241aae46ac0be736cb56f068ea8902ebf3553 Mon Sep 17 00:00:00 2001
From: Color2333 <1552429809@qq.com>
Date: Fri, 24 Apr 2026 12:16:33 +0800
Subject: [PATCH 39/39] =?UTF-8?q?fix(arxiv):=20=E5=88=A0=E9=99=A4=20FALLBA?=
=?UTF-8?q?CK=5FCS=5FCATEGORIES=20=E4=B8=AD=E9=87=8D=E5=A4=8D=E7=9A=84=20c?=
=?UTF-8?q?s.CL=20=E5=88=86=E7=B1=BB?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
回应 PR #17 opencode review 建议(中优先级):
fetch_categories 的 fallback 列表里 cs.CL 被错写了两次
(Computation and Language / Computational Linguistics),
官方 arxiv 分类只有一个 cs.CL = Computation and Language,删除重复项。
Co-Authored-By: Claude Opus 4.7 (1M context)
---
packages/integrations/arxiv_client.py | 1 -
1 file changed, 1 deletion(-)
diff --git a/packages/integrations/arxiv_client.py b/packages/integrations/arxiv_client.py
index f87e5c1..27e4dc5 100644
--- a/packages/integrations/arxiv_client.py
+++ b/packages/integrations/arxiv_client.py
@@ -181,7 +181,6 @@ def fetch_categories(self) -> list[dict]:
{"code": "cs.CL", "name": "Computation and Language", "description": ""},
{"code": "cs.AI", "name": "Artificial Intelligence", "description": ""},
{"code": "cs.NE", "name": "Neural and Evolutionary Computing", "description": ""},
- {"code": "cs.CL", "name": "Computational Linguistics", "description": ""},
{"code": "cs.IR", "name": "Information Retrieval", "description": ""},
{"code": "cs.IT", "name": "Information Theory", "description": ""},
{"code": "cs.CR", "name": "Cryptography and Security", "description": ""},