From 1e2ad6170a97526fb760953f64e4786aa16fb5cc Mon Sep 17 00:00:00 2001 From: iven Date: Tue, 19 May 2026 00:32:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20Phase=202A-1=20AI=20=E4=BE=A7?= =?UTF-8?q?=E8=BE=B9=E6=A0=8F=E9=AA=A8=E6=9E=B6=20=E2=80=94=20=E6=B5=AE?= =?UTF-8?q?=E5=8A=A8=E6=8C=89=E9=92=AE=20+=20=E8=81=8A=E5=A4=A9=20Drawer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 Web 管理后台 AI 侧边栏: - 右下角渐变色浮动按钮(RobotOutlined),hover 放大效果 - AiSidebar Drawer 组件:聊天消息列表 + 输入框 + 发送按钮 - 自动检测当前页面患者 ID,携带 patient_id 上下文到 /ai/chat - 权限检查:无 ai.chat.send 权限时禁用输入并提示 - 气泡样式对话:用户消息蓝色右对齐,助手消息灰色左对齐 - 清空对话、加载态(思考中 Spin)、Enter 发送 + Shift+Enter 换行 新增文件: - apps/web/src/api/ai/chat.ts — AI 聊天 API 模块 - apps/web/src/components/ai/AiSidebar.tsx — 侧边栏组件 修改文件: - apps/web/src/layouts/MainLayout.tsx — 集成浮动按钮 + AiSidebar --- apps/web/src/api/ai/chat.ts | 27 +++ apps/web/src/components/ai/AiSidebar.tsx | 255 +++++++++++++++++++++++ apps/web/src/layouts/MainLayout.tsx | 37 ++++ 3 files changed, 319 insertions(+) create mode 100644 apps/web/src/api/ai/chat.ts create mode 100644 apps/web/src/components/ai/AiSidebar.tsx diff --git a/apps/web/src/api/ai/chat.ts b/apps/web/src/api/ai/chat.ts new file mode 100644 index 0000000..32e4f34 --- /dev/null +++ b/apps/web/src/api/ai/chat.ts @@ -0,0 +1,27 @@ +import client from '../client'; + +export interface ChatHistoryItem { + role: 'user' | 'assistant'; + content: string; +} + +export interface ChatResponse { + reply: string; + message_id: string; + iterations: number; +} + +export const aiChatApi = { + sendMessage: async ( + message: string, + history: ChatHistoryItem[], + patientId?: string + ): Promise => { + const resp = await client.post('/ai/chat', { + message, + history, + ...(patientId ? { patient_id: patientId } : {}), + }); + return resp.data.data as ChatResponse; + }, +}; diff --git a/apps/web/src/components/ai/AiSidebar.tsx b/apps/web/src/components/ai/AiSidebar.tsx new file mode 100644 index 0000000..50b6989 --- /dev/null +++ b/apps/web/src/components/ai/AiSidebar.tsx @@ -0,0 +1,255 @@ +import { useState, useRef, useEffect, useCallback } from 'react'; +import { Drawer, Input, Button, Space, Typography, Spin, Tag, theme } from 'antd'; +import { SendOutlined, RobotOutlined, DeleteOutlined } from '@ant-design/icons'; +import { useLocation } from 'react-router-dom'; +import { aiChatApi, type ChatHistoryItem } from '../../api/ai/chat'; +import { useAuthStore } from '../../stores/auth'; + +const { Text } = Typography; +const { TextArea } = Input; + +interface ChatMessage { + id: string; + role: 'user' | 'assistant'; + content: string; +} + +function extractPatientId(pathname: string): string | null { + const match = pathname.match(/\/health\/patients\/([0-9a-f-]+)/i); + return match?.[1] ?? null; +} + +export default function AiSidebar({ + open, + onClose, +}: { + open: boolean; + onClose: () => void; +}) { + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(''); + const [loading, setLoading] = useState(false); + const messagesEndRef = useRef(null); + const location = useLocation(); + const { token } = theme.useToken(); + + const patientId = extractPatientId(location.pathname); + const permissions = useAuthStore((s) => s.permissions); + const canChat = permissions.includes('ai.chat.send'); + + // 欢迎消息 + useEffect(() => { + if (open && messages.length === 0) { + setMessages([ + { + id: 'welcome', + role: 'assistant', + content: patientId + ? '你好!我是 AI 健康助手。当前已关联患者档案,你可以问我关于该患者的体征、化验报告、用药等信息。' + : '你好!我是 AI 健康助手。你可以向我咨询健康相关问题,或打开患者详情页查看患者数据。', + }, + ]); + } + }, [open]); // eslint-disable-line react-hooks/exhaustive-deps + + const scrollToBottom = useCallback(() => { + setTimeout(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, 100); + }, []); + + useEffect(() => { + scrollToBottom(); + }, [messages, scrollToBottom]); + + const handleSend = async () => { + const text = input.trim(); + if (!text || loading || !canChat) return; + + const userMsg: ChatMessage = { + id: `u-${Date.now()}`, + role: 'user', + content: text, + }; + + const newMessages = [...messages, userMsg]; + setMessages(newMessages); + setInput(''); + setLoading(true); + + try { + const history: ChatHistoryItem[] = newMessages + .filter((m) => m.id !== 'welcome') + .map((m) => ({ + role: m.role, + content: m.content, + })); + + const resp = await aiChatApi.sendMessage(text, history, patientId ?? undefined); + + setMessages((prev) => [ + ...prev, + { + id: resp.message_id, + role: 'assistant', + content: resp.reply, + }, + ]); + } 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 handleClear = () => { + setMessages([ + { + id: 'welcome', + role: 'assistant', + content: '对话已清空。有什么可以帮你的?', + }, + ]); + }; + + return ( + + + AI 健康助手 + {patientId && ( + + 已关联患者 + + )} + + } + placement="right" + width={400} + open={open} + onClose={onClose} + styles={{ + body: { display: 'flex', flexDirection: 'column', padding: 0 }, + }} + extra={ +