refactor: 全栈架构重构与质量硬化——API 错误统一、工具域重组、安全加固、前端组件化

后端核心变更:
  - API 层: 新增 AppError 枚举统一错误类型,替代散落的 (StatusCode, String)
  - Agent 工具域: 重组为 astro/system/ 和 astro/research/ 两级域,新增 ProcessPaperTool 流水线工具
  - 安全: 新增 SSRF 双层防护 (同步字符串级 + 异步 DNS 解析级),覆盖 IPv4/IPv6 私网段
  - 弱密码检测: 扩展弱密码列表并增加最小长度检查
  - LLM 客户端: 新增 ChatCompleter/Embedder trait,支持依赖注入与批量向量化 embed_batch
  - 批量处理: AssetBatch 从串行改为 Semaphore 并发池 (BATCH_CONCURRENCY=3)
  - 分块器: 重写为三阶段结构化管线 (章节解析→短节合并→带标题路径子块)
  - RAG: embedding 计算移出事务,RetrievalResult 新增 headings/section_index 字段
  - 检索: ADS/arXiv 并行检索 (tokio::join!),去重改用 HashSet,本地库回填批量 IN 查询
  - 天体查询: Sesame API 升级到 v4,新增视差误差/自行/视向速度/多波段测光字段
  - 迁移: 14 个增量文件合并为单一 init.sql,支持 sqlx::migrate! 内存库集成测试
  - 测试: circuit_breaker/hooks/task_board/session/memory/streaming_executor 新增修正 15+ 测试

  前端架构重构:
  - 目录重组: features/ → pages/ + components/ + hooks/ 三层分离
  - App.tsx 从 1181 行压缩至 ~174 行 (逻辑抽入 9 个自定义 Hook)
  - Agent 面板拆分为 AgentSessionSidebar/AgentMessageList/AgentInputArea 子组件
  - 新增 GlobalDialog/PaperDetailModal/UncachedPaperModal 通用对话框组件
  - 工具函数抽取: celestial.ts (天体坐标格式), paper.tsx (文献信息渲染)
This commit is contained in:
fmq
2026-06-25 23:45:37 +08:00
parent b11b8ad015
commit 5db4cc5998
131 changed files with 13934 additions and 7887 deletions
+57 -88
View File
@@ -5,7 +5,6 @@
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::sse::{Event, Sse},
Json,
};
@@ -16,6 +15,7 @@ use std::convert::Infallible;
use std::sync::Arc;
use tracing::{error, info};
use super::error::{ApiResult, AppError};
use super::AppState;
use crate::agent::runtime::{AgentRuntime, AgentStreamEvent};
@@ -84,7 +84,7 @@ pub async fn get_agent_modes() -> Json<Vec<AgentModeDto>> {
pub async fn chat_agent(
State(state): State<Arc<AppState>>,
Json(req): Json<AgentChatRequest>,
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, (StatusCode, String)> {
) -> ApiResult<Sse<impl Stream<Item = Result<Event, Infallible>>>> {
info!(
"接收到智能体对话请求: question='{}', session_id={:?}, has_image={}",
req.question,
@@ -103,25 +103,24 @@ pub async fn chat_agent(
info!("重试复用已有图片: {}", existing_path);
existing_path.clone()
} else {
return Err((
StatusCode::BAD_REQUEST,
format!("图片文件不存在: {}", existing_path),
));
return Err(AppError::bad_request(format!(
"图片文件不存在: {}",
existing_path
)));
}
} else {
if img.data.is_empty() {
return Err((StatusCode::BAD_REQUEST, "图片数据为空".to_string()));
return Err(AppError::bad_request("图片数据为空"));
}
if !img.mime_type.starts_with("image/") {
return Err((
StatusCode::BAD_REQUEST,
format!("不支持的图片类型: {}", img.mime_type),
));
return Err(AppError::bad_request(format!(
"不支持的图片类型: {}",
img.mime_type
)));
}
if state.vision_llm.is_none() {
return Err((
StatusCode::BAD_REQUEST,
"图片分析功能未启用。请配置 LLM_VISION_MODEL 环境变量后重试。".to_string(),
return Err(AppError::bad_request(
"图片分析功能未启用。请配置 LLM_VISION_MODEL 环境变量后重试。",
));
}
let ext = img.mime_type.strip_prefix("image/").unwrap_or("png");
@@ -130,27 +129,16 @@ pub async fn chat_agent(
.library_dir
.join(".agent_images")
.join("uploads");
std::fs::create_dir_all(&upload_dir).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("创建上传目录失败: {}", e),
)
})?;
std::fs::create_dir_all(&upload_dir)
.map_err(|e| AppError::internal(format!("创建上传目录失败: {}", e)))?;
let filename = format!("{}.{}", uuid::Uuid::new_v4(), ext);
let filepath = upload_dir.join(&filename);
use base64::{engine::general_purpose, Engine as _};
let bytes = general_purpose::STANDARD.decode(&img.data).map_err(|e| {
(
StatusCode::BAD_REQUEST,
format!("图片 base64 解码失败: {}", e),
)
})?;
std::fs::write(&filepath, &bytes).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("保存图片失败: {}", e),
)
})?;
let bytes = general_purpose::STANDARD
.decode(&img.data)
.map_err(|e| AppError::bad_request(format!("图片 base64 解码失败: {}", e)))?;
std::fs::write(&filepath, &bytes)
.map_err(|e| AppError::internal(format!("保存图片失败: {}", e)))?;
let rel = filepath
.strip_prefix(&state.config.library_dir)
.unwrap_or(&filepath)
@@ -244,7 +232,7 @@ pub struct SessionSummary {
pub async fn list_sessions(
State(state): State<Arc<AppState>>,
Query(params): Query<SessionListParams>,
) -> Result<Json<Vec<SessionSummary>>, (StatusCode, String)> {
) -> ApiResult<Json<Vec<SessionSummary>>> {
let limit = params.limit.unwrap_or(50);
let offset = params.offset.unwrap_or(0);
@@ -259,12 +247,7 @@ pub async fn list_sessions(
.bind(offset)
.fetch_all(&state.db)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("查询会话列表失败: {}", e),
)
})?;
.map_err(|e| AppError::internal(format!("查询会话列表失败: {}", e)))?;
let sessions: Vec<SessionSummary> = rows
.iter()
@@ -311,7 +294,7 @@ pub struct MessageRecord {
pub async fn get_session(
State(state): State<Arc<AppState>>,
Path(session_id): Path<String>,
) -> Result<Json<SessionDetail>, (StatusCode, String)> {
) -> ApiResult<Json<SessionDetail>> {
// 查询会话元信息
let session_row = sqlx::query(
"SELECT session_id, title, model, mode, turn_count, summary, created_at, updated_at \
@@ -321,13 +304,8 @@ pub async fn get_session(
.bind(&session_id)
.fetch_optional(&state.db)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("查询会话失败: {}", e),
)
})?
.ok_or((StatusCode::NOT_FOUND, format!("会话 {} 不存在", session_id)))?;
.map_err(|e| AppError::internal(format!("查询会话失败: {}", e)))?
.ok_or_else(|| AppError::not_found(format!("会话 {} 不存在", session_id)))?;
let session = SessionSummary {
session_id: session_row.get(0),
@@ -350,7 +328,7 @@ pub async fn get_session(
.bind(&session_id)
.fetch_all(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("查询消息列表失败: {}", e)))?;
.map_err(|e| AppError::internal(format!("查询消息列表失败: {}", e)))?;
let messages: Vec<MessageRecord> = msg_rows
.iter()
@@ -384,20 +362,20 @@ pub async fn get_session(
pub async fn delete_session(
State(state): State<Arc<AppState>>,
Path(session_id): Path<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
) -> ApiResult<Json<serde_json::Value>> {
let result = sqlx::query(
"UPDATE agent_sessions SET deleted_at = CURRENT_TIMESTAMP WHERE session_id = ? AND deleted_at IS NULL"
)
.bind(&session_id)
.execute(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("删除会话失败: {}", e)))?;
.map_err(|e| AppError::internal(format!("删除会话失败: {}", e)))?;
if result.rows_affected() == 0 {
return Err((
StatusCode::NOT_FOUND,
format!("会话 {} 不存在或已删除", session_id),
));
return Err(AppError::not_found(format!(
"会话 {} 不存在或已删除",
session_id
)));
}
info!("会话已软删除: {}", session_id);
@@ -411,7 +389,7 @@ pub async fn delete_session(
pub async fn stop_agent(
State(state): State<Arc<AppState>>,
Path(session_id): Path<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
) -> ApiResult<Json<serde_json::Value>> {
if let Ok(mut cancelled) = state.cancelled_runs.lock() {
cancelled.insert(session_id.clone());
}
@@ -435,7 +413,7 @@ pub struct AgentMetricsResponse {
pub async fn get_agent_metrics(
State(state): State<Arc<AppState>>,
) -> Result<Json<AgentMetricsResponse>, (StatusCode, String)> {
) -> ApiResult<Json<AgentMetricsResponse>> {
// 总会话数
let total_sessions: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM agent_sessions WHERE deleted_at IS NULL")
@@ -524,7 +502,7 @@ pub struct AuditLogEntry {
pub async fn get_session_audit(
State(state): State<Arc<AppState>>,
Path(session_id): Path<String>,
) -> Result<Json<Vec<AuditLogEntry>>, (StatusCode, String)> {
) -> ApiResult<Json<Vec<AuditLogEntry>>> {
let rows = sqlx::query(
"SELECT id, step, tool_name, status, elapsed_ms, output_preview, created_at \
FROM agent_audit_log \
@@ -534,12 +512,7 @@ pub async fn get_session_audit(
.bind(&session_id)
.fetch_all(&state.db)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("查询审计日志失败: {}", e),
)
})?;
.map_err(|e| AppError::internal(format!("查询审计日志失败: {}", e)))?;
let entries: Vec<AuditLogEntry> = rows
.iter()
@@ -570,16 +543,13 @@ pub struct AnswerQuestionRequest {
pub async fn answer_question(
State(state): State<Arc<AppState>>,
Json(req): Json<AnswerQuestionRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
) -> ApiResult<Json<serde_json::Value>> {
use crate::agent::tools::ask_user::UserAnswer;
let mut pending = match state.pending_questions.lock() {
Ok(p) => p,
Err(_) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
"服务器内部状态异常,请稍后重试".to_string(),
));
return Err(AppError::internal("服务器内部状态异常,请稍后重试"));
}
};
let question_id = req.question_id.clone();
@@ -598,13 +568,13 @@ pub async fn answer_question(
serde_json::json!({"status": "ok", "question_id": question_id}),
))
}
Err(_) => Err((StatusCode::GONE, "问题已超时或已被回答".to_string())),
Err(_) => Err(AppError::gone("问题已超时或已被回答")),
}
}
None => Err((
StatusCode::NOT_FOUND,
format!("未找到待回答问题: {}", question_id),
)),
None => Err(AppError::not_found(format!(
"未找到待回答问题: {}",
question_id
))),
}
}
@@ -635,10 +605,10 @@ pub async fn respond_permission(
State(state): State<Arc<AppState>>,
Path(session_id): Path<String>,
Json(req): Json<super::PermissionResponse>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
) -> ApiResult<Json<serde_json::Value>> {
let mut perms = match state.pending_permissions.lock() {
Ok(p) => p,
Err(_) => return Err((StatusCode::INTERNAL_SERVER_ERROR, "内部状态异常".into())),
Err(_) => return Err(AppError::internal("内部状态异常")),
};
// 按 tool_call_id 查找匹配的权限请求
@@ -658,12 +628,11 @@ pub async fn respond_permission(
);
Ok(Json(serde_json::json!({"status": "ok"})))
}
Err(_) => Err((StatusCode::GONE, "权限请求已超时或已处理".into())),
Err(_) => Err(AppError::gone("权限请求已超时或已处理")),
}
}
None => Err((
StatusCode::NOT_FOUND,
"未找到该权限请求(可能已超时或已处理)".into(),
None => Err(AppError::not_found(
"未找到该权限请求(可能已超时或已处理)",
)),
}
}
@@ -706,10 +675,10 @@ pub struct BranchResponse {
pub async fn branch_session(
State(state): State<Arc<AppState>>,
Path(session_id): Path<String>,
) -> Result<Json<BranchResponse>, (StatusCode, String)> {
) -> ApiResult<Json<BranchResponse>> {
let result = crate::agent::runtime::session::branch_session(&state.db, &session_id)
.await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
.map_err(|e| AppError::bad_request(e.to_string()))?;
Ok(Json(BranchResponse {
branch_session_id: result.branch_session_id,
@@ -735,11 +704,11 @@ pub struct RetryResponse {
pub async fn retry_session(
State(state): State<Arc<AppState>>,
Path(session_id): Path<String>,
) -> Result<Json<RetryResponse>, (StatusCode, String)> {
) -> ApiResult<Json<RetryResponse>> {
let (retried_message, new_turn_index, image_path) =
crate::agent::runtime::session::retry_last_turn(&state.db, &session_id)
.await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
.map_err(|e| AppError::bad_request(e.to_string()))?;
Ok(Json(RetryResponse {
retried_message,
@@ -777,16 +746,16 @@ pub async fn rewind_session(
State(state): State<Arc<AppState>>,
Path(session_id): Path<String>,
Json(req): Json<RewindRequest>,
) -> Result<Json<RewindResponse>, (StatusCode, String)> {
) -> ApiResult<Json<RewindResponse>> {
let result = if let Some(msg_id) = req.message_id {
crate::agent::runtime::session::rewind_to_message(&state.db, &session_id, msg_id)
.await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?
.map_err(|e| AppError::bad_request(e.to_string()))?
} else {
let n = req.n.unwrap_or(1);
crate::agent::runtime::session::rewind_n_turns(&state.db, &session_id, n)
.await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?
.map_err(|e| AppError::bad_request(e.to_string()))?
};
Ok(Json(RewindResponse {
@@ -810,10 +779,10 @@ pub struct RestoreResponse {
pub async fn restore_rewound_session(
State(state): State<Arc<AppState>>,
Path(session_id): Path<String>,
) -> Result<Json<RestoreResponse>, (StatusCode, String)> {
) -> ApiResult<Json<RestoreResponse>> {
let count = crate::agent::runtime::session::restore_rewound(&state.db, &session_id)
.await
.map_err(|e| (StatusCode::CONFLICT, e.to_string()))?;
.map_err(|e| AppError::conflict(e.to_string()))?;
Ok(Json(RestoreResponse {
restored_count: count,