From 8e5bc97f931867b13aa88f2f3b1df87d30731b4c Mon Sep 17 00:00:00 2001 From: iven Date: Tue, 19 May 2026 11:44:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20Day=209=20=E2=80=94=20Web=20ChatPa?= =?UTF-8?q?ge=20+=20=E4=BC=9A=E8=AF=9D=20API=20=E5=89=8D=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChatPage 组件: 左侧会话列表(260px) + 右侧聊天区 + RichMessage 富消息 - 新建/选择/重命名/关闭会话,session_id 模式消息持久化 - aiChatApi 新增 createSession/listSessions/renameSession/closeSession - 路由 /ai/chat 注册,支持 display_hints 富消息渲染 - App.tsx 路由权限校验覆盖 --- apps/web/src/App.tsx | 3 + apps/web/src/api/ai/chat.ts | 40 ++- apps/web/src/pages/ai/ChatPage.tsx | 379 +++++++++++++++++++++++++++++ 3 files changed, 421 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/pages/ai/ChatPage.tsx diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 5593241..8c8a21d 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -47,6 +47,7 @@ const AiAnalysisList = lazy(() => import('./pages/health/AiAnalysisList')); const AiUsageDashboard = lazy(() => import('./pages/health/AiUsageDashboard')); const AiConfigPage = lazy(() => import('./pages/health/AiConfigPage')); const AiKnowledgePage = lazy(() => import('./pages/health/AiKnowledgePage')); +const AiChatPage = lazy(() => import('./pages/ai/ChatPage')); const AlertList = lazy(() => import('./pages/health/AlertList')); const AlertDashboard = lazy(() => import('./pages/health/AlertDashboard')); 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/offline-events", "/health/ai-prompts", "/health/ai-analysis", "/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/oauth-clients", "/health/dialysis", "/health/action-inbox", "/health/follow-up-templates", "/health/care-plans", "/health/shifts", @@ -329,6 +331,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/web/src/api/ai/chat.ts b/apps/web/src/api/ai/chat.ts index deb55d8..446d4b9 100644 --- a/apps/web/src/api/ai/chat.ts +++ b/apps/web/src/api/ai/chat.ts @@ -54,17 +54,55 @@ export interface ChatResponse { 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 = { sendMessage: async ( message: string, history: ChatHistoryItem[], - patientId?: string + patientId?: string, + sessionId?: string ): Promise => { const resp = await client.post('/ai/chat', { message, history, ...(patientId ? { patient_id: patientId } : {}), + ...(sessionId ? { session_id: sessionId } : {}), }); return resp.data.data as ChatResponse; }, + + createSession: async ( + patientId?: string, + title?: string + ): Promise => { + const resp = await client.post('/ai/chat/sessions', { + ...(patientId ? { patient_id: patientId } : {}), + ...(title ? { title } : {}), + }); + return resp.data.data as ChatSession; + }, + + listSessions: async (): Promise => { + const resp = await client.get('/ai/chat/sessions'); + return resp.data.data as ChatSession[]; + }, + + renameSession: async ( + sessionId: string, + title: string + ): Promise => { + await client.put(`/ai/chat/sessions/${sessionId}/rename`, { title }); + }, + + closeSession: async (sessionId: string): Promise => { + await client.post(`/ai/chat/sessions/${sessionId}/close`); + }, }; diff --git a/apps/web/src/pages/ai/ChatPage.tsx b/apps/web/src/pages/ai/ChatPage.tsx new file mode 100644 index 0000000..cae3738 --- /dev/null +++ b/apps/web/src/pages/ai/ChatPage.tsx @@ -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([]); + const [activeId, setActiveId] = useState(null); + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(''); + const [loading, setLoading] = useState(false); + const [sessionsLoading, setSessionsLoading] = useState(false); + const messagesEndRef = useRef(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: ( + + ), + 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 ( + + +
+ +
+ ( + handleSelectSession(session.id)} + actions={ + canManage + ? [ + { + e.stopPropagation(); + handleRename(session); + }} + />, + { + e.stopPropagation(); + handleClose(session.id); + }} + />, + ] + : [] + } + > + } + title={ + + {session.title ?? '新会话'} + + } + description={ + + {new Date(session.updated_at).toLocaleDateString()} + + } + /> + + )} + /> +
+ + + {/* 标题栏 */} +
+ + + {activeSession?.title ?? '选择或创建一个会话'} + +
+ + {/* 消息区 */} +
+ {!activeId ? ( +
+ +
+ 点击左侧「新建会话」开始对话 +
+
+ ) : ( + messages.map((msg) => ( +
+
+ {msg.content} + {msg.displayHints && msg.displayHints.length > 0 && ( + + )} +
+
+ )) + )} + {loading && ( +
+
+ 思考中... +
+
+ )} +
+
+ + {/* 输入区 */} + {activeId && ( +
+ +