From a48a3d99069145a192b1230388a69d210b01a327 Mon Sep 17 00:00:00 2001 From: iven Date: Tue, 19 May 2026 11:39:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai):=20Day=208=20=E2=80=94=20=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=20CRUD=20API=20+=20chat=5Fhandler=20session=20?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 4 个会话端点: POST/GET /ai/chat/sessions, PUT rename, POST close - ChatRequest 增加 session_id 字段(Optional,向后兼容) - session_id 模式: DB 加载历史 + 持久化消息 + Tool 调用日志写入 - 无 session_id 时保持原有 history 数组模式不变 - 权限: ai.chat.session.list / ai.chat.session.manage --- crates/erp-ai/src/handler/chat_handler.rs | 255 +++++++++++++++++++++- crates/erp-ai/src/module.rs | 16 ++ 2 files changed, 269 insertions(+), 2 deletions(-) diff --git a/crates/erp-ai/src/handler/chat_handler.rs b/crates/erp-ai/src/handler/chat_handler.rs index 827ef64..adf6034 100644 --- a/crates/erp-ai/src/handler/chat_handler.rs +++ b/crates/erp-ai/src/handler/chat_handler.rs @@ -27,6 +27,8 @@ pub struct ChatRequest { pub history: Option>, /// 可选:关联患者 ID(从用户档案中获取) pub patient_id: Option, + /// 可选:会话 ID(传入时走会话持久化模式) + pub session_id: Option, } #[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)] @@ -98,8 +100,34 @@ where // 构建 Agent 消息历史 let mut messages = vec![]; - // 将前端传来的历史转换为 Agent ChatMessage - if let Some(ref hist) = body.history { + // session_id 模式:从 DB 加载历史 + if let Some(sid) = body.session_id { + let db_messages = ai_state + .chat_message + .list_messages(ctx.tenant_id, sid, 50) + .await + .unwrap_or_default(); + for m in &db_messages { + if m.deleted_at.is_some() { + continue; + } + let role = match m.role.as_str() { + "user" => ChatMessageRole::User, + "tool" => ChatMessageRole::Tool, + _ => ChatMessageRole::Assistant, + }; + let tool_calls: Option> = m + .tool_calls + .as_ref() + .and_then(|v| serde_json::from_value(v.clone()).ok()); + messages.push(ChatMessage { + role, + content: m.content.clone().unwrap_or_default(), + tool_calls, + tool_call_id: m.tool_call_id.clone(), + }); + } + } else if let Some(ref hist) = body.history { let filtered: Vec<&ChatHistoryItem> = hist .iter() .filter(|h| h.role == "user" || h.role == "assistant") @@ -250,6 +278,69 @@ where tracing::warn!(error = %e, "Failed to log chat usage"); } + // session_id 模式:持久化消息 + let assistant_uuid = uuid::Uuid::parse_str(&message_id).unwrap_or(uuid::Uuid::now_v7()); + if let Some(sid) = body.session_id { + use crate::service::chat_message::{SaveMessageParams, SaveToolCallLogParams}; + + // 保存用户消息 + if let Err(e) = ai_state + .chat_message + .save_message(SaveMessageParams { + tenant_id: ctx.tenant_id, + session_id: sid, + role: "user".to_string(), + content: Some(message.to_string()), + tool_calls: None, + tool_call_id: None, + token_count: None, + user_id: ctx.user_id, + }) + .await + { + tracing::warn!(error = %e, "Failed to save user message to session"); + } + + // 保存助手回复 + if let Err(e) = ai_state + .chat_message + .save_message(SaveMessageParams { + tenant_id: ctx.tenant_id, + session_id: sid, + role: "assistant".to_string(), + content: Some(result.reply.clone()), + tool_calls: None, + tool_call_id: None, + token_count: Some((result.total_input_tokens + result.total_output_tokens) as i32), + user_id: ctx.user_id, + }) + .await + { + tracing::warn!(error = %e, "Failed to save assistant message to session"); + } + + // 保存 Tool 调用日志 + for tc_log in &result.tool_calls { + if let Err(e) = ai_state + .chat_message + .save_tool_call_log(SaveToolCallLogParams { + tenant_id: ctx.tenant_id, + session_id: sid, + message_id: assistant_uuid, + tool_name: tc_log.tool_name.clone(), + parameters: None, + result_summary: None, + execution_ms: tc_log.duration_ms as i32, + success: tc_log.success, + user_id: ctx.user_id, + }) + .await + { + tracing::warn!(error = %e, tool = %tc_log.tool_name, "Failed to save tool call log"); + } + } + } + Ok(Json(ApiResponse::ok(ChatResponse { reply: result.reply, message_id, @@ -261,3 +352,163 @@ where }, }))) } + +// === 会话 CRUD === + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct SessionResponse { + pub id: uuid::Uuid, + pub title: Option, + pub patient_id: Option, + pub status: String, + pub created_at: String, + pub updated_at: String, +} + +impl From for SessionResponse { + fn from(m: crate::entity::ai_chat_session::Model) -> Self { + Self { + id: m.id, + title: m.title, + patient_id: m.patient_id, + status: m.status, + created_at: m.created_at.to_rfc3339(), + updated_at: m.updated_at.to_rfc3339(), + } + } +} + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct CreateSessionRequest { + pub patient_id: Option, + pub title: Option, +} + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct RenameSessionRequest { + pub title: String, +} + +#[utoipa::path( + post, + path = "/ai/chat/sessions", + request_body = CreateSessionRequest, + responses((status = 200, description = "创建会话")), + tag = "AI 会话", + security(("bearer_auth" = [])), +)] +pub async fn create_session( + Extension(ctx): Extension, + State(state): State, + Json(body): Json, +) -> Result>, erp_core::error::AppError> +where + AiState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "ai.chat.session.manage")?; + let ai_state = AiState::from_ref(&state); + let session = ai_state + .chat_session + .create(ctx.tenant_id, ctx.user_id, body.patient_id, body.title) + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to create session"); + erp_core::error::AppError::Internal("创建会话失败".into()) + })?; + Ok(Json(ApiResponse::ok(SessionResponse::from(session)))) +} + +#[utoipa::path( + get, + path = "/ai/chat/sessions", + responses((status = 200, description = "会话列表")), + tag = "AI 会话", + security(("bearer_auth" = [])), +)] +pub async fn list_sessions( + Extension(ctx): Extension, + State(state): State, +) -> Result>>, erp_core::error::AppError> +where + AiState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "ai.chat.session.list")?; + let ai_state = AiState::from_ref(&state); + let sessions = ai_state + .chat_session + .list(ctx.tenant_id, ctx.user_id) + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to list sessions"); + erp_core::error::AppError::Internal("获取会话列表失败".into()) + })?; + let resp: Vec = sessions.into_iter().map(SessionResponse::from).collect(); + Ok(Json(ApiResponse::ok(resp))) +} + +#[utoipa::path( + put, + path = "/ai/chat/sessions/{session_id}/rename", + responses((status = 200, description = "重命名成功")), + tag = "AI 会话", + security(("bearer_auth" = [])), +)] +pub async fn rename_session( + Extension(ctx): Extension, + State(state): State, + axum::extract::Path(session_id): axum::extract::Path, + Json(body): Json, +) -> Result>, erp_core::error::AppError> +where + AiState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "ai.chat.session.manage")?; + let ai_state = AiState::from_ref(&state); + let ok = ai_state + .chat_session + .rename(ctx.tenant_id, session_id, body.title) + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to rename session"); + erp_core::error::AppError::Internal("重命名会话失败".into()) + })?; + if !ok { + return Err(erp_core::error::AppError::Validation("会话不存在".into())); + } + Ok(Json(ApiResponse::ok(()))) +} + +#[utoipa::path( + post, + path = "/ai/chat/sessions/{session_id}/close", + responses((status = 200, description = "关闭成功")), + tag = "AI 会话", + security(("bearer_auth" = [])), +)] +pub async fn close_session( + Extension(ctx): Extension, + State(state): State, + axum::extract::Path(session_id): axum::extract::Path, +) -> Result>, erp_core::error::AppError> +where + AiState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "ai.chat.session.manage")?; + let ai_state = AiState::from_ref(&state); + let ok = ai_state + .chat_session + .close(ctx.tenant_id, session_id) + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to close session"); + erp_core::error::AppError::Internal("关闭会话失败".into()) + })?; + if !ok { + return Err(erp_core::error::AppError::Validation("会话不存在".into())); + } + Ok(Json(ApiResponse::ok(()))) +} diff --git a/crates/erp-ai/src/module.rs b/crates/erp-ai/src/module.rs index 80b6c70..7192ce3 100644 --- a/crates/erp-ai/src/module.rs +++ b/crates/erp-ai/src/module.rs @@ -431,6 +431,22 @@ impl AiModule { "/ai/chat", axum::routing::post(crate::handler::chat_handler::chat), ) + .route( + "/ai/chat/sessions", + axum::routing::post(crate::handler::chat_handler::create_session), + ) + .route( + "/ai/chat/sessions", + axum::routing::get(crate::handler::chat_handler::list_sessions), + ) + .route( + "/ai/chat/sessions/{session_id}/rename", + axum::routing::put(crate::handler::chat_handler::rename_session), + ) + .route( + "/ai/chat/sessions/{session_id}/close", + axum::routing::post(crate::handler::chat_handler::close_session), + ) .route( "/ai/config", axum::routing::get(crate::handler::config_handler::get_config),