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:
138
crates/erp-ai/src/handler/chat_handler.rs
Normal file
138
crates/erp-ai/src/handler/chat_handler.rs
Normal 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,
|
||||
})))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -356,6 +356,10 @@ impl AiModule {
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route(
|
||||
"/ai/chat",
|
||||
axum::routing::post(crate::handler::chat_handler::chat),
|
||||
)
|
||||
.route(
|
||||
"/ai/analyze/lab-report",
|
||||
axum::routing::post(crate::handler::stream_lab_report),
|
||||
|
||||
@@ -148,6 +148,7 @@ mod m20260512_000143_seed_copilot_alert_rules;
|
||||
mod m20260513_000144_enforce_version_optimistic_lock;
|
||||
mod m20260513_000145_seed_missing_permissions;
|
||||
mod m20260515_000146_seed_menu_permissions_phase2;
|
||||
mod m20260516_000147_seed_ai_chat_permission;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
@@ -303,6 +304,7 @@ impl MigratorTrait for Migrator {
|
||||
Box::new(m20260513_000144_enforce_version_optimistic_lock::Migration),
|
||||
Box::new(m20260513_000145_seed_missing_permissions::Migration),
|
||||
Box::new(m20260515_000146_seed_menu_permissions_phase2::Migration),
|
||||
Box::new(m20260516_000147_seed_ai_chat_permission::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
//! 新增 ai.chat.send 权限码 — AI 客服聊天
|
||||
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let db = manager.get_connection();
|
||||
let sys = "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
// 注册权限到所有租户
|
||||
db.execute_unprepared(&format!(
|
||||
r#"
|
||||
INSERT INTO permissions (id, tenant_id, code, name, resource, action, description,
|
||||
created_at, updated_at, created_by, updated_by, deleted_at, version)
|
||||
SELECT gen_random_uuid(), t.id, 'ai.chat.send', 'AI 客服聊天', 'ai', 'chat.send', 'AI 客服聊天',
|
||||
NOW(), NOW(), '{sys}', '{sys}', NULL, 1
|
||||
FROM tenant t
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM permissions p
|
||||
WHERE p.code = 'ai.chat.send' AND p.tenant_id = t.id AND p.deleted_at IS NULL
|
||||
)
|
||||
"#
|
||||
)).await?;
|
||||
|
||||
// 绑定到管理员角色
|
||||
db.execute_unprepared(
|
||||
r#"
|
||||
INSERT INTO role_permissions (role_id, permission_id, tenant_id, created_by, updated_by, version)
|
||||
SELECT r.id, p.id, t.id, r.id, r.id, 1
|
||||
FROM tenant t
|
||||
JOIN roles r ON r.tenant_id = t.id AND r.name = '管理员' AND r.deleted_at IS NULL
|
||||
JOIN permissions p ON p.tenant_id = t.id AND p.code = 'ai.chat.send' AND p.deleted_at IS NULL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM role_permissions rp
|
||||
WHERE rp.role_id = r.id AND rp.permission_id = p.id AND rp.tenant_id = t.id
|
||||
)
|
||||
"#,
|
||||
).await?;
|
||||
|
||||
// 绑定到患者角色(患者需要使用 AI 客服)
|
||||
db.execute_unprepared(
|
||||
r#"
|
||||
INSERT INTO role_permissions (role_id, permission_id, tenant_id, created_by, updated_by, version)
|
||||
SELECT r.id, p.id, t.id, r.id, r.id, 1
|
||||
FROM tenant t
|
||||
JOIN roles r ON r.tenant_id = t.id AND r.name = '患者' AND r.deleted_at IS NULL
|
||||
JOIN permissions p ON p.tenant_id = t.id AND p.code = 'ai.chat.send' AND p.deleted_at IS NULL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM role_permissions rp
|
||||
WHERE rp.role_id = r.id AND rp.permission_id = p.id AND rp.tenant_id = t.id
|
||||
)
|
||||
"#,
|
||||
).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user