feat(ai): Day 5 — ChatResponse display_hints + Web RichMessage 渲染
- ChatResponse 增加 display_hints 字段,Orchestrator 收集 Tool 产生的 DisplayHint - DisplayHint 实现 utoipa::ToSchema - Web ChatResponse 类型同步,DisplayHint 8 种联合类型 - RichMessage 组件:InsightCard/RiskAlert/LabReportCard/TrendChart/PatientProfile/VitalCard - AiSidebar 消息中渲染 display_hints 富消息 - 小程序 AiChatResponse 类型同步
This commit is contained in:
@@ -10,8 +10,19 @@ export interface AiChatMessage {
|
|||||||
export interface AiChatResponse {
|
export interface AiChatResponse {
|
||||||
reply: string;
|
reply: string;
|
||||||
message_id: 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 客服 */
|
/** 发送消息给 AI 客服 */
|
||||||
export async function sendAiMessage(
|
export async function sendAiMessage(
|
||||||
message: string,
|
message: string,
|
||||||
|
|||||||
@@ -5,10 +5,53 @@ export interface ChatHistoryItem {
|
|||||||
content: string;
|
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 {
|
export interface ChatResponse {
|
||||||
reply: string;
|
reply: string;
|
||||||
message_id: string;
|
message_id: string;
|
||||||
iterations: number;
|
iterations: number;
|
||||||
|
display_hints?: DisplayHint[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const aiChatApi = {
|
export const aiChatApi = {
|
||||||
|
|||||||
@@ -18,9 +18,10 @@ import {
|
|||||||
SafetyCertificateOutlined,
|
SafetyCertificateOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useLocation } from 'react-router-dom';
|
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 { analysisApi, type HealthSummaryResponse } from '../../api/ai/analysis';
|
||||||
import { useAuthStore } from '../../stores/auth';
|
import { useAuthStore } from '../../stores/auth';
|
||||||
|
import RichMessage from './RichMessage';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
@@ -29,6 +30,7 @@ interface ChatMessage {
|
|||||||
id: string;
|
id: string;
|
||||||
role: 'user' | 'assistant';
|
role: 'user' | 'assistant';
|
||||||
content: string;
|
content: string;
|
||||||
|
displayHints?: DisplayHint[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractPatientId(pathname: string): string | null {
|
function extractPatientId(pathname: string): string | null {
|
||||||
@@ -146,8 +148,9 @@ export default function AiSidebar({
|
|||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
id: resp.message_id,
|
id: resp.message_id,
|
||||||
role: 'assistant',
|
role: 'assistant' as const,
|
||||||
content: resp.reply,
|
content: resp.reply,
|
||||||
|
displayHints: resp.display_hints,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -333,6 +336,9 @@ export default function AiSidebar({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{msg.content}
|
{msg.content}
|
||||||
|
{msg.displayHints && msg.displayHints.length > 0 && (
|
||||||
|
<RichMessage hints={msg.displayHints} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
180
apps/web/src/components/ai/RichMessage.tsx
Normal file
180
apps/web/src/components/ai/RichMessage.tsx
Normal file
@@ -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<string, string> = {
|
||||||
|
high: 'red',
|
||||||
|
medium: 'orange',
|
||||||
|
low: 'green',
|
||||||
|
};
|
||||||
|
|
||||||
|
const RISK_LEVEL_COLOR: Record<string, string> = {
|
||||||
|
critical: '#cf1322',
|
||||||
|
high: 'red',
|
||||||
|
medium: 'orange',
|
||||||
|
low: 'green',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RichMessage({ hints }: { hints: DisplayHint[] }) {
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 8 }}>
|
||||||
|
{hints.map((hint, i) => (
|
||||||
|
<RichHint key={i} hint={hint} token={token} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RichHint({ hint, token }: { hint: DisplayHint; token: { colorBorderSecondary: string; colorTextSecondary: string; colorPrimary: string } }) {
|
||||||
|
switch (hint.type) {
|
||||||
|
case 'insight_card':
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
title={
|
||||||
|
<span style={{ fontSize: 12 }}>
|
||||||
|
<HeartOutlined style={{ marginRight: 4, color: token.colorPrimary }} />
|
||||||
|
{hint.title}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
styles={{ body: { padding: '6px 12px' } }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||||
|
{hint.items.map((item, j) => (
|
||||||
|
<Tag key={j} color={SEVERITY_COLOR[hint.severity] ?? 'blue'} style={{ fontSize: 11, margin: 0 }}>
|
||||||
|
{item}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'risk_alert':
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: `1px solid ${RISK_LEVEL_COLOR[hint.level] ?? token.colorBorderSecondary}`,
|
||||||
|
background: `${RISK_LEVEL_COLOR[hint.level] ?? token.colorBorderSecondary}10`,
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<WarningOutlined style={{ color: RISK_LEVEL_COLOR[hint.level] ?? token.colorPrimary, marginRight: 6 }} />
|
||||||
|
{hint.message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'lab_report_card':
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
title={
|
||||||
|
<span style={{ fontSize: 12 }}>
|
||||||
|
<ExperimentOutlined style={{ marginRight: 4 }} />
|
||||||
|
化验报告 {hint.report_date}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
styles={{ body: { padding: '6px 12px', fontSize: 12 } }}
|
||||||
|
>
|
||||||
|
{hint.abnormal_count > 0 ? (
|
||||||
|
<Tag color="red" style={{ fontSize: 11 }}>
|
||||||
|
{hint.abnormal_count} 项异常
|
||||||
|
</Tag>
|
||||||
|
) : (
|
||||||
|
<Tag color="green" style={{ fontSize: 11 }}>正常</Tag>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'trend_chart':
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
title={
|
||||||
|
<span style={{ fontSize: 12 }}>
|
||||||
|
<LineChartOutlined style={{ marginRight: 4 }} />
|
||||||
|
趋势分析({hint.period})
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
styles={{ body: { padding: '6px 12px', fontSize: 12 } }}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: 4 }}>
|
||||||
|
{hint.metrics.map((m, j) => (
|
||||||
|
<Tag key={j} style={{ fontSize: 11, margin: '0 4px 2px 0' }}>{m}</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }}>{hint.summary}</Text>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'patient_profile':
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
title={
|
||||||
|
<span style={{ fontSize: 12 }}>
|
||||||
|
<UserOutlined style={{ marginRight: 4 }} />
|
||||||
|
患者档案
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
styles={{ body: { padding: '6px 12px', fontSize: 12 } }}
|
||||||
|
>
|
||||||
|
{hint.chronic_conditions.length > 0 && (
|
||||||
|
<div style={{ marginBottom: 4 }}>
|
||||||
|
{hint.chronic_conditions.map((c, j) => (
|
||||||
|
<Tag key={j} color="orange" style={{ fontSize: 11, margin: '0 4px 2px 0' }}>{c}</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hint.medication_count > 0 && (
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }}>当前用药 {hint.medication_count} 种</Text>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'vital_card':
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
title={
|
||||||
|
<span style={{ fontSize: 12 }}>
|
||||||
|
<HeartOutlined style={{ marginRight: 4, color: token.colorPrimary }} />
|
||||||
|
体征数据
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
styles={{ body: { padding: '6px 12px', fontSize: 12 } }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||||
|
{hint.values.slice(-5).map(([date, val], j) => (
|
||||||
|
<span key={j}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }}>{date.slice(5)}:</Text>{' '}
|
||||||
|
{val} {hint.unit}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'action_confirm':
|
||||||
|
return (
|
||||||
|
<Card size="small" styles={{ body: { padding: '8px 12px', fontSize: 13 } }}>
|
||||||
|
<Text>{hint.summary}</Text>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'text':
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,6 +49,7 @@ pub struct AgentRunResult {
|
|||||||
pub total_output_tokens: u32,
|
pub total_output_tokens: u32,
|
||||||
pub iterations: usize,
|
pub iterations: usize,
|
||||||
pub tool_calls: Vec<ToolCallLog>,
|
pub tool_calls: Vec<ToolCallLog>,
|
||||||
|
pub display_hints: Vec<super::tool::DisplayHint>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AgentOrchestrator {
|
impl AgentOrchestrator {
|
||||||
@@ -76,6 +77,7 @@ impl AgentOrchestrator {
|
|||||||
let mut total_input_tokens = 0u32;
|
let mut total_input_tokens = 0u32;
|
||||||
let mut total_output_tokens = 0u32;
|
let mut total_output_tokens = 0u32;
|
||||||
let mut tool_call_logs: Vec<ToolCallLog> = Vec::new();
|
let mut tool_call_logs: Vec<ToolCallLog> = Vec::new();
|
||||||
|
let mut display_hints: Vec<super::tool::DisplayHint> = Vec::new();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
iterations += 1;
|
iterations += 1;
|
||||||
@@ -107,6 +109,7 @@ impl AgentOrchestrator {
|
|||||||
total_output_tokens,
|
total_output_tokens,
|
||||||
iterations,
|
iterations,
|
||||||
tool_calls: tool_call_logs,
|
tool_calls: tool_call_logs,
|
||||||
|
display_hints,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -155,21 +158,25 @@ impl AgentOrchestrator {
|
|||||||
// 执行每个 Tool Call(受沙箱 allowed_tools 约束)
|
// 执行每个 Tool Call(受沙箱 allowed_tools 约束)
|
||||||
for tc in &tool_calls {
|
for tc in &tool_calls {
|
||||||
let start = std::time::Instant::now();
|
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) => {
|
Some(tool) => {
|
||||||
if let Some(allowed) = allowed_tools {
|
if let Some(allowed) = allowed_tools {
|
||||||
if !allowed.contains(tc.name.as_str()) {
|
if !allowed.contains(tc.name.as_str()) {
|
||||||
(format!("Tool '{}' 在当前角色下不可用", tc.name), false)
|
(
|
||||||
|
format!("Tool '{}' 在当前角色下不可用", tc.name),
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
let result = tool.execute(ctx, tc.arguments.clone()).await;
|
let result = tool.execute(ctx, tc.arguments.clone()).await;
|
||||||
(result.output, true)
|
(result.output, true, result.display_hint)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let result = tool.execute(ctx, tc.arguments.clone()).await;
|
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();
|
let duration = start.elapsed();
|
||||||
|
|
||||||
@@ -179,6 +186,10 @@ impl AgentOrchestrator {
|
|||||||
success,
|
success,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if let Some(h) = hint {
|
||||||
|
display_hints.push(h);
|
||||||
|
}
|
||||||
|
|
||||||
messages.push(ChatMessage {
|
messages.push(ChatMessage {
|
||||||
role: ChatMessageRole::Tool,
|
role: ChatMessageRole::Tool,
|
||||||
content: tool_result,
|
content: tool_result,
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ pub struct ToolResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 前端渲染提示 — 告诉前端如何富化展示 Tool 返回的数据
|
/// 前端渲染提示 — 告诉前端如何富化展示 Tool 返回的数据
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
pub enum DisplayHint {
|
pub enum DisplayHint {
|
||||||
VitalCard {
|
VitalCard {
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ pub struct ChatResponse {
|
|||||||
pub reply: String,
|
pub reply: String,
|
||||||
pub message_id: String,
|
pub message_id: String,
|
||||||
pub iterations: usize,
|
pub iterations: usize,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub display_hints: Option<Vec<crate::agent::tool::DisplayHint>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
@@ -252,5 +254,10 @@ where
|
|||||||
reply: result.reply,
|
reply: result.reply,
|
||||||
message_id,
|
message_id,
|
||||||
iterations: result.iterations,
|
iterations: result.iterations,
|
||||||
|
display_hints: if result.display_hints.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(result.display_hints)
|
||||||
|
},
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user