☰
第 7 章 聊天会话模块实现
2026/10/17 7:45:52 网站建设 项目流程

📌 本章目标:把前面学的东西串起来,做一个完整的聊天界面

⭐⭐⭐ 难度:进阶 | ⏱️ 预计时间:2 小时


7.1 整体布局介绍

聊天会话模块是工作台的核心界面,整体布局是「三栏式」:

┌──────────┬───────────────────────┬──────────────┐ │ │ │ │ │ 侧边栏 │ 聊天主区域 │ 执行面板 │ │(Sidebar)│(Conversation)│(ExecPanel)│ │ │ │ │ │ - Agent │ - 消息列表 │ - 输出 │ │ 列表 │ - 输入框 │ - 文件 │ │ - 会话 │ │ - 工具调用 │ │ 列表 │ │ │ └──────────┴───────────────────────┴──────────────┘

三栏的宽度:

  • 侧边栏:280px
  • 聊天主区域:自适应(flex: 1)
  • 执行面板:400px(可折叠)

💡小贴士:三栏布局用 Flex 布局实现非常方便。中间栏用flex: 1自动撑满剩余空间。


7.2 布局组件

先把整体骨架搭起来。

工作台主布局

创建src/pages/workbench/components/WorkbenchLayout/index.tsx:

import React from 'react'; import { observer } from 'mobx-react-lite'; import Sidebar from '../Sidebar'; import Conversation from '../Conversation'; import ExecPanel from '../ExecPanel'; import styles from './index.less'; const WorkbenchLayout = observer(() => { return ( <div className={styles.workbench}> {/* 左侧边栏 */} <Sidebar /> {/* 中间聊天区域 */} <div className={styles.chatArea}> <Conversation /> </div> {/* 右侧执行面板 */} <ExecPanel /> </div> ); }); export default WorkbenchLayout;

创建对应的样式src/pages/workbench/components/WorkbenchLayout/index.less:

.workbench { display: flex; height: 100vh; overflow: hidden; background: @bg-body; } .chatArea { flex: 1; display: flex; flex-direction: column; min-width: 0; // 防止内容撑开导致布局错乱 border-left: 1px solid #e8e8e8; border-right: 1px solid #e8e8e8; }

7.3 侧边栏(Sidebar)

侧边栏分为两个主要区域:

  • Agent 列表(顶部)
  • 会话列表(下方,跟随 Agent 切换)

Agent 卡片组件

创建src/pages/workbench/components/Sidebar/AgentCard.tsx:

import React from 'react'; import { Avatar } from 'antd'; import { RobotOutlined } from '@ant-design/icons'; import { observer } from 'mobx-react-lite'; import { useStore } from '@/store/StoreContext'; import type { Agent } from '@/services/gateway/types'; import styles from './AgentCard.less'; interface Props { agent: Agent; } const AgentCard = observer(({ agent }: Props) => { const { agentStore } = useStore(); const isSelected = agentStore.selectedAgentId === agent.id; return ( <div className={`${styles.agentCard} ${isSelected ? styles.selected : ''}`} onClick={() => agentStore.selectAgent(agent.id)} > <div className={styles.avatarWrapper}> <Avatar icon={<RobotOutlined />} style={{ backgroundColor: isSelected ? '@primary-color' : '#ccc' }} /> {/* 在线状态点 */} <span className={`${styles.statusDot} ${agent.online ? styles.online : styles.offline}`} /> </div> <div className={styles.info}> <div className={styles.name}>{agent.name}</div> <div className={styles.desc}>{agent.description}</div> </div> </div> ); }); export default AgentCard;

样式src/pages/workbench/components/Sidebar/AgentCard.less:

.agentCard { display: flex; align-items: center; padding: @spacing-sm @spacing-md; cursor: pointer; border-radius: @radius-sm; margin-bottom: @spacing-xs; transition: background-color 0.2s; &:hover { background: rgba(0, 0, 0, 0.04); } &.selected { background: rgba(22, 119, 255, 0.1); } .avatarWrapper { position: relative; margin-right: @spacing-sm; } .statusDot { position: absolute; bottom: 0; right: 0; width: 10px; height: 10px; border-radius: 50%; border: 2px solid #fff; &.online { background: @success-color; } &.offline { background: @text-disabled; } } .info { flex: 1; min-width: 0; } .name { font-weight: 500; color: @text-primary; .text-ellipsis(); } .desc { font-size: 12px; color: @text-secondary; .text-ellipsis(); } }

会话列表组件

创建src/pages/workbench/components/Sidebar/ConversationList.tsx:

import React from 'react'; import { observer } from 'mobx-react-lite'; import { useStore } from '@/store/StoreContext'; import { PinFilled, PlusOutlined } from '@ant-design/icons'; import { Empty, Spin } from 'antd'; import styles from './ConversationList.less'; const ConversationList = observer(() => { const { agentStore, conversationStore, rootStore } = useStore(); const agentId = agentStore.selectedAgentId; if (!agentId) { return <Empty description="请选择一个 Agent" className={styles.empty} />; } if (conversationStore.loadingAgentId === agentId) { return <Spin className={styles.loading} tip="加载中..." />; } const conversations = conversationStore.getConversations(agentId); const selectedId = conversationStore.getSelectedConversationId(agentId); return ( <div className={styles.conversationList}> {/* 新对话按钮 */} <div className={styles.newChatBtn} onClick={() => rootStore.startNewConversation()}> <PlusOutlined /> <span>新建对话</span> </div> {/* 会话列表 */} {conversations.length === 0 ? ( <Empty description="暂无对话" className={styles.empty} /> ) : ( conversations.map(conv => ( <div key={conv.id} className={`${styles.convItem} ${selectedId === conv.id ? styles.selected : ''}`} onClick={() => rootStore.openConversation(conv.id)} > <div className={styles.convHeader}> {conv.pinned && <PinFilled className={styles.pinIcon} />} <span className={styles.convTitle}>{conv.title || '新对话'}</span> {conv.unreadCount > 0 && ( <span className={styles.unreadBadge}> {conv.unreadCount > 99 ? '99+' : conv.unreadCount} </span> )} </div> <div className={styles.convLastMsg}> {conv.lastMessage || '暂无消息'} </div> </div> )) )} </div> ); }); export default ConversationList;

💡小贴士:注意这里用的是rootStore.openConversation(),而不是直接调用conversationStore.selectConversation()。

为什么?因为打开会话不只是选一下,还要加载历史消息、更新执行面板等,这些是跨 Store 的业务逻辑,应该在根 Store 里统一处理。

侧边栏主组件

创建src/pages/workbench/components/Sidebar/index.tsx:

import React from 'react'; import { Input } from 'antd'; import { SearchOutlined } from '@ant-design/icons'; import { observer } from 'mobx-react-lite'; import { useStore } from '@/store/StoreContext'; import AgentCard from './AgentCard'; import ConversationList from './ConversationList'; import styles from './index.less'; const Sidebar = observer(() => { const { agentStore } = useStore(); return ( <div className={styles.sidebar}> {/* Logo 区域 */} <div className={styles.logo}> <span className={styles.logoText}>AI 工作台</span> </div> {/* 搜索框 */} <div className={styles.search}> <Input prefix={<SearchOutlined />} placeholder="搜索 Agent..." value={agentStore.keyword} onChange={e => agentStore.setKeyword(e.target.value)} allowClear /> </div> {/* Agent 列表 */} <div className={styles.agentList}> <div className={styles.sectionTitle}>我的 Agent</div> {agentStore.filteredAgents.map(agent => ( <AgentCard key={agent.id} agent={agent} /> ))} </div> {/* 分隔线 */} <div className={styles.divider} /> {/* 会话列表 */} <div className={styles.convSection}> <div className={styles.sectionTitle}>会话列表</div> <ConversationList /> </div> </div> ); }); export default Sidebar;

样式src/pages/workbench/components/Sidebar/index.less:

.sidebar { width: @sidebar-width; display: flex; flex-direction: column; background: @bg-card; padding: @spacing-md; overflow: hidden; } .logo { padding: @spacing-sm 0 @spacing-md; .logoText { font-size: 18px; font-weight: 600; color: @primary-color; } } .search { margin-bottom: @spacing-md; } .sectionTitle { font-size: 12px; color: @text-secondary; margin-bottom: @spacing-sm; font-weight: 500; } .agentList { flex-shrink: 0; } .divider { height: 1px; background: #e8e8e8; margin: @spacing-md 0; } .convSection { flex: 1; min-height: 0; display: flex; flex-direction: column; }

7.4 消息列表(MessageList)

消息列表是聊天界面的核心,需要实现:

  • 消息气泡(用户和 AI 不同样式)
  • 自动滚动到底部
  • 流式回复效果
  • 「正在输入」指示器

消息气泡组件

创建src/pages/workbench/components/Conversation/MessageBubble.tsx:

import React from 'react'; import { Avatar } from 'antd'; import { RobotOutlined, UserOutlined } from '@ant-design/icons'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import type { Message } from '@/store/SessionStore'; import styles from './MessageBubble.less'; interface Props { message: Message; } const MessageBubble: React.FC<Props> = ({ message }) => { const isUser = message.role === 'user'; return ( <div className={`${styles.messageRow} ${isUser ? styles.user : styles.assistant}`}> {/* 头像 */} {!isUser && ( <Avatar className={styles.avatar} icon={<RobotOutlined />} style={{ backgroundColor: '#1677ff' }} /> )} {/* 消息气泡 */} <div className={styles.bubble}> {isUser ? ( <div className={styles.textContent}>{message.content}</div> ) : ( <div className={styles.markdownContent}> <ReactMarkdown remarkPlugins={[remarkGfm]}> {message.content || ' '} </ReactMarkdown> {/* 如果内容为空且正在流式回复,显示光标 */} {!message.content && <span className={styles.cursor}>▊</span>} </div> )} {/* 状态提示 */} {message.status === 'error' && ( <div className={styles.errorTip}>发送失败,请重试</div> )} </div> {/* 用户头像在右边 */} {isUser && ( <Avatar className={styles.avatar} icon={<UserOutlined />} style={{ backgroundColor: '#52c41a' }} /> )} </div> ); }; export default MessageBubble;

样式src/pages/workbench/components/Conversation/MessageBubble.less:

.messageRow { display: flex; align-items: flex-start; margin-bottom: @spacing-md; padding: 0 @spacing-lg; &.user { flex-direction: row-reverse; .bubble { background: @primary-color; color: #fff; border-radius: @radius-lg @radius-sm @radius-lg @radius-lg; } } &.assistant { .bubble { background: @bg-card; color: @text-primary; border-radius: @radius-sm @radius-lg @radius-lg @radius-lg; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06); } } } .avatar { flex-shrink: 0; margin: 0 @spacing-sm; } .bubble { max-width: 70%; padding: @spacing-sm @spacing-md; word-wrap: break-word; white-space: pre-wrap; line-height: 1.6; } .markdownContent { :global { p { margin: 0 0 @spacing-sm; &:last-child { margin-bottom: 0; } } code { background: rgba(0, 0, 0, 0.06); padding: 2px 6px; border-radius: 4px; font-size: 0.9em; } pre { background: #f6f8fa; padding: @spacing-md; border-radius: @radius-sm; overflow-x: auto; } } } .cursor { display: inline-block; animation: blink 1s infinite; color: @text-secondary; } @keyframes blink { 0%, 50% { opacity: 1; } 51%, 100% { opacity: 0; } } .errorTip { font-size: 12px; color: @error-color; margin-top: @spacing-xs; }

消息列表主组件

创建src/pages/workbench/components/Conversation/MessageList.tsx:

import React, { useEffect, useRef } from 'react'; import { observer } from 'mobx-react-lite'; import { Empty, Spin } from 'antd'; import { useStore } from '@/store/StoreContext'; import MessageBubble from './MessageBubble'; import styles from './MessageList.less'; const MessageList = observer(() => { const { sessionStore } = useStore(); const listRef = useRef<HTMLDivElement>(null); // 有新消息时自动滚动到底部 useEffect(() => { if (listRef.current) { listRef.current.scrollTop = listRef.current.scrollHeight; } }, [sessionStore.messages.length, sessionStore.streamingPhase]); if (sessionStore.loading) { return ( <div className={styles.loading}> <Spin tip="加载消息..." /> </div> ); } if (!sessionStore.sessionId) { return <Empty description="请选择一个对话" className={styles.empty} />; } if (sessionStore.messages.length === 0) { return ( <div className={styles.emptyState}> <div className={styles.welcome}> <h2>👋 你好!</h2> <p>我是你的 AI 助手,有什么可以帮你的吗?</p> </div> </div> ); } return ( <div className={styles.messageList} ref={listRef}> {sessionStore.messages.map(msg => ( <MessageBubble key={msg.id} message={msg} /> ))} {/* 思考中指示器 */} {sessionStore.streamingPhase === 'thinking' && ( <div className={styles.thinking}> <div className={styles.thinkingDots}> <span /> <span /> <span /> </div> <span className={styles.thinkingText}>AI 正在思考...</span> </div> )} </div> ); }); export default MessageList;

样式src/pages/workbench/components/Conversation/MessageList.less:

.messageList { flex: 1; overflow-y: auto; padding: @spacing-lg 0; scroll-behavior: smooth; } .loading, .empty { flex: 1; .flex-center(); } .emptyState { flex: 1; .flex-center(); .welcome { text-align: center; color: @text-secondary; h2 { font-size: 24px; margin-bottom: @spacing-md; color: @text-primary; } } } .thinking { display: flex; align-items: center; padding: @spacing-sm @spacing-lg; gap: @spacing-sm; } .thinkingDots { display: flex; gap: 4px; span { width: 8px; height: 8px; background: @text-disabled; border-radius: 50%; animation: bounce 1.4s infinite ease-in-out both; &:nth-child(1) { animation-delay: -0.32s; } &:nth-child(2) { animation-delay: -0.16s; } } } @keyframes bounce { 0%, 80%, 100% { transform: scale(0); } 40% { transform: scale(1); } } .thinkingText { font-size: 13px; color: @text-secondary; }

7.5 输入框(Composer)

输入框是用户和 AI 交互的入口。

基本实现

创建src/pages/workbench/components/Conversation/Composer.tsx:

import React, { useState, useRef, useEffect } from 'react'; import { Button, Input } from 'antd'; import { SendOutlined, StopOutlined } from '@ant-design/icons'; import { observer } from 'mobx-react-lite'; import { useStore } from '@/store/StoreContext'; import styles from './Composer.less'; const { TextArea } = Input; const Composer = observer(() => { const { sessionStore, rootStore } = useStore(); const [content, setContent] = useState(''); const textareaRef = useRef<HTMLTextAreaElement>(null); const isStreaming = sessionStore.streamingPhase !== null; const canSend = content.trim().length > 0 && !isStreaming; // 发送消息 const handleSend = () => { if (!canSend) return; rootStore.sendMessage(content.trim()); setContent(''); }; // 停止回复 const handleStop = () => { rootStore.stopReply(); }; // 快捷键:Enter 发送,Shift+Enter 换行 const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } }; // 自动聚焦 useEffect(() => { if (sessionStore.sessionId) { textareaRef.current?.focus(); } }, [sessionStore.sessionId]); if (!sessionStore.sessionId) { return null; } return ( <div className={styles.composer}> <div className={styles.composerInner}> <TextArea ref={textareaRef} value={content} onChange={e => setContent(e.target.value)} onKeyDown={handleKeyDown} placeholder="输入消息,按 Enter 发送,Shift+Enter 换行..." autoSize={{ minRows: 1, maxRows: 6 }} className={styles.textarea} /> <div className={styles.actions}> {isStreaming ? ( <Button type="primary" danger icon={<StopOutlined />} onClick={handleStop} > 停止 </Button> ) : ( <Button type="primary" icon={<SendOutlined />} onClick={handleSend} disabled={!canSend} > 发送 </Button> )} </div> </div> </div> ); }); export default Composer;

样式src/pages/workbench/components/Conversation/Composer.less:

.composer { padding: @spacing-md @spacing-lg; background: @bg-card; border-top: 1px solid #e8e8e8; } .composerInner { background: @bg-body; border-radius: @radius-md; padding: @spacing-sm; } .textarea { border: none !important; background: transparent !important; resize: none !important; box-shadow: none !important; &:focus { box-shadow: none !important; } } .actions { display: flex; justify-content: flex-end; padding-top: @spacing-xs; border-top: 1px solid #e8e8e8; margin-top: @spacing-xs; }

在根 Store 中补充发送方法

在RootStore中添加发送消息和停止回复的方法:

// 发送消息asyncsendMessage(content:string){constagentId=this.agentStore.selectedAgentId;constconversationId=this.conversationStore.getSelectedConversationId(agentId!);if(!agentId||!content.trim())return;// 如果没有选中会话,先创建一个草稿会话lettargetConvId=conversationId;if(!targetConvId){targetConvId=this.startNewConversation();}// 本地回显consttempId='temp-'+Date.now();this.sessionStore.appendMessage({id:tempId,role:'user',content:content.trim(),createdAt:newDate().toISOString(),status:'sending',});try{constresult=awaitthis.gateway.sendMessage({agentId,conversationId:targetConvId,content:content.trim(),});// 替换临时 IDconstmsg=this.sessionStore.messages.find(m=>m.id===tempId);if(msg){msg.id=result.messageId;msg.status='sent';}}catch(error){constmsg=this.sessionStore.messages.find(m=>m.id===tempId);if(msg){msg.status='error';}}}// 停止回复stopReply(){this.sessionStore.setStreamingPhase(null);// 实际项目中需要调用后端的 stop 接口}

7.6 聊天主区域整合

创建src/pages/workbench/components/Conversation/index.tsx:

import React from 'react'; import { observer } from 'mobx-react-lite'; import { useStore } from '@/store/StoreContext'; import MessageList from './MessageList'; import Composer from './Composer'; import styles from './index.less'; const Conversation = observer(() => { const { conversationStore, agentStore } = useStore(); const selectedConv = agentStore.selectedAgentId ? conversationStore.getSelectedConversation(agentStore.selectedAgentId) : null; return ( <div className={styles.conversation}> {/* 头部 */} <div className={styles.header}> <div className={styles.title}> {selectedConv?.title || '新对话'} </div> </div> {/* 消息列表 */} <MessageList /> {/* 输入框 */} <Composer /> </div> ); }); export default Conversation;

样式src/pages/workbench/components/Conversation/index.less:

.conversation { flex: 1; display: flex; flex-direction: column; min-height: 0; // 让子元素的 overflow-y 能生效 background: @bg-body; } .header { height: 56px; flex-shrink: 0; display: flex; align-items: center; padding: 0 @spacing-lg; background: @bg-card; border-bottom: 1px solid #e8e8e8; .title { font-size: 16px; font-weight: 500; color: @text-primary; } }

7.7 本章小结

你学到了什么?

  1. ✅ 学会了三栏式布局的实现
  2. ✅ 实现了侧边栏(Agent 列表 + 会话列表)
  3. ✅ 实现了消息气泡组件(区分用户/AI,支持 Markdown)
  4. ✅ 实现了消息列表(自动滚动、思考中动画)
  5. ✅ 实现了输入框(快捷键、发送/停止按钮)
  6. ✅ 理解了本地回显的完整流程

组件结构总览

WorkbenchLayout ├── Sidebar │ ├── AgentCard × N │ └── ConversationList │ └── ConvItem × N ├── Conversation │ ├── Header(会话标题) │ ├── MessageList │ │ └── MessageBubble × N │ └── Composer(输入框) └── ExecPanel(下一章讲)

✏️ 动手练习

  1. 跟着本章把三栏布局搭出来
  2. 实现消息列表和输入框,用 Mock 数据演示一下效果
  3. 增加功能:消息发送后自动滚动到底部
  4. 增加功能:按 Enter 发送,Shift+Enter 换行
  5. 增加功能:AI 正在回复时显示「停止」按钮,点击可以停止
  6. 挑战:实现消息的「重新发送」功能(失败的消息点击可以重发)

下一章我们来做右侧的执行面板——展示 AI 调用工具的详细过程,让用户知道 AI 「正在干嘛」。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询