Skip to content

Commit c7cde04

Browse files
committed
feat: sse output and fastgpt app id
1 parent b17ad60 commit c7cde04

13 files changed

Lines changed: 475 additions & 102 deletions

File tree

frontend/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
"eject": "react-scripts eject"
1111
},
1212
"dependencies": {
13-
"antd": "^5.26.3",
13+
"@ant-design/x": "^2.1.3",
14+
"antd": "^6.1.4",
1415
"axios": "^1.10.0",
1516
"react": "^18.2.0",
1617
"react-dom": "^18.2.0",

frontend/src/App.jsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import Subjects from './pages/Subjects';
77
import ProfilePage from './pages/ProfilePage';
88
import LoginCallback from './pages/LoginCallback';
99
import AdminDashboard from './pages/AdminDashboard';
10+
import Chat from './pages/Chat';
1011

1112
const theme = {
1213
token: {
@@ -23,6 +24,7 @@ function App() {
2324
<Route path="/login" element={<Login />} />
2425
<Route path="/login/callback" element={<LoginCallback />} />
2526
<Route path="/subjects" element={<Subjects />} />
27+
<Route path="/chat" element={<Chat />} />
2628
<Route path="/profile" element={<ProfilePage />} />
2729

2830
{/* 管理员路由 */}

frontend/src/api/fastgpt.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,52 @@ export const deleteApp = (token, id) => {
2929
headers: getAuthHeader(token)
3030
});
3131
};
32+
33+
// Chat Interfaces
34+
export const chatCompletion = async (data) => {
35+
const token = localStorage.getItem('token');
36+
if (data.stream) {
37+
// Return fetch promise for streaming
38+
return fetch(`${BASE_URL}/fastgpt/v1/chat/completions`, {
39+
method: 'POST',
40+
headers: {
41+
'Content-Type': 'application/json',
42+
...getAuthHeader(token)
43+
},
44+
body: JSON.stringify(data)
45+
});
46+
}
47+
// Standard axios for non-stream
48+
return axios.post(`${BASE_URL}/fastgpt/v1/chat/completions`, data, {
49+
headers: getAuthHeader(token)
50+
});
51+
};
52+
53+
export const getHistories = (data) => {
54+
const token = localStorage.getItem('token');
55+
return axios.post(`${BASE_URL}/fastgpt/core/chat/history/getHistories`, data, {
56+
headers: getAuthHeader(token)
57+
});
58+
};
59+
60+
export const getPaginationRecords = (data) => {
61+
const token = localStorage.getItem('token');
62+
return axios.post(`${BASE_URL}/fastgpt/core/chat/getPaginationRecords`, data, {
63+
headers: getAuthHeader(token)
64+
});
65+
};
66+
67+
export const updateHistory = (data) => {
68+
const token = localStorage.getItem('token');
69+
return axios.post(`${BASE_URL}/fastgpt/core/chat/history/updateHistory`, data, {
70+
headers: getAuthHeader(token)
71+
});
72+
};
73+
74+
export const delHistory = (appId, chatId) => {
75+
const token = localStorage.getItem('token');
76+
return axios.delete(`${BASE_URL}/fastgpt/core/chat/history/delHistory`, {
77+
params: { appId, chatId },
78+
headers: getAuthHeader(token)
79+
});
80+
};

frontend/src/pages/Chat.jsx

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
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;

frontend/src/pages/Subjects.jsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ const Subjects = () => {
3838
subjects = subjects.map(item => ({
3939
subject_name: item.subject_name || item.name || item.SubjectName,
4040
subject_link: item.subject_link || item.link || item.SubjectLink,
41+
app_id: item.app_id
4142
}));
4243
} else {
4344
subjects = [];
@@ -51,9 +52,11 @@ const Subjects = () => {
5152
.finally(() => setLoading(false));
5253
}, [navigate]);
5354

54-
const handleSubjectClick = (link) => {
55-
if (link) {
56-
window.open(link, '_blank');
55+
const handleSubjectClick = (item) => {
56+
if (item.app_id) {
57+
navigate('/chat', { state: { appId: item.app_id, title: item.subject_name } });
58+
} else if (item.subject_link) {
59+
window.open(item.subject_link, '_blank');
5760
} else {
5861
message.warning('无效链接');
5962
}
@@ -144,7 +147,7 @@ const Subjects = () => {
144147
transition: 'all 0.3s'
145148
}}
146149
bodyStyle={{ padding: 24, display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center', height: '100%', justifyContent: 'center' }}
147-
onClick={() => handleSubjectClick(item.subject_link)}
150+
onClick={() => handleSubjectClick(item)}
148151
>
149152
<div style={{
150153
width: 64, height: 64, borderRadius: '50%', background: item.subject_link ? '#e6f7ff' : '#f5f5f5',

0 commit comments

Comments
 (0)