@lobehub/chat 0.107.11 → 0.107.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/FUNDING.yml +13 -0
- package/CHANGELOG.md +79 -0
- package/README.md +33 -3
- package/README.zh-CN.md +33 -3
- package/package.json +1 -2
- package/src/app/chat/(desktop)/features/ChatInput/DragUpload.tsx +1 -1
- package/src/app/chat/(desktop)/features/ChatInput/Footer/LocalFiles.tsx +3 -3
- package/src/app/chat/(mobile)/features/ChatInput/Files.tsx +7 -6
- package/src/app/chat/(mobile)/features/ChatInput/Mobile.tsx +2 -0
- package/src/app/chat/(mobile)/features/ChatInput/index.tsx +0 -2
- package/src/app/chat/features/ChatHeader/ShareButton/Preview.tsx +1 -1
- package/src/app/chat/features/ChatInput/ActionBar/Clear.tsx +1 -1
- package/src/app/chat/features/ChatInput/ActionBar/FileUpload.tsx +1 -1
- package/src/app/chat/features/ChatInput/useSend.ts +2 -2
- package/src/app/chat/features/Conversation/ChatList/Messages/User.tsx +2 -2
- package/src/app/settings/common/Common.tsx +1 -1
- package/src/components/FileList/EditableFileList.tsx +35 -0
- package/src/components/FileList/FileGrid.tsx +36 -0
- package/src/components/FileList/FileItem.tsx +73 -0
- package/src/components/FileList/index.tsx +62 -0
- package/src/components/FileList/style.ts +24 -0
- package/src/features/AgentSetting/AgentPlugin/MarketList.tsx +1 -1
- package/src/layout/AppLayout.desktop.tsx +1 -0
- package/src/layout/GlobalLayout/Locale.tsx +1 -0
- package/src/services/chat.ts +1 -1
- package/src/store/chat/actions/message.test.ts +481 -0
- package/src/store/chat/actions/message.ts +4 -1
- package/src/store/chat/actions/topic.ts +1 -0
- package/src/app/chat/components/FileList/FileItem.style.ts +0 -84
- package/src/app/chat/components/FileList/FileItem.tsx +0 -73
- package/src/app/chat/components/FileList/Lightbox.tsx +0 -46
- package/src/app/chat/components/FileList/index.tsx +0 -60
- /package/src/store/{files → file}/index.ts +0 -0
- /package/src/store/{files → file}/initialState.ts +0 -0
- /package/src/store/{files → file}/selectors.test.ts +0 -0
- /package/src/store/{files → file}/selectors.ts +0 -0
- /package/src/store/{files → file}/slices/images/action.test.ts +0 -0
- /package/src/store/{files → file}/slices/images/action.ts +0 -0
- /package/src/store/{files → file}/slices/images/index.ts +0 -0
- /package/src/store/{files → file}/slices/images/initialState.ts +0 -0
- /package/src/store/{files → file}/store.ts +0 -0
package/src/services/chat.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { merge } from 'lodash-es';
|
|
|
3
3
|
|
|
4
4
|
import { VISION_MODEL_WHITE_LIST } from '@/const/llm';
|
|
5
5
|
import { DEFAULT_AGENT_CONFIG } from '@/const/settings';
|
|
6
|
-
import { filesSelectors, useFileStore } from '@/store/
|
|
6
|
+
import { filesSelectors, useFileStore } from '@/store/file';
|
|
7
7
|
import { usePluginStore } from '@/store/plugin';
|
|
8
8
|
import { pluginSelectors } from '@/store/plugin/selectors';
|
|
9
9
|
import { ChatMessage } from '@/types/chatMessage';
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
import { act, renderHook, waitFor } from '@testing-library/react';
|
|
2
|
+
import useSWR, { mutate } from 'swr';
|
|
3
|
+
import { Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { LOADING_FLAT } from '@/const/message';
|
|
6
|
+
import { chatService } from '@/services/chat';
|
|
7
|
+
import { messageService } from '@/services/message';
|
|
8
|
+
import { topicService } from '@/services/topic';
|
|
9
|
+
import { chatSelectors } from '@/store/chat/selectors';
|
|
10
|
+
import { ChatMessage } from '@/types/chatMessage';
|
|
11
|
+
|
|
12
|
+
import { useChatStore } from '../store';
|
|
13
|
+
|
|
14
|
+
// Mock service
|
|
15
|
+
vi.mock('@/services/message', () => ({
|
|
16
|
+
messageService: {
|
|
17
|
+
getMessages: vi.fn(),
|
|
18
|
+
updateMessageError: vi.fn(),
|
|
19
|
+
removeMessage: vi.fn(),
|
|
20
|
+
createAssistantMessage: vi.fn(() => Promise.resolve('content-content-content')),
|
|
21
|
+
removeMessages: vi.fn(() => Promise.resolve()),
|
|
22
|
+
create: vi.fn(() => Promise.resolve('new-message-id')),
|
|
23
|
+
updateMessageContent: vi.fn(),
|
|
24
|
+
clearAllMessage: vi.fn(() => Promise.resolve()),
|
|
25
|
+
},
|
|
26
|
+
}));
|
|
27
|
+
vi.mock('@/services/topic', () => ({
|
|
28
|
+
topicService: {
|
|
29
|
+
removeTopic: vi.fn(() => Promise.resolve()),
|
|
30
|
+
},
|
|
31
|
+
}));
|
|
32
|
+
vi.mock('@/services/chat', () => ({
|
|
33
|
+
chatService: {
|
|
34
|
+
createAssistantMessage: vi.fn(() => Promise.resolve('assistant-message')),
|
|
35
|
+
},
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
vi.mock('@/store/chat/selectors', () => ({
|
|
39
|
+
chatSelectors: {
|
|
40
|
+
currentChats: vi.fn(),
|
|
41
|
+
},
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
const realCoreProcessMessage = useChatStore.getState().coreProcessMessage;
|
|
45
|
+
const realRefreshMessages = useChatStore.getState().refreshMessages;
|
|
46
|
+
// Mock state
|
|
47
|
+
const mockState = {
|
|
48
|
+
activeId: 'session-id',
|
|
49
|
+
activeTopicId: 'topic-id',
|
|
50
|
+
messages: [],
|
|
51
|
+
refreshMessages: vi.fn(),
|
|
52
|
+
refreshTopic: vi.fn(),
|
|
53
|
+
coreProcessMessage: vi.fn(),
|
|
54
|
+
saveToTopic: vi.fn(),
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
vi.clearAllMocks();
|
|
59
|
+
useChatStore.setState(mockState, false);
|
|
60
|
+
});
|
|
61
|
+
afterEach(() => {
|
|
62
|
+
vi.restoreAllMocks();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('chatMessage actions', () => {
|
|
66
|
+
it('clearMessage should remove messages from the active session and topic', async () => {
|
|
67
|
+
const { result } = renderHook(() => useChatStore());
|
|
68
|
+
const clearSpy = vi.spyOn(result.current, 'clearMessage');
|
|
69
|
+
const switchTopicSpy = vi.spyOn(result.current, 'switchTopic');
|
|
70
|
+
|
|
71
|
+
await act(async () => {
|
|
72
|
+
await result.current.clearMessage();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
expect(clearSpy).toHaveBeenCalled();
|
|
76
|
+
expect(result.current.refreshMessages).toHaveBeenCalled();
|
|
77
|
+
expect(result.current.refreshTopic).toHaveBeenCalled();
|
|
78
|
+
expect(switchTopicSpy).toHaveBeenCalled();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('deleteMessage should remove a message by id', async () => {
|
|
82
|
+
const { result } = renderHook(() => useChatStore());
|
|
83
|
+
const messageId = 'message-id';
|
|
84
|
+
const deleteSpy = vi.spyOn(result.current, 'deleteMessage');
|
|
85
|
+
|
|
86
|
+
await act(async () => {
|
|
87
|
+
await result.current.deleteMessage(messageId);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
expect(deleteSpy).toHaveBeenCalledWith(messageId);
|
|
91
|
+
expect(result.current.refreshMessages).toHaveBeenCalled();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('clearAllMessages should remove all messages', async () => {
|
|
95
|
+
const { result } = renderHook(() => useChatStore());
|
|
96
|
+
const clearAllSpy = vi.spyOn(result.current, 'clearAllMessages');
|
|
97
|
+
|
|
98
|
+
await act(async () => {
|
|
99
|
+
await result.current.clearAllMessages();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(clearAllSpy).toHaveBeenCalled();
|
|
103
|
+
expect(result.current.refreshMessages).toHaveBeenCalled();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('updateInputMessage should update the input message state', () => {
|
|
107
|
+
const { result } = renderHook(() => useChatStore());
|
|
108
|
+
const newInputMessage = 'Updated message';
|
|
109
|
+
act(() => {
|
|
110
|
+
result.current.updateInputMessage(newInputMessage);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
expect(result.current.inputMessage).toEqual(newInputMessage);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('clearMessage', () => {
|
|
117
|
+
beforeEach(() => {
|
|
118
|
+
vi.clearAllMocks(); // 清除 mocks
|
|
119
|
+
useChatStore.setState(mockState, false); // 重置 state
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
afterEach(() => {
|
|
123
|
+
vi.restoreAllMocks(); // 恢复所有模拟
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should remove messages from the active session and topic, then refresh topics and messages', async () => {
|
|
127
|
+
const { result } = renderHook(() => useChatStore());
|
|
128
|
+
const switchTopicSpy = vi.spyOn(result.current, 'switchTopic');
|
|
129
|
+
const refreshTopicSpy = vi.spyOn(result.current, 'refreshTopic');
|
|
130
|
+
|
|
131
|
+
await act(async () => {
|
|
132
|
+
await result.current.clearMessage();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
expect(mockState.refreshMessages).toHaveBeenCalled();
|
|
136
|
+
expect(refreshTopicSpy).toHaveBeenCalled();
|
|
137
|
+
expect(switchTopicSpy).toHaveBeenCalled();
|
|
138
|
+
|
|
139
|
+
// 检查 activeTopicId 是否被清除,需要在状态更新后进行检查
|
|
140
|
+
expect(useChatStore.getState().activeTopicId).toBeUndefined();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('should call removeTopic if there is an activeTopicId', async () => {
|
|
144
|
+
const { result } = renderHook(() => useChatStore());
|
|
145
|
+
const switchTopicSpy = vi.spyOn(result.current, 'switchTopic');
|
|
146
|
+
const refreshTopicSpy = vi.spyOn(result.current, 'refreshTopic');
|
|
147
|
+
|
|
148
|
+
await act(async () => {
|
|
149
|
+
await result.current.clearMessage();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
expect(mockState.activeTopicId).not.toBeUndefined(); // 确保在测试前 activeTopicId 存在
|
|
153
|
+
expect(refreshTopicSpy).toHaveBeenCalled();
|
|
154
|
+
expect(mockState.refreshMessages).toHaveBeenCalled();
|
|
155
|
+
expect(topicService.removeTopic).toHaveBeenCalledWith(mockState.activeTopicId);
|
|
156
|
+
expect(switchTopicSpy).toHaveBeenCalled();
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe('sendMessage', () => {
|
|
161
|
+
it('should not send message if there is no active session', async () => {
|
|
162
|
+
useChatStore.setState({ activeId: undefined });
|
|
163
|
+
const { result } = renderHook(() => useChatStore());
|
|
164
|
+
const message = 'Test message';
|
|
165
|
+
|
|
166
|
+
await act(async () => {
|
|
167
|
+
await result.current.sendMessage(message);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
expect(messageService.create).not.toHaveBeenCalled();
|
|
171
|
+
expect(result.current.refreshMessages).not.toHaveBeenCalled();
|
|
172
|
+
expect(result.current.coreProcessMessage).not.toHaveBeenCalled();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('should not send message if message is empty and there are no files', async () => {
|
|
176
|
+
const { result } = renderHook(() => useChatStore());
|
|
177
|
+
const message = '';
|
|
178
|
+
|
|
179
|
+
await act(async () => {
|
|
180
|
+
await result.current.sendMessage(message);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
expect(messageService.create).not.toHaveBeenCalled();
|
|
184
|
+
expect(result.current.refreshMessages).not.toHaveBeenCalled();
|
|
185
|
+
expect(result.current.coreProcessMessage).not.toHaveBeenCalled();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('should not send message if message is empty and there are empty files', async () => {
|
|
189
|
+
const { result } = renderHook(() => useChatStore());
|
|
190
|
+
const message = '';
|
|
191
|
+
|
|
192
|
+
await act(async () => {
|
|
193
|
+
await result.current.sendMessage(message, []);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
expect(messageService.create).not.toHaveBeenCalled();
|
|
197
|
+
expect(result.current.refreshMessages).not.toHaveBeenCalled();
|
|
198
|
+
expect(result.current.coreProcessMessage).not.toHaveBeenCalled();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('should create message and call coreProcessMessage if message or files are provided', async () => {
|
|
202
|
+
const { result } = renderHook(() => useChatStore());
|
|
203
|
+
const message = 'Test message';
|
|
204
|
+
const files = [{ id: 'file-id', url: 'file-url' }];
|
|
205
|
+
|
|
206
|
+
// Mock messageService.create to resolve with a message id
|
|
207
|
+
(messageService.create as Mock).mockResolvedValue('new-message-id');
|
|
208
|
+
|
|
209
|
+
await act(async () => {
|
|
210
|
+
await result.current.sendMessage(message, files);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
expect(messageService.create).toHaveBeenCalledWith({
|
|
214
|
+
content: message,
|
|
215
|
+
files: files.map((f) => f.id),
|
|
216
|
+
role: 'user',
|
|
217
|
+
sessionId: mockState.activeId,
|
|
218
|
+
topicId: mockState.activeTopicId,
|
|
219
|
+
});
|
|
220
|
+
expect(result.current.refreshMessages).toHaveBeenCalled();
|
|
221
|
+
expect(result.current.coreProcessMessage).toHaveBeenCalled();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// it('should auto-create topic and switch to it if enabled and threshold is reached', async () => {
|
|
225
|
+
// const { result } = renderHook(() => useChatStore());
|
|
226
|
+
// const message = 'Test message';
|
|
227
|
+
// const autoCreateTopicThreshold = 5;
|
|
228
|
+
// const enableAutoCreateTopic = true;
|
|
229
|
+
//
|
|
230
|
+
// // Mock state with the necessary settings
|
|
231
|
+
// useChatStore.setState({
|
|
232
|
+
// ...mockState,
|
|
233
|
+
// messages: Array(autoCreateTopicThreshold).fill({}), // Fill with dummy messages to reach threshold
|
|
234
|
+
// });
|
|
235
|
+
//
|
|
236
|
+
// // Mock messageService.create to resolve with a message id
|
|
237
|
+
// (messageService.create as vi.Mock).mockResolvedValue('new-message-id');
|
|
238
|
+
//
|
|
239
|
+
// await act(async () => {
|
|
240
|
+
// await result.current.sendMessage(message);
|
|
241
|
+
// });
|
|
242
|
+
//
|
|
243
|
+
// expect(result.current.saveToTopic).toHaveBeenCalled();
|
|
244
|
+
// expect(result.current.switchTopic).toHaveBeenCalled();
|
|
245
|
+
// });
|
|
246
|
+
// 其他可能的测试用例...
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe('resendMessage action', () => {
|
|
250
|
+
it('should resend a message by id and refresh messages', async () => {
|
|
251
|
+
const { result } = renderHook(() => useChatStore());
|
|
252
|
+
const messageId = 'message-id';
|
|
253
|
+
|
|
254
|
+
// Mock the currentChats selector to return a list that includes the message to be resent
|
|
255
|
+
(chatSelectors.currentChats as Mock).mockReturnValue([
|
|
256
|
+
// ... other messages
|
|
257
|
+
{ id: messageId, role: 'user', content: 'Resend this message' },
|
|
258
|
+
// ... other messages
|
|
259
|
+
]);
|
|
260
|
+
|
|
261
|
+
// Mock the coreProcessMessage function to resolve immediately
|
|
262
|
+
mockState.coreProcessMessage.mockResolvedValue(undefined);
|
|
263
|
+
|
|
264
|
+
await act(async () => {
|
|
265
|
+
await result.current.resendMessage(messageId);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
expect(messageService.removeMessage).not.toHaveBeenCalledWith(messageId);
|
|
269
|
+
expect(mockState.coreProcessMessage).toHaveBeenCalledWith(expect.any(Array), messageId);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('should not perform any action if the message id does not exist', async () => {
|
|
273
|
+
const { result } = renderHook(() => useChatStore());
|
|
274
|
+
const messageId = 'non-existing-message-id';
|
|
275
|
+
|
|
276
|
+
// Mock the currentChats selector to return a list that does not include the message to be resent
|
|
277
|
+
(chatSelectors.currentChats as Mock).mockReturnValue([
|
|
278
|
+
// ... other messages
|
|
279
|
+
]);
|
|
280
|
+
|
|
281
|
+
await act(async () => {
|
|
282
|
+
await result.current.resendMessage(messageId);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
expect(messageService.removeMessage).not.toHaveBeenCalledWith(messageId);
|
|
286
|
+
expect(mockState.coreProcessMessage).not.toHaveBeenCalled();
|
|
287
|
+
expect(mockState.refreshMessages).not.toHaveBeenCalled();
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
describe('updateMessageContent action', () => {
|
|
292
|
+
it('should call messageService.updateMessageContent with correct parameters', async () => {
|
|
293
|
+
const { result } = renderHook(() => useChatStore());
|
|
294
|
+
const messageId = 'message-id';
|
|
295
|
+
const newContent = 'Updated content';
|
|
296
|
+
|
|
297
|
+
await act(async () => {
|
|
298
|
+
await result.current.updateMessageContent(messageId, newContent);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
expect(messageService.updateMessageContent).toHaveBeenCalledWith(messageId, newContent);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('should dispatch message update action', async () => {
|
|
305
|
+
const { result } = renderHook(() => useChatStore());
|
|
306
|
+
const messageId = 'message-id';
|
|
307
|
+
const newContent = 'Updated content';
|
|
308
|
+
const dispatchMessageSpy = vi.spyOn(result.current, 'dispatchMessage');
|
|
309
|
+
|
|
310
|
+
await act(async () => {
|
|
311
|
+
await result.current.updateMessageContent(messageId, newContent);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
expect(dispatchMessageSpy).toHaveBeenCalledWith({
|
|
315
|
+
id: messageId,
|
|
316
|
+
key: 'content',
|
|
317
|
+
type: 'updateMessage',
|
|
318
|
+
value: newContent,
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('should refresh messages after updating content', async () => {
|
|
323
|
+
const { result } = renderHook(() => useChatStore());
|
|
324
|
+
const messageId = 'message-id';
|
|
325
|
+
const newContent = 'Updated content';
|
|
326
|
+
|
|
327
|
+
await act(async () => {
|
|
328
|
+
await result.current.updateMessageContent(messageId, newContent);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
expect(result.current.refreshMessages).toHaveBeenCalled();
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
describe('coreProcessMessage action', () => {
|
|
336
|
+
it('should handle the core AI message processing', async () => {
|
|
337
|
+
useChatStore.setState({ coreProcessMessage: realCoreProcessMessage });
|
|
338
|
+
|
|
339
|
+
const { result } = renderHook(() => useChatStore());
|
|
340
|
+
const userMessage = {
|
|
341
|
+
id: 'user-message-id',
|
|
342
|
+
role: 'user',
|
|
343
|
+
content: 'Hello, world!',
|
|
344
|
+
sessionId: mockState.activeId,
|
|
345
|
+
topicId: mockState.activeTopicId,
|
|
346
|
+
} as ChatMessage;
|
|
347
|
+
const messages = [userMessage];
|
|
348
|
+
|
|
349
|
+
// 模拟 AI 响应
|
|
350
|
+
const aiResponse = 'Hello, human!';
|
|
351
|
+
(chatService.createAssistantMessage as Mock).mockResolvedValue(aiResponse);
|
|
352
|
+
|
|
353
|
+
// 模拟消息创建
|
|
354
|
+
(messageService.create as Mock).mockResolvedValue('assistant-message-id');
|
|
355
|
+
|
|
356
|
+
await act(async () => {
|
|
357
|
+
await result.current.coreProcessMessage(messages, userMessage.id);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// 验证是否创建了代表 AI 响应的消息
|
|
361
|
+
expect(messageService.create).toHaveBeenCalledWith(
|
|
362
|
+
expect.objectContaining({
|
|
363
|
+
role: 'assistant',
|
|
364
|
+
content: LOADING_FLAT,
|
|
365
|
+
fromModel: expect.anything(),
|
|
366
|
+
parentId: userMessage.id,
|
|
367
|
+
sessionId: mockState.activeId,
|
|
368
|
+
topicId: mockState.activeTopicId,
|
|
369
|
+
}),
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
// 验证 AI 服务是否被调用
|
|
373
|
+
expect(chatService.createAssistantMessage).toHaveBeenCalled();
|
|
374
|
+
|
|
375
|
+
// 验证消息列表是否刷新
|
|
376
|
+
expect(mockState.refreshMessages).toHaveBeenCalled();
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
describe('stopGenerateMessage action', () => {
|
|
381
|
+
it('should stop generating message and set loading states correctly', async () => {
|
|
382
|
+
const { result } = renderHook(() => useChatStore());
|
|
383
|
+
const toggleChatLoadingSpy = vi.spyOn(result.current, 'toggleChatLoading');
|
|
384
|
+
const abortController = new AbortController();
|
|
385
|
+
|
|
386
|
+
// 设置模拟 abortController
|
|
387
|
+
useChatStore.setState({ abortController });
|
|
388
|
+
|
|
389
|
+
await act(async () => {
|
|
390
|
+
result.current.stopGenerateMessage();
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
expect(abortController.signal.aborted).toBe(true);
|
|
394
|
+
expect(toggleChatLoadingSpy).toHaveBeenCalledWith(false, undefined, expect.any(String));
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('should not do anything if there is no abortController', async () => {
|
|
398
|
+
const { result } = renderHook(() => useChatStore());
|
|
399
|
+
|
|
400
|
+
// 确保没有设置 abortController
|
|
401
|
+
useChatStore.setState({ abortController: undefined });
|
|
402
|
+
|
|
403
|
+
await act(async () => {
|
|
404
|
+
result.current.stopGenerateMessage();
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// 由于没有 abortController,不应调用任何方法
|
|
408
|
+
expect(result.current.abortController).toBeUndefined();
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
describe('refreshMessages action', () => {
|
|
413
|
+
beforeEach(() => {
|
|
414
|
+
vi.mock('swr', async () => {
|
|
415
|
+
const actual = await vi.importActual('swr');
|
|
416
|
+
return {
|
|
417
|
+
...(actual as any),
|
|
418
|
+
mutate: vi.fn(),
|
|
419
|
+
};
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
afterEach(() => {
|
|
423
|
+
// 在每个测试用例开始前恢复到实际的 SWR 实现
|
|
424
|
+
vi.resetAllMocks();
|
|
425
|
+
});
|
|
426
|
+
it('should refresh messages by calling mutate with current activeId and activeTopicId', async () => {
|
|
427
|
+
useChatStore.setState({ refreshMessages: realRefreshMessages });
|
|
428
|
+
|
|
429
|
+
const { result } = renderHook(() => useChatStore());
|
|
430
|
+
const activeId = useChatStore.getState().activeId;
|
|
431
|
+
const activeTopicId = useChatStore.getState().activeTopicId;
|
|
432
|
+
|
|
433
|
+
// 在这里,我们不需要再次模拟 mutate,因为它已经在顶部被模拟了
|
|
434
|
+
await act(async () => {
|
|
435
|
+
await result.current.refreshMessages();
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
// 确保 mutate 调用了正确的参数
|
|
439
|
+
expect(mutate).toHaveBeenCalledWith([activeId, activeTopicId]);
|
|
440
|
+
});
|
|
441
|
+
it('should handle errors during refreshing messages', async () => {
|
|
442
|
+
useChatStore.setState({ refreshMessages: realRefreshMessages });
|
|
443
|
+
const { result } = renderHook(() => useChatStore());
|
|
444
|
+
|
|
445
|
+
// 设置模拟错误
|
|
446
|
+
(mutate as Mock).mockImplementation(() => {
|
|
447
|
+
throw new Error('Mutate error');
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
await act(async () => {
|
|
451
|
+
await expect(result.current.refreshMessages()).rejects.toThrow('Mutate error');
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
// 确保恢复 mutate 的模拟,以免影响其他测试
|
|
455
|
+
(mutate as Mock).mockReset();
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
describe('useFetchMessages hook', () => {
|
|
460
|
+
// beforeEach(() => {
|
|
461
|
+
// vi.mocked(useSWR).mockRestore();
|
|
462
|
+
// });
|
|
463
|
+
|
|
464
|
+
it('should fetch messages for given session and topic ids', async () => {
|
|
465
|
+
const sessionId = 'session-id';
|
|
466
|
+
const topicId = 'topic-id';
|
|
467
|
+
const messages = [{ id: 'message-id', content: 'Hello' }];
|
|
468
|
+
|
|
469
|
+
// 设置模拟返回值
|
|
470
|
+
(messageService.getMessages as Mock).mockResolvedValue(messages);
|
|
471
|
+
|
|
472
|
+
// renderHook 在 @testing-library/react-hooks 中使用,所以我们使用 render 来替代
|
|
473
|
+
const { result } = renderHook(() => useChatStore().useFetchMessages(sessionId, topicId));
|
|
474
|
+
|
|
475
|
+
// 等待异步操作完成
|
|
476
|
+
await waitFor(() => {
|
|
477
|
+
expect(result.current.data).toEqual(messages);
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
});
|
|
@@ -146,10 +146,13 @@ export const chatMessage: StateCreator<
|
|
|
146
146
|
},
|
|
147
147
|
sendMessage: async (message, files) => {
|
|
148
148
|
const { coreProcessMessage, activeTopicId, activeId } = get();
|
|
149
|
-
if (!
|
|
149
|
+
if (!activeId) return;
|
|
150
150
|
|
|
151
151
|
const fileIdList = files?.map((f) => f.id);
|
|
152
152
|
|
|
153
|
+
// if message is empty and no files, then stop
|
|
154
|
+
if (!message && (!fileIdList || fileIdList?.length === 0)) return;
|
|
155
|
+
|
|
153
156
|
let newMessage: CreateMessageParams = {
|
|
154
157
|
content: message,
|
|
155
158
|
// if message has attached with files, then add files to message and the agent
|
|
@@ -125,6 +125,7 @@ export const chatTopic: StateCreator<
|
|
|
125
125
|
onSuccess: (topics) => {
|
|
126
126
|
set({ topics, topicsInit: true }, false, n('useFetchTopics(success)', { sessionId }));
|
|
127
127
|
},
|
|
128
|
+
dedupingInterval: 0,
|
|
128
129
|
}),
|
|
129
130
|
useSearchTopics: (keywords) =>
|
|
130
131
|
useSWR<ChatTopic[]>(keywords, topicService.searchTopics, {
|
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
import { createStyles } from 'antd-style';
|
|
2
|
-
|
|
3
|
-
export const IMAGE_SIZE = 64;
|
|
4
|
-
const imageBorderRaidus = 8;
|
|
5
|
-
|
|
6
|
-
export const useStyles = createStyles(({ css, cx, token, isDarkMode }) => {
|
|
7
|
-
const closeIcon = cx(css`
|
|
8
|
-
cursor: pointer;
|
|
9
|
-
|
|
10
|
-
position: absolute;
|
|
11
|
-
top: -6px;
|
|
12
|
-
right: -6px;
|
|
13
|
-
|
|
14
|
-
width: 16px;
|
|
15
|
-
height: 16px;
|
|
16
|
-
|
|
17
|
-
color: ${isDarkMode ? token.colorTextQuaternary : token.colorTextTertiary};
|
|
18
|
-
|
|
19
|
-
opacity: 0;
|
|
20
|
-
background: ${isDarkMode ? token.colorTextSecondary : token.colorBgContainer};
|
|
21
|
-
border-radius: 50%;
|
|
22
|
-
|
|
23
|
-
transition: all 0.2s ease-in;
|
|
24
|
-
|
|
25
|
-
&:hover {
|
|
26
|
-
scale: 1.2;
|
|
27
|
-
}
|
|
28
|
-
`);
|
|
29
|
-
|
|
30
|
-
return {
|
|
31
|
-
alwaysShowClose: css`
|
|
32
|
-
opacity: 1 !important;
|
|
33
|
-
`,
|
|
34
|
-
closeIcon,
|
|
35
|
-
container: css`
|
|
36
|
-
cursor: pointer;
|
|
37
|
-
|
|
38
|
-
position: relative;
|
|
39
|
-
|
|
40
|
-
width: ${IMAGE_SIZE}px;
|
|
41
|
-
height: ${IMAGE_SIZE}px;
|
|
42
|
-
|
|
43
|
-
border-radius: ${imageBorderRaidus}px;
|
|
44
|
-
|
|
45
|
-
&:hover {
|
|
46
|
-
.${closeIcon} {
|
|
47
|
-
opacity: 1;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
`,
|
|
51
|
-
image: css`
|
|
52
|
-
opacity: ${isDarkMode ? 0.6 : 1};
|
|
53
|
-
object-fit: cover;
|
|
54
|
-
border-radius: 8px;
|
|
55
|
-
animation: fade-in 0.3s ease-in;
|
|
56
|
-
|
|
57
|
-
@keyframes fade-in {
|
|
58
|
-
from {
|
|
59
|
-
scale: 1.2;
|
|
60
|
-
opacity: 1;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
to {
|
|
64
|
-
scale: 1;
|
|
65
|
-
opacity: 1;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
`,
|
|
69
|
-
imageCtn: css`
|
|
70
|
-
position: relative;
|
|
71
|
-
`,
|
|
72
|
-
imageWrapper: css`
|
|
73
|
-
overflow: hidden;
|
|
74
|
-
width: ${IMAGE_SIZE}px;
|
|
75
|
-
height: ${IMAGE_SIZE}px;
|
|
76
|
-
border-radius: ${imageBorderRaidus}px;
|
|
77
|
-
`,
|
|
78
|
-
notFound: css`
|
|
79
|
-
color: ${token.colorTextSecondary};
|
|
80
|
-
background: ${token.colorFillTertiary};
|
|
81
|
-
border-radius: ${imageBorderRaidus}px;
|
|
82
|
-
`,
|
|
83
|
-
};
|
|
84
|
-
});
|
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
import { CloseCircleFilled } from '@ant-design/icons';
|
|
2
|
-
import { Icon } from '@lobehub/ui';
|
|
3
|
-
import { Skeleton } from 'antd';
|
|
4
|
-
import { LucideImageOff } from 'lucide-react';
|
|
5
|
-
import Image from 'next/image';
|
|
6
|
-
import { memo } from 'react';
|
|
7
|
-
import { Center, Flexbox } from 'react-layout-kit';
|
|
8
|
-
|
|
9
|
-
import { useFileStore } from '@/store/files';
|
|
10
|
-
|
|
11
|
-
import { IMAGE_SIZE, useStyles } from './FileItem.style';
|
|
12
|
-
|
|
13
|
-
interface FileItemProps {
|
|
14
|
-
alwaysShowClose?: boolean;
|
|
15
|
-
editable: boolean;
|
|
16
|
-
id: string;
|
|
17
|
-
onClick: () => void;
|
|
18
|
-
}
|
|
19
|
-
const FileItem = memo<FileItemProps>(({ editable, id, onClick, alwaysShowClose }) => {
|
|
20
|
-
const { styles, cx } = useStyles();
|
|
21
|
-
const [useFetchFile, removeFile] = useFileStore((s) => [s.useFetchFile, s.removeFile]);
|
|
22
|
-
|
|
23
|
-
const { data, isLoading } = useFetchFile(id);
|
|
24
|
-
|
|
25
|
-
return (
|
|
26
|
-
<Flexbox className={styles.container} onClick={onClick}>
|
|
27
|
-
{isLoading ? (
|
|
28
|
-
<Skeleton
|
|
29
|
-
active
|
|
30
|
-
title={{
|
|
31
|
-
style: { borderRadius: 8, height: IMAGE_SIZE },
|
|
32
|
-
width: IMAGE_SIZE,
|
|
33
|
-
}}
|
|
34
|
-
/>
|
|
35
|
-
) : (
|
|
36
|
-
<Flexbox className={styles.imageCtn}>
|
|
37
|
-
<div className={styles.imageWrapper}>
|
|
38
|
-
{data ? (
|
|
39
|
-
<Image
|
|
40
|
-
alt={data.name || ''}
|
|
41
|
-
className={styles.image}
|
|
42
|
-
fetchPriority={'high'}
|
|
43
|
-
height={IMAGE_SIZE}
|
|
44
|
-
loading={'lazy'}
|
|
45
|
-
src={data.url}
|
|
46
|
-
width={IMAGE_SIZE}
|
|
47
|
-
/>
|
|
48
|
-
) : (
|
|
49
|
-
<Center className={styles.notFound} height={'100%'}>
|
|
50
|
-
<Icon icon={LucideImageOff} size={{ fontSize: 28 }} />
|
|
51
|
-
</Center>
|
|
52
|
-
)}
|
|
53
|
-
</div>
|
|
54
|
-
</Flexbox>
|
|
55
|
-
)}
|
|
56
|
-
{/* only show close icon when editable */}
|
|
57
|
-
{editable && (
|
|
58
|
-
<Center
|
|
59
|
-
className={cx(styles.closeIcon, alwaysShowClose && styles.alwaysShowClose)}
|
|
60
|
-
onClick={(e) => {
|
|
61
|
-
e.stopPropagation();
|
|
62
|
-
|
|
63
|
-
removeFile(id);
|
|
64
|
-
}}
|
|
65
|
-
>
|
|
66
|
-
<CloseCircleFilled />
|
|
67
|
-
</Center>
|
|
68
|
-
)}
|
|
69
|
-
</Flexbox>
|
|
70
|
-
);
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
export default FileItem;
|