@lobehub/chat 1.22.3 → 1.22.4

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,946 @@
1
+ import { act, renderHook } from '@testing-library/react';
2
+ import { Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import { LOADING_FLAT } from '@/const/message';
5
+ import { DEFAULT_AGENT_CHAT_CONFIG, DEFAULT_AGENT_CONFIG } from '@/const/settings';
6
+ import { chatService } from '@/services/chat';
7
+ import { messageService } from '@/services/message';
8
+ import { topicService } from '@/services/topic';
9
+ import { useAgentStore } from '@/store/agent';
10
+ import { agentSelectors } from '@/store/agent/selectors';
11
+ import { chatSelectors } from '@/store/chat/selectors';
12
+ import { messageMapKey } from '@/store/chat/utils/messageMapKey';
13
+ import { sessionMetaSelectors } from '@/store/session/selectors';
14
+ import { UploadFileItem } from '@/types/files/upload';
15
+ import { ChatMessage } from '@/types/message';
16
+
17
+ import { useChatStore } from '../../store';
18
+
19
+ vi.stubGlobal(
20
+ 'fetch',
21
+ vi.fn(() => Promise.resolve(new Response('mock'))),
22
+ );
23
+
24
+ vi.mock('zustand/traditional');
25
+ // Mock service
26
+ vi.mock('@/services/message', () => ({
27
+ messageService: {
28
+ getMessages: vi.fn(),
29
+ updateMessageError: vi.fn(),
30
+ removeMessage: vi.fn(),
31
+ removeMessagesByAssistant: vi.fn(),
32
+ removeMessages: vi.fn(() => Promise.resolve()),
33
+ createMessage: vi.fn(() => Promise.resolve('new-message-id')),
34
+ updateMessage: vi.fn(),
35
+ removeAllMessages: vi.fn(() => Promise.resolve()),
36
+ },
37
+ }));
38
+ vi.mock('@/services/topic', () => ({
39
+ topicService: {
40
+ createTopic: vi.fn(() => Promise.resolve()),
41
+ removeTopic: vi.fn(() => Promise.resolve()),
42
+ },
43
+ }));
44
+ vi.mock('@/services/chat', async (importOriginal) => {
45
+ const module = await importOriginal();
46
+
47
+ return {
48
+ chatService: {
49
+ createAssistantMessage: vi.fn(() => Promise.resolve('assistant-message')),
50
+ createAssistantMessageStream: (module as any).chatService.createAssistantMessageStream,
51
+ },
52
+ };
53
+ });
54
+
55
+ const realCoreProcessMessage = useChatStore.getState().internal_coreProcessMessage;
56
+
57
+ // Mock state
58
+ const mockState = {
59
+ activeId: 'session-id',
60
+ activeTopicId: 'topic-id',
61
+ messages: [],
62
+ refreshMessages: vi.fn(),
63
+ refreshTopic: vi.fn(),
64
+ internal_coreProcessMessage: vi.fn(),
65
+ saveToTopic: vi.fn(),
66
+ };
67
+
68
+ beforeEach(() => {
69
+ vi.clearAllMocks();
70
+ useChatStore.setState(mockState, false);
71
+ vi.spyOn(agentSelectors, 'currentAgentConfig').mockImplementation(() => DEFAULT_AGENT_CONFIG);
72
+ vi.spyOn(agentSelectors, 'currentAgentChatConfig').mockImplementation(
73
+ () => DEFAULT_AGENT_CHAT_CONFIG,
74
+ );
75
+ vi.spyOn(sessionMetaSelectors, 'currentAgentMeta').mockImplementation(() => ({ tags: [] }));
76
+ });
77
+
78
+ afterEach(() => {
79
+ process.env.NEXT_PUBLIC_BASE_PATH = undefined;
80
+
81
+ vi.restoreAllMocks();
82
+ });
83
+
84
+ describe('chatMessage actions', () => {
85
+ describe('sendMessage', () => {
86
+ it('should not send message if there is no active session', async () => {
87
+ useChatStore.setState({ activeId: undefined });
88
+ const { result } = renderHook(() => useChatStore());
89
+ const message = 'Test message';
90
+
91
+ await act(async () => {
92
+ await result.current.sendMessage({ message });
93
+ });
94
+
95
+ expect(messageService.createMessage).not.toHaveBeenCalled();
96
+ expect(result.current.refreshMessages).not.toHaveBeenCalled();
97
+ expect(result.current.internal_coreProcessMessage).not.toHaveBeenCalled();
98
+ });
99
+
100
+ it('should not send message if message is empty and there are no files', async () => {
101
+ const { result } = renderHook(() => useChatStore());
102
+ const message = '';
103
+
104
+ await act(async () => {
105
+ await result.current.sendMessage({ message });
106
+ });
107
+
108
+ expect(messageService.createMessage).not.toHaveBeenCalled();
109
+ expect(result.current.refreshMessages).not.toHaveBeenCalled();
110
+ expect(result.current.internal_coreProcessMessage).not.toHaveBeenCalled();
111
+ });
112
+
113
+ it('should not send message if message is empty and there are empty files', async () => {
114
+ const { result } = renderHook(() => useChatStore());
115
+ const message = '';
116
+
117
+ await act(async () => {
118
+ await result.current.sendMessage({ message, files: [] });
119
+ });
120
+
121
+ expect(messageService.createMessage).not.toHaveBeenCalled();
122
+ expect(result.current.refreshMessages).not.toHaveBeenCalled();
123
+ expect(result.current.internal_coreProcessMessage).not.toHaveBeenCalled();
124
+ });
125
+
126
+ it('should create message and call internal_coreProcessMessage if message or files are provided', async () => {
127
+ const { result } = renderHook(() => useChatStore());
128
+ const message = 'Test message';
129
+ const files = [{ id: 'file-id' } as UploadFileItem];
130
+
131
+ // Mock messageService.create to resolve with a message id
132
+ (messageService.createMessage as Mock).mockResolvedValue('new-message-id');
133
+
134
+ await act(async () => {
135
+ await result.current.sendMessage({ message, files });
136
+ });
137
+
138
+ expect(messageService.createMessage).toHaveBeenCalledWith({
139
+ content: message,
140
+ files: files.map((f) => f.id),
141
+ role: 'user',
142
+ sessionId: mockState.activeId,
143
+ topicId: mockState.activeTopicId,
144
+ });
145
+ expect(result.current.internal_coreProcessMessage).toHaveBeenCalled();
146
+ });
147
+
148
+ describe('auto-create topic', () => {
149
+ it('should not auto-create topic if enableAutoCreateTopic is false', async () => {
150
+ const { result } = renderHook(() => useChatStore());
151
+ const message = 'Test message';
152
+ const autoCreateTopicThreshold = 5;
153
+ const enableAutoCreateTopic = false;
154
+
155
+ // Mock messageService.create to resolve with a message id
156
+ (messageService.createMessage as Mock).mockResolvedValue('new-message-id');
157
+
158
+ // Mock agent config to simulate auto-create topic behavior
159
+ (agentSelectors.currentAgentConfig as Mock).mockImplementation(() => ({
160
+ autoCreateTopicThreshold,
161
+ enableAutoCreateTopic,
162
+ }));
163
+
164
+ // Mock saveToTopic and switchTopic to simulate not being called
165
+ const saveToTopicMock = vi.fn();
166
+ const switchTopicMock = vi.fn();
167
+
168
+ await act(async () => {
169
+ useChatStore.setState({
170
+ ...mockState,
171
+ // Mock the currentChats selector to return a list that does not reach the threshold
172
+ messagesMap: {
173
+ [messageMapKey('session-id')]: Array.from(
174
+ { length: autoCreateTopicThreshold + 1 },
175
+ (_, i) => ({
176
+ id: `msg-${i}`,
177
+ }),
178
+ ) as any,
179
+ },
180
+ activeTopicId: undefined,
181
+ saveToTopic: saveToTopicMock,
182
+ switchTopic: switchTopicMock,
183
+ });
184
+
185
+ await result.current.sendMessage({ message });
186
+ });
187
+
188
+ expect(saveToTopicMock).not.toHaveBeenCalled();
189
+ expect(switchTopicMock).not.toHaveBeenCalled();
190
+ });
191
+
192
+ it('should auto-create topic and switch to it if enabled and threshold is reached', async () => {
193
+ const { result } = renderHook(() => useChatStore());
194
+ const message = 'Test message';
195
+ const autoCreateTopicThreshold = 5;
196
+ const enableAutoCreateTopic = true;
197
+
198
+ // Mock agent config to simulate auto-create topic behavior
199
+ (agentSelectors.currentAgentConfig as Mock).mockImplementation(() => ({
200
+ autoCreateTopicThreshold,
201
+ enableAutoCreateTopic,
202
+ }));
203
+
204
+ // Mock messageService.create to resolve with a message id
205
+ (messageService.createMessage as Mock).mockResolvedValue('new-message-id');
206
+
207
+ // Mock saveToTopic to resolve with a topic id and switchTopic to switch to the new topic
208
+ const createTopicMock = vi.fn(() => Promise.resolve('new-topic-id'));
209
+ const switchTopicMock = vi.fn();
210
+
211
+ act(() => {
212
+ useChatStore.setState({
213
+ ...mockState,
214
+ activeId: 'session_id',
215
+ messagesMap: {
216
+ [messageMapKey('session_id')]: Array.from(
217
+ { length: autoCreateTopicThreshold },
218
+ (_, i) => ({
219
+ id: `msg-${i}`,
220
+ }),
221
+ ) as any,
222
+ },
223
+ activeTopicId: undefined,
224
+ createTopic: createTopicMock,
225
+ switchTopic: switchTopicMock,
226
+ });
227
+ });
228
+
229
+ await act(async () => {
230
+ await result.current.sendMessage({ message });
231
+ });
232
+
233
+ expect(createTopicMock).toHaveBeenCalled();
234
+ expect(switchTopicMock).toHaveBeenCalledWith('new-topic-id', true);
235
+ });
236
+
237
+ it('should not auto-create topic, if autoCreateTopic = false and reached topic threshold', async () => {
238
+ const { result } = renderHook(() => useChatStore());
239
+ act(() => {
240
+ useAgentStore.setState({
241
+ activeId: 'abc',
242
+ agentMap: {
243
+ abc: {
244
+ chatConfig: {
245
+ enableAutoCreateTopic: false,
246
+ autoCreateTopicThreshold: 1,
247
+ },
248
+ },
249
+ },
250
+ });
251
+
252
+ useChatStore.setState({
253
+ // Mock the currentChats selector to return a list that does not reach the threshold
254
+ messagesMap: {
255
+ [messageMapKey('inbox')]: [{ id: '1' }, { id: '2' }] as ChatMessage[],
256
+ },
257
+ activeTopicId: 'inbox',
258
+ });
259
+ });
260
+
261
+ await act(async () => {
262
+ await result.current.sendMessage({ message: 'test' });
263
+ });
264
+
265
+ expect(topicService.createTopic).not.toHaveBeenCalled();
266
+ });
267
+
268
+ it('should not auto-create topic if autoCreateTopicThreshold is not reached', async () => {
269
+ const { result } = renderHook(() => useChatStore());
270
+ const message = 'Test message';
271
+ const autoCreateTopicThreshold = 5;
272
+ const enableAutoCreateTopic = true;
273
+
274
+ // Mock messageService.create to resolve with a message id
275
+ (messageService.createMessage as Mock).mockResolvedValue('new-message-id');
276
+
277
+ // Mock agent config to simulate auto-create topic behavior
278
+ (agentSelectors.currentAgentChatConfig as Mock).mockImplementation(() => ({
279
+ autoCreateTopicThreshold,
280
+ enableAutoCreateTopic,
281
+ }));
282
+
283
+ // Mock saveToTopic and switchTopic to simulate not being called
284
+ const createTopicMock = vi.fn();
285
+ const switchTopicMock = vi.fn();
286
+
287
+ await act(async () => {
288
+ useChatStore.setState({
289
+ ...mockState,
290
+ activeId: 'session_id',
291
+ messagesMap: {
292
+ // Mock the currentChats selector to return a list that does not reach the threshold
293
+ [messageMapKey('session_id')]: Array.from(
294
+ { length: autoCreateTopicThreshold - 3 },
295
+ (_, i) => ({
296
+ id: `msg-${i}`,
297
+ }),
298
+ ) as any,
299
+ },
300
+ activeTopicId: undefined,
301
+ createTopic: createTopicMock,
302
+ switchTopic: switchTopicMock,
303
+ });
304
+
305
+ await result.current.sendMessage({ message });
306
+ });
307
+
308
+ expect(createTopicMock).not.toHaveBeenCalled();
309
+ expect(switchTopicMock).not.toHaveBeenCalled();
310
+ });
311
+ });
312
+
313
+ it('should handle RAG query when internal_shouldUseRAG returns true', async () => {
314
+ const { result } = renderHook(() => useChatStore());
315
+ const message = 'Test RAG query';
316
+
317
+ vi.spyOn(result.current, 'internal_shouldUseRAG').mockReturnValue(true);
318
+
319
+ await act(async () => {
320
+ await result.current.sendMessage({ message });
321
+ });
322
+
323
+ expect(result.current.internal_coreProcessMessage).toHaveBeenCalledWith(
324
+ expect.any(Array),
325
+ expect.any(String),
326
+ expect.objectContaining({
327
+ ragQuery: message,
328
+ }),
329
+ );
330
+ });
331
+
332
+ it('should not use RAG when internal_shouldUseRAG returns false', async () => {
333
+ const { result } = renderHook(() => useChatStore());
334
+ const message = 'Test without RAG';
335
+
336
+ vi.spyOn(result.current, 'internal_shouldUseRAG').mockReturnValue(false);
337
+ vi.spyOn(result.current, 'internal_retrieveChunks');
338
+
339
+ await act(async () => {
340
+ await result.current.sendMessage({ message });
341
+ });
342
+
343
+ expect(result.current.internal_retrieveChunks).not.toHaveBeenCalled();
344
+ expect(result.current.internal_coreProcessMessage).toHaveBeenCalledWith(
345
+ expect.any(Array),
346
+ expect.any(String),
347
+ expect.not.objectContaining({
348
+ ragQuery: expect.anything(),
349
+ }),
350
+ );
351
+ });
352
+
353
+ it('should add user message and not call internal_coreProcessMessage if onlyAddUserMessage = true', async () => {
354
+ const { result } = renderHook(() => useChatStore());
355
+
356
+ await act(async () => {
357
+ await result.current.sendMessage({ message: 'test', onlyAddUserMessage: true });
358
+ });
359
+
360
+ expect(messageService.createMessage).toHaveBeenCalled();
361
+ expect(result.current.internal_coreProcessMessage).not.toHaveBeenCalled();
362
+ });
363
+
364
+ it('当 isWelcomeQuestion 为 true 时,正确地传递给 internal_coreProcessMessage', async () => {
365
+ const { result } = renderHook(() => useChatStore());
366
+
367
+ await act(async () => {
368
+ await result.current.sendMessage({ message: 'test', isWelcomeQuestion: true });
369
+ });
370
+
371
+ expect(result.current.internal_coreProcessMessage).toHaveBeenCalledWith(
372
+ expect.anything(),
373
+ expect.anything(),
374
+ { isWelcomeQuestion: true },
375
+ );
376
+ });
377
+
378
+ it('当只有文件而没有消息内容时,正确发送消息', async () => {
379
+ const { result } = renderHook(() => useChatStore());
380
+
381
+ await act(async () => {
382
+ await result.current.sendMessage({ message: '', files: [{ id: 'file-1' }] as any });
383
+ });
384
+
385
+ expect(messageService.createMessage).toHaveBeenCalledWith({
386
+ content: '',
387
+ files: ['file-1'],
388
+ role: 'user',
389
+ sessionId: 'session-id',
390
+ topicId: 'topic-id',
391
+ });
392
+ });
393
+
394
+ it('当同时有文件和消息内容时,正确发送消息并关联文件', async () => {
395
+ const { result } = renderHook(() => useChatStore());
396
+
397
+ await act(async () => {
398
+ await result.current.sendMessage({ message: 'test', files: [{ id: 'file-1' }] as any });
399
+ });
400
+
401
+ expect(messageService.createMessage).toHaveBeenCalledWith({
402
+ content: 'test',
403
+ files: ['file-1'],
404
+ role: 'user',
405
+ sessionId: 'session-id',
406
+ topicId: 'topic-id',
407
+ });
408
+ });
409
+
410
+ it('当 createMessage 抛出错误时,正确处理错误而不影响整个应用', async () => {
411
+ const { result } = renderHook(() => useChatStore());
412
+ vi.spyOn(messageService, 'createMessage').mockRejectedValue(
413
+ new Error('create message error'),
414
+ );
415
+
416
+ await expect(result.current.sendMessage({ message: 'test' })).rejects.toThrow(
417
+ 'create message error',
418
+ );
419
+
420
+ expect(result.current.internal_coreProcessMessage).not.toHaveBeenCalled();
421
+ });
422
+
423
+ // it('自动创建主题成功后,正确地将消息复制到新主题,并删除之前的临时消息', async () => {
424
+ // const { result } = renderHook(() => useChatStore());
425
+ // act(() => {
426
+ // useAgentStore.setState({
427
+ // agentConfig: { enableAutoCreateTopic: true, autoCreateTopicThreshold: 1 },
428
+ // });
429
+ //
430
+ // useChatStore.setState({
431
+ // // Mock the currentChats selector to return a list that does not reach the threshold
432
+ // messagesMap: {
433
+ // [messageMapKey('inbox')]: [{ id: '1' }, { id: '2' }] as ChatMessage[],
434
+ // },
435
+ // activeId: 'inbox',
436
+ // });
437
+ // });
438
+ // vi.spyOn(topicService, 'createTopic').mockResolvedValue('new-topic');
439
+ //
440
+ // await act(async () => {
441
+ // await result.current.sendMessage({ message: 'test' });
442
+ // });
443
+ //
444
+ // expect(result.current.messagesMap[messageMapKey('inbox')]).toEqual([
445
+ // // { id: '1' },
446
+ // // { id: '2' },
447
+ // // { id: 'temp-id', content: 'test', role: 'user' },
448
+ // ]);
449
+ // // expect(result.current.getMessages('session-id')).toEqual([]);
450
+ // });
451
+
452
+ // it('自动创建主题失败时,正确地处理错误,不会影响后续的消息发送', async () => {
453
+ // const { result } = renderHook(() => useChatStore());
454
+ // result.current.setAgentConfig({ enableAutoCreateTopic: true, autoCreateTopicThreshold: 1 });
455
+ // result.current.setMessages([{ id: '1' }, { id: '2' }] as any);
456
+ // vi.spyOn(topicService, 'createTopic').mockRejectedValue(new Error('create topic error'));
457
+ //
458
+ // await act(async () => {
459
+ // await result.current.sendMessage({ message: 'test' });
460
+ // });
461
+ //
462
+ // expect(result.current.getMessages('session-id')).toEqual([
463
+ // { id: '1' },
464
+ // { id: '2' },
465
+ // { id: 'new-message-id', content: 'test', role: 'user' },
466
+ // ]);
467
+ // });
468
+
469
+ // it('当 activeTopicId 不存在且 autoCreateTopic 为 true,但消息数量未达到阈值时,正确地总结主题标题', async () => {
470
+ // const { result } = renderHook(() => useChatStore());
471
+ // result.current.setAgentConfig({ enableAutoCreateTopic: true, autoCreateTopicThreshold: 10 });
472
+ // result.current.setMessages([{ id: '1' }, { id: '2' }] as any);
473
+ // result.current.setActiveTopic({ id: 'topic-1', title: '' });
474
+ //
475
+ // await act(async () => {
476
+ // await result.current.sendMessage({ message: 'test' });
477
+ // });
478
+ //
479
+ // expect(result.current.summaryTopicTitle).toHaveBeenCalledWith('topic-1', [
480
+ // { id: '1' },
481
+ // { id: '2' },
482
+ // { id: 'new-message-id', content: 'test', role: 'user' },
483
+ // { id: 'assistant-message', role: 'assistant' },
484
+ // ]);
485
+ // });
486
+ //
487
+ // it('当 activeTopicId 存在且主题标题为空时,正确地总结主题标题', async () => {
488
+ // const { result } = renderHook(() => useChatStore());
489
+ // result.current.setActiveTopic({ id: 'topic-1', title: '' });
490
+ // result.current.setMessages([{ id: '1' }, { id: '2' }] as any, 'session-id', 'topic-1');
491
+ //
492
+ // await act(async () => {
493
+ // await result.current.sendMessage({ message: 'test' });
494
+ // });
495
+ //
496
+ // expect(result.current.summaryTopicTitle).toHaveBeenCalledWith('topic-1', [
497
+ // { id: '1' },
498
+ // { id: '2' },
499
+ // { id: 'new-message-id', content: 'test', role: 'user' },
500
+ // { id: 'assistant-message', role: 'assistant' },
501
+ // ]);
502
+ // });
503
+ });
504
+
505
+ describe('regenerateMessage', () => {
506
+ it('should create a new message', async () => {
507
+ const { result } = renderHook(() => useChatStore());
508
+ const messageId = 'message-id';
509
+ const resendMessageSpy = vi.spyOn(result.current, 'internal_resendMessage');
510
+
511
+ act(() => {
512
+ useChatStore.setState({
513
+ activeId: 'session-id',
514
+ activeTopicId: undefined,
515
+ messagesMap: {
516
+ [messageMapKey('session-id')]: [
517
+ {
518
+ id: messageId,
519
+ tools: [{ id: 'tool1' }, { id: 'tool2' }],
520
+ traceId: 'abc',
521
+ } as ChatMessage,
522
+ ],
523
+ },
524
+ });
525
+ });
526
+ await act(async () => {
527
+ await result.current.regenerateMessage(messageId);
528
+ });
529
+
530
+ expect(resendMessageSpy).toHaveBeenCalledWith(messageId, 'abc');
531
+ });
532
+ });
533
+
534
+ describe('delAndRegenerateMessage', () => {
535
+ it('should remove a message and create a new message', async () => {
536
+ const { result } = renderHook(() => useChatStore());
537
+ const messageId = 'message-id';
538
+ const deleteMessageSpy = vi.spyOn(result.current, 'deleteMessage');
539
+ const resendMessageSpy = vi.spyOn(result.current, 'internal_resendMessage');
540
+
541
+ act(() => {
542
+ useChatStore.setState({
543
+ activeId: 'session-id',
544
+ activeTopicId: undefined,
545
+ messagesMap: {
546
+ [messageMapKey('session-id')]: [
547
+ { id: messageId, tools: [{ id: 'tool1' }, { id: 'tool2' }] } as ChatMessage,
548
+ ],
549
+ },
550
+ });
551
+ });
552
+ await act(async () => {
553
+ await result.current.delAndRegenerateMessage(messageId);
554
+ });
555
+
556
+ expect(deleteMessageSpy).toHaveBeenCalledWith(messageId);
557
+ expect(resendMessageSpy).toHaveBeenCalled();
558
+ expect(result.current.refreshMessages).toHaveBeenCalled();
559
+ });
560
+ });
561
+
562
+ describe('stopGenerateMessage', () => {
563
+ it('should stop generating message and set loading states correctly', async () => {
564
+ const { result } = renderHook(() => useChatStore());
565
+ const internal_toggleChatLoadingSpy = vi.spyOn(result.current, 'internal_toggleChatLoading');
566
+ const abortController = new AbortController();
567
+
568
+ act(() => {
569
+ useChatStore.setState({ abortController });
570
+ });
571
+
572
+ await act(async () => {
573
+ result.current.stopGenerateMessage();
574
+ });
575
+
576
+ expect(abortController.signal.aborted).toBe(true);
577
+ expect(internal_toggleChatLoadingSpy).toHaveBeenCalledWith(
578
+ false,
579
+ undefined,
580
+ expect.any(String),
581
+ );
582
+ });
583
+
584
+ it('should not do anything if there is no abortController', async () => {
585
+ const { result } = renderHook(() => useChatStore());
586
+
587
+ await act(async () => {
588
+ // 确保没有设置 abortController
589
+ useChatStore.setState({ abortController: undefined });
590
+
591
+ result.current.stopGenerateMessage();
592
+ });
593
+
594
+ // 由于没有 abortController,不应调用任何方法
595
+ expect(result.current.abortController).toBeUndefined();
596
+ });
597
+
598
+ it('should return early if abortController is undefined', () => {
599
+ act(() => {
600
+ useChatStore.setState({ abortController: undefined });
601
+ });
602
+
603
+ const { result } = renderHook(() => useChatStore());
604
+
605
+ const spy = vi.spyOn(result.current, 'internal_toggleChatLoading');
606
+
607
+ act(() => {
608
+ result.current.stopGenerateMessage();
609
+ });
610
+
611
+ expect(spy).not.toHaveBeenCalled();
612
+ });
613
+
614
+ it('should call abortController.abort()', () => {
615
+ const abortMock = vi.fn();
616
+ const abortController = { abort: abortMock } as unknown as AbortController;
617
+ act(() => {
618
+ useChatStore.setState({ abortController });
619
+ });
620
+ const { result } = renderHook(() => useChatStore());
621
+
622
+ act(() => {
623
+ result.current.stopGenerateMessage();
624
+ });
625
+
626
+ expect(abortMock).toHaveBeenCalled();
627
+ });
628
+
629
+ it('should call internal_toggleChatLoading with correct parameters', () => {
630
+ const abortController = new AbortController();
631
+ act(() => {
632
+ useChatStore.setState({ abortController });
633
+ });
634
+ const { result } = renderHook(() => useChatStore());
635
+ const spy = vi.spyOn(result.current, 'internal_toggleChatLoading');
636
+
637
+ act(() => {
638
+ result.current.stopGenerateMessage();
639
+ });
640
+
641
+ expect(spy).toHaveBeenCalledWith(false, undefined, expect.any(String));
642
+ });
643
+ });
644
+
645
+ describe('internal_coreProcessMessage', () => {
646
+ it('should handle the core AI message processing', async () => {
647
+ useChatStore.setState({ internal_coreProcessMessage: realCoreProcessMessage });
648
+
649
+ const { result } = renderHook(() => useChatStore());
650
+ const userMessage = {
651
+ id: 'user-message-id',
652
+ role: 'user',
653
+ content: 'Hello, world!',
654
+ sessionId: mockState.activeId,
655
+ topicId: mockState.activeTopicId,
656
+ } as ChatMessage;
657
+ const messages = [userMessage];
658
+
659
+ // 模拟 AI 响应
660
+ const aiResponse = 'Hello, human!';
661
+ (chatService.createAssistantMessage as Mock).mockResolvedValue(aiResponse);
662
+ const spy = vi.spyOn(chatService, 'createAssistantMessageStream');
663
+ // 模拟消息创建
664
+ (messageService.createMessage as Mock).mockResolvedValue('assistant-message-id');
665
+
666
+ await act(async () => {
667
+ await result.current.internal_coreProcessMessage(messages, userMessage.id);
668
+ });
669
+
670
+ // 验证是否创建了代表 AI 响应的消息
671
+ expect(messageService.createMessage).toHaveBeenCalledWith(
672
+ expect.objectContaining({
673
+ role: 'assistant',
674
+ content: LOADING_FLAT,
675
+ fromModel: expect.anything(),
676
+ parentId: userMessage.id,
677
+ sessionId: mockState.activeId,
678
+ topicId: mockState.activeTopicId,
679
+ }),
680
+ );
681
+
682
+ // 验证 AI 服务是否被调用
683
+ expect(spy).toHaveBeenCalled();
684
+
685
+ // 验证消息列表是否刷新
686
+ expect(mockState.refreshMessages).toHaveBeenCalled();
687
+ });
688
+ });
689
+
690
+ describe('internal_resendMessage', () => {
691
+ it('should resend a message by id and refresh messages', async () => {
692
+ const { result } = renderHook(() => useChatStore());
693
+ const messageId = 'message-id';
694
+
695
+ act(() => {
696
+ useChatStore.setState({
697
+ activeId: 'session-id',
698
+ activeTopicId: undefined,
699
+ // Mock the currentChats selector to return a list that includes the message to be resent
700
+ messagesMap: {
701
+ [messageMapKey('session-id')]: [
702
+ { id: messageId, role: 'user', content: 'Resend this message' } as ChatMessage,
703
+ ],
704
+ },
705
+ });
706
+ });
707
+
708
+ // Mock the internal_coreProcessMessage function to resolve immediately
709
+ mockState.internal_coreProcessMessage.mockResolvedValue(undefined);
710
+
711
+ await act(async () => {
712
+ await result.current.internal_resendMessage(messageId);
713
+ });
714
+
715
+ expect(messageService.removeMessage).not.toHaveBeenCalledWith(messageId);
716
+ expect(mockState.internal_coreProcessMessage).toHaveBeenCalledWith(
717
+ expect.any(Array),
718
+ messageId,
719
+ {},
720
+ );
721
+ });
722
+
723
+ it('should not perform any action if the message id does not exist', async () => {
724
+ const { result } = renderHook(() => useChatStore());
725
+ const messageId = 'non-existing-message-id';
726
+
727
+ act(() => {
728
+ useChatStore.setState({
729
+ activeId: 'session-id',
730
+ activeTopicId: undefined,
731
+ // Mock the currentChats selector to return a list that does not include the message to be resent
732
+ messagesMap: {
733
+ [messageMapKey('session-id')]: [],
734
+ },
735
+ });
736
+ });
737
+
738
+ await act(async () => {
739
+ await result.current.internal_resendMessage(messageId);
740
+ });
741
+
742
+ expect(messageService.removeMessage).not.toHaveBeenCalledWith(messageId);
743
+ expect(mockState.internal_coreProcessMessage).not.toHaveBeenCalled();
744
+ expect(mockState.refreshMessages).not.toHaveBeenCalled();
745
+ });
746
+ });
747
+
748
+ describe('internal_fetchAIChatMessage', () => {
749
+ it('should fetch AI chat message and return content', async () => {
750
+ const { result } = renderHook(() => useChatStore());
751
+ const messages = [{ id: 'message-id', content: 'Hello', role: 'user' }] as ChatMessage[];
752
+ const assistantMessageId = 'assistant-message-id';
753
+ const aiResponse = 'Hello, human!';
754
+
755
+ (fetch as Mock).mockResolvedValueOnce(new Response(aiResponse));
756
+
757
+ await act(async () => {
758
+ const response = await result.current.internal_fetchAIChatMessage(
759
+ messages,
760
+ assistantMessageId,
761
+ );
762
+ expect(response.isFunctionCall).toEqual(false);
763
+ });
764
+ });
765
+
766
+ it('should handle errors during AI response fetching', async () => {
767
+ const { result } = renderHook(() => useChatStore());
768
+ const messages = [{ id: 'message-id', content: 'Hello', role: 'user' }] as ChatMessage[];
769
+ const assistantMessageId = 'assistant-message-id';
770
+
771
+ // Mock fetch to reject with an error
772
+ const errorMessage = 'Error fetching AI response';
773
+ vi.mocked(fetch).mockRejectedValueOnce(new Error(errorMessage));
774
+
775
+ await act(async () => {
776
+ expect(
777
+ await result.current.internal_fetchAIChatMessage(messages, assistantMessageId),
778
+ ).toEqual({
779
+ isFunctionCall: false,
780
+ });
781
+ });
782
+ });
783
+
784
+ it('should generate correct contextMessages for "user" role', async () => {
785
+ const messageId = 'message-id';
786
+ const messages = [
787
+ { id: 'msg-1', role: 'system' },
788
+ { id: messageId, role: 'user', meta: { avatar: '😀' } },
789
+ { id: 'msg-3', role: 'assistant' },
790
+ ];
791
+ act(() => {
792
+ useChatStore.setState({
793
+ messagesMap: {
794
+ [chatSelectors.currentChatKey(mockState as any)]: messages as ChatMessage[],
795
+ },
796
+ });
797
+ });
798
+ const { result } = renderHook(() => useChatStore());
799
+
800
+ await act(async () => {
801
+ await result.current.internal_resendMessage(messageId);
802
+ });
803
+
804
+ expect(result.current.internal_coreProcessMessage).toHaveBeenCalledWith(
805
+ messages.slice(0, 2),
806
+ messageId,
807
+ { traceId: undefined },
808
+ );
809
+ });
810
+
811
+ it('should generate correct contextMessages for "assistant" role', async () => {
812
+ const messageId = 'message-id';
813
+ const messages = [
814
+ { id: 'msg-1', role: 'system' },
815
+ { id: 'msg-2', role: 'user', meta: { avatar: '😀' } },
816
+ { id: messageId, role: 'assistant', parentId: 'msg-2' },
817
+ ];
818
+ useChatStore.setState({
819
+ messagesMap: {
820
+ [chatSelectors.currentChatKey(mockState as any)]: messages as ChatMessage[],
821
+ },
822
+ });
823
+ const { result } = renderHook(() => useChatStore());
824
+
825
+ await act(async () => {
826
+ await result.current.internal_resendMessage(messageId);
827
+ });
828
+
829
+ expect(result.current.internal_coreProcessMessage).toHaveBeenCalledWith(
830
+ messages.slice(0, 2),
831
+ 'msg-2',
832
+ { traceId: undefined },
833
+ );
834
+ });
835
+
836
+ it('should return early if contextMessages is empty', async () => {
837
+ const messageId = 'message-id';
838
+ useChatStore.setState({
839
+ messagesMap: { [chatSelectors.currentChatKey(mockState as any)]: [] },
840
+ });
841
+ const { result } = renderHook(() => useChatStore());
842
+
843
+ await act(async () => {
844
+ await result.current.internal_resendMessage(messageId);
845
+ });
846
+
847
+ expect(result.current.internal_coreProcessMessage).not.toHaveBeenCalled();
848
+ });
849
+ });
850
+
851
+ describe('internal_toggleChatLoading', () => {
852
+ it('should set loading state and create an AbortController when loading is true', () => {
853
+ const { result } = renderHook(() => useChatStore());
854
+ const action = 'loading-action';
855
+
856
+ act(() => {
857
+ result.current.internal_toggleChatLoading(true, 'message-id', action);
858
+ });
859
+
860
+ const state = useChatStore.getState();
861
+ expect(state.abortController).toBeInstanceOf(AbortController);
862
+ expect(state.chatLoadingIds).toEqual(['message-id']);
863
+ });
864
+
865
+ it('should clear loading state and abort controller when loading is false', () => {
866
+ const { result } = renderHook(() => useChatStore());
867
+ const action = 'stop-loading-action';
868
+
869
+ // Set initial loading state
870
+ act(() => {
871
+ result.current.internal_toggleChatLoading(true, 'message-id', 'start-loading-action');
872
+ });
873
+
874
+ // Stop loading
875
+ act(() => {
876
+ result.current.internal_toggleChatLoading(false, undefined, action);
877
+ });
878
+
879
+ const state = useChatStore.getState();
880
+ expect(state.abortController).toBeUndefined();
881
+ expect(state.chatLoadingIds).toEqual([]);
882
+ });
883
+
884
+ it('should attach beforeunload event listener when loading starts', () => {
885
+ const { result } = renderHook(() => useChatStore());
886
+ const addEventListenerSpy = vi.spyOn(window, 'addEventListener');
887
+
888
+ act(() => {
889
+ result.current.internal_toggleChatLoading(true, 'message-id', 'loading-action');
890
+ });
891
+
892
+ expect(addEventListenerSpy).toHaveBeenCalledWith('beforeunload', expect.any(Function));
893
+ });
894
+
895
+ it('should remove beforeunload event listener when loading stops', () => {
896
+ const { result } = renderHook(() => useChatStore());
897
+ const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');
898
+
899
+ // Start and then stop loading to trigger the removal of the event listener
900
+ act(() => {
901
+ result.current.internal_toggleChatLoading(true, 'message-id', 'start-loading-action');
902
+ result.current.internal_toggleChatLoading(false, undefined, 'stop-loading-action');
903
+ });
904
+
905
+ expect(removeEventListenerSpy).toHaveBeenCalledWith('beforeunload', expect.any(Function));
906
+ });
907
+
908
+ it('should not create a new AbortController if one already exists', () => {
909
+ const { result } = renderHook(() => useChatStore());
910
+ const abortController = new AbortController();
911
+
912
+ act(() => {
913
+ useChatStore.setState({ abortController });
914
+ result.current.internal_toggleChatLoading(true, 'message-id', 'loading-action');
915
+ });
916
+
917
+ const state = useChatStore.getState();
918
+ expect(state.abortController).toEqual(abortController);
919
+ });
920
+ });
921
+
922
+ describe('internal_toggleToolCallingStreaming', () => {
923
+ it('should add message id to messageLoadingIds when loading is true', () => {
924
+ const { result } = renderHook(() => useChatStore());
925
+ const messageId = 'message-id';
926
+
927
+ act(() => {
928
+ result.current.internal_toggleToolCallingStreaming(messageId, [true]);
929
+ });
930
+
931
+ expect(result.current.toolCallingStreamIds[messageId]).toEqual([true]);
932
+ });
933
+
934
+ it('should remove message id from messageLoadingIds when loading is false', () => {
935
+ const { result } = renderHook(() => useChatStore());
936
+ const messageId = 'ddd-id';
937
+
938
+ act(() => {
939
+ result.current.internal_toggleToolCallingStreaming(messageId, [true]);
940
+ result.current.internal_toggleToolCallingStreaming(messageId, undefined);
941
+ });
942
+
943
+ expect(result.current.toolCallingStreamIds[messageId]).toBeUndefined();
944
+ });
945
+ });
946
+ });