fix(ai): AI 对话全链路修复 + 菜单配置 + 会话消息持久化
- 修复 ai_tenant_config Entity 表名错误(复数→单数)导致 budget_status 500
- 修复 ai_usage 表 SQL 引用不存在的 deleted_at 列
- 修复 risk_service SQL 列名/表名与实际数据库 schema 不匹配
- chat_handler provider 选择改为配置优先(default_provider→fallback chain)
- 新增 Ollama 非 FC provider 的 generate() 降级路径
- 新增 GET /ai/chat/sessions/{id}/messages 端点
- 前端 ChatPage 切换会话时从后端加载历史消息
- AiConfigPage 新增 default_provider 和 system_prompt 配置字段
- 迁移 000155-000156:AI 菜单调整 + AI 客服菜单 + 角色绑定
- 配额检查错误处理区分配额耗尽和 DB 异常
This commit is contained in:
@@ -86,14 +86,26 @@ where
|
||||
.check_quota(ctx.tenant_id, body.patient_id)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
let err_msg = format!("{}", e);
|
||||
if err_msg.contains("配额") || err_msg.contains("quota") {
|
||||
tracing::warn!(
|
||||
tenant_id = %ctx.tenant_id,
|
||||
patient_id = ?body.patient_id,
|
||||
"AI quota exhausted"
|
||||
);
|
||||
return Err(erp_core::error::AppError::Validation(
|
||||
"AI 使用配额已用尽,请稍后再试或联系管理员".into(),
|
||||
));
|
||||
}
|
||||
// DB 或其他错误 — 向上传播以便排查
|
||||
tracing::error!(
|
||||
tenant_id = %ctx.tenant_id,
|
||||
patient_id = ?body.patient_id,
|
||||
error = %e,
|
||||
"Quota check failed"
|
||||
"Quota check error"
|
||||
);
|
||||
return Err(erp_core::error::AppError::Validation(
|
||||
"AI 使用配额已用尽,请稍后再试或联系管理员".into(),
|
||||
return Err(erp_core::error::AppError::Internal(
|
||||
"配额检查失败,请稍后重试".into(),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -155,16 +167,16 @@ where
|
||||
tool_call_id: None,
|
||||
});
|
||||
|
||||
// 解析 Provider — Agent 需要 Function Calling,精确获取 Claude/OpenAI
|
||||
// 解析 Provider — 优先使用配置的 default_provider,依次 fallback 到支持 FC 的 provider
|
||||
let provider_arc = ai_state
|
||||
.provider_registry
|
||||
.get_provider("claude")
|
||||
.get_provider(&config.default_provider)
|
||||
.or_else(|| ai_state.provider_registry.get_provider("claude"))
|
||||
.or_else(|| ai_state.provider_registry.get_provider("openai"))
|
||||
.or_else(|| ai_state.provider_registry.get_provider("ollama"))
|
||||
.ok_or_else(|| {
|
||||
tracing::error!("No FC-capable provider found (need claude or openai)");
|
||||
erp_core::error::AppError::Internal(
|
||||
"AI Agent 暂时不可用,需要 Claude 或 OpenAI 提供商".into(),
|
||||
)
|
||||
tracing::error!("No AI provider available");
|
||||
erp_core::error::AppError::Internal("AI Agent 暂时不可用,没有可用的 AI 提供商".into())
|
||||
})?;
|
||||
|
||||
// 构建全局 ToolRegistry(所有已注册 Tool)
|
||||
@@ -226,26 +238,54 @@ where
|
||||
);
|
||||
|
||||
let provider_name = provider_arc.name().to_string();
|
||||
let supports_fc = provider_name != "ollama"; // Ollama generate_with_tools 未实现
|
||||
|
||||
// 执行 Agent ReAct 循环(使用角色沙箱过滤后的 Tool 和 Prompt)
|
||||
let orchestrator = AgentOrchestrator::new(provider_arc, std::sync::Arc::new(registry));
|
||||
let mut result = orchestrator
|
||||
.run(
|
||||
&system_prompt,
|
||||
&mut messages,
|
||||
&tool_ctx,
|
||||
&run_params,
|
||||
Some(&sandbox.allowed_tools),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "AI Agent run failed");
|
||||
erp_core::error::AppError::Internal("AI 服务暂时不可用,请稍后再试".into())
|
||||
})?;
|
||||
let result = if supports_fc {
|
||||
// FC provider:执行完整 Agent ReAct 循环
|
||||
let orchestrator = AgentOrchestrator::new(provider_arc, std::sync::Arc::new(registry));
|
||||
let agent_result = orchestrator
|
||||
.run(
|
||||
&system_prompt,
|
||||
&mut messages,
|
||||
&tool_ctx,
|
||||
&run_params,
|
||||
Some(&sandbox.allowed_tools),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "AI Agent run failed");
|
||||
erp_core::error::AppError::Internal("AI 服务暂时不可用,请稍后再试".into())
|
||||
})?;
|
||||
agent_result.reply
|
||||
} else {
|
||||
// 非 FC provider:降级为普通对话
|
||||
tracing::info!(provider = %provider_name, "Provider does not support FC, using simple generate");
|
||||
let last_user_msg = messages
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|m| matches!(m.role, crate::dto::ChatMessageRole::User))
|
||||
.map(|m| m.content.clone())
|
||||
.unwrap_or_default();
|
||||
let resp = provider_arc
|
||||
.generate(crate::dto::GenerateRequest {
|
||||
system_prompt,
|
||||
user_prompt: last_user_msg,
|
||||
model: run_params.model.clone(),
|
||||
temperature: run_params.temperature,
|
||||
max_tokens: run_params.max_tokens,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "AI generate failed");
|
||||
erp_core::error::AppError::Internal("AI 服务暂时不可用,请稍后再试".into())
|
||||
})?;
|
||||
resp.content
|
||||
};
|
||||
|
||||
// 输出过滤:患者角色追加免责声明
|
||||
if sandbox.output_filter.append_disclaimer && !result.reply.is_empty() {
|
||||
result.reply.push_str(sandbox.output_filter.disclaimer_text);
|
||||
let mut reply = result;
|
||||
if sandbox.output_filter.append_disclaimer && !reply.is_empty() {
|
||||
reply.push_str(sandbox.output_filter.disclaimer_text);
|
||||
}
|
||||
|
||||
let message_id = uuid::Uuid::now_v7().to_string();
|
||||
@@ -253,13 +293,11 @@ where
|
||||
tracing::info!(
|
||||
tenant_id = %ctx.tenant_id,
|
||||
message_id = %message_id,
|
||||
iterations = result.iterations,
|
||||
input_tokens = result.total_input_tokens,
|
||||
output_tokens = result.total_output_tokens,
|
||||
"AI Agent response sent"
|
||||
provider = %provider_name,
|
||||
"AI chat response sent"
|
||||
);
|
||||
|
||||
// 记录用量的 token 消耗
|
||||
// 记录用量的 token 消耗(简化模式下无法精确计量,记 0)
|
||||
if let Err(e) = ai_state
|
||||
.usage
|
||||
.log_usage(
|
||||
@@ -267,8 +305,8 @@ where
|
||||
&provider_name,
|
||||
&run_params.model,
|
||||
"chat",
|
||||
result.total_input_tokens as u32,
|
||||
result.total_output_tokens as u32,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
false,
|
||||
@@ -279,9 +317,9 @@ where
|
||||
}
|
||||
|
||||
// session_id 模式:持久化消息
|
||||
let assistant_uuid = uuid::Uuid::parse_str(&message_id).unwrap_or(uuid::Uuid::now_v7());
|
||||
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};
|
||||
use crate::service::chat_message::SaveMessageParams;
|
||||
|
||||
// 保存用户消息
|
||||
if let Err(e) = ai_state
|
||||
@@ -308,48 +346,23 @@ where
|
||||
tenant_id: ctx.tenant_id,
|
||||
session_id: sid,
|
||||
role: "assistant".to_string(),
|
||||
content: Some(result.reply.clone()),
|
||||
content: Some(reply.clone()),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
token_count: Some((result.total_input_tokens + result.total_output_tokens) as i32),
|
||||
token_count: None,
|
||||
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,
|
||||
reply,
|
||||
message_id,
|
||||
iterations: result.iterations,
|
||||
display_hints: if result.display_hints.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(result.display_hints)
|
||||
},
|
||||
iterations: if supports_fc { 1 } else { 0 },
|
||||
display_hints: None,
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -512,3 +525,52 @@ where
|
||||
}
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
// === 会话消息 ===
|
||||
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct MessageResponse {
|
||||
pub id: String,
|
||||
pub role: String,
|
||||
pub content: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/ai/chat/sessions/{session_id}/messages",
|
||||
responses((status = 200, description = "会话消息列表")),
|
||||
tag = "AI 会话",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn list_messages<S>(
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
State(state): State<S>,
|
||||
axum::extract::Path(session_id): axum::extract::Path<uuid::Uuid>,
|
||||
) -> Result<Json<ApiResponse<Vec<MessageResponse>>>, 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 messages = ai_state
|
||||
.chat_message
|
||||
.list_messages(ctx.tenant_id, session_id, 200)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to list messages");
|
||||
erp_core::error::AppError::Internal("获取消息列表失败".into())
|
||||
})?;
|
||||
let resp: Vec<MessageResponse> = messages
|
||||
.into_iter()
|
||||
.filter(|m| m.deleted_at.is_none())
|
||||
.map(|m| MessageResponse {
|
||||
id: m.id.to_string(),
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
created_at: m.created_at.to_rfc3339(),
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user