feat(web): Day 9 — Web ChatPage + 会话 API 前端
- ChatPage 组件: 左侧会话列表(260px) + 右侧聊天区 + RichMessage 富消息 - 新建/选择/重命名/关闭会话,session_id 模式消息持久化 - aiChatApi 新增 createSession/listSessions/renameSession/closeSession - 路由 /ai/chat 注册,支持 display_hints 富消息渲染 - App.tsx 路由权限校验覆盖
This commit is contained in:
@@ -47,6 +47,7 @@ const AiAnalysisList = lazy(() => import('./pages/health/AiAnalysisList'));
|
|||||||
const AiUsageDashboard = lazy(() => import('./pages/health/AiUsageDashboard'));
|
const AiUsageDashboard = lazy(() => import('./pages/health/AiUsageDashboard'));
|
||||||
const AiConfigPage = lazy(() => import('./pages/health/AiConfigPage'));
|
const AiConfigPage = lazy(() => import('./pages/health/AiConfigPage'));
|
||||||
const AiKnowledgePage = lazy(() => import('./pages/health/AiKnowledgePage'));
|
const AiKnowledgePage = lazy(() => import('./pages/health/AiKnowledgePage'));
|
||||||
|
const AiChatPage = lazy(() => import('./pages/ai/ChatPage'));
|
||||||
const AlertList = lazy(() => import('./pages/health/AlertList'));
|
const AlertList = lazy(() => import('./pages/health/AlertList'));
|
||||||
const AlertDashboard = lazy(() => import('./pages/health/AlertDashboard'));
|
const AlertDashboard = lazy(() => import('./pages/health/AlertDashboard'));
|
||||||
const AlertRuleList = lazy(() => import('./pages/health/AlertRuleList'));
|
const AlertRuleList = lazy(() => import('./pages/health/AlertRuleList'));
|
||||||
@@ -257,6 +258,7 @@ export default function App() {
|
|||||||
"/health/points-rules", "/health/points-products", "/health/points-orders",
|
"/health/points-rules", "/health/points-products", "/health/points-orders",
|
||||||
"/health/offline-events", "/health/ai-prompts", "/health/ai-analysis",
|
"/health/offline-events", "/health/ai-prompts", "/health/ai-analysis",
|
||||||
"/health/ai-usage", "/health/ai-config", "/health/ai-knowledge", "/health/alerts", "/health/alert-dashboard",
|
"/health/ai-usage", "/health/ai-config", "/health/ai-knowledge", "/health/alerts", "/health/alert-dashboard",
|
||||||
|
"/ai/chat",
|
||||||
"/health/alert-rules", "/health/devices", "/health/realtime-monitor",
|
"/health/alert-rules", "/health/devices", "/health/realtime-monitor",
|
||||||
"/health/oauth-clients", "/health/dialysis", "/health/action-inbox",
|
"/health/oauth-clients", "/health/dialysis", "/health/action-inbox",
|
||||||
"/health/follow-up-templates", "/health/care-plans", "/health/shifts",
|
"/health/follow-up-templates", "/health/care-plans", "/health/shifts",
|
||||||
@@ -329,6 +331,7 @@ export default function App() {
|
|||||||
<Route path="/health/ai-usage" element={<AiUsageDashboard />} />
|
<Route path="/health/ai-usage" element={<AiUsageDashboard />} />
|
||||||
<Route path="/health/ai-config" element={<AiConfigPage />} />
|
<Route path="/health/ai-config" element={<AiConfigPage />} />
|
||||||
<Route path="/health/ai-knowledge" element={<AiKnowledgePage />} />
|
<Route path="/health/ai-knowledge" element={<AiKnowledgePage />} />
|
||||||
|
<Route path="/ai/chat" element={<AiChatPage />} />
|
||||||
<Route path="/health/alerts" element={<AlertList />} />
|
<Route path="/health/alerts" element={<AlertList />} />
|
||||||
<Route path="/health/alert-dashboard" element={<AlertDashboard />} />
|
<Route path="/health/alert-dashboard" element={<AlertDashboard />} />
|
||||||
<Route path="/health/alert-rules" element={<AlertRuleList />} />
|
<Route path="/health/alert-rules" element={<AlertRuleList />} />
|
||||||
|
|||||||
@@ -54,17 +54,55 @@ export interface ChatResponse {
|
|||||||
display_hints?: DisplayHint[];
|
display_hints?: DisplayHint[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChatSession {
|
||||||
|
id: string;
|
||||||
|
title: string | null;
|
||||||
|
patient_id: string | null;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const aiChatApi = {
|
export const aiChatApi = {
|
||||||
sendMessage: async (
|
sendMessage: async (
|
||||||
message: string,
|
message: string,
|
||||||
history: ChatHistoryItem[],
|
history: ChatHistoryItem[],
|
||||||
patientId?: string
|
patientId?: string,
|
||||||
|
sessionId?: string
|
||||||
): Promise<ChatResponse> => {
|
): Promise<ChatResponse> => {
|
||||||
const resp = await client.post('/ai/chat', {
|
const resp = await client.post('/ai/chat', {
|
||||||
message,
|
message,
|
||||||
history,
|
history,
|
||||||
...(patientId ? { patient_id: patientId } : {}),
|
...(patientId ? { patient_id: patientId } : {}),
|
||||||
|
...(sessionId ? { session_id: sessionId } : {}),
|
||||||
});
|
});
|
||||||
return resp.data.data as ChatResponse;
|
return resp.data.data as ChatResponse;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
createSession: async (
|
||||||
|
patientId?: string,
|
||||||
|
title?: string
|
||||||
|
): Promise<ChatSession> => {
|
||||||
|
const resp = await client.post('/ai/chat/sessions', {
|
||||||
|
...(patientId ? { patient_id: patientId } : {}),
|
||||||
|
...(title ? { title } : {}),
|
||||||
|
});
|
||||||
|
return resp.data.data as ChatSession;
|
||||||
|
},
|
||||||
|
|
||||||
|
listSessions: async (): Promise<ChatSession[]> => {
|
||||||
|
const resp = await client.get('/ai/chat/sessions');
|
||||||
|
return resp.data.data as ChatSession[];
|
||||||
|
},
|
||||||
|
|
||||||
|
renameSession: async (
|
||||||
|
sessionId: string,
|
||||||
|
title: string
|
||||||
|
): Promise<void> => {
|
||||||
|
await client.put(`/ai/chat/sessions/${sessionId}/rename`, { title });
|
||||||
|
},
|
||||||
|
|
||||||
|
closeSession: async (sessionId: string): Promise<void> => {
|
||||||
|
await client.post(`/ai/chat/sessions/${sessionId}/close`);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
379
apps/web/src/pages/ai/ChatPage.tsx
Normal file
379
apps/web/src/pages/ai/ChatPage.tsx
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Layout,
|
||||||
|
List,
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Typography,
|
||||||
|
Spin,
|
||||||
|
PlusOutlined,
|
||||||
|
MessageOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
theme,
|
||||||
|
Modal,
|
||||||
|
Space,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
SendOutlined,
|
||||||
|
RobotOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import {
|
||||||
|
aiChatApi,
|
||||||
|
type ChatHistoryItem,
|
||||||
|
type ChatSession,
|
||||||
|
type DisplayHint,
|
||||||
|
} from '../../api/ai/chat';
|
||||||
|
import RichMessage from '../../components/ai/RichMessage';
|
||||||
|
import { useAuthStore } from '../../stores/auth';
|
||||||
|
|
||||||
|
const { Sider, Content } = Layout;
|
||||||
|
const { Text } = Typography;
|
||||||
|
const { TextArea } = Input;
|
||||||
|
|
||||||
|
interface ChatMessage {
|
||||||
|
id: string;
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
displayHints?: DisplayHint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChatPage() {
|
||||||
|
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [sessionsLoading, setSessionsLoading] = useState(false);
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
const permissions = useAuthStore((s) => s.permissions);
|
||||||
|
const canManage = permissions.includes('ai.chat.session.manage');
|
||||||
|
|
||||||
|
const loadSessions = useCallback(async () => {
|
||||||
|
setSessionsLoading(true);
|
||||||
|
try {
|
||||||
|
const list = await aiChatApi.listSessions();
|
||||||
|
setSessions(list);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
} finally {
|
||||||
|
setSessionsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSessions();
|
||||||
|
}, [loadSessions]);
|
||||||
|
|
||||||
|
const scrollToBottom = useCallback(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, 100);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollToBottom();
|
||||||
|
}, [messages, scrollToBottom]);
|
||||||
|
|
||||||
|
const handleNewSession = async () => {
|
||||||
|
try {
|
||||||
|
const session = await aiChatApi.createSession();
|
||||||
|
setSessions((prev) => [session, ...prev]);
|
||||||
|
setActiveId(session.id);
|
||||||
|
setMessages([
|
||||||
|
{
|
||||||
|
id: 'welcome',
|
||||||
|
role: 'assistant',
|
||||||
|
content: '你好!我是 AI 健康助手。有什么可以帮你的?',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectSession = (id: string) => {
|
||||||
|
setActiveId(id);
|
||||||
|
setMessages([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
const text = input.trim();
|
||||||
|
if (!text || loading || !activeId) return;
|
||||||
|
|
||||||
|
const userMsg: ChatMessage = {
|
||||||
|
id: `u-${Date.now()}`,
|
||||||
|
role: 'user',
|
||||||
|
content: text,
|
||||||
|
};
|
||||||
|
setMessages((prev) => [...prev, userMsg]);
|
||||||
|
setInput('');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const history: ChatHistoryItem[] = messages
|
||||||
|
.filter((m) => m.id !== 'welcome')
|
||||||
|
.map((m) => ({ role: m.role, content: m.content }));
|
||||||
|
|
||||||
|
const resp = await aiChatApi.sendMessage(text, history, undefined, activeId);
|
||||||
|
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: resp.message_id,
|
||||||
|
role: 'assistant' as const,
|
||||||
|
content: resp.reply,
|
||||||
|
displayHints: resp.display_hints,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} catch {
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: `err-${Date.now()}`,
|
||||||
|
role: 'assistant',
|
||||||
|
content: '抱歉,AI 服务暂时不可用,请稍后再试。',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRename = (session: ChatSession) => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '重命名会话',
|
||||||
|
content: (
|
||||||
|
<Input
|
||||||
|
id="rename-input"
|
||||||
|
defaultValue={session.title ?? ''}
|
||||||
|
placeholder="输入新名称"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
onOk: async () => {
|
||||||
|
const input = document.querySelector('#rename-input') as HTMLInputElement;
|
||||||
|
const newTitle = input?.value?.trim();
|
||||||
|
if (newTitle) {
|
||||||
|
await aiChatApi.renameSession(session.id, newTitle);
|
||||||
|
setSessions((prev) =>
|
||||||
|
prev.map((s) => (s.id === session.id ? { ...s, title: newTitle } : s))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = async (id: string) => {
|
||||||
|
await aiChatApi.closeSession(id);
|
||||||
|
setSessions((prev) => prev.filter((s) => s.id !== id));
|
||||||
|
if (activeId === id) {
|
||||||
|
setActiveId(null);
|
||||||
|
setMessages([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeSession = sessions.find((s) => s.id === activeId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout style={{ height: 'calc(100vh - 64px)', background: token.colorBgContainer }}>
|
||||||
|
<Sider
|
||||||
|
width={260}
|
||||||
|
style={{
|
||||||
|
background: token.colorBgLayout,
|
||||||
|
borderRight: `1px solid ${token.colorBorderSecondary}`,
|
||||||
|
overflow: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ padding: 12 }}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
block
|
||||||
|
onClick={handleNewSession}
|
||||||
|
disabled={!canManage}
|
||||||
|
>
|
||||||
|
新建会话
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<List
|
||||||
|
loading={sessionsLoading}
|
||||||
|
dataSource={sessions}
|
||||||
|
renderItem={(session) => (
|
||||||
|
<List.Item
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
background:
|
||||||
|
activeId === session.id
|
||||||
|
? token.colorPrimaryBg
|
||||||
|
: 'transparent',
|
||||||
|
}}
|
||||||
|
onClick={() => handleSelectSession(session.id)}
|
||||||
|
actions={
|
||||||
|
canManage
|
||||||
|
? [
|
||||||
|
<EditOutlined
|
||||||
|
key="rename"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleRename(session);
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
<DeleteOutlined
|
||||||
|
key="close"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleClose(session.id);
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<List.Item.Meta
|
||||||
|
avatar={<MessageOutlined style={{ color: token.colorPrimary, marginTop: 4 }} />}
|
||||||
|
title={
|
||||||
|
<Text ellipsis style={{ fontSize: 13 }}>
|
||||||
|
{session.title ?? '新会话'}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||||
|
{new Date(session.updated_at).toLocaleDateString()}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Sider>
|
||||||
|
|
||||||
|
<Content style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{/* 标题栏 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '12px 20px',
|
||||||
|
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||||
|
background: token.colorBgContainer,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space>
|
||||||
|
<RobotOutlined style={{ color: token.colorPrimary }} />
|
||||||
|
<Text strong>{activeSession?.title ?? '选择或创建一个会话'}</Text>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 消息区 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
overflowY: 'auto',
|
||||||
|
padding: '16px 24px',
|
||||||
|
background: token.colorBgLayout,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!activeId ? (
|
||||||
|
<div style={{ textAlign: 'center', marginTop: 80 }}>
|
||||||
|
<RobotOutlined style={{ fontSize: 48, color: token.colorTextQuaternary }} />
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<Text type="secondary">点击左侧「新建会话」开始对话</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
messages.map((msg) => (
|
||||||
|
<div
|
||||||
|
key={msg.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start',
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxWidth: '75%',
|
||||||
|
padding: '10px 14px',
|
||||||
|
borderRadius: 12,
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
background:
|
||||||
|
msg.role === 'user'
|
||||||
|
? token.colorPrimary
|
||||||
|
: token.colorBgContainer,
|
||||||
|
color:
|
||||||
|
msg.role === 'user'
|
||||||
|
? token.colorTextLightSolid
|
||||||
|
: token.colorText,
|
||||||
|
borderBottomRightRadius: msg.role === 'user' ? 4 : 12,
|
||||||
|
borderBottomLeftRadius: msg.role === 'assistant' ? 4 : 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{msg.content}
|
||||||
|
{msg.displayHints && msg.displayHints.length > 0 && (
|
||||||
|
<RichMessage hints={msg.displayHints} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{loading && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-start', marginBottom: 12 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '10px 18px',
|
||||||
|
borderRadius: 12,
|
||||||
|
borderBottomLeftRadius: 4,
|
||||||
|
background: token.colorBgContainer,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Spin size="small" /> <Text type="secondary">思考中...</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 输入区 */}
|
||||||
|
{activeId && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: 16,
|
||||||
|
borderTop: `1px solid ${token.colorBorderSecondary}`,
|
||||||
|
background: token.colorBgContainer,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space.Compact style={{ width: '100%' }}>
|
||||||
|
<TextArea
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="输入消息... (Enter 发送, Shift+Enter 换行)"
|
||||||
|
disabled={loading}
|
||||||
|
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||||
|
style={{ borderRadius: '8px 0 0 8px' }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<SendOutlined />}
|
||||||
|
onClick={handleSend}
|
||||||
|
loading={loading}
|
||||||
|
disabled={!input.trim()}
|
||||||
|
style={{ height: 'auto', borderRadius: '0 8px 8px 0', minHeight: 40 }}
|
||||||
|
/>
|
||||||
|
</Space.Compact>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Content>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user