feat(ai): Day 8 — 会话 CRUD API + chat_handler session 模式
- 新增 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
This commit is contained in:
@@ -27,6 +27,8 @@ pub struct ChatRequest {
|
|||||||
pub history: Option<Vec<ChatHistoryItem>>,
|
pub history: Option<Vec<ChatHistoryItem>>,
|
||||||
/// 可选:关联患者 ID(从用户档案中获取)
|
/// 可选:关联患者 ID(从用户档案中获取)
|
||||||
pub patient_id: Option<uuid::Uuid>,
|
pub patient_id: Option<uuid::Uuid>,
|
||||||
|
/// 可选:会话 ID(传入时走会话持久化模式)
|
||||||
|
pub session_id: Option<uuid::Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
|
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
|
||||||
@@ -98,8 +100,34 @@ where
|
|||||||
// 构建 Agent 消息历史
|
// 构建 Agent 消息历史
|
||||||
let mut messages = vec![];
|
let mut messages = vec![];
|
||||||
|
|
||||||
// 将前端传来的历史转换为 Agent ChatMessage
|
// session_id 模式:从 DB 加载历史
|
||||||
if let Some(ref hist) = body.history {
|
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<Vec<crate::dto::ToolCall>> = 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
|
let filtered: Vec<&ChatHistoryItem> = hist
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|h| h.role == "user" || h.role == "assistant")
|
.filter(|h| h.role == "user" || h.role == "assistant")
|
||||||
@@ -250,6 +278,69 @@ where
|
|||||||
tracing::warn!(error = %e, "Failed to log chat usage");
|
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 {
|
Ok(Json(ApiResponse::ok(ChatResponse {
|
||||||
reply: result.reply,
|
reply: result.reply,
|
||||||
message_id,
|
message_id,
|
||||||
@@ -261,3 +352,163 @@ where
|
|||||||
},
|
},
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === 会话 CRUD ===
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||||
|
pub struct SessionResponse {
|
||||||
|
pub id: uuid::Uuid,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub patient_id: Option<uuid::Uuid>,
|
||||||
|
pub status: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<crate::entity::ai_chat_session::Model> 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<uuid::Uuid>,
|
||||||
|
pub title: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<S>(
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
State(state): State<S>,
|
||||||
|
Json(body): Json<CreateSessionRequest>,
|
||||||
|
) -> Result<Json<ApiResponse<SessionResponse>>, erp_core::error::AppError>
|
||||||
|
where
|
||||||
|
AiState: FromRef<S>,
|
||||||
|
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<S>(
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
State(state): State<S>,
|
||||||
|
) -> Result<Json<ApiResponse<Vec<SessionResponse>>>, erp_core::error::AppError>
|
||||||
|
where
|
||||||
|
AiState: FromRef<S>,
|
||||||
|
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<SessionResponse> = 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<S>(
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
State(state): State<S>,
|
||||||
|
axum::extract::Path(session_id): axum::extract::Path<uuid::Uuid>,
|
||||||
|
Json(body): Json<RenameSessionRequest>,
|
||||||
|
) -> Result<Json<ApiResponse<()>>, erp_core::error::AppError>
|
||||||
|
where
|
||||||
|
AiState: FromRef<S>,
|
||||||
|
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<S>(
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
State(state): State<S>,
|
||||||
|
axum::extract::Path(session_id): axum::extract::Path<uuid::Uuid>,
|
||||||
|
) -> Result<Json<ApiResponse<()>>, erp_core::error::AppError>
|
||||||
|
where
|
||||||
|
AiState: FromRef<S>,
|
||||||
|
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(())))
|
||||||
|
}
|
||||||
|
|||||||
@@ -431,6 +431,22 @@ impl AiModule {
|
|||||||
"/ai/chat",
|
"/ai/chat",
|
||||||
axum::routing::post(crate::handler::chat_handler::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(
|
.route(
|
||||||
"/ai/config",
|
"/ai/config",
|
||||||
axum::routing::get(crate::handler::config_handler::get_config),
|
axum::routing::get(crate::handler::config_handler::get_config),
|
||||||
|
|||||||
Reference in New Issue
Block a user