@lobehub/chat 0.107.14 → 0.107.16

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.
@@ -0,0 +1,391 @@
1
+ import { act, renderHook, waitFor } from '@testing-library/react';
2
+ import { 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 { ChatMessage } from '@/types/chatMessage';
10
+ import { ChatTopic } from '@/types/topic';
11
+
12
+ import { useChatStore } from '../store';
13
+
14
+ // Mock topicService 和 messageService
15
+ vi.mock('@/services/topic', () => ({
16
+ topicService: {
17
+ removeTopics: vi.fn(),
18
+ removeAllTopic: vi.fn(),
19
+ removeTopic: vi.fn(),
20
+ createTopic: vi.fn(),
21
+ updateTitle: vi.fn(),
22
+ updateFavorite: vi.fn(),
23
+ batchRemoveTopics: vi.fn(),
24
+ getTopics: vi.fn(),
25
+ searchTopics: vi.fn(),
26
+ },
27
+ }));
28
+
29
+ vi.mock('@/services/message', () => ({
30
+ messageService: {
31
+ removeMessages: vi.fn(),
32
+ },
33
+ }));
34
+
35
+ beforeEach(() => {
36
+ // Setup initial state and mocks before each test
37
+ vi.clearAllMocks();
38
+ useChatStore.setState(
39
+ {
40
+ activeId: undefined,
41
+ activeTopicId: undefined,
42
+ // ... initial state
43
+ },
44
+ false,
45
+ );
46
+ });
47
+
48
+ afterEach(() => {
49
+ // Cleanup mocks after each test
50
+ vi.restoreAllMocks();
51
+ });
52
+
53
+ describe('topic action', () => {
54
+ describe('openNewTopicOrSaveTopic', () => {
55
+ it('should call switchTopic if activeTopicId exists', async () => {
56
+ const { result } = renderHook(() => useChatStore());
57
+ await act(async () => {
58
+ useChatStore.setState({ activeTopicId: 'existing-topic-id' });
59
+ });
60
+
61
+ const switchTopicSpy = vi.spyOn(result.current, 'switchTopic');
62
+
63
+ await act(async () => {
64
+ result.current.openNewTopicOrSaveTopic();
65
+ });
66
+
67
+ expect(switchTopicSpy).toHaveBeenCalled();
68
+ });
69
+
70
+ it('should call saveToTopic if activeTopicId does not exist', async () => {
71
+ const { result } = renderHook(() => useChatStore());
72
+ await act(async () => {
73
+ useChatStore.setState({ activeTopicId: '' });
74
+ });
75
+
76
+ const saveToTopicSpy = vi.spyOn(result.current, 'saveToTopic');
77
+
78
+ await act(async () => {
79
+ await result.current.openNewTopicOrSaveTopic();
80
+ });
81
+
82
+ expect(saveToTopicSpy).toHaveBeenCalled();
83
+ });
84
+ });
85
+ describe('saveToTopic', () => {
86
+ it('should not create a topic if there are no messages', async () => {
87
+ const { result } = renderHook(() => useChatStore());
88
+ useChatStore.setState({ messages: [] });
89
+
90
+ const createTopicSpy = vi.spyOn(topicService, 'createTopic');
91
+
92
+ const topicId = await result.current.saveToTopic();
93
+
94
+ expect(createTopicSpy).not.toHaveBeenCalled();
95
+ expect(topicId).toBeUndefined();
96
+ });
97
+
98
+ it('should create a topic and bind messages to it', async () => {
99
+ const { result } = renderHook(() => useChatStore());
100
+ const messages = [{ id: 'message1' }, { id: 'message2' }] as ChatMessage[];
101
+ useChatStore.setState({ messages, activeId: 'session-id' });
102
+
103
+ const createTopicSpy = vi
104
+ .spyOn(topicService, 'createTopic')
105
+ .mockResolvedValue('new-topic-id');
106
+
107
+ const topicId = await result.current.saveToTopic();
108
+
109
+ expect(createTopicSpy).toHaveBeenCalledWith(
110
+ expect.objectContaining({
111
+ sessionId: 'session-id',
112
+ messages: messages.map((m) => m.id),
113
+ }),
114
+ );
115
+ expect(topicId).toEqual('new-topic-id');
116
+ });
117
+ });
118
+ describe('refreshTopic', () => {
119
+ beforeEach(() => {
120
+ vi.mock('swr', async () => {
121
+ const actual = await vi.importActual('swr');
122
+ return {
123
+ ...(actual as any),
124
+ mutate: vi.fn(),
125
+ };
126
+ });
127
+ });
128
+ afterEach(() => {
129
+ // 在每个测试用例开始前恢复到实际的 SWR 实现
130
+ vi.resetAllMocks();
131
+ });
132
+
133
+ it('should call mutate to refresh topics', async () => {
134
+ const { result } = renderHook(() => useChatStore());
135
+ const activeId = 'test-session-id';
136
+ useChatStore.setState({ activeId });
137
+
138
+ // Mock the mutate function to resolve immediately
139
+
140
+ await act(async () => {
141
+ await result.current.refreshTopic();
142
+ });
143
+
144
+ // Check if mutate has been called with the active session ID
145
+ expect(mutate).toHaveBeenCalledWith(activeId);
146
+ });
147
+
148
+ it('should handle errors during refreshing topics', async () => {
149
+ const { result } = renderHook(() => useChatStore());
150
+ const activeId = 'test-session-id';
151
+ useChatStore.setState({ activeId });
152
+
153
+ // Mock the mutate function to throw an error
154
+ // 设置模拟错误
155
+ (mutate as Mock).mockImplementation(() => {
156
+ throw new Error('Mutate error');
157
+ });
158
+
159
+ await act(async () => {
160
+ await expect(result.current.refreshTopic()).rejects.toThrow('Mutate error');
161
+ });
162
+
163
+ // 确保恢复 mutate 的模拟,以免影响其他测试
164
+ (mutate as Mock).mockReset();
165
+ });
166
+
167
+ // Additional tests for refreshTopic can be added here...
168
+ });
169
+ describe('favoriteTopic', () => {
170
+ it('should update the favorite state of a topic and refresh topics', async () => {
171
+ const { result } = renderHook(() => useChatStore());
172
+ const topicId = 'topic-id';
173
+ const favState = true;
174
+
175
+ const updateFavoriteSpy = vi.spyOn(topicService, 'updateFavorite').mockResolvedValue(true);
176
+
177
+ const refreshTopicSpy = vi.spyOn(result.current, 'refreshTopic');
178
+
179
+ await act(async () => {
180
+ await result.current.favoriteTopic(topicId, favState);
181
+ });
182
+
183
+ expect(updateFavoriteSpy).toHaveBeenCalledWith(topicId, favState);
184
+ expect(refreshTopicSpy).toHaveBeenCalled();
185
+ });
186
+ });
187
+ describe('useFetchTopics', () => {
188
+ it('should fetch topics for a given session id', async () => {
189
+ const sessionId = 'test-session-id';
190
+ const topics = [{ id: 'topic-id', title: 'Test Topic' }];
191
+
192
+ // Mock the topicService.getTopics to resolve with topics array
193
+ (topicService.getTopics as Mock).mockResolvedValue(topics);
194
+
195
+ // Use the hook with the session id
196
+ const { result } = renderHook(() => useChatStore().useFetchTopics(sessionId));
197
+
198
+ // Wait for the hook to resolve and update the state
199
+ await waitFor(() => {
200
+ expect(result.current.data).toEqual(topics);
201
+ });
202
+ expect(useChatStore.getState().topicsInit).toBeTruthy();
203
+ expect(useChatStore.getState().topics).toEqual(topics);
204
+ });
205
+ });
206
+ describe('useSearchTopics', () => {
207
+ it('should search topics with the given keywords', async () => {
208
+ const keywords = 'search-term';
209
+ const searchResults = [{ id: 'searched-topic-id', title: 'Searched Topic' }];
210
+
211
+ // Mock the topicService.searchTopics to resolve with search results
212
+ (topicService.searchTopics as Mock).mockResolvedValue(searchResults);
213
+
214
+ // Use the hook with the keywords
215
+ const { result } = renderHook(() => useChatStore().useSearchTopics(keywords));
216
+
217
+ // Wait for the hook to resolve and update the state
218
+ await waitFor(() => {
219
+ expect(result.current.data).toEqual(searchResults);
220
+ });
221
+ });
222
+ });
223
+ describe('updateTopicTitle', () => {
224
+ it('should call topicService.updateTitle with correct parameters and refresh the topic', async () => {
225
+ const topicId = 'topic-id';
226
+ const newTitle = 'Updated Topic Title';
227
+ // Mock the topicService.updateTitle to resolve immediately
228
+ (topicService.updateTitle as Mock).mockResolvedValue(undefined);
229
+
230
+ const { result } = renderHook(() => useChatStore());
231
+
232
+ const refreshTopicSpy = vi.spyOn(result.current, 'refreshTopic');
233
+
234
+ // Call the action with the topicId and newTitle
235
+ await act(async () => {
236
+ await result.current.updateTopicTitle(topicId, newTitle);
237
+ });
238
+
239
+ // Verify that the topicService.updateTitle was called with correct parameters
240
+ expect(topicService.updateTitle).toHaveBeenCalledWith(topicId, newTitle);
241
+
242
+ // Verify that the refreshTopic was called to update the state
243
+ expect(refreshTopicSpy).toHaveBeenCalled();
244
+ });
245
+ });
246
+ describe('switchTopic', () => {
247
+ it('should update activeTopicId and call refreshMessages', async () => {
248
+ const topicId = 'topic-id';
249
+ const { result } = renderHook(() => useChatStore());
250
+
251
+ const refreshMessagesSpy = vi.spyOn(result.current, 'refreshMessages');
252
+ // Call the switchTopic action with the topicId
253
+ await act(async () => {
254
+ await result.current.switchTopic(topicId);
255
+ });
256
+
257
+ // Verify that the activeTopicId has been updated
258
+ expect(useChatStore.getState().activeTopicId).toBe(topicId);
259
+
260
+ // Verify that the refreshMessages was called to update the messages
261
+ expect(refreshMessagesSpy).toHaveBeenCalled();
262
+ });
263
+ });
264
+ describe('removeSessionTopics', () => {
265
+ it('should remove all topics from the current session and refresh the topic list', async () => {
266
+ const { result } = renderHook(() => useChatStore());
267
+ const activeId = 'test-session-id';
268
+ await act(async () => {
269
+ useChatStore.setState({ activeId });
270
+ });
271
+ const refreshTopicSpy = vi.spyOn(result.current, 'refreshTopic');
272
+ const switchTopicSpy = vi.spyOn(result.current, 'switchTopic');
273
+
274
+ await act(async () => {
275
+ await result.current.removeSessionTopics();
276
+ });
277
+
278
+ expect(topicService.removeTopics).toHaveBeenCalledWith(activeId);
279
+ expect(refreshTopicSpy).toHaveBeenCalled();
280
+ expect(switchTopicSpy).toHaveBeenCalled();
281
+ });
282
+ });
283
+ describe('removeAllTopics', () => {
284
+ it('should remove all topics and refresh the topic list', async () => {
285
+ const { result } = renderHook(() => useChatStore());
286
+
287
+ const refreshTopicSpy = vi.spyOn(result.current, 'refreshTopic');
288
+
289
+ await act(async () => {
290
+ await result.current.removeAllTopics();
291
+ });
292
+
293
+ expect(topicService.removeAllTopic).toHaveBeenCalled();
294
+ expect(refreshTopicSpy).toHaveBeenCalled();
295
+ });
296
+ });
297
+ describe('removeTopic', () => {
298
+ it('should remove a specific topic and its messages, then refresh the topic list', async () => {
299
+ const topicId = 'topic-1';
300
+ const { result } = renderHook(() => useChatStore());
301
+ const activeId = 'test-session-id';
302
+
303
+ await act(async () => {
304
+ useChatStore.setState({ activeId });
305
+ });
306
+
307
+ const refreshTopicSpy = vi.spyOn(result.current, 'refreshTopic');
308
+ const switchTopicSpy = vi.spyOn(result.current, 'switchTopic');
309
+
310
+ await act(async () => {
311
+ await result.current.removeTopic(topicId);
312
+ });
313
+
314
+ expect(messageService.removeMessages).toHaveBeenCalledWith(activeId, topicId);
315
+ expect(topicService.removeTopic).toHaveBeenCalledWith(topicId);
316
+ expect(refreshTopicSpy).toHaveBeenCalled();
317
+ expect(switchTopicSpy).toHaveBeenCalled();
318
+ });
319
+ });
320
+ describe('removeUnstarredTopic', () => {
321
+ it('should remove unstarred topics and refresh the topic list', async () => {
322
+ const { result } = renderHook(() => useChatStore());
323
+ // Set up mock state with unstarred topics
324
+ await act(async () => {
325
+ useChatStore.setState({
326
+ topics: [
327
+ { id: 'topic-1', favorite: false },
328
+ { id: 'topic-2', favorite: true },
329
+ { id: 'topic-3', favorite: false },
330
+ ] as ChatTopic[],
331
+ });
332
+ });
333
+ const refreshTopicSpy = vi.spyOn(result.current, 'refreshTopic');
334
+ const switchTopicSpy = vi.spyOn(result.current, 'switchTopic');
335
+
336
+ await act(async () => {
337
+ await result.current.removeUnstarredTopic();
338
+ });
339
+
340
+ expect(topicService.batchRemoveTopics).toHaveBeenCalledWith(['topic-1', 'topic-3']);
341
+ expect(refreshTopicSpy).toHaveBeenCalled();
342
+ expect(switchTopicSpy).toHaveBeenCalled();
343
+ });
344
+ });
345
+ describe('updateTopicLoading', () => {
346
+ it('should call update topicLoadingId', async () => {
347
+ const { result } = renderHook(() => useChatStore());
348
+ expect(result.current.topicLoadingId).toBeUndefined();
349
+
350
+ // Call the action with the topicId and newTitle
351
+ await act(async () => {
352
+ await result.current.updateTopicLoading('loading-id');
353
+ });
354
+
355
+ expect(result.current.topicLoadingId).toEqual('loading-id');
356
+ });
357
+ });
358
+ describe('summaryTopicTitle', () => {
359
+ it('should auto-summarize the topic title and update it', async () => {
360
+ const topicId = 'topic-1';
361
+ const messages = [{ id: 'message-1', content: 'Hello' }] as ChatMessage[];
362
+ const topics = [{ id: 'topic-1', title: 'Test Topic' }] as ChatTopic[];
363
+ const { result } = renderHook(() => useChatStore());
364
+ await act(async () => {
365
+ useChatStore.setState({ topics });
366
+ });
367
+
368
+ // Mock the `updateTopicTitleInSummary` and `refreshTopic` for spying
369
+ const updateTopicTitleInSummarySpy = vi.spyOn(result.current, 'updateTopicTitleInSummary');
370
+ const refreshTopicSpy = vi.spyOn(result.current, 'refreshTopic');
371
+
372
+ // Mock the `chatService.fetchPresetTaskResult` to simulate the AI response
373
+ vi.spyOn(chatService, 'fetchPresetTaskResult').mockImplementation((params) => {
374
+ if (params) {
375
+ params.onFinish?.('Summarized Title');
376
+ }
377
+ return Promise.resolve(undefined);
378
+ });
379
+
380
+ await act(async () => {
381
+ await result.current.summaryTopicTitle(topicId, messages);
382
+ });
383
+
384
+ // Verify that the title was updated and the topic was refreshed
385
+ expect(updateTopicTitleInSummarySpy).toHaveBeenCalledWith(topicId, LOADING_FLAT);
386
+ expect(refreshTopicSpy).toHaveBeenCalled();
387
+
388
+ // TODO: need to test with fetchPresetTaskResult
389
+ });
390
+ });
391
+ });
@@ -22,7 +22,7 @@ const n = setNamespace('topic');
22
22
 
23
23
  export interface ChatTopicAction {
24
24
  favoriteTopic: (id: string, favState: boolean) => Promise<void>;
25
- openNewTopicOrSaveTopic: () => void;
25
+ openNewTopicOrSaveTopic: () => Promise<void>;
26
26
  refreshTopic: () => Promise<void>;
27
27
  removeAllTopics: () => Promise<void>;
28
28
  removeSessionTopics: () => Promise<void>;
@@ -45,7 +45,7 @@ export const chatTopic: StateCreator<
45
45
  ChatTopicAction
46
46
  > = (set, get) => ({
47
47
  // create
48
- openNewTopicOrSaveTopic: () => {
48
+ openNewTopicOrSaveTopic: async () => {
49
49
  const { switchTopic, saveToTopic, activeTopicId } = get();
50
50
  const hasTopic = !!activeTopicId;
51
51
 
@@ -60,23 +60,17 @@ export const chatTopic: StateCreator<
60
60
  const messages = chatSelectors.currentChats(get());
61
61
  if (messages.length === 0) return;
62
62
 
63
- const { activeId, summaryTopicTitle, refreshTopic, refreshMessages } = get();
63
+ const { activeId, summaryTopicTitle, refreshTopic } = get();
64
64
 
65
- // 1. create topic
65
+ // 1. create topic and bind these messages
66
66
  const topicId = await topicService.createTopic({
67
67
  sessionId: activeId,
68
68
  title: t('topic.defaultTitle', { ns: 'chat' }),
69
+ messages: messages.map((m) => m.id),
69
70
  });
70
71
  await refreshTopic();
71
72
 
72
- // 2.add topicId to these message
73
- await messageService.bindMessagesToTopic(
74
- topicId,
75
- messages.map((m) => m.id),
76
- );
77
- await refreshMessages();
78
-
79
- // 3. auto summary topic Title
73
+ // 2. auto summary topic Title
80
74
  // we don't need to wait for summary, just let it run async
81
75
  summaryTopicTitle(topicId, messages);
82
76
 
@@ -0,0 +1,109 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import { LobeAgentConfig } from '@/types/agent';
4
+ import { ChatMessage } from '@/types/chatMessage';
5
+ import { OpenAIChatMessage } from '@/types/openai/chat';
6
+ import { encodeAsync } from '@/utils/tokenizer';
7
+ import * as tokenizerObj from '@/utils/tokenizer';
8
+
9
+ import { chatHelpers } from './helpers';
10
+
11
+ // Mock encodeAsync function
12
+ vi.mock('@/utils/tokenizer', () => ({
13
+ encodeAsync: vi.fn((text) => Promise.resolve(text.length)),
14
+ }));
15
+
16
+ describe('chatHelpers', () => {
17
+ describe('getMessagesTokenCount', () => {
18
+ it('returns token count for a list of messages', async () => {
19
+ const messages = [{ content: 'Hello' }, { content: 'World' }] as OpenAIChatMessage[];
20
+ const tokenCount = await chatHelpers.getMessagesTokenCount(messages);
21
+ expect(tokenCount).toBe('HelloWorld'.length);
22
+ });
23
+
24
+ it('returns 0 for an empty array', async () => {
25
+ const tokenCount = await chatHelpers.getMessagesTokenCount([]);
26
+ expect(tokenCount).toBe(0);
27
+ });
28
+
29
+ it('handles messages with empty content', async () => {
30
+ const messages = [
31
+ { content: 'Hello' },
32
+ { content: '' },
33
+ { content: 'World' },
34
+ ] as OpenAIChatMessage[];
35
+ const tokenCount = await chatHelpers.getMessagesTokenCount(messages);
36
+ expect(tokenCount).toBe('HelloWorld'.length);
37
+ });
38
+
39
+ it('throws an error when encodeAsync fails', async () => {
40
+ vi.spyOn(tokenizerObj, 'encodeAsync').mockRejectedValue(new Error('Test error'));
41
+ await expect(
42
+ chatHelpers.getMessagesTokenCount([{ content: 'Hello' }] as OpenAIChatMessage[]),
43
+ ).rejects.toThrow('Test error');
44
+ });
45
+ });
46
+
47
+ describe('getMessageById', () => {
48
+ const messages = [
49
+ { id: '1', content: 'Hello' },
50
+ { id: '2', content: 'World' },
51
+ ] as ChatMessage[];
52
+
53
+ it('finds a message by id', () => {
54
+ const message = chatHelpers.getMessageById(messages, '1');
55
+ expect(message).toEqual({ id: '1', content: 'Hello' });
56
+ });
57
+
58
+ it('returns undefined for an invalid id', () => {
59
+ const message = chatHelpers.getMessageById(messages, '3');
60
+ expect(message).toBeUndefined();
61
+ });
62
+
63
+ it('returns undefined for an empty array', () => {
64
+ const message = chatHelpers.getMessageById([], '1');
65
+ expect(message).toBeUndefined();
66
+ });
67
+ });
68
+
69
+ describe('getSlicedMessagesWithConfig', () => {
70
+ const messages = [
71
+ { id: '1', content: 'First' },
72
+ { id: '2', content: 'Second' },
73
+ { id: '3', content: 'Third' },
74
+ ] as ChatMessage[];
75
+
76
+ it('returns all messages if history is disabled', () => {
77
+ const config = { enableHistoryCount: false, historyCount: 0 } as LobeAgentConfig;
78
+ const slicedMessages = chatHelpers.getSlicedMessagesWithConfig(messages, config);
79
+ expect(slicedMessages).toEqual(messages);
80
+ });
81
+
82
+ it('returns last N messages based on historyCount', () => {
83
+ const config = { enableHistoryCount: true, historyCount: 2 } as LobeAgentConfig;
84
+ const slicedMessages = chatHelpers.getSlicedMessagesWithConfig(messages, config);
85
+ expect(slicedMessages).toEqual([
86
+ { id: '2', content: 'Second' },
87
+ { id: '3', content: 'Third' },
88
+ ]);
89
+ });
90
+
91
+ it('returns empty array when historyCount is negative', () => {
92
+ const config = { enableHistoryCount: true, historyCount: -1 } as LobeAgentConfig;
93
+ const slicedMessages = chatHelpers.getSlicedMessagesWithConfig(messages, config);
94
+ expect(slicedMessages).toEqual([]);
95
+ });
96
+
97
+ it('returns all messages if historyCount exceeds the array length', () => {
98
+ const config = { enableHistoryCount: true, historyCount: 5 } as LobeAgentConfig;
99
+ const slicedMessages = chatHelpers.getSlicedMessagesWithConfig(messages, config);
100
+ expect(slicedMessages).toEqual(messages);
101
+ });
102
+
103
+ it('returns an empty array for an empty message array', () => {
104
+ const config = { enableHistoryCount: true, historyCount: 2 } as LobeAgentConfig;
105
+ const slicedMessages = chatHelpers.getSlicedMessagesWithConfig([], config);
106
+ expect(slicedMessages).toEqual([]);
107
+ });
108
+ });
109
+ });
@@ -6,18 +6,25 @@ import { encodeAsync } from '@/utils/tokenizer';
6
6
  export const getMessagesTokenCount = async (messages: OpenAIChatMessage[]) =>
7
7
  encodeAsync(messages.map((m) => m.content).join(''));
8
8
 
9
+ export const getMessageById = (messages: ChatMessage[], id: string) =>
10
+ messages.find((m) => m.id === id);
11
+
9
12
  const getSlicedMessagesWithConfig = (
10
13
  messages: ChatMessage[],
11
14
  config: LobeAgentConfig,
12
15
  ): ChatMessage[] => {
13
- // 如果没有开启历史消息数限制,或者限制为 0,则直接返回
16
+ // if historyCount is not enabled or set to 0, return all messages
14
17
  if (!config.enableHistoryCount || !config.historyCount) return messages;
15
18
 
16
- // 如果开启了,则返回尾部的N条消息
17
- return messages.reverse().slice(0, config.historyCount).reverse();
19
+ // if historyCount is negative, return empty array
20
+ if (config.historyCount <= 0) return [];
21
+
22
+ // if historyCount is positive, return last N messages
23
+ return messages.slice(-config.historyCount);
18
24
  };
19
25
 
20
26
  export const chatHelpers = {
27
+ getMessageById,
21
28
  getMessagesTokenCount,
22
29
  getSlicedMessagesWithConfig,
23
30
  };