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:
iven
2026-05-19 11:44:38 +08:00
parent a48a3d9906
commit 8e5bc97f93
3 changed files with 421 additions and 1 deletions

View 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>
);
}