@lobehub/chat 1.22.3 → 1.22.5

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.
@@ -1,20 +1,12 @@
1
1
  import * as lobeUIModules from '@lobehub/ui';
2
2
  import { act, renderHook, waitFor } from '@testing-library/react';
3
- import useSWR, { mutate } from 'swr';
3
+ import { mutate } from 'swr';
4
4
  import { Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
5
 
6
- import { LOADING_FLAT } from '@/const/message';
7
- import { DEFAULT_AGENT_CHAT_CONFIG, DEFAULT_AGENT_CONFIG } from '@/const/settings';
8
6
  import { TraceEventType } from '@/const/trace';
9
- import { chatService } from '@/services/chat';
10
7
  import { messageService } from '@/services/message';
11
8
  import { topicService } from '@/services/topic';
12
- import { useAgentStore } from '@/store/agent';
13
- import { agentSelectors } from '@/store/agent/selectors';
14
- import { chatSelectors } from '@/store/chat/selectors';
15
- import { messageMapKey } from '@/store/chat/slices/message/utils';
16
- import { sessionMetaSelectors } from '@/store/session/selectors';
17
- import { UploadFileItem } from '@/types/files/upload';
9
+ import { messageMapKey } from '@/store/chat/utils/messageMapKey';
18
10
  import { ChatMessage } from '@/types/message';
19
11
 
20
12
  import { useChatStore } from '../../store';
@@ -44,18 +36,7 @@ vi.mock('@/services/topic', () => ({
44
36
  removeTopic: vi.fn(() => Promise.resolve()),
45
37
  },
46
38
  }));
47
- vi.mock('@/services/chat', async (importOriginal) => {
48
- const module = await importOriginal();
49
-
50
- return {
51
- chatService: {
52
- createAssistantMessage: vi.fn(() => Promise.resolve('assistant-message')),
53
- createAssistantMessageStream: (module as any).chatService.createAssistantMessageStream,
54
- },
55
- };
56
- });
57
39
 
58
- const realCoreProcessMessage = useChatStore.getState().internal_coreProcessMessage;
59
40
  const realRefreshMessages = useChatStore.getState().refreshMessages;
60
41
  // Mock state
61
42
  const mockState = {
@@ -71,11 +52,6 @@ const mockState = {
71
52
  beforeEach(() => {
72
53
  vi.clearAllMocks();
73
54
  useChatStore.setState(mockState, false);
74
- vi.spyOn(agentSelectors, 'currentAgentConfig').mockImplementation(() => DEFAULT_AGENT_CONFIG);
75
- vi.spyOn(agentSelectors, 'currentAgentChatConfig').mockImplementation(
76
- () => DEFAULT_AGENT_CHAT_CONFIG,
77
- );
78
- vi.spyOn(sessionMetaSelectors, 'currentAgentMeta').mockImplementation(() => ({ tags: [] }));
79
55
  });
80
56
 
81
57
  afterEach(() => {
@@ -177,74 +153,42 @@ describe('chatMessage actions', () => {
177
153
  });
178
154
  });
179
155
 
180
- describe('deleteToolMessage', () => {
181
- it('deleteMessage should remove a message by id', async () => {
182
- const { result } = renderHook(() => useChatStore());
156
+ describe('copyMessage', () => {
157
+ it('should call copyToClipboard with correct content', async () => {
183
158
  const messageId = 'message-id';
184
- const updateMessageSpy = vi.spyOn(messageService, 'updateMessage');
185
- const removeMessageSpy = vi.spyOn(messageService, 'removeMessage');
159
+ const content = 'Test content';
160
+ const { result } = renderHook(() => useChatStore());
161
+ const copyToClipboardSpy = vi.spyOn(lobeUIModules, 'copyToClipboard');
186
162
 
187
- act(() => {
188
- useChatStore.setState({
189
- activeId: 'session-id',
190
- activeTopicId: undefined,
191
- messagesMap: {
192
- [messageMapKey('session-id')]: [
193
- {
194
- id: messageId,
195
- role: 'assistant',
196
- tools: [{ id: 'tool1' }, { id: 'tool2' }],
197
- } as ChatMessage,
198
- { id: '2', parentId: messageId, tool_call_id: 'tool1', role: 'tool' } as ChatMessage,
199
- { id: '3', tool_call_id: 'tool2', role: 'tool' } as ChatMessage,
200
- ],
201
- },
202
- });
203
- });
204
163
  await act(async () => {
205
- await result.current.deleteToolMessage('2');
164
+ await result.current.copyMessage(messageId, content);
206
165
  });
207
166
 
208
- expect(removeMessageSpy).toHaveBeenCalled();
209
- expect(updateMessageSpy).toHaveBeenCalledWith('message-id', {
210
- tools: [{ id: 'tool2' }],
211
- });
212
- expect(result.current.refreshMessages).toHaveBeenCalled();
167
+ expect(copyToClipboardSpy).toHaveBeenCalledWith(content);
213
168
  });
214
- });
215
169
 
216
- describe('delAndRegenerateMessage', () => {
217
- it('should remove a message and create a new message', async () => {
218
- const { result } = renderHook(() => useChatStore());
170
+ it('should call internal_traceMessage with correct parameters', async () => {
219
171
  const messageId = 'message-id';
220
- const deleteMessageSpy = vi.spyOn(result.current, 'deleteMessage');
221
- const resendMessageSpy = vi.spyOn(result.current, 'internal_resendMessage');
172
+ const content = 'Test content';
173
+ const { result } = renderHook(() => useChatStore());
174
+ const internal_traceMessageSpy = vi.spyOn(result.current, 'internal_traceMessage');
222
175
 
223
- act(() => {
224
- useChatStore.setState({
225
- activeId: 'session-id',
226
- activeTopicId: undefined,
227
- messagesMap: {
228
- [messageMapKey('session-id')]: [
229
- { id: messageId, tools: [{ id: 'tool1' }, { id: 'tool2' }] } as ChatMessage,
230
- ],
231
- },
232
- });
233
- });
234
176
  await act(async () => {
235
- await result.current.delAndRegenerateMessage(messageId);
177
+ await result.current.copyMessage(messageId, content);
236
178
  });
237
179
 
238
- expect(deleteMessageSpy).toHaveBeenCalledWith(messageId);
239
- expect(resendMessageSpy).toHaveBeenCalled();
240
- expect(result.current.refreshMessages).toHaveBeenCalled();
180
+ expect(internal_traceMessageSpy).toHaveBeenCalledWith(messageId, {
181
+ eventType: TraceEventType.CopyMessage,
182
+ });
241
183
  });
242
184
  });
243
- describe('regenerateMessage', () => {
244
- it('should create a new message', async () => {
185
+
186
+ describe('deleteToolMessage', () => {
187
+ it('deleteMessage should remove a message by id', async () => {
245
188
  const { result } = renderHook(() => useChatStore());
246
189
  const messageId = 'message-id';
247
- const resendMessageSpy = vi.spyOn(result.current, 'internal_resendMessage');
190
+ const updateMessageSpy = vi.spyOn(messageService, 'updateMessage');
191
+ const removeMessageSpy = vi.spyOn(messageService, 'removeMessage');
248
192
 
249
193
  act(() => {
250
194
  useChatStore.setState({
@@ -254,18 +198,24 @@ describe('chatMessage actions', () => {
254
198
  [messageMapKey('session-id')]: [
255
199
  {
256
200
  id: messageId,
201
+ role: 'assistant',
257
202
  tools: [{ id: 'tool1' }, { id: 'tool2' }],
258
- traceId: 'abc',
259
203
  } as ChatMessage,
204
+ { id: '2', parentId: messageId, tool_call_id: 'tool1', role: 'tool' } as ChatMessage,
205
+ { id: '3', tool_call_id: 'tool2', role: 'tool' } as ChatMessage,
260
206
  ],
261
207
  },
262
208
  });
263
209
  });
264
210
  await act(async () => {
265
- await result.current.regenerateMessage(messageId);
211
+ await result.current.deleteToolMessage('2');
266
212
  });
267
213
 
268
- expect(resendMessageSpy).toHaveBeenCalledWith(messageId, 'abc');
214
+ expect(removeMessageSpy).toHaveBeenCalled();
215
+ expect(updateMessageSpy).toHaveBeenCalledWith('message-id', {
216
+ tools: [{ id: 'tool2' }],
217
+ });
218
+ expect(result.current.refreshMessages).toHaveBeenCalled();
269
219
  });
270
220
  });
271
221
 
@@ -293,35 +243,17 @@ describe('chatMessage actions', () => {
293
243
 
294
244
  expect(result.current.inputMessage).toEqual(newInputMessage);
295
245
  });
296
- });
297
-
298
- describe('copyMessage', () => {
299
- it('should call copyToClipboard with correct content', async () => {
300
- const messageId = 'message-id';
301
- const content = 'Test content';
302
- const { result } = renderHook(() => useChatStore());
303
- const copyToClipboardSpy = vi.spyOn(lobeUIModules, 'copyToClipboard');
304
-
305
- await act(async () => {
306
- await result.current.copyMessage(messageId, content);
307
- });
308
246
 
309
- expect(copyToClipboardSpy).toHaveBeenCalledWith(content);
310
- });
311
-
312
- it('should call internal_traceMessage with correct parameters', async () => {
313
- const messageId = 'message-id';
314
- const content = 'Test content';
247
+ it('should not update state if message is the same as current inputMessage', () => {
248
+ const inputMessage = 'Test input message';
249
+ useChatStore.setState({ inputMessage });
315
250
  const { result } = renderHook(() => useChatStore());
316
- const internal_traceMessageSpy = vi.spyOn(result.current, 'internal_traceMessage');
317
251
 
318
- await act(async () => {
319
- await result.current.copyMessage(messageId, content);
252
+ act(() => {
253
+ result.current.updateInputMessage(inputMessage);
320
254
  });
321
255
 
322
- expect(internal_traceMessageSpy).toHaveBeenCalledWith(messageId, {
323
- eventType: TraceEventType.CopyMessage,
324
- });
256
+ expect(result.current.inputMessage).toBe(inputMessage);
325
257
  });
326
258
  });
327
259
 
@@ -383,387 +315,7 @@ describe('chatMessage actions', () => {
383
315
  });
384
316
  });
385
317
 
386
- describe('sendMessage', () => {
387
- it('should not send message if there is no active session', async () => {
388
- useChatStore.setState({ activeId: undefined });
389
- const { result } = renderHook(() => useChatStore());
390
- const message = 'Test message';
391
-
392
- await act(async () => {
393
- await result.current.sendMessage({ message });
394
- });
395
-
396
- expect(messageService.createMessage).not.toHaveBeenCalled();
397
- expect(result.current.refreshMessages).not.toHaveBeenCalled();
398
- expect(result.current.internal_coreProcessMessage).not.toHaveBeenCalled();
399
- });
400
-
401
- it('should not send message if message is empty and there are no files', async () => {
402
- const { result } = renderHook(() => useChatStore());
403
- const message = '';
404
-
405
- await act(async () => {
406
- await result.current.sendMessage({ message });
407
- });
408
-
409
- expect(messageService.createMessage).not.toHaveBeenCalled();
410
- expect(result.current.refreshMessages).not.toHaveBeenCalled();
411
- expect(result.current.internal_coreProcessMessage).not.toHaveBeenCalled();
412
- });
413
-
414
- it('should not send message if message is empty and there are empty files', async () => {
415
- const { result } = renderHook(() => useChatStore());
416
- const message = '';
417
-
418
- await act(async () => {
419
- await result.current.sendMessage({ message, files: [] });
420
- });
421
-
422
- expect(messageService.createMessage).not.toHaveBeenCalled();
423
- expect(result.current.refreshMessages).not.toHaveBeenCalled();
424
- expect(result.current.internal_coreProcessMessage).not.toHaveBeenCalled();
425
- });
426
-
427
- it('should create message and call internal_coreProcessMessage if message or files are provided', async () => {
428
- const { result } = renderHook(() => useChatStore());
429
- const message = 'Test message';
430
- const files = [{ id: 'file-id' } as UploadFileItem];
431
-
432
- // Mock messageService.create to resolve with a message id
433
- (messageService.createMessage as Mock).mockResolvedValue('new-message-id');
434
-
435
- await act(async () => {
436
- await result.current.sendMessage({ message, files });
437
- });
438
-
439
- expect(messageService.createMessage).toHaveBeenCalledWith({
440
- content: message,
441
- files: files.map((f) => f.id),
442
- role: 'user',
443
- sessionId: mockState.activeId,
444
- topicId: mockState.activeTopicId,
445
- });
446
- expect(result.current.internal_coreProcessMessage).toHaveBeenCalled();
447
- });
448
-
449
- describe('auto-create topic', () => {
450
- it('should not auto-create topic if enableAutoCreateTopic is false', async () => {
451
- const { result } = renderHook(() => useChatStore());
452
- const message = 'Test message';
453
- const autoCreateTopicThreshold = 5;
454
- const enableAutoCreateTopic = false;
455
-
456
- // Mock messageService.create to resolve with a message id
457
- (messageService.createMessage as Mock).mockResolvedValue('new-message-id');
458
-
459
- // Mock agent config to simulate auto-create topic behavior
460
- (agentSelectors.currentAgentConfig as Mock).mockImplementation(() => ({
461
- autoCreateTopicThreshold,
462
- enableAutoCreateTopic,
463
- }));
464
-
465
- // Mock saveToTopic and switchTopic to simulate not being called
466
- const saveToTopicMock = vi.fn();
467
- const switchTopicMock = vi.fn();
468
-
469
- await act(async () => {
470
- useChatStore.setState({
471
- ...mockState,
472
- // Mock the currentChats selector to return a list that does not reach the threshold
473
- messagesMap: {
474
- [messageMapKey('session-id')]: Array.from(
475
- { length: autoCreateTopicThreshold + 1 },
476
- (_, i) => ({
477
- id: `msg-${i}`,
478
- }),
479
- ) as any,
480
- },
481
- activeTopicId: undefined,
482
- saveToTopic: saveToTopicMock,
483
- switchTopic: switchTopicMock,
484
- });
485
-
486
- await result.current.sendMessage({ message });
487
- });
488
-
489
- expect(saveToTopicMock).not.toHaveBeenCalled();
490
- expect(switchTopicMock).not.toHaveBeenCalled();
491
- });
492
-
493
- it('should auto-create topic and switch to it if enabled and threshold is reached', async () => {
494
- const { result } = renderHook(() => useChatStore());
495
- const message = 'Test message';
496
- const autoCreateTopicThreshold = 5;
497
- const enableAutoCreateTopic = true;
498
-
499
- // Mock agent config to simulate auto-create topic behavior
500
- (agentSelectors.currentAgentConfig as Mock).mockImplementation(() => ({
501
- autoCreateTopicThreshold,
502
- enableAutoCreateTopic,
503
- }));
504
-
505
- // Mock messageService.create to resolve with a message id
506
- (messageService.createMessage as Mock).mockResolvedValue('new-message-id');
507
-
508
- // Mock saveToTopic to resolve with a topic id and switchTopic to switch to the new topic
509
- const createTopicMock = vi.fn(() => Promise.resolve('new-topic-id'));
510
- const switchTopicMock = vi.fn();
511
-
512
- act(() => {
513
- useChatStore.setState({
514
- ...mockState,
515
- activeId: 'session_id',
516
- messagesMap: {
517
- [messageMapKey('session_id')]: Array.from(
518
- { length: autoCreateTopicThreshold },
519
- (_, i) => ({
520
- id: `msg-${i}`,
521
- }),
522
- ) as any,
523
- },
524
- activeTopicId: undefined,
525
- createTopic: createTopicMock,
526
- switchTopic: switchTopicMock,
527
- });
528
- });
529
-
530
- await act(async () => {
531
- await result.current.sendMessage({ message });
532
- });
533
-
534
- expect(createTopicMock).toHaveBeenCalled();
535
- expect(switchTopicMock).toHaveBeenCalledWith('new-topic-id', true);
536
- });
537
-
538
- it('should not auto-create topic, if autoCreateTopic = false and reached topic threshold', async () => {
539
- const { result } = renderHook(() => useChatStore());
540
- act(() => {
541
- useAgentStore.setState({
542
- activeId: 'abc',
543
- agentMap: {
544
- abc: {
545
- chatConfig: {
546
- enableAutoCreateTopic: false,
547
- autoCreateTopicThreshold: 1,
548
- },
549
- },
550
- },
551
- });
552
-
553
- useChatStore.setState({
554
- // Mock the currentChats selector to return a list that does not reach the threshold
555
- messagesMap: {
556
- [messageMapKey('inbox')]: [{ id: '1' }, { id: '2' }] as ChatMessage[],
557
- },
558
- activeTopicId: 'inbox',
559
- });
560
- });
561
-
562
- await act(async () => {
563
- await result.current.sendMessage({ message: 'test' });
564
- });
565
-
566
- expect(topicService.createTopic).not.toHaveBeenCalled();
567
- });
568
-
569
- it('should not auto-create topic if autoCreateTopicThreshold is not reached', async () => {
570
- const { result } = renderHook(() => useChatStore());
571
- const message = 'Test message';
572
- const autoCreateTopicThreshold = 5;
573
- const enableAutoCreateTopic = true;
574
-
575
- // Mock messageService.create to resolve with a message id
576
- (messageService.createMessage as Mock).mockResolvedValue('new-message-id');
577
-
578
- // Mock agent config to simulate auto-create topic behavior
579
- (agentSelectors.currentAgentChatConfig as Mock).mockImplementation(() => ({
580
- autoCreateTopicThreshold,
581
- enableAutoCreateTopic,
582
- }));
583
-
584
- // Mock saveToTopic and switchTopic to simulate not being called
585
- const createTopicMock = vi.fn();
586
- const switchTopicMock = vi.fn();
587
-
588
- await act(async () => {
589
- useChatStore.setState({
590
- ...mockState,
591
- activeId: 'session_id',
592
- messagesMap: {
593
- // Mock the currentChats selector to return a list that does not reach the threshold
594
- [messageMapKey('session_id')]: Array.from(
595
- { length: autoCreateTopicThreshold - 3 },
596
- (_, i) => ({
597
- id: `msg-${i}`,
598
- }),
599
- ) as any,
600
- },
601
- activeTopicId: undefined,
602
- createTopic: createTopicMock,
603
- switchTopic: switchTopicMock,
604
- });
605
-
606
- await result.current.sendMessage({ message });
607
- });
608
-
609
- expect(createTopicMock).not.toHaveBeenCalled();
610
- expect(switchTopicMock).not.toHaveBeenCalled();
611
- });
612
- });
613
-
614
- it('should add user message and not call internal_coreProcessMessage if onlyAddUserMessage = true', async () => {
615
- const { result } = renderHook(() => useChatStore());
616
-
617
- await act(async () => {
618
- await result.current.sendMessage({ message: 'test', onlyAddUserMessage: true });
619
- });
620
-
621
- expect(messageService.createMessage).toHaveBeenCalled();
622
- expect(result.current.internal_coreProcessMessage).not.toHaveBeenCalled();
623
- });
624
-
625
- it('当 isWelcomeQuestion 为 true 时,正确地传递给 internal_coreProcessMessage', async () => {
626
- const { result } = renderHook(() => useChatStore());
627
-
628
- await act(async () => {
629
- await result.current.sendMessage({ message: 'test', isWelcomeQuestion: true });
630
- });
631
-
632
- expect(result.current.internal_coreProcessMessage).toHaveBeenCalledWith(
633
- expect.anything(),
634
- expect.anything(),
635
- { isWelcomeQuestion: true },
636
- );
637
- });
638
-
639
- it('当只有文件而没有消息内容时,正确发送消息', async () => {
640
- const { result } = renderHook(() => useChatStore());
641
-
642
- await act(async () => {
643
- await result.current.sendMessage({ message: '', files: [{ id: 'file-1' }] as any });
644
- });
645
-
646
- expect(messageService.createMessage).toHaveBeenCalledWith({
647
- content: '',
648
- files: ['file-1'],
649
- role: 'user',
650
- sessionId: 'session-id',
651
- topicId: 'topic-id',
652
- });
653
- });
654
-
655
- it('当同时有文件和消息内容时,正确发送消息并关联文件', async () => {
656
- const { result } = renderHook(() => useChatStore());
657
-
658
- await act(async () => {
659
- await result.current.sendMessage({ message: 'test', files: [{ id: 'file-1' }] as any });
660
- });
661
-
662
- expect(messageService.createMessage).toHaveBeenCalledWith({
663
- content: 'test',
664
- files: ['file-1'],
665
- role: 'user',
666
- sessionId: 'session-id',
667
- topicId: 'topic-id',
668
- });
669
- });
670
-
671
- it('当 createMessage 抛出错误时,正确处理错误而不影响整个应用', async () => {
672
- const { result } = renderHook(() => useChatStore());
673
- vi.spyOn(messageService, 'createMessage').mockRejectedValue(
674
- new Error('create message error'),
675
- );
676
-
677
- await expect(result.current.sendMessage({ message: 'test' })).rejects.toThrow(
678
- 'create message error',
679
- );
680
-
681
- expect(result.current.internal_coreProcessMessage).not.toHaveBeenCalled();
682
- });
683
-
684
- // it('自动创建主题成功后,正确地将消息复制到新主题,并删除之前的临时消息', async () => {
685
- // const { result } = renderHook(() => useChatStore());
686
- // act(() => {
687
- // useAgentStore.setState({
688
- // agentConfig: { enableAutoCreateTopic: true, autoCreateTopicThreshold: 1 },
689
- // });
690
- //
691
- // useChatStore.setState({
692
- // // Mock the currentChats selector to return a list that does not reach the threshold
693
- // messagesMap: {
694
- // [messageMapKey('inbox')]: [{ id: '1' }, { id: '2' }] as ChatMessage[],
695
- // },
696
- // activeId: 'inbox',
697
- // });
698
- // });
699
- // vi.spyOn(topicService, 'createTopic').mockResolvedValue('new-topic');
700
- //
701
- // await act(async () => {
702
- // await result.current.sendMessage({ message: 'test' });
703
- // });
704
- //
705
- // expect(result.current.messagesMap[messageMapKey('inbox')]).toEqual([
706
- // // { id: '1' },
707
- // // { id: '2' },
708
- // // { id: 'temp-id', content: 'test', role: 'user' },
709
- // ]);
710
- // // expect(result.current.getMessages('session-id')).toEqual([]);
711
- // });
712
-
713
- // it('自动创建主题失败时,正确地处理错误,不会影响后续的消息发送', async () => {
714
- // const { result } = renderHook(() => useChatStore());
715
- // result.current.setAgentConfig({ enableAutoCreateTopic: true, autoCreateTopicThreshold: 1 });
716
- // result.current.setMessages([{ id: '1' }, { id: '2' }] as any);
717
- // vi.spyOn(topicService, 'createTopic').mockRejectedValue(new Error('create topic error'));
718
- //
719
- // await act(async () => {
720
- // await result.current.sendMessage({ message: 'test' });
721
- // });
722
- //
723
- // expect(result.current.getMessages('session-id')).toEqual([
724
- // { id: '1' },
725
- // { id: '2' },
726
- // { id: 'new-message-id', content: 'test', role: 'user' },
727
- // ]);
728
- // });
729
-
730
- // it('当 activeTopicId 不存在且 autoCreateTopic 为 true,但消息数量未达到阈值时,正确地总结主题标题', async () => {
731
- // const { result } = renderHook(() => useChatStore());
732
- // result.current.setAgentConfig({ enableAutoCreateTopic: true, autoCreateTopicThreshold: 10 });
733
- // result.current.setMessages([{ id: '1' }, { id: '2' }] as any);
734
- // result.current.setActiveTopic({ id: 'topic-1', title: '' });
735
- //
736
- // await act(async () => {
737
- // await result.current.sendMessage({ message: 'test' });
738
- // });
739
- //
740
- // expect(result.current.summaryTopicTitle).toHaveBeenCalledWith('topic-1', [
741
- // { id: '1' },
742
- // { id: '2' },
743
- // { id: 'new-message-id', content: 'test', role: 'user' },
744
- // { id: 'assistant-message', role: 'assistant' },
745
- // ]);
746
- // });
747
- //
748
- // it('当 activeTopicId 存在且主题标题为空时,正确地总结主题标题', async () => {
749
- // const { result } = renderHook(() => useChatStore());
750
- // result.current.setActiveTopic({ id: 'topic-1', title: '' });
751
- // result.current.setMessages([{ id: '1' }, { id: '2' }] as any, 'session-id', 'topic-1');
752
- //
753
- // await act(async () => {
754
- // await result.current.sendMessage({ message: 'test' });
755
- // });
756
- //
757
- // expect(result.current.summaryTopicTitle).toHaveBeenCalledWith('topic-1', [
758
- // { id: '1' },
759
- // { id: '2' },
760
- // { id: 'new-message-id', content: 'test', role: 'user' },
761
- // { id: 'assistant-message', role: 'assistant' },
762
- // ]);
763
- // });
764
- });
765
-
766
- describe('toggleMessageEditing action', () => {
318
+ describe('toggleMessageEditing ', () => {
767
319
  it('should add message id to messageEditingIds when editing is true', () => {
768
320
  const { result } = renderHook(() => useChatStore());
769
321
  const messageId = 'message-id';
@@ -786,67 +338,32 @@ describe('chatMessage actions', () => {
786
338
 
787
339
  expect(result.current.messageEditingIds).not.toContain(messageId);
788
340
  });
789
- });
790
341
 
791
- describe('internal_resendMessage action', () => {
792
- it('should resend a message by id and refresh messages', async () => {
793
- const { result } = renderHook(() => useChatStore());
342
+ it('should update messageEditingIds correctly when enabling editing', () => {
794
343
  const messageId = 'message-id';
344
+ const { result } = renderHook(() => useChatStore());
795
345
 
796
346
  act(() => {
797
- useChatStore.setState({
798
- activeId: 'session-id',
799
- activeTopicId: undefined,
800
- // Mock the currentChats selector to return a list that includes the message to be resent
801
- messagesMap: {
802
- [messageMapKey('session-id')]: [
803
- { id: messageId, role: 'user', content: 'Resend this message' } as ChatMessage,
804
- ],
805
- },
806
- });
807
- });
808
-
809
- // Mock the internal_coreProcessMessage function to resolve immediately
810
- mockState.internal_coreProcessMessage.mockResolvedValue(undefined);
811
-
812
- await act(async () => {
813
- await result.current.internal_resendMessage(messageId);
347
+ result.current.toggleMessageEditing(messageId, true);
814
348
  });
815
349
 
816
- expect(messageService.removeMessage).not.toHaveBeenCalledWith(messageId);
817
- expect(mockState.internal_coreProcessMessage).toHaveBeenCalledWith(
818
- expect.any(Array),
819
- messageId,
820
- {},
821
- );
350
+ expect(result.current.messageEditingIds).toContain(messageId);
822
351
  });
823
352
 
824
- it('should not perform any action if the message id does not exist', async () => {
353
+ it('should update messageEditingIds correctly when disabling editing', () => {
354
+ const messageId = 'message-id';
355
+ useChatStore.setState({ messageEditingIds: [messageId] });
825
356
  const { result } = renderHook(() => useChatStore());
826
- const messageId = 'non-existing-message-id';
827
357
 
828
358
  act(() => {
829
- useChatStore.setState({
830
- activeId: 'session-id',
831
- activeTopicId: undefined,
832
- // Mock the currentChats selector to return a list that does not include the message to be resent
833
- messagesMap: {
834
- [messageMapKey('session-id')]: [],
835
- },
836
- });
837
- });
838
-
839
- await act(async () => {
840
- await result.current.internal_resendMessage(messageId);
359
+ result.current.toggleMessageEditing(messageId, false);
841
360
  });
842
361
 
843
- expect(messageService.removeMessage).not.toHaveBeenCalledWith(messageId);
844
- expect(mockState.internal_coreProcessMessage).not.toHaveBeenCalled();
845
- expect(mockState.refreshMessages).not.toHaveBeenCalled();
362
+ expect(result.current.messageEditingIds).not.toContain(messageId);
846
363
  });
847
364
  });
848
365
 
849
- describe('internal_updateMessageContent action', () => {
366
+ describe('internal_updateMessageContent', () => {
850
367
  it('should call messageService.internal_updateMessageContent with correct parameters', async () => {
851
368
  const { result } = renderHook(() => useChatStore());
852
369
  const messageId = 'message-id';
@@ -889,113 +406,6 @@ describe('chatMessage actions', () => {
889
406
  });
890
407
  });
891
408
 
892
- describe('internal_coreProcessMessage action', () => {
893
- it('should handle the core AI message processing', async () => {
894
- useChatStore.setState({ internal_coreProcessMessage: realCoreProcessMessage });
895
-
896
- const { result } = renderHook(() => useChatStore());
897
- const userMessage = {
898
- id: 'user-message-id',
899
- role: 'user',
900
- content: 'Hello, world!',
901
- sessionId: mockState.activeId,
902
- topicId: mockState.activeTopicId,
903
- } as ChatMessage;
904
- const messages = [userMessage];
905
-
906
- // 模拟 AI 响应
907
- const aiResponse = 'Hello, human!';
908
- (chatService.createAssistantMessage as Mock).mockResolvedValue(aiResponse);
909
- const spy = vi.spyOn(chatService, 'createAssistantMessageStream');
910
- // 模拟消息创建
911
- (messageService.createMessage as Mock).mockResolvedValue('assistant-message-id');
912
-
913
- await act(async () => {
914
- await result.current.internal_coreProcessMessage(messages, userMessage.id);
915
- });
916
-
917
- // 验证是否创建了代表 AI 响应的消息
918
- expect(messageService.createMessage).toHaveBeenCalledWith(
919
- expect.objectContaining({
920
- role: 'assistant',
921
- content: LOADING_FLAT,
922
- fromModel: expect.anything(),
923
- parentId: userMessage.id,
924
- sessionId: mockState.activeId,
925
- topicId: mockState.activeTopicId,
926
- }),
927
- );
928
-
929
- // 验证 AI 服务是否被调用
930
- expect(spy).toHaveBeenCalled();
931
-
932
- // 验证消息列表是否刷新
933
- expect(mockState.refreshMessages).toHaveBeenCalled();
934
- });
935
- });
936
-
937
- describe('stopGenerateMessage action', () => {
938
- it('should stop generating message and set loading states correctly', async () => {
939
- const { result } = renderHook(() => useChatStore());
940
- const internal_toggleChatLoadingSpy = vi.spyOn(result.current, 'internal_toggleChatLoading');
941
- const abortController = new AbortController();
942
-
943
- act(() => {
944
- useChatStore.setState({ abortController });
945
- });
946
-
947
- await act(async () => {
948
- result.current.stopGenerateMessage();
949
- });
950
-
951
- expect(abortController.signal.aborted).toBe(true);
952
- expect(internal_toggleChatLoadingSpy).toHaveBeenCalledWith(
953
- false,
954
- undefined,
955
- expect.any(String),
956
- );
957
- });
958
-
959
- it('should not do anything if there is no abortController', async () => {
960
- const { result } = renderHook(() => useChatStore());
961
-
962
- await act(async () => {
963
- // 确保没有设置 abortController
964
- useChatStore.setState({ abortController: undefined });
965
-
966
- result.current.stopGenerateMessage();
967
- });
968
-
969
- // 由于没有 abortController,不应调用任何方法
970
- expect(result.current.abortController).toBeUndefined();
971
- });
972
- });
973
-
974
- describe('toggleMessageEditing', () => {
975
- it('should update messageEditingIds correctly when enabling editing', () => {
976
- const messageId = 'message-id';
977
- const { result } = renderHook(() => useChatStore());
978
-
979
- act(() => {
980
- result.current.toggleMessageEditing(messageId, true);
981
- });
982
-
983
- expect(result.current.messageEditingIds).toContain(messageId);
984
- });
985
-
986
- it('should update messageEditingIds correctly when disabling editing', () => {
987
- const messageId = 'message-id';
988
- useChatStore.setState({ messageEditingIds: [messageId] });
989
- const { result } = renderHook(() => useChatStore());
990
-
991
- act(() => {
992
- result.current.toggleMessageEditing(messageId, false);
993
- });
994
-
995
- expect(result.current.messageEditingIds).not.toContain(messageId);
996
- });
997
- });
998
-
999
409
  describe('refreshMessages action', () => {
1000
410
  beforeEach(() => {
1001
411
  vi.mock('swr', async () => {
@@ -1065,181 +475,7 @@ describe('chatMessage actions', () => {
1065
475
  });
1066
476
  });
1067
477
 
1068
- describe('internal_fetchAIChatMessage', () => {
1069
- it('should fetch AI chat message and return content', async () => {
1070
- const { result } = renderHook(() => useChatStore());
1071
- const messages = [{ id: 'message-id', content: 'Hello', role: 'user' }] as ChatMessage[];
1072
- const assistantMessageId = 'assistant-message-id';
1073
- const aiResponse = 'Hello, human!';
1074
-
1075
- (fetch as Mock).mockResolvedValueOnce(new Response(aiResponse));
1076
-
1077
- await act(async () => {
1078
- const response = await result.current.internal_fetchAIChatMessage(
1079
- messages,
1080
- assistantMessageId,
1081
- );
1082
- expect(response.isFunctionCall).toEqual(false);
1083
- });
1084
- });
1085
-
1086
- it('should handle errors during AI response fetching', async () => {
1087
- const { result } = renderHook(() => useChatStore());
1088
- const messages = [{ id: 'message-id', content: 'Hello', role: 'user' }] as ChatMessage[];
1089
- const assistantMessageId = 'assistant-message-id';
1090
-
1091
- // Mock fetch to reject with an error
1092
- const errorMessage = 'Error fetching AI response';
1093
- vi.mocked(fetch).mockRejectedValueOnce(new Error(errorMessage));
1094
-
1095
- await act(async () => {
1096
- expect(
1097
- await result.current.internal_fetchAIChatMessage(messages, assistantMessageId),
1098
- ).toEqual({
1099
- isFunctionCall: false,
1100
- });
1101
- });
1102
- });
1103
-
1104
- it('should generate correct contextMessages for "user" role', async () => {
1105
- const messageId = 'message-id';
1106
- const messages = [
1107
- { id: 'msg-1', role: 'system' },
1108
- { id: messageId, role: 'user', meta: { avatar: '😀' } },
1109
- { id: 'msg-3', role: 'assistant' },
1110
- ];
1111
- act(() => {
1112
- useChatStore.setState({
1113
- messagesMap: {
1114
- [chatSelectors.currentChatKey(mockState as any)]: messages as ChatMessage[],
1115
- },
1116
- });
1117
- });
1118
- const { result } = renderHook(() => useChatStore());
1119
-
1120
- await act(async () => {
1121
- await result.current.internal_resendMessage(messageId);
1122
- });
1123
-
1124
- expect(result.current.internal_coreProcessMessage).toHaveBeenCalledWith(
1125
- messages.slice(0, 2),
1126
- messageId,
1127
- { traceId: undefined },
1128
- );
1129
- });
1130
-
1131
- it('should generate correct contextMessages for "assistant" role', async () => {
1132
- const messageId = 'message-id';
1133
- const messages = [
1134
- { id: 'msg-1', role: 'system' },
1135
- { id: 'msg-2', role: 'user', meta: { avatar: '😀' } },
1136
- { id: messageId, role: 'assistant', parentId: 'msg-2' },
1137
- ];
1138
- useChatStore.setState({
1139
- messagesMap: {
1140
- [chatSelectors.currentChatKey(mockState as any)]: messages as ChatMessage[],
1141
- },
1142
- });
1143
- const { result } = renderHook(() => useChatStore());
1144
-
1145
- await act(async () => {
1146
- await result.current.internal_resendMessage(messageId);
1147
- });
1148
-
1149
- expect(result.current.internal_coreProcessMessage).toHaveBeenCalledWith(
1150
- messages.slice(0, 2),
1151
- 'msg-2',
1152
- { traceId: undefined },
1153
- );
1154
- });
1155
-
1156
- it('should return early if contextMessages is empty', async () => {
1157
- const messageId = 'message-id';
1158
- useChatStore.setState({
1159
- messagesMap: { [chatSelectors.currentChatKey(mockState as any)]: [] },
1160
- });
1161
- const { result } = renderHook(() => useChatStore());
1162
-
1163
- await act(async () => {
1164
- await result.current.internal_resendMessage(messageId);
1165
- });
1166
-
1167
- expect(result.current.internal_coreProcessMessage).not.toHaveBeenCalled();
1168
- });
1169
- });
1170
-
1171
- describe('internal_toggleChatLoading', () => {
1172
- it('should set loading state and create an AbortController when loading is true', () => {
1173
- const { result } = renderHook(() => useChatStore());
1174
- const action = 'loading-action';
1175
-
1176
- act(() => {
1177
- result.current.internal_toggleChatLoading(true, 'message-id', action);
1178
- });
1179
-
1180
- const state = useChatStore.getState();
1181
- expect(state.abortController).toBeInstanceOf(AbortController);
1182
- expect(state.chatLoadingIds).toEqual(['message-id']);
1183
- });
1184
-
1185
- it('should clear loading state and abort controller when loading is false', () => {
1186
- const { result } = renderHook(() => useChatStore());
1187
- const action = 'stop-loading-action';
1188
-
1189
- // Set initial loading state
1190
- act(() => {
1191
- result.current.internal_toggleChatLoading(true, 'message-id', 'start-loading-action');
1192
- });
1193
-
1194
- // Stop loading
1195
- act(() => {
1196
- result.current.internal_toggleChatLoading(false, undefined, action);
1197
- });
1198
-
1199
- const state = useChatStore.getState();
1200
- expect(state.abortController).toBeUndefined();
1201
- expect(state.chatLoadingIds).toEqual([]);
1202
- });
1203
-
1204
- it('should attach beforeunload event listener when loading starts', () => {
1205
- const { result } = renderHook(() => useChatStore());
1206
- const addEventListenerSpy = vi.spyOn(window, 'addEventListener');
1207
-
1208
- act(() => {
1209
- result.current.internal_toggleChatLoading(true, 'message-id', 'loading-action');
1210
- });
1211
-
1212
- expect(addEventListenerSpy).toHaveBeenCalledWith('beforeunload', expect.any(Function));
1213
- });
1214
-
1215
- it('should remove beforeunload event listener when loading stops', () => {
1216
- const { result } = renderHook(() => useChatStore());
1217
- const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');
1218
-
1219
- // Start and then stop loading to trigger the removal of the event listener
1220
- act(() => {
1221
- result.current.internal_toggleChatLoading(true, 'message-id', 'start-loading-action');
1222
- result.current.internal_toggleChatLoading(false, undefined, 'stop-loading-action');
1223
- });
1224
-
1225
- expect(removeEventListenerSpy).toHaveBeenCalledWith('beforeunload', expect.any(Function));
1226
- });
1227
-
1228
- it('should not create a new AbortController if one already exists', () => {
1229
- const { result } = renderHook(() => useChatStore());
1230
- const abortController = new AbortController();
1231
-
1232
- act(() => {
1233
- useChatStore.setState({ abortController });
1234
- result.current.internal_toggleChatLoading(true, 'message-id', 'loading-action');
1235
- });
1236
-
1237
- const state = useChatStore.getState();
1238
- expect(state.abortController).toEqual(abortController);
1239
- });
1240
- });
1241
-
1242
- describe('internal_toggleMessageLoading action', () => {
478
+ describe('internal_toggleMessageLoading', () => {
1243
479
  it('should add message id to messageLoadingIds when loading is true', () => {
1244
480
  const { result } = renderHook(() => useChatStore());
1245
481
  const messageId = 'message-id';
@@ -1264,93 +500,6 @@ describe('chatMessage actions', () => {
1264
500
  });
1265
501
  });
1266
502
 
1267
- describe('internal_toggleToolCallingStreaming action', () => {
1268
- it('should add message id to messageLoadingIds when loading is true', () => {
1269
- const { result } = renderHook(() => useChatStore());
1270
- const messageId = 'message-id';
1271
-
1272
- act(() => {
1273
- result.current.internal_toggleToolCallingStreaming(messageId, [true]);
1274
- });
1275
-
1276
- expect(result.current.toolCallingStreamIds[messageId]).toEqual([true]);
1277
- });
1278
-
1279
- it('should remove message id from messageLoadingIds when loading is false', () => {
1280
- const { result } = renderHook(() => useChatStore());
1281
- const messageId = 'ddd-id';
1282
-
1283
- act(() => {
1284
- result.current.internal_toggleToolCallingStreaming(messageId, [true]);
1285
- result.current.internal_toggleToolCallingStreaming(messageId, undefined);
1286
- });
1287
-
1288
- expect(result.current.toolCallingStreamIds[messageId]).toBeUndefined();
1289
- });
1290
- });
1291
-
1292
- describe('stopGenerateMessage', () => {
1293
- it('should return early if abortController is undefined', () => {
1294
- act(() => {
1295
- useChatStore.setState({ abortController: undefined });
1296
- });
1297
-
1298
- const { result } = renderHook(() => useChatStore());
1299
-
1300
- const spy = vi.spyOn(result.current, 'internal_toggleChatLoading');
1301
-
1302
- act(() => {
1303
- result.current.stopGenerateMessage();
1304
- });
1305
-
1306
- expect(spy).not.toHaveBeenCalled();
1307
- });
1308
-
1309
- it('should call abortController.abort()', () => {
1310
- const abortMock = vi.fn();
1311
- const abortController = { abort: abortMock } as unknown as AbortController;
1312
- act(() => {
1313
- useChatStore.setState({ abortController });
1314
- });
1315
- const { result } = renderHook(() => useChatStore());
1316
-
1317
- act(() => {
1318
- result.current.stopGenerateMessage();
1319
- });
1320
-
1321
- expect(abortMock).toHaveBeenCalled();
1322
- });
1323
-
1324
- it('should call internal_toggleChatLoading with correct parameters', () => {
1325
- const abortController = new AbortController();
1326
- act(() => {
1327
- useChatStore.setState({ abortController });
1328
- });
1329
- const { result } = renderHook(() => useChatStore());
1330
- const spy = vi.spyOn(result.current, 'internal_toggleChatLoading');
1331
-
1332
- act(() => {
1333
- result.current.stopGenerateMessage();
1334
- });
1335
-
1336
- expect(spy).toHaveBeenCalledWith(false, undefined, expect.any(String));
1337
- });
1338
- });
1339
-
1340
- describe('updateInputMessage', () => {
1341
- it('should not update state if message is the same as current inputMessage', () => {
1342
- const inputMessage = 'Test input message';
1343
- useChatStore.setState({ inputMessage });
1344
- const { result } = renderHook(() => useChatStore());
1345
-
1346
- act(() => {
1347
- result.current.updateInputMessage(inputMessage);
1348
- });
1349
-
1350
- expect(result.current.inputMessage).toBe(inputMessage);
1351
- });
1352
- });
1353
-
1354
503
  describe('modifyMessageContent', () => {
1355
504
  it('should call internal_traceMessage with correct parameters before updating', async () => {
1356
505
  const messageId = 'message-id';