|
| 1 | +import React, { useState, useEffect, useRef } from 'react'; |
| 2 | +import { useLocation, useNavigate } from 'react-router-dom'; |
| 3 | +import { Layout, Button, List, Typography, message, Spin, Empty, theme, Avatar } from 'antd'; |
| 4 | +import { PlusOutlined, DeleteOutlined, LeftOutlined, UserOutlined, RobotOutlined } from '@ant-design/icons'; |
| 5 | +// Assuming @ant-design/x is installed. If not, this line needs the package. |
| 6 | +import { Bubble, Sender } from '@ant-design/x'; |
| 7 | +import { chatCompletion, getHistories, getPaginationRecords, delHistory } from '../api/fastgpt'; |
| 8 | + |
| 9 | +const { Sider, Content, Header } = Layout; |
| 10 | +const { Text, Title } = Typography; |
| 11 | + |
| 12 | +const Chat = () => { |
| 13 | + const { token: { colorBgContainer, colorBorderSecondary } } = theme.useToken(); |
| 14 | + const location = useLocation(); |
| 15 | + const navigate = useNavigate(); |
| 16 | + const { appId, title } = location.state || {}; // Get passed state |
| 17 | + |
| 18 | + const [histories, setHistories] = useState([]); |
| 19 | + const [activeChatId, setActiveChatId] = useState(null); |
| 20 | + const [messages, setMessages] = useState([]); |
| 21 | + const [loading, setLoading] = useState(false); // Loading history list |
| 22 | + const [sending, setSending] = useState(false); // Sending message |
| 23 | + const [inputValue, setInputValue] = useState(''); |
| 24 | + |
| 25 | + // Initial Load |
| 26 | + useEffect(() => { |
| 27 | + if (!appId) { |
| 28 | + message.error('缺少应用ID'); |
| 29 | + navigate('/subjects'); |
| 30 | + return; |
| 31 | + } |
| 32 | + loadHistories(); |
| 33 | + }, [appId, navigate]); |
| 34 | + |
| 35 | + // Load messages when activeChatId changes |
| 36 | + useEffect(() => { |
| 37 | + if (activeChatId) { |
| 38 | + loadMessages(activeChatId); |
| 39 | + } else { |
| 40 | + setMessages([]); |
| 41 | + } |
| 42 | + }, [activeChatId]); |
| 43 | + |
| 44 | + const loadHistories = async () => { |
| 45 | + setLoading(true); |
| 46 | + try { |
| 47 | + const res = await getHistories({ appId }); |
| 48 | + // Adjust according to actual response structure |
| 49 | + // fastgpt usually returns data as array or { list: [] } |
| 50 | + const list = res.data?.data || []; |
| 51 | + setHistories(list); |
| 52 | + |
| 53 | + if (list.length > 0 && !activeChatId) { |
| 54 | + // Automatically select first history? Or start new? |
| 55 | + // Let's create new if empty, or just stay empty |
| 56 | + // setActiveChatId(list[0].chatId); |
| 57 | + } |
| 58 | + } catch (err) { |
| 59 | + console.error(err); |
| 60 | + message.error('加载历史会话失败'); |
| 61 | + } finally { |
| 62 | + setLoading(false); |
| 63 | + } |
| 64 | + }; |
| 65 | + |
| 66 | + const loadMessages = async (chatId) => { |
| 67 | + try { |
| 68 | + // Assuming offset=0, pageSize=100 for simplicity |
| 69 | + const res = await getPaginationRecords({ appId, chatId, offset: 0, pageSize: 50 }); |
| 70 | + const records = res.data?.data || []; |
| 71 | + // Records might be in reverse order or need formatted |
| 72 | + // FastGPT records usually: { role: 'user'/'assistant', content: '...' } |
| 73 | + // We need to map to Bubble format |
| 74 | + // Map and reverse if needed |
| 75 | + const mapped = records.map(r => ({ |
| 76 | + key: r._id || Math.random().toString(), |
| 77 | + role: r.obj === 'Human' || r.role === 'user' ? 'user' : 'ai', |
| 78 | + content: r.value || r.content, |
| 79 | + })); |
| 80 | + // FastGPT histories often return latest first? Check API. |
| 81 | + // Usually chat UI needs oldest first. |
| 82 | + setMessages(mapped.reverse()); |
| 83 | + } catch (err) { |
| 84 | + console.error(err); |
| 85 | + message.error('加载消息记录失败'); |
| 86 | + } |
| 87 | + }; |
| 88 | + |
| 89 | + const handleNewChat = () => { |
| 90 | + setActiveChatId(null); |
| 91 | + setMessages([]); |
| 92 | + }; |
| 93 | + |
| 94 | + const handleDeleteHistory = async (e, chatId) => { |
| 95 | + e.stopPropagation(); |
| 96 | + try { |
| 97 | + await delHistory(appId, chatId); |
| 98 | + message.success('删除成功'); |
| 99 | + setHistories(prev => prev.filter(h => h.chatId !== chatId)); |
| 100 | + if (activeChatId === chatId) { |
| 101 | + setActiveChatId(null); |
| 102 | + } |
| 103 | + } catch (err) { |
| 104 | + message.error('删除失败'); |
| 105 | + } |
| 106 | + }; |
| 107 | + |
| 108 | + const onSend = async (val) => { |
| 109 | + if (!val.trim()) return; |
| 110 | + const currentInput = val; |
| 111 | + setInputValue(''); |
| 112 | + setSending(true); |
| 113 | + |
| 114 | + const newMsg = { key: Date.now().toString(), role: 'user', content: currentInput }; |
| 115 | + setMessages(prev => [...prev, newMsg]); |
| 116 | + |
| 117 | + // Use existing chatId or generate/let backend generate |
| 118 | + // For fastgpt, if we don't pass chatId, it might create one but we need to catch it. |
| 119 | + // Ideally we generate a chatId on frontend for new chat if backend supports it, or use response. |
| 120 | + // Let's rely on backend returning `chatId` or just using the one we have. |
| 121 | + // If activeChatId is null, we generate one or wait for first response? |
| 122 | + // FastGPT API usually accepts `chatId`. |
| 123 | + const targetChatId = activeChatId || Date.now().toString(); // Simple ID generation |
| 124 | + if (!activeChatId) { |
| 125 | + setActiveChatId(targetChatId); |
| 126 | + // Optimistically add to history list? |
| 127 | + // Better reload histories after first message |
| 128 | + } |
| 129 | + |
| 130 | + try { |
| 131 | + // Stream handling |
| 132 | + const response = await chatCompletion({ |
| 133 | + appId, |
| 134 | + chatId: targetChatId, |
| 135 | + stream: true, |
| 136 | + detail: false, |
| 137 | + messages: [ |
| 138 | + ...messages.map(m => ({ |
| 139 | + role: m.role === 'user' ? 'user' : 'assistant', |
| 140 | + content: m.content |
| 141 | + })), // Send full context? Or just last? FastGPT usually handles context if chatId is provided. |
| 142 | + // Wait, FastGPT backend manages history. We typically ONLY send the NEW message if we provide chatId? |
| 143 | + // Check `handler.HandleChatCompletion`. It forwards to FastGPT. |
| 144 | + // FastGPT usually stores history. So we might need to send only the new message or last N messages. |
| 145 | + // Sending ONLY the new message is safer if backend has state. |
| 146 | + { role: 'user', content: currentInput } |
| 147 | + ] |
| 148 | + }); |
| 149 | + |
| 150 | + // Prepare AI message placeholder |
| 151 | + const aiMsgKey = (Date.now() + 1).toString(); |
| 152 | + setMessages(prev => [...prev, { key: aiMsgKey, role: 'ai', content: '' }]); |
| 153 | + |
| 154 | + const reader = response.body.getReader(); |
| 155 | + const decoder = new TextDecoder(); |
| 156 | + let aiContent = ''; |
| 157 | + |
| 158 | + while (true) { |
| 159 | + const { done, value } = await reader.read(); |
| 160 | + if (done) break; |
| 161 | + const chunk = decoder.decode(value); |
| 162 | + // Parse SSE format: data: {...} |
| 163 | + const lines = chunk.split('\n'); |
| 164 | + for (const line of lines) { |
| 165 | + if (line.startsWith('data: ')) { |
| 166 | + const jsonStr = line.slice(6); |
| 167 | + if (jsonStr === '[DONE]') continue; |
| 168 | + try { |
| 169 | + const data = JSON.parse(jsonStr); |
| 170 | + // FastGPT stream chunk structure: choices[0].delta.content |
| 171 | + const content = data.choices?.[0]?.delta?.content || ''; |
| 172 | + if (content) { |
| 173 | + aiContent += content; |
| 174 | + setMessages(prev => prev.map(m => |
| 175 | + m.key === aiMsgKey ? { ...m, content: aiContent } : m |
| 176 | + )); |
| 177 | + } |
| 178 | + } catch (e) { |
| 179 | + // ignore parse error for partial chunks |
| 180 | + } |
| 181 | + } |
| 182 | + } |
| 183 | + } |
| 184 | + |
| 185 | + // Refresh history list if it was a new chat |
| 186 | + if (!histories.find(h => h.chatId === targetChatId)) { |
| 187 | + loadHistories(); |
| 188 | + } |
| 189 | + |
| 190 | + } catch (err) { |
| 191 | + console.error(err); |
| 192 | + message.error('发送失败'); |
| 193 | + setMessages(prev => prev.map(m => |
| 194 | + m.role === 'ai' && m.content === '' ? { ...m, content: 'Error: Failed to get response' } : m |
| 195 | + )); |
| 196 | + } finally { |
| 197 | + setSending(false); |
| 198 | + } |
| 199 | + }; |
| 200 | + |
| 201 | + return ( |
| 202 | + <Layout style={{ height: '100vh', background: '#fff' }}> |
| 203 | + <Sider |
| 204 | + width={300} |
| 205 | + theme="light" |
| 206 | + style={{ borderRight: `1px solid ${colorBorderSecondary}` }} |
| 207 | + > |
| 208 | + <div style={{ padding: 16, borderBottom: `1px solid ${colorBorderSecondary}`, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> |
| 209 | + <Button type="text" icon={<LeftOutlined />} onClick={() => navigate('/subjects')}>返回</Button> |
| 210 | + <Button type="primary" icon={<PlusOutlined />} onClick={handleNewChat}>新对话</Button> |
| 211 | + </div> |
| 212 | + <List |
| 213 | + dataSource={histories} |
| 214 | + loading={loading} |
| 215 | + style={{ height: 'calc(100vh - 64px)', overflowY: 'auto' }} |
| 216 | + renderItem={item => ( |
| 217 | + <List.Item |
| 218 | + onClick={() => setActiveChatId(item.chatId)} |
| 219 | + className={activeChatId === item.chatId ? 'ant-list-item-active' : ''} |
| 220 | + style={{ |
| 221 | + cursor: 'pointer', |
| 222 | + padding: '12px 16px', |
| 223 | + background: activeChatId === item.chatId ? '#f0faff' : 'transparent', |
| 224 | + borderLeft: activeChatId === item.chatId ? '3px solid #1890ff' : '3px solid transparent' |
| 225 | + }} |
| 226 | + actions={[ |
| 227 | + <DeleteOutlined onClick={(e) => handleDeleteHistory(e, item.chatId)} style={{ color: '#999' }} /> |
| 228 | + ]} |
| 229 | + > |
| 230 | + <div style={{ width: '100%', overflow: 'hidden' }}> |
| 231 | + <Text strong ellipsis>{item.title || '未命名会话'}</Text> |
| 232 | + <br/> |
| 233 | + <Text type="secondary" style={{ fontSize: 12 }}>{new Date(item.updateTime || Date.now()).toLocaleDateString()}</Text> |
| 234 | + </div> |
| 235 | + </List.Item> |
| 236 | + )} |
| 237 | + /> |
| 238 | + </Sider> |
| 239 | + |
| 240 | + <Layout> |
| 241 | + <Header style={{ background: colorBgContainer, borderBottom: `1px solid ${colorBorderSecondary}`, padding: '0 24px' }}> |
| 242 | + <Title level={4} style={{ margin: '14px 0' }}>{title || 'AI 助手'}</Title> |
| 243 | + </Header> |
| 244 | + <Content style={{ padding: 24, display: 'flex', flexDirection: 'column' }}> |
| 245 | + <div style={{ flex: 1, overflowY: 'auto', marginBottom: 24 }}> |
| 246 | + {messages.length === 0 ? ( |
| 247 | + <Empty description="开始一个新的对话" /> |
| 248 | + ) : ( |
| 249 | + messages.map(msg => ( |
| 250 | + <Bubble |
| 251 | + key={msg.key} |
| 252 | + placement={msg.role === 'user' ? 'end' : 'start'} |
| 253 | + content={msg.content} |
| 254 | + avatar={<Avatar icon={msg.role === 'user' ? <UserOutlined /> : <RobotOutlined />} />} |
| 255 | + loading={msg.role === 'ai' && !msg.content && sending} |
| 256 | + /> |
| 257 | + )) |
| 258 | + )} |
| 259 | + </div> |
| 260 | + <div style={{ maxWidth: 800, margin: '0 auto', width: '100%' }}> |
| 261 | + <Sender |
| 262 | + value={inputValue} |
| 263 | + onChange={setInputValue} |
| 264 | + onSubmit={onSend} |
| 265 | + loading={sending} |
| 266 | + placeholder="输入您的问题..." |
| 267 | + /> |
| 268 | + </div> |
| 269 | + </Content> |
| 270 | + </Layout> |
| 271 | + </Layout> |
| 272 | + ); |
| 273 | +}; |
| 274 | + |
| 275 | +export default Chat; |
0 commit comments