diff --git a/apps/miniprogram/src/services/ai-chat.ts b/apps/miniprogram/src/services/ai-chat.ts index 44a88f9..bbc5baa 100644 --- a/apps/miniprogram/src/services/ai-chat.ts +++ b/apps/miniprogram/src/services/ai-chat.ts @@ -10,8 +10,19 @@ export interface AiChatMessage { export interface AiChatResponse { reply: string; message_id: string; + display_hints?: DisplayHint[]; } +export type DisplayHint = + | { type: 'vital_card'; indicator_type: string; values: [string, number][]; unit: string } + | { type: 'lab_report_card'; report_date: string; abnormal_count: number } + | { type: 'action_confirm'; action_type: string; summary: string; confirm_payload: unknown } + | { type: 'risk_alert'; level: string; message: string } + | { type: 'trend_chart'; metrics: string[]; period: string; summary: string } + | { type: 'insight_card'; title: string; severity: string; items: string[] } + | { type: 'patient_profile'; chronic_conditions: string[]; medication_count: number } + | { type: 'text' }; + /** 发送消息给 AI 客服 */ export async function sendAiMessage( message: string, diff --git a/apps/web/src/api/ai/chat.ts b/apps/web/src/api/ai/chat.ts index 32e4f34..deb55d8 100644 --- a/apps/web/src/api/ai/chat.ts +++ b/apps/web/src/api/ai/chat.ts @@ -5,10 +5,53 @@ export interface ChatHistoryItem { content: string; } +export type DisplayHint = + | { + type: 'vital_card'; + indicator_type: string; + values: [string, number][]; + unit: string; + } + | { + type: 'lab_report_card'; + report_date: string; + abnormal_count: number; + } + | { + type: 'action_confirm'; + action_type: string; + summary: string; + confirm_payload: unknown; + } + | { + type: 'risk_alert'; + level: string; + message: string; + } + | { + type: 'trend_chart'; + metrics: string[]; + period: string; + summary: string; + } + | { + type: 'insight_card'; + title: string; + severity: string; + items: string[]; + } + | { + type: 'patient_profile'; + chronic_conditions: string[]; + medication_count: number; + } + | { type: 'text' }; + export interface ChatResponse { reply: string; message_id: string; iterations: number; + display_hints?: DisplayHint[]; } export const aiChatApi = { diff --git a/apps/web/src/components/ai/AiSidebar.tsx b/apps/web/src/components/ai/AiSidebar.tsx index 6400f6e..9a28bc4 100644 --- a/apps/web/src/components/ai/AiSidebar.tsx +++ b/apps/web/src/components/ai/AiSidebar.tsx @@ -18,9 +18,10 @@ import { SafetyCertificateOutlined, } from '@ant-design/icons'; import { useLocation } from 'react-router-dom'; -import { aiChatApi, type ChatHistoryItem } from '../../api/ai/chat'; +import { aiChatApi, type ChatHistoryItem, type DisplayHint } from '../../api/ai/chat'; import { analysisApi, type HealthSummaryResponse } from '../../api/ai/analysis'; import { useAuthStore } from '../../stores/auth'; +import RichMessage from './RichMessage'; const { Text } = Typography; const { TextArea } = Input; @@ -29,6 +30,7 @@ interface ChatMessage { id: string; role: 'user' | 'assistant'; content: string; + displayHints?: DisplayHint[]; } function extractPatientId(pathname: string): string | null { @@ -146,8 +148,9 @@ export default function AiSidebar({ ...prev, { id: resp.message_id, - role: 'assistant', + role: 'assistant' as const, content: resp.reply, + displayHints: resp.display_hints, }, ]); } catch { @@ -333,6 +336,9 @@ export default function AiSidebar({ }} > {msg.content} + {msg.displayHints && msg.displayHints.length > 0 && ( + + )} ))} diff --git a/apps/web/src/components/ai/RichMessage.tsx b/apps/web/src/components/ai/RichMessage.tsx new file mode 100644 index 0000000..eb1f011 --- /dev/null +++ b/apps/web/src/components/ai/RichMessage.tsx @@ -0,0 +1,180 @@ +import { Card, Tag, Typography, theme } from 'antd'; +import { + WarningOutlined, + HeartOutlined, + LineChartOutlined, + ExperimentOutlined, + UserOutlined, +} from '@ant-design/icons'; +import type { DisplayHint } from '../../api/ai/chat'; + +const { Text } = Typography; + +const SEVERITY_COLOR: Record = { + high: 'red', + medium: 'orange', + low: 'green', +}; + +const RISK_LEVEL_COLOR: Record = { + critical: '#cf1322', + high: 'red', + medium: 'orange', + low: 'green', +}; + +export default function RichMessage({ hints }: { hints: DisplayHint[] }) { + const { token } = theme.useToken(); + + return ( +
+ {hints.map((hint, i) => ( + + ))} +
+ ); +} + +function RichHint({ hint, token }: { hint: DisplayHint; token: { colorBorderSecondary: string; colorTextSecondary: string; colorPrimary: string } }) { + switch (hint.type) { + case 'insight_card': + return ( + + + {hint.title} + + } + styles={{ body: { padding: '6px 12px' } }} + > +
+ {hint.items.map((item, j) => ( + + {item} + + ))} +
+
+ ); + + case 'risk_alert': + return ( +
+ + {hint.message} +
+ ); + + case 'lab_report_card': + return ( + + + 化验报告 {hint.report_date} + + } + styles={{ body: { padding: '6px 12px', fontSize: 12 } }} + > + {hint.abnormal_count > 0 ? ( + + {hint.abnormal_count} 项异常 + + ) : ( + 正常 + )} + + ); + + case 'trend_chart': + return ( + + + 趋势分析({hint.period}) + + } + styles={{ body: { padding: '6px 12px', fontSize: 12 } }} + > +
+ {hint.metrics.map((m, j) => ( + {m} + ))} +
+ {hint.summary} +
+ ); + + case 'patient_profile': + return ( + + + 患者档案 + + } + styles={{ body: { padding: '6px 12px', fontSize: 12 } }} + > + {hint.chronic_conditions.length > 0 && ( +
+ {hint.chronic_conditions.map((c, j) => ( + {c} + ))} +
+ )} + {hint.medication_count > 0 && ( + 当前用药 {hint.medication_count} 种 + )} +
+ ); + + case 'vital_card': + return ( + + + 体征数据 + + } + styles={{ body: { padding: '6px 12px', fontSize: 12 } }} + > +
+ {hint.values.slice(-5).map(([date, val], j) => ( + + {date.slice(5)}:{' '} + {val} {hint.unit} + + ))} +
+
+ ); + + case 'action_confirm': + return ( + + {hint.summary} + + ); + + case 'text': + default: + return null; + } +} diff --git a/crates/erp-ai/src/agent/orchestrator.rs b/crates/erp-ai/src/agent/orchestrator.rs index 7c210c6..987f671 100644 --- a/crates/erp-ai/src/agent/orchestrator.rs +++ b/crates/erp-ai/src/agent/orchestrator.rs @@ -49,6 +49,7 @@ pub struct AgentRunResult { pub total_output_tokens: u32, pub iterations: usize, pub tool_calls: Vec, + pub display_hints: Vec, } impl AgentOrchestrator { @@ -76,6 +77,7 @@ impl AgentOrchestrator { let mut total_input_tokens = 0u32; let mut total_output_tokens = 0u32; let mut tool_call_logs: Vec = Vec::new(); + let mut display_hints: Vec = Vec::new(); loop { iterations += 1; @@ -107,6 +109,7 @@ impl AgentOrchestrator { total_output_tokens, iterations, tool_calls: tool_call_logs, + display_hints, }); } }; @@ -155,21 +158,25 @@ impl AgentOrchestrator { // 执行每个 Tool Call(受沙箱 allowed_tools 约束) for tc in &tool_calls { let start = std::time::Instant::now(); - let (tool_result, success) = match self.tool_registry.get(&tc.name) { + let (tool_result, success, hint) = match self.tool_registry.get(&tc.name) { Some(tool) => { if let Some(allowed) = allowed_tools { if !allowed.contains(tc.name.as_str()) { - (format!("Tool '{}' 在当前角色下不可用", tc.name), false) + ( + format!("Tool '{}' 在当前角色下不可用", tc.name), + false, + None, + ) } else { let result = tool.execute(ctx, tc.arguments.clone()).await; - (result.output, true) + (result.output, true, result.display_hint) } } else { let result = tool.execute(ctx, tc.arguments.clone()).await; - (result.output, true) + (result.output, true, result.display_hint) } } - None => (format!("未知 Tool: {}", tc.name), false), + None => (format!("未知 Tool: {}", tc.name), false, None), }; let duration = start.elapsed(); @@ -179,6 +186,10 @@ impl AgentOrchestrator { success, }); + if let Some(h) = hint { + display_hints.push(h); + } + messages.push(ChatMessage { role: ChatMessageRole::Tool, content: tool_result, diff --git a/crates/erp-ai/src/agent/tool.rs b/crates/erp-ai/src/agent/tool.rs index faa5fc4..6377dd5 100644 --- a/crates/erp-ai/src/agent/tool.rs +++ b/crates/erp-ai/src/agent/tool.rs @@ -30,7 +30,7 @@ pub struct ToolResult { } /// 前端渲染提示 — 告诉前端如何富化展示 Tool 返回的数据 -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[serde(tag = "type", rename_all = "snake_case")] pub enum DisplayHint { VitalCard { diff --git a/crates/erp-ai/src/handler/chat_handler.rs b/crates/erp-ai/src/handler/chat_handler.rs index 0c38f19..827ef64 100644 --- a/crates/erp-ai/src/handler/chat_handler.rs +++ b/crates/erp-ai/src/handler/chat_handler.rs @@ -40,6 +40,8 @@ pub struct ChatResponse { pub reply: String, pub message_id: String, pub iterations: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub display_hints: Option>, } #[utoipa::path( @@ -252,5 +254,10 @@ where reply: result.reply, message_id, iterations: result.iterations, + display_hints: if result.display_hints.is_empty() { + None + } else { + Some(result.display_hints) + }, }))) }