feat(ai): 新增 AI 客服聊天功能 + 消息页重构为小华助手

- 新增 POST /ai/chat 端点,由 LLM(Ollama qwen3)担任 24h 健康客服"小华"
- 新增 ai.chat.send 权限,绑定管理员/患者/医生/护士/健康管理师角色
- 消息页从咨询列表重构为单窗口 AI 对话(欢迎态 + 聊天态 + 快捷问诊)
- 通知功能迁移到"我的"页面菜单项(带未读角标),独立通知列表页
- 修复气泡文字截断:改用百分比 max-width + block Text + pre-wrap 换行
- 修复权限绑定:迁移 SQL 角色名从英文改为中文(admin→管理员,patient→患者)
This commit is contained in:
iven
2026-05-17 00:49:41 +08:00
parent 4be28de3ce
commit 710b2e2423
14 changed files with 952 additions and 439 deletions

View File

@@ -0,0 +1,138 @@
use axum::Json;
use axum::extract::{Extension, FromRef, State};
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, TenantContext};
use serde::{Deserialize, Serialize};
use crate::dto::GenerateRequest;
use crate::state::AiState;
// === 请求 / 响应 ===
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct ChatRequest {
pub message: String,
pub history: Option<Vec<ChatHistoryItem>>,
}
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
pub struct ChatHistoryItem {
pub role: String,
pub content: String,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct ChatResponse {
pub reply: String,
pub message_id: String,
}
const SYSTEM_PROMPT: &str = r#"你是 HMS 健康管理平台的 AI 客服助手"小华"。你的职责是:
1. 回答用户的健康咨询问题
2. 帮助用户了解体检报告指标
3. 提供预约挂号、用药提醒等服务指导
4. 推荐健康生活方式
注意:
- 你不能替代医生的诊断,遇到需要诊断的问题请建议用户就医
- 不能推荐具体药物,只能提供一般性健康建议
- 语气要亲切、专业、耐心
- 回复要简洁明了,避免过长
- 如果用户问的问题超出健康范围,礼貌引导回到健康话题"#;
#[utoipa::path(
post,
path = "/ai/chat",
request_body = ChatRequest,
responses((status = 200, description = "AI 客服回复")),
tag = "AI 客服",
security(("bearer_auth" = [])),
)]
pub async fn chat<S>(
Extension(ctx): Extension<TenantContext>,
State(state): State<S>,
Json(body): Json<ChatRequest>,
) -> Result<Json<ApiResponse<ChatResponse>>, erp_core::error::AppError>
where
AiState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "ai.chat.send")?;
let message = body.message.trim();
if message.is_empty() {
return Err(erp_core::error::AppError::Validation("消息不能为空".into()));
}
if message.len() > 2000 {
return Err(erp_core::error::AppError::Validation(
"消息长度不能超过 2000 字".into(),
));
}
let user_prompt = match body.history {
Some(ref hist) if !hist.is_empty() => {
let filtered: Vec<&ChatHistoryItem> = hist
.iter()
.filter(|h| h.role == "user" || h.role == "assistant")
.collect();
let start = filtered.len().saturating_sub(10);
let ctx: String = filtered[start..]
.iter()
.map(|h| {
format!(
"{}: {}",
if h.role == "user" { "用户" } else { "助手" },
h.content
)
})
.collect::<Vec<_>>()
.join("\n");
format!("历史对话:\n{}\n\n用户最新消息: {}", ctx, message)
}
_ => message.to_string(),
};
let ai_state = AiState::from_ref(&state);
let resolved = ai_state
.provider_registry
.resolve("auto")
.await
.map_err(|e| {
tracing::error!(error = %e, "AI provider resolve failed");
erp_core::error::AppError::Internal("AI 服务暂时不可用,请稍后再试".into())
})?;
let req = GenerateRequest {
system_prompt: SYSTEM_PROMPT.to_string(),
user_prompt,
model: String::new(),
temperature: 0.7,
max_tokens: 1024,
};
tracing::info!(
tenant_id = %ctx.tenant_id,
user_id = %ctx.user_id,
msg_len = message.len(),
"AI chat request"
);
let resp = resolved.provider().generate(req).await.map_err(|e| {
tracing::error!(error = %e, "AI chat generate failed");
erp_core::error::AppError::Internal("AI 服务暂时不可用,请稍后再试".into())
})?;
let message_id = uuid::Uuid::now_v7().to_string();
tracing::info!(
tenant_id = %ctx.tenant_id,
message_id = %message_id,
tokens = resp.output_tokens,
"AI chat response sent"
);
Ok(Json(ApiResponse::ok(ChatResponse {
reply: resp.content,
message_id,
})))
}

View File

@@ -11,6 +11,7 @@ use std::convert::Infallible;
use crate::dto::{AnalysisSseEvent, AnalysisType};
use crate::state::AiState;
pub mod chat_handler;
pub mod insight_handler;
pub mod risk_handler;
pub mod rule_handler;