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={ +