refactor: 全栈架构重构与质量硬化

核心架构重构:
- Config 按职责拆分为 LlmConfig/EmbeddingConfig/VisionConfig/CdsConfig/StorageConfig 五个子结构
- AppState 拆分为 LlmState/DataSourceState/SessionState 三个子结构,消除 50+ 平铺字段
- 新增 ServiceError 结构化错误类型替代 handler 中的 msg.contains() 字符串匹配
- 移除 api::handlers 兼容命名空间,路由直接引用 agent/auth/papers 等模块

认证性能优化:
- login_rate_limiter/upload_rate_limiter 从 Mutex<HashMap> 迁移为 DashMap(无锁)
- 新增 session_last_active: DashMap<String, AtomicU64>,auth 中间件快速路径免写锁
- 会话过期清理改为按间隔触发(300s),避免每次请求全表扫描
- MAX_SESSIONS 1000→10000,SSE 广播通道 256→1024

Agent 工具增强:
- AgentTool trait 新增 is_internal()/display_name(),SSE 事件携带工具元数据
- 新增 GET /chat/tools 端点暴露注册工具列表
- pending_questions/pending_permissions 增加 created_at 时间戳,自动清理过期条目(10min TTL)
- Agent 超时现在正确 abort 后台任务并设置取消令牌

观测数据源修复:
- FITS 解析: APOGEE/DESI 改用 read_image+切片替代 read_rows(修复 fitsio panic)
- ZTF: CIRCLE 参数分隔符 +→空格(修复 IRSA 400),半径自动裁剪至硬上限
- MAST TESS: parse_tic_json 兼容数组/对象两种 API 响应格式
- 统一检索: per_target_limit 默认 50→1,sources 支持 per-source release/version
- Gaia 测光从 VizieR 镜像切换至官方 TAP 服务

RAG 并发优化:
- 向量化降级从串行改为并发 5 条/批(buffer_unordered)
- 混合检索 RRF 合并从借用改为 owned RetrievalResult

安全加固:
- PDF 中间件: URL 解码 %2F/%2E 后判扩展名;文件名过滤非 ASCII + 禁 \ 防头注入
- chat_agent 日志截断问题内容至 50 字符;list_sessions 强制 limit clamp

前端双主题:
- 设计令牌三层架构: primitive→semantic→component,浅色暖纸张学术/暗色 Night Indigo
- useTheme hook + ThemeToggle 侧边栏组件 + main.tsx 防 FOUC 初始化
- 全组件从硬编码 slate 色迁移至语义令牌(bg-surface/text-content/border-subtle 等)
- 新增 ToastContainer 非阻塞通知系统

部署优化:
- deploy.sh 引入 SSH ControlMaster 单次密码复用
This commit is contained in:
fmq
2026-07-11 14:57:40 +08:00
parent 8f1ed6d08c
commit eaf85707b5
142 changed files with 6349 additions and 3470 deletions
+62 -12
View File
@@ -80,13 +80,44 @@ pub async fn get_agent_modes() -> Json<Vec<AgentModeDto>> {
Json(modes)
}
#[derive(Debug, Serialize)]
pub struct AgentToolDto {
pub name: String,
pub display_name: String,
pub is_internal: bool,
}
// ── GET /api/chat/tools ──
// 获取系统注册的工具元数据列表
pub async fn get_agent_tools(State(state): State<Arc<AppState>>) -> Json<Vec<AgentToolDto>> {
use crate::agent::tools::ToolRegistry;
let skill_registry = state.skill_registry.clone();
let registry = ToolRegistry::new(skill_registry);
let tools = registry
.list()
.iter()
.map(|t| AgentToolDto {
name: t.name().to_string(),
display_name: t.display_name().to_string(),
is_internal: t.is_internal(),
})
.collect();
Json(tools)
}
pub async fn chat_agent(
State(state): State<Arc<AppState>>,
Json(req): Json<AgentChatRequest>,
) -> ApiResult<Sse<impl Stream<Item = Result<Event, Infallible>>>> {
// 截断日志中的问题内容,避免打印敏感信息
let question_preview = if req.question.len() > 50 {
format!("{}...", &req.question[..50])
} else {
req.question.clone()
};
info!(
"接收到智能体对话请求: question='{}', session_id={:?}, has_image={}",
req.question,
question_preview,
req.session_id,
req.image.is_some()
);
@@ -97,7 +128,7 @@ pub async fn chat_agent(
Some(ref img) => {
// 如果带有 path 字段(重试场景),复用已有文件,不重新保存
let relative_path: String = if let Some(ref existing_path) = img.path {
let full = state.config.library_dir.join(existing_path);
let full = state.config.storage.library_dir.join(existing_path);
if full.exists() {
info!("重试复用已有图片: {}", existing_path);
existing_path.clone()
@@ -117,7 +148,7 @@ pub async fn chat_agent(
img.mime_type
)));
}
if state.vision_llm.is_none() {
if state.llm.vision.is_none() {
return Err(AppError::bad_request(
"图片分析功能未启用。请配置 LLM_VISION_MODEL 环境变量后重试。",
));
@@ -125,6 +156,7 @@ pub async fn chat_agent(
let ext = img.mime_type.strip_prefix("image/").unwrap_or("png");
let upload_dir = state
.config
.storage
.library_dir
.join(".agent")
.join("images")
@@ -142,7 +174,7 @@ pub async fn chat_agent(
.await
.map_err(|e| AppError::internal(format!("保存图片失败: {}", e)))?;
let rel = filepath
.strip_prefix(&state.config.library_dir)
.strip_prefix(&state.config.storage.library_dir)
.unwrap_or(&filepath)
.display()
.to_string();
@@ -171,7 +203,7 @@ pub async fn chat_agent(
let session_id = req.session_id.clone();
// 在后台 tokio 任务中执行 Agent 循环
tokio::spawn(async move {
let agent_handle = tokio::spawn(async move {
match runtime
.run_turn_with_image_context(
session_id,
@@ -197,11 +229,17 @@ pub async fn chat_agent(
// 将 mpsc 通道转换为 SSE 事件流(带 10 分钟超时)
const SSE_TIMEOUT_SECS: u64 = 600;
let cancelled_runs = state.session.cancelled_runs.clone();
let stream = async_stream::stream! {
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(SSE_TIMEOUT_SECS);
loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
// 超时:通知 CancellationHook 停止 Agent,并中止后台任务
if let Some(sid) = &req.session_id {
cancelled_runs.insert(sid.clone(), ());
}
agent_handle.abort();
let timeout_event = AgentStreamEvent::Error {
message: "Agent 执行超时(10 分钟),请重试。".to_string(),
};
@@ -220,6 +258,11 @@ pub async fn chat_agent(
}
Ok(None) => break, // channel closed
Err(_) => {
// 超时:通知 CancellationHook 停止 Agent,并中止后台任务
if let Some(sid) = &req.session_id {
cancelled_runs.insert(sid.clone(), ());
}
agent_handle.abort();
let timeout_event = AgentStreamEvent::Error {
message: "Agent 执行超时(10 分钟),请重试。".to_string(),
};
@@ -247,8 +290,8 @@ pub async fn list_sessions(
State(state): State<Arc<AppState>>,
Query(params): Query<SessionListParams>,
) -> ApiResult<Json<Vec<crate::services::session::SessionSummary>>> {
let limit = params.limit.unwrap_or(50);
let offset = params.offset.unwrap_or(0);
let limit = params.limit.unwrap_or(50).clamp(1, 200);
let offset = params.offset.unwrap_or(0).max(0);
let sessions = crate::services::session::list_sessions_service(&state.db, limit, offset)
.await
@@ -299,7 +342,7 @@ pub async fn stop_agent(
State(state): State<Arc<AppState>>,
Path(session_id): Path<String>,
) -> ApiResult<Json<serde_json::Value>> {
state.cancelled_runs.insert(session_id.clone(), ());
state.session.cancelled_runs.insert(session_id.clone(), ());
info!("已接收并记录手动中止请求,会话 ID: {}", session_id);
Ok(Json(
serde_json::json!({ "status": "stopping", "session_id": session_id }),
@@ -349,7 +392,7 @@ pub async fn answer_question(
) -> ApiResult<Json<serde_json::Value>> {
use crate::agent::tools::ask_user::UserAnswer;
let mut pending = state.pending_questions.lock().await;
let mut pending = state.session.pending_questions.lock().await;
let question_id = req.question_id.clone();
match pending.remove(&question_id) {
@@ -379,10 +422,15 @@ pub async fn answer_question(
// ── GET /api/chat/pending_questions ──
// 获取当前待回答的问题(前端轮询或初始化)
/// 待回答问题的 TTL(10 分钟),超过此时间自动清理
const PENDING_TTL_SECS: u64 = 600;
pub async fn get_pending_questions(
State(state): State<Arc<AppState>>,
) -> Json<Vec<serde_json::Value>> {
let pending = state.pending_questions.lock().await;
let mut pending = state.session.pending_questions.lock().await;
// 清理过期条目(agent 崩溃后不会被 answer_question 清理)
pending.retain(|_, pq| pq.created_at.elapsed().as_secs() < PENDING_TTL_SECS);
let questions: Vec<serde_json::Value> = pending
.iter()
.map(|(id, pq)| {
@@ -401,7 +449,7 @@ pub async fn respond_permission(
Path(session_id): Path<String>,
Json(req): Json<super::PermissionResponse>,
) -> ApiResult<Json<serde_json::Value>> {
let mut perms = state.pending_permissions.lock().await;
let mut perms = state.session.pending_permissions.lock().await;
// 按 tool_call_id 查找匹配的权限请求
let perm_id = perms
@@ -440,7 +488,9 @@ pub async fn respond_permission(
pub async fn get_pending_permissions(
State(state): State<Arc<AppState>>,
) -> Json<Vec<serde_json::Value>> {
let perms = state.pending_permissions.lock().await;
let mut perms = state.session.pending_permissions.lock().await;
// 清理过期条目(agent 崩溃后不会被 respond_permission 清理)
perms.retain(|_, p| p.created_at.elapsed().as_secs() < PENDING_TTL_SECS);
let result: Vec<serde_json::Value> = perms
.iter()
.map(|(id, p)| {