Docker 容器化部署
- 提供 Mode A (Alpine musl, ~23MB) 和 Mode B (Distroless glibc, ~87MB)
两种镜像,Docker Compose 一键启动
- build.rs 支持 SKIP_DASHBOARD_BUILD 跳过前端构建
- 国内镜像加速 (npm/apt/apk) 通过 USE_MIRRORS build-arg 控制
安全:Cookie-Based 鉴权系统
- HttpOnly/SameSite=Strict Cookie 会话管理(24h 过期自动清理)
- 登录/登出/验证接口 + 中间件注入
- 前端登录页面 + 退出按钮
- 三层 CORS:localhost 鉴权 / 全放通 bookmarklet / 受保护路由
- 书签脚本 fetch 添加 credentials:'include'
Coordinator 模式 (P2)
- 4 个 meta-tool (delegate_task/check_task/task_stop/synthesize)
- WorkerPool + Semaphore 并发控制 + 超时保护
- 前端协调者模式开关
Hook 系统:UserPromptSubmit 事件 (P2)
- 第 13 个生命周期事件,fire-and-forget 审计
FTS5 全文搜索 (P3)
- agent_sessions_fts + agent_messages_fts 虚拟表
- search_history Agent 工具 + /api/search/history HTTP 接口
- 前端防抖搜索框 + 仅当前会话筛选
工具加载优化 (P3)
- defer_loading 延迟加载 (7 个重型工具)
- is_readonly 只读标记 (9 个查询工具)
- classifier_summary 工具目录供 LLM 按需判断
模型回退策略 (P3)
- LLM_FALLBACK_MODEL 优先回退 + LLM_FALLBACK_CHAIN 链式轮换
- LlmClient model 改为 Arc<RwLock> 支持运行时切换
- 连续 3 次过载后自动切换
压缩记忆桥接 (P3)
- 压缩丢弃消息 → 子代理提取持久记忆 (extract_memories_from_compaction)
git2 依赖修复
- 切换到 vendored-libgit2,消除 OpenSSL 系统依赖
260 lines
8.7 KiB
Rust
260 lines
8.7 KiB
Rust
// src/agent/tools/paper.rs — 文献下载、解析、内容读取工具
|
||
|
||
use async_trait::async_trait;
|
||
use serde_json::json;
|
||
use tracing::info;
|
||
|
||
use crate::agent::tools::{AgentTool, InterruptBehavior, ToolContext, ToolOutput};
|
||
|
||
// ── GetPaperContentTool ──
|
||
|
||
/// 获取文献内容工具:仅从本地读取已解析的 Markdown 全文
|
||
pub struct GetPaperContentTool;
|
||
|
||
#[async_trait]
|
||
impl AgentTool for GetPaperContentTool {
|
||
fn name(&self) -> &str {
|
||
"get_paper_content"
|
||
}
|
||
|
||
fn description(&self) -> &str {
|
||
"读取并在本地库中获取已解析的文献 Markdown 完整文本内容。\
|
||
注意:本工具仅能读取已在数据库注册且已解析的文献,不会自动触发下载或解析。\
|
||
若文献未下载或未解析,本工具会返回详细指引。"
|
||
}
|
||
|
||
fn parameters(&self) -> serde_json::Value {
|
||
json!({
|
||
"type": "object",
|
||
"properties": {
|
||
"bibcode": {
|
||
"type": "string",
|
||
"description": "文献的唯一标识符,支持 ADS Bibcode、DOI 或 arXiv ID"
|
||
}
|
||
},
|
||
"required": ["bibcode"]
|
||
})
|
||
}
|
||
|
||
/// 纯读取操作,并发安全
|
||
fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool {
|
||
true
|
||
}
|
||
|
||
async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> ToolOutput {
|
||
let bibcode = match args.get("bibcode").and_then(|b| b.as_str()) {
|
||
Some(b) => b.to_string(),
|
||
None => return ToolOutput::error("缺少必需参数 'bibcode'"),
|
||
};
|
||
|
||
info!("[GetPaperContent] 获取文献内容: {}", bibcode);
|
||
let state = &ctx.app_state;
|
||
|
||
let paths = crate::api::helpers::check_paper_paths_in_db(
|
||
&state.db,
|
||
&state.config.library_dir,
|
||
&bibcode,
|
||
)
|
||
.await;
|
||
let md_opt = match paths {
|
||
Ok(Some((_, _, md_opt, _))) => md_opt,
|
||
Ok(None) => return ToolOutput::error(
|
||
"获取文献内容失败:该文献未在本地数据库中注册,请先使用 search_papers 搜索该文献。",
|
||
),
|
||
Err(e) => return ToolOutput::error(format!("获取文献内容失败: {}", e)),
|
||
};
|
||
|
||
let md_rel = match md_opt {
|
||
Some(rel) => rel,
|
||
None => {
|
||
return ToolOutput::error(
|
||
"获取文献内容失败:该文献尚未完成结构化解析。如果未下载,请先调用 download_paper;如果已下载,请先调用 parse_paper 进行解析。",
|
||
)
|
||
}
|
||
};
|
||
|
||
let md_abs = state.config.library_dir.join(&md_rel);
|
||
if !md_abs.exists() {
|
||
return ToolOutput::error(
|
||
"获取文献内容失败:文献本地 Markdown 文件已丢失,请重新调用 parse_paper 进行解析。",
|
||
);
|
||
}
|
||
|
||
match std::fs::read_to_string(&md_abs) {
|
||
Ok(content) => ToolOutput::success(
|
||
content.clone(),
|
||
json!({ "bibcode": bibcode, "chars": content.len() }),
|
||
),
|
||
Err(e) => ToolOutput::error(format!("获取文献内容失败,读取本地文件错误: {}", e)),
|
||
}
|
||
}
|
||
|
||
fn is_readonly(&self) -> bool {
|
||
true
|
||
}
|
||
}
|
||
|
||
// ── DownloadPaperTool ──
|
||
|
||
/// 下载文献全文资源工具
|
||
pub struct DownloadPaperTool;
|
||
|
||
#[async_trait]
|
||
impl AgentTool for DownloadPaperTool {
|
||
fn name(&self) -> &str {
|
||
"download_paper"
|
||
}
|
||
|
||
fn description(&self) -> &str {
|
||
"下载指定文献的全文资源(PDF 或 HTML)至本地图书馆,为后续的结构化解析做好准备。\
|
||
适用于:需要阅读或分析新搜寻到的、尚未下载的文献。"
|
||
}
|
||
|
||
fn parameters(&self) -> serde_json::Value {
|
||
json!({
|
||
"type": "object",
|
||
"properties": {
|
||
"bibcode": {
|
||
"type": "string",
|
||
"description": "文献的唯一标识符,支持 ADS Bibcode、DOI 或 arXiv ID"
|
||
},
|
||
"force": {
|
||
"type": "boolean",
|
||
"description": "是否强制重新下载(即使本地已下载该文献)"
|
||
}
|
||
},
|
||
"required": ["bibcode"]
|
||
})
|
||
}
|
||
|
||
/// 下载有副作用,中断时应阻塞以完成
|
||
fn interrupt_behavior(&self) -> InterruptBehavior {
|
||
InterruptBehavior::Block
|
||
}
|
||
|
||
/// 下载失败时应中止兄弟并行执行(避免继续处理同一文献)
|
||
fn causes_sibling_abort(&self) -> bool {
|
||
true
|
||
}
|
||
|
||
async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> ToolOutput {
|
||
let bibcode = match args.get("bibcode").and_then(|b| b.as_str()) {
|
||
Some(b) => b.to_string(),
|
||
None => return ToolOutput::error("缺少必需参数 'bibcode'"),
|
||
};
|
||
let force = args.get("force").and_then(|f| f.as_bool()).unwrap_or(false);
|
||
|
||
info!("[DownloadPaper] 下载文献: {}, 强制重下: {}", bibcode, force);
|
||
let state = &ctx.app_state;
|
||
|
||
match state
|
||
.downloader
|
||
.download_paper_service(&state.db, &state.config.library_dir, &bibcode, force)
|
||
.await
|
||
{
|
||
Ok(paper) => ToolOutput::success(
|
||
format!(
|
||
"文献 {} 全文资源下载成功。格式 - PDF: {}, HTML: {}",
|
||
bibcode, paper.has_pdf, paper.has_html
|
||
),
|
||
json!({ "bibcode": bibcode, "has_pdf": paper.has_pdf, "has_html": paper.has_html }),
|
||
),
|
||
Err(e) => ToolOutput::error(format!("文献 {} 下载失败: {}", bibcode, e)),
|
||
}
|
||
}
|
||
|
||
fn defer_loading(&self) -> bool {
|
||
true
|
||
}
|
||
}
|
||
|
||
// ── ParsePaperTool ──
|
||
|
||
/// 结构化解析文献内容工具
|
||
pub struct ParsePaperTool;
|
||
|
||
#[async_trait]
|
||
impl AgentTool for ParsePaperTool {
|
||
fn name(&self) -> &str {
|
||
"parse_paper"
|
||
}
|
||
|
||
fn description(&self) -> &str {
|
||
"将指定文献本地已下载的 HTML 或 PDF 资源解析为结构化的 Markdown 文本,并保存至本地 Markdown 文件夹。\
|
||
注意:调用此工具前必须确保文献已被成功下载(已执行 download_paper)。"
|
||
}
|
||
|
||
fn parameters(&self) -> serde_json::Value {
|
||
json!({
|
||
"type": "object",
|
||
"properties": {
|
||
"bibcode": {
|
||
"type": "string",
|
||
"description": "文献的唯一标识符,支持 ADS Bibcode、DOI 或 arXiv ID"
|
||
},
|
||
"force": {
|
||
"type": "boolean",
|
||
"description": "是否强制重新解析(即使本地已解析过该文献)"
|
||
}
|
||
},
|
||
"required": ["bibcode"]
|
||
})
|
||
}
|
||
|
||
/// 解析有副作用(写文件),中断时应阻塞以完成
|
||
fn interrupt_behavior(&self) -> InterruptBehavior {
|
||
InterruptBehavior::Block
|
||
}
|
||
|
||
/// 解析失败时应中止兄弟并行执行
|
||
fn causes_sibling_abort(&self) -> bool {
|
||
true
|
||
}
|
||
|
||
async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> ToolOutput {
|
||
let bibcode = match args.get("bibcode").and_then(|b| b.as_str()) {
|
||
Some(b) => b.to_string(),
|
||
None => return ToolOutput::error("缺少必需参数 'bibcode'"),
|
||
};
|
||
let force = args.get("force").and_then(|f| f.as_bool()).unwrap_or(false);
|
||
|
||
info!("[ParsePaper] 解析文献: {}, 强制重析: {}", bibcode, force);
|
||
let state = &ctx.app_state;
|
||
|
||
match crate::services::parser::parse_paper_service(
|
||
&state.db,
|
||
&state.config.library_dir,
|
||
&state.qiniu,
|
||
&state.config,
|
||
&bibcode,
|
||
force,
|
||
)
|
||
.await
|
||
{
|
||
Ok(markdown) => ToolOutput::success(
|
||
format!(
|
||
"文献 {} 结构化解析成功。解析后 Markdown 字符总数: {}",
|
||
bibcode,
|
||
markdown.len()
|
||
),
|
||
json!({ "bibcode": bibcode, "chars": markdown.len() }),
|
||
),
|
||
Err(e) => {
|
||
let msg = e.to_string();
|
||
if msg.contains("请先下载") {
|
||
ToolOutput::error(format!(
|
||
"文献 {} 解析失败:未检测到已下载的本地资源文件,请先调用 download_paper 工具进行下载。",
|
||
bibcode
|
||
))
|
||
} else {
|
||
ToolOutput::error(format!("文献 {} 解析失败: {}", bibcode, msg))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn defer_loading(&self) -> bool {
|
||
true
|
||
}
|
||
}
|