@lobehub/chat 1.124.0 → 1.124.1

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.
Files changed (48) hide show
  1. package/.env.example +5 -0
  2. package/CHANGELOG.md +33 -0
  3. package/Dockerfile +2 -0
  4. package/Dockerfile.database +2 -0
  5. package/Dockerfile.pglite +2 -0
  6. package/changelog/v1.json +12 -0
  7. package/docs/self-hosting/environment-variables/model-provider.mdx +18 -0
  8. package/docs/self-hosting/environment-variables/model-provider.zh-CN.mdx +20 -0
  9. package/locales/ar/chat.json +2 -0
  10. package/locales/bg-BG/chat.json +2 -0
  11. package/locales/de-DE/chat.json +2 -0
  12. package/locales/en-US/chat.json +2 -0
  13. package/locales/es-ES/chat.json +2 -0
  14. package/locales/fa-IR/chat.json +2 -0
  15. package/locales/fr-FR/chat.json +2 -0
  16. package/locales/it-IT/chat.json +2 -0
  17. package/locales/ja-JP/chat.json +2 -0
  18. package/locales/ko-KR/chat.json +2 -0
  19. package/locales/nl-NL/chat.json +2 -0
  20. package/locales/pl-PL/chat.json +2 -0
  21. package/locales/pt-BR/chat.json +2 -0
  22. package/locales/ru-RU/chat.json +2 -0
  23. package/locales/tr-TR/chat.json +2 -0
  24. package/locales/vi-VN/chat.json +2 -0
  25. package/locales/zh-CN/chat.json +2 -0
  26. package/locales/zh-CN/modelProvider.json +1 -1
  27. package/locales/zh-TW/chat.json +2 -0
  28. package/package.json +1 -1
  29. package/packages/model-bank/src/aiModels/aihubmix.ts +38 -4
  30. package/packages/model-bank/src/aiModels/groq.ts +26 -8
  31. package/packages/model-bank/src/aiModels/hunyuan.ts +3 -3
  32. package/packages/model-bank/src/aiModels/modelscope.ts +13 -2
  33. package/packages/model-bank/src/aiModels/moonshot.ts +25 -5
  34. package/packages/model-bank/src/aiModels/novita.ts +40 -9
  35. package/packages/model-bank/src/aiModels/openrouter.ts +0 -13
  36. package/packages/model-bank/src/aiModels/qwen.ts +62 -1
  37. package/packages/model-bank/src/aiModels/siliconcloud.ts +20 -0
  38. package/packages/model-bank/src/aiModels/volcengine.ts +141 -15
  39. package/packages/model-runtime/src/newapi/index.test.ts +49 -42
  40. package/packages/model-runtime/src/newapi/index.ts +124 -143
  41. package/src/app/[variants]/(main)/settings/provider/(detail)/newapi/page.tsx +1 -1
  42. package/src/config/llm.ts +8 -0
  43. package/src/features/ChatInput/Desktop/index.tsx +16 -4
  44. package/src/locales/default/chat.ts +1 -0
  45. package/src/locales/default/modelProvider.ts +1 -1
  46. package/src/store/chat/slices/aiChat/actions/__tests__/cancel-functionality.test.ts +107 -0
  47. package/src/store/chat/slices/aiChat/actions/__tests__/generateAIChatV2.test.ts +352 -7
  48. package/src/store/chat/slices/aiChat/actions/generateAIChatV2.ts +2 -1
package/src/config/llm.ts CHANGED
@@ -186,6 +186,10 @@ export const getLLMConfig = () => {
186
186
 
187
187
  ENABLED_AIHUBMIX: z.boolean(),
188
188
  AIHUBMIX_API_KEY: z.string().optional(),
189
+
190
+ ENABLED_NEWAPI: z.boolean(),
191
+ NEWAPI_API_KEY: z.string().optional(),
192
+ NEWAPI_PROXY_URL: z.string().optional(),
189
193
  },
190
194
  runtimeEnv: {
191
195
  API_KEY_SELECT_MODE: process.env.API_KEY_SELECT_MODE,
@@ -368,6 +372,10 @@ export const getLLMConfig = () => {
368
372
  ENABLED_AIHUBMIX: !!process.env.AIHUBMIX_API_KEY,
369
373
  AIHUBMIX_API_KEY: process.env.AIHUBMIX_API_KEY,
370
374
 
375
+ ENABLED_NEWAPI: !!process.env.NEWAPI_API_KEY,
376
+ NEWAPI_API_KEY: process.env.NEWAPI_API_KEY,
377
+ NEWAPI_PROXY_URL: process.env.NEWAPI_PROXY_URL,
378
+
371
379
  ENABLED_NEBIUS: !!process.env.NEBIUS_API_KEY,
372
380
  NEBIUS_API_KEY: process.env.NEBIUS_API_KEY,
373
381
  },
@@ -1,9 +1,11 @@
1
1
  'use client';
2
2
 
3
3
  import { ChatInput, ChatInputActionBar } from '@lobehub/editor/react';
4
+ import { Text } from '@lobehub/ui';
4
5
  import { createStyles } from 'antd-style';
5
6
  import { memo, useEffect } from 'react';
6
- import { Flexbox } from 'react-layout-kit';
7
+ import { useTranslation } from 'react-i18next';
8
+ import { Center, Flexbox } from 'react-layout-kit';
7
9
 
8
10
  import { useChatInputStore } from '@/features/ChatInput/store';
9
11
  import { useChatStore } from '@/store/chat';
@@ -12,7 +14,6 @@ import { chatSelectors } from '@/store/chat/selectors';
12
14
  import ActionBar from '../ActionBar';
13
15
  import InputEditor from '../InputEditor';
14
16
  import SendArea from '../SendArea';
15
- import ShortcutHint from '../SendArea/ShortcutHint';
16
17
  import TypoBar from '../TypoBar';
17
18
  import FilePreview from './FilePreview';
18
19
 
@@ -28,6 +29,9 @@ const useStyles = createStyles(({ css, token }) => ({
28
29
  }
29
30
  }
30
31
  `,
32
+ footnote: css`
33
+ font-size: 10px;
34
+ `,
31
35
  fullscreen: css`
32
36
  position: absolute;
33
37
  z-index: 100;
@@ -42,6 +46,7 @@ const useStyles = createStyles(({ css, token }) => ({
42
46
  }));
43
47
 
44
48
  const DesktopChatInput = memo<{ showFootnote?: boolean }>(({ showFootnote }) => {
49
+ const { t } = useTranslation('chat');
45
50
  const [slashMenuRef, expand, showTypoBar, editor, leftActions] = useChatInputStore((s) => [
46
51
  s.slashMenuRef,
47
52
  s.expand,
@@ -65,7 +70,8 @@ const DesktopChatInput = memo<{ showFootnote?: boolean }>(({ showFootnote }) =>
65
70
  {!expand && fileNode}
66
71
  <Flexbox
67
72
  className={cx(styles.container, expand && styles.fullscreen)}
68
- paddingBlock={showFootnote ? 0 : '0 12px'}
73
+ gap={8}
74
+ paddingBlock={showFootnote ? '0 8px' : '0 12px'}
69
75
  paddingInline={12}
70
76
  >
71
77
  <ChatInput
@@ -85,7 +91,13 @@ const DesktopChatInput = memo<{ showFootnote?: boolean }>(({ showFootnote }) =>
85
91
  {expand && fileNode}
86
92
  <InputEditor />
87
93
  </ChatInput>
88
- {showFootnote && !expand && <ShortcutHint />}
94
+ {showFootnote && !expand && (
95
+ <Center style={{ pointerEvents: 'none', zIndex: 100 }}>
96
+ <Text className={styles.footnote} type={'secondary'}>
97
+ {t('input.disclaimer')}
98
+ </Text>
99
+ </Center>
100
+ )}
89
101
  </Flexbox>
90
102
  </>
91
103
  );
@@ -71,6 +71,7 @@ export default {
71
71
  input: {
72
72
  addAi: '添加一条 AI 消息',
73
73
  addUser: '添加一条用户消息',
74
+ disclaimer: 'AI 也可能会犯错,请检查重要信息',
74
75
  errorMsg: '消息发送失败,请检查网络后重试: {{errorMsg}}',
75
76
  more: '更多',
76
77
  send: '发送',
@@ -164,7 +164,7 @@ export default {
164
164
  title: 'API 密钥',
165
165
  },
166
166
  apiUrl: {
167
- desc: 'New API 服务的 API 地址,大部分时候需要带 /v1',
167
+ desc: 'New API 服务的 API 地址,大部分时候不要带 /v1',
168
168
  title: 'API 地址',
169
169
  },
170
170
  enabled: {
@@ -0,0 +1,107 @@
1
+ import { act, renderHook } from '@testing-library/react';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+
4
+ import { useChatStore } from '../../../../store';
5
+
6
+ describe('Cancel send message functionality tests', () => {
7
+ describe('cancelSendMessageInServer', () => {
8
+ it('should be able to call cancel method normally', () => {
9
+ const { result } = renderHook(() => useChatStore());
10
+
11
+ // Initial state setup
12
+ act(() => {
13
+ useChatStore.setState({
14
+ activeId: 'session-1',
15
+ activeTopicId: 'topic-1',
16
+ mainSendMessageOperations: {},
17
+ });
18
+ });
19
+
20
+ // Test method exists
21
+ expect(typeof result.current.cancelSendMessageInServer).toBe('function');
22
+
23
+ // Test method can be called safely
24
+ expect(() => {
25
+ act(() => {
26
+ result.current.cancelSendMessageInServer();
27
+ });
28
+ }).not.toThrow();
29
+ });
30
+
31
+ it('should be able to call with specified topic ID', () => {
32
+ const { result } = renderHook(() => useChatStore());
33
+
34
+ act(() => {
35
+ useChatStore.setState({
36
+ activeId: 'session-1',
37
+ mainSendMessageOperations: {},
38
+ });
39
+ });
40
+
41
+ expect(() => {
42
+ act(() => {
43
+ result.current.cancelSendMessageInServer('topic-2');
44
+ });
45
+ }).not.toThrow();
46
+ });
47
+ });
48
+
49
+ describe('clearSendMessageError', () => {
50
+ it('should be able to call clear error method normally', () => {
51
+ const { result } = renderHook(() => useChatStore());
52
+
53
+ act(() => {
54
+ useChatStore.setState({
55
+ activeId: 'session-1',
56
+ activeTopicId: 'topic-1',
57
+ mainSendMessageOperations: {},
58
+ });
59
+ });
60
+
61
+ expect(typeof result.current.clearSendMessageError).toBe('function');
62
+
63
+ expect(() => {
64
+ act(() => {
65
+ result.current.clearSendMessageError();
66
+ });
67
+ }).not.toThrow();
68
+ });
69
+ });
70
+
71
+ describe('Internal methods', () => {
72
+ it('should have internal state management methods', () => {
73
+ const { result } = renderHook(() => useChatStore());
74
+
75
+ expect(typeof result.current.internal_toggleSendMessageOperation).toBe('function');
76
+ expect(typeof result.current.internal_updateSendMessageOperation).toBe('function');
77
+ });
78
+
79
+ it('internal_toggleSendMessageOperation should work normally', () => {
80
+ const { result } = renderHook(() => useChatStore());
81
+
82
+ act(() => {
83
+ useChatStore.setState({ mainSendMessageOperations: {} });
84
+ });
85
+
86
+ expect(() => {
87
+ act(() => {
88
+ const abortController = result.current.internal_toggleSendMessageOperation(
89
+ 'test-key',
90
+ true,
91
+ );
92
+ expect(abortController).toBeInstanceOf(AbortController);
93
+ });
94
+ }).not.toThrow();
95
+ });
96
+ });
97
+
98
+ describe('State structure', () => {
99
+ it('should have mainSendMessageOperations state', () => {
100
+ const { result } = renderHook(() => useChatStore());
101
+
102
+ // Ensure state exists
103
+ expect(result.current.mainSendMessageOperations).toBeDefined();
104
+ expect(typeof result.current.mainSendMessageOperations).toBe('object');
105
+ });
106
+ });
107
+ });
@@ -1,4 +1,5 @@
1
1
  import { act, renderHook } from '@testing-library/react';
2
+ import { TRPCClientError } from '@trpc/client';
2
3
  import { Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
4
 
4
5
  import { LOADING_FLAT } from '@/const/message';
@@ -10,7 +11,6 @@ import {
10
11
  } from '@/const/settings';
11
12
  import { aiChatService } from '@/services/aiChat';
12
13
  import { chatService } from '@/services/chat';
13
- //
14
14
  import { messageService } from '@/services/message';
15
15
  import { agentChatConfigSelectors, agentSelectors } from '@/store/agent/selectors';
16
16
  import { sessionMetaSelectors } from '@/store/session/selectors';
@@ -18,6 +18,8 @@ import { UploadFileItem } from '@/types/files/upload';
18
18
  import { ChatMessage } from '@/types/message';
19
19
 
20
20
  import { useChatStore } from '../../../../store';
21
+ import { messageMapKey } from '../../../../utils/messageMapKey';
22
+ import { generateAIChatV2 } from '../generateAIChatV2';
21
23
 
22
24
  vi.stubGlobal(
23
25
  'fetch',
@@ -115,7 +117,10 @@ const mockState = {
115
117
  refreshTopic: vi.fn(),
116
118
  internal_execAgentRuntime: vi.fn(),
117
119
  saveToTopic: vi.fn(),
118
- };
120
+ switchTopic: vi.fn(),
121
+ internal_shouldUseRAG: () => false,
122
+ internal_retrieveChunks: vi.fn(),
123
+ } as any;
119
124
 
120
125
  beforeEach(() => {
121
126
  vi.clearAllMocks();
@@ -136,11 +141,11 @@ afterEach(() => {
136
141
  describe('generateAIChatV2 actions', () => {
137
142
  describe('sendMessageInServer', () => {
138
143
  it('should not send message if there is no active session', async () => {
139
- useChatStore.setState({ activeId: undefined });
140
144
  const { result } = renderHook(() => useChatStore());
141
145
  const message = 'Test message';
142
146
 
143
147
  await act(async () => {
148
+ useChatStore.setState({ activeId: undefined });
144
149
  await result.current.sendMessage({ message });
145
150
  });
146
151
 
@@ -252,7 +257,7 @@ describe('generateAIChatV2 actions', () => {
252
257
  expect(result.current.internal_execAgentRuntime).not.toHaveBeenCalled();
253
258
  });
254
259
 
255
- it(' isWelcomeQuestion true 时,正确地传递给 internal_execAgentRuntime', async () => {
260
+ it('should pass isWelcomeQuestion correctly to internal_execAgentRuntime when isWelcomeQuestion is true', async () => {
256
261
  const { result } = renderHook(() => useChatStore());
257
262
 
258
263
  await act(async () => {
@@ -266,7 +271,7 @@ describe('generateAIChatV2 actions', () => {
266
271
  );
267
272
  });
268
273
 
269
- it('当只有文件而没有消息内容时,正确发送消息', async () => {
274
+ it('should send message correctly when only files are provided without message content', async () => {
270
275
  const { result } = renderHook(() => useChatStore());
271
276
 
272
277
  await act(async () => {
@@ -290,7 +295,7 @@ describe('generateAIChatV2 actions', () => {
290
295
  );
291
296
  });
292
297
 
293
- it('当同时有文件和消息内容时,正确发送消息并关联文件', async () => {
298
+ it('should send message correctly when both files and message content are provided', async () => {
294
299
  const { result } = renderHook(() => useChatStore());
295
300
 
296
301
  await act(async () => {
@@ -314,7 +319,7 @@ describe('generateAIChatV2 actions', () => {
314
319
  );
315
320
  });
316
321
 
317
- it(' createMessage 抛出错误时,正确处理错误而不影响整个应用', async () => {
322
+ it('should handle errors correctly when createMessage throws error without affecting the app', async () => {
318
323
  const { result } = renderHook(() => useChatStore());
319
324
  vi.spyOn(aiChatService, 'sendMessageInServer').mockRejectedValue(
320
325
  new Error('create message error'),
@@ -443,4 +448,344 @@ describe('generateAIChatV2 actions', () => {
443
448
  expect(mockState.refreshMessages).toHaveBeenCalled();
444
449
  });
445
450
  });
451
+
452
+ describe('Error handling tests', () => {
453
+ it('should set error message when sendMessageInServer throws a regular error', async () => {
454
+ const { result } = renderHook(() => useChatStore());
455
+ const errorMessage = 'Network error';
456
+ const mockError = new TRPCClientError(errorMessage);
457
+ (mockError as any).data = { code: 'BAD_REQUEST' };
458
+
459
+ vi.spyOn(aiChatService, 'sendMessageInServer').mockRejectedValue(mockError);
460
+
461
+ await act(async () => {
462
+ await result.current.sendMessage({ message: 'test' });
463
+ });
464
+
465
+ const operationKey = messageMapKey('session-id', 'topic-id');
466
+ expect(result.current.mainSendMessageOperations[operationKey]?.inputSendErrorMsg).toBe(
467
+ errorMessage,
468
+ );
469
+ });
470
+
471
+ it('should not set error message when receiving a cancel signal', async () => {
472
+ const { result } = renderHook(() => useChatStore());
473
+ const abortError = new Error('AbortError');
474
+ abortError.name = 'AbortError';
475
+
476
+ vi.spyOn(aiChatService, 'sendMessageInServer').mockRejectedValue(abortError);
477
+
478
+ await act(async () => {
479
+ await result.current.sendMessage({ message: 'test' });
480
+ });
481
+
482
+ const operationKey = messageMapKey('session-id', 'topic-id');
483
+ expect(
484
+ result.current.mainSendMessageOperations[operationKey]?.inputSendErrorMsg,
485
+ ).toBeUndefined();
486
+ });
487
+ });
488
+
489
+ describe('Topic switching tests', () => {
490
+ it('should automatically switch to newly created topic when no active topic exists', async () => {
491
+ const { result } = renderHook(() => useChatStore());
492
+ const mockSwitchTopic = vi.fn();
493
+
494
+ await act(async () => {
495
+ useChatStore.setState({
496
+ ...mockState,
497
+ activeTopicId: undefined,
498
+ switchTopic: mockSwitchTopic,
499
+ });
500
+ await result.current.sendMessage({ message: 'test' });
501
+ });
502
+
503
+ expect(mockSwitchTopic).toHaveBeenCalledWith('topic-id', true);
504
+ });
505
+
506
+ it('should not need to switch topic when active topic exists', async () => {
507
+ const { result } = renderHook(() => useChatStore());
508
+ const mockSwitchTopic = vi.fn();
509
+
510
+ await act(async () => {
511
+ useChatStore.setState({
512
+ ...mockState,
513
+ switchTopic: mockSwitchTopic,
514
+ });
515
+ await result.current.sendMessage({ message: 'test' });
516
+ });
517
+
518
+ expect(mockSwitchTopic).not.toHaveBeenCalled();
519
+ });
520
+ });
521
+
522
+ describe('Cancel send message tests', () => {
523
+ it('should correctly cancel the current active send operation', () => {
524
+ const { result } = renderHook(() => useChatStore());
525
+ const mockAbort = vi.fn();
526
+ const mockSetJSONState = vi.fn();
527
+
528
+ act(() => {
529
+ useChatStore.setState({
530
+ activeId: 'session-1',
531
+ activeTopicId: 'topic-1',
532
+ mainSendMessageOperations: {
533
+ [messageMapKey('session-1', 'topic-1')]: {
534
+ isLoading: true,
535
+ abortController: { abort: mockAbort, signal: {} as any },
536
+ inputEditorTempState: { content: 'saved content' },
537
+ },
538
+ },
539
+ mainInputEditor: { setJSONState: mockSetJSONState } as any,
540
+ });
541
+ });
542
+
543
+ act(() => {
544
+ result.current.cancelSendMessageInServer();
545
+ });
546
+
547
+ expect(mockAbort).toHaveBeenCalledWith('User cancelled sendMessageInServer operation');
548
+ expect(
549
+ result.current.mainSendMessageOperations[messageMapKey('session-1', 'topic-1')]?.isLoading,
550
+ ).toBe(false);
551
+ });
552
+
553
+ it('should cancel the operation for the corresponding topic when topic ID is specified', () => {
554
+ const { result } = renderHook(() => useChatStore());
555
+ const mockAbort = vi.fn();
556
+
557
+ act(() => {
558
+ useChatStore.setState({
559
+ activeId: 'session-1',
560
+ mainSendMessageOperations: {
561
+ [messageMapKey('session-1', 'topic-2')]: {
562
+ isLoading: true,
563
+ abortController: { abort: mockAbort, signal: {} as any },
564
+ },
565
+ },
566
+ });
567
+ });
568
+
569
+ act(() => {
570
+ result.current.cancelSendMessageInServer('topic-2');
571
+ });
572
+
573
+ expect(mockAbort).toHaveBeenCalledWith('User cancelled sendMessageInServer operation');
574
+ });
575
+
576
+ it('should handle safely without throwing error when operation does not exist', () => {
577
+ const { result } = renderHook(() => useChatStore());
578
+
579
+ act(() => {
580
+ useChatStore.setState({ mainSendMessageOperations: {} });
581
+ });
582
+
583
+ expect(() => {
584
+ act(() => {
585
+ result.current.cancelSendMessageInServer('non-existing-topic');
586
+ });
587
+ }).not.toThrow();
588
+ });
589
+ });
590
+
591
+ describe('Clear send error tests', () => {
592
+ it('should correctly clear error state for current topic', () => {
593
+ const { result } = renderHook(() => useChatStore());
594
+
595
+ act(() => {
596
+ useChatStore.setState({
597
+ activeId: 'session-1',
598
+ activeTopicId: 'topic-1',
599
+ mainSendMessageOperations: {
600
+ [messageMapKey('session-1', 'topic-1')]: {
601
+ isLoading: false,
602
+ inputSendErrorMsg: 'Some error',
603
+ },
604
+ },
605
+ });
606
+ });
607
+
608
+ act(() => {
609
+ result.current.clearSendMessageError();
610
+ });
611
+
612
+ expect(
613
+ result.current.mainSendMessageOperations[messageMapKey('session-1', 'topic-1')],
614
+ ).toBeUndefined();
615
+ });
616
+
617
+ it('should handle safely when no error operation exists', () => {
618
+ const { result } = renderHook(() => useChatStore());
619
+
620
+ act(() => {
621
+ useChatStore.setState({ mainSendMessageOperations: {} });
622
+ });
623
+
624
+ expect(() => {
625
+ act(() => {
626
+ result.current.clearSendMessageError();
627
+ });
628
+ }).not.toThrow();
629
+ });
630
+ });
631
+
632
+ describe('Operation state management tests', () => {
633
+ it('should correctly create new send operation', () => {
634
+ const { result } = renderHook(() => useChatStore());
635
+ let abortController: AbortController | undefined;
636
+
637
+ act(() => {
638
+ abortController = result.current.internal_toggleSendMessageOperation('test-key', true);
639
+ });
640
+
641
+ expect(abortController!).toBeInstanceOf(AbortController);
642
+ expect(result.current.mainSendMessageOperations['test-key']?.isLoading).toBe(true);
643
+ expect(result.current.mainSendMessageOperations['test-key']?.abortController).toBe(
644
+ abortController,
645
+ );
646
+ });
647
+
648
+ it('should correctly stop send operation', () => {
649
+ const { result } = renderHook(() => useChatStore());
650
+ const mockAbortController = { abort: vi.fn() } as any;
651
+
652
+ let abortController: AbortController | undefined;
653
+ act(() => {
654
+ result.current.internal_updateSendMessageOperation('test-key', {
655
+ isLoading: true,
656
+ abortController: mockAbortController,
657
+ });
658
+
659
+ abortController = result.current.internal_toggleSendMessageOperation('test-key', false);
660
+ });
661
+
662
+ expect(abortController).toBeUndefined();
663
+ expect(result.current.mainSendMessageOperations['test-key']?.isLoading).toBe(false);
664
+ expect(result.current.mainSendMessageOperations['test-key']?.abortController).toBeNull();
665
+ });
666
+
667
+ it('should correctly handle cancel reason and call abort method', () => {
668
+ const { result } = renderHook(() => useChatStore());
669
+ const mockAbortController = { abort: vi.fn() } as any;
670
+
671
+ result.current.internal_updateSendMessageOperation('test-key', {
672
+ isLoading: true,
673
+ abortController: mockAbortController,
674
+ });
675
+
676
+ result.current.internal_toggleSendMessageOperation('test-key', false, 'Test cancel reason');
677
+
678
+ expect(mockAbortController.abort).toHaveBeenCalledWith('Test cancel reason');
679
+ });
680
+
681
+ it('should support multiple parallel operations', () => {
682
+ const { result } = renderHook(() => useChatStore());
683
+
684
+ let abortController1, abortController2;
685
+ act(() => {
686
+ abortController1 = result.current.internal_toggleSendMessageOperation('pkey1', true);
687
+ abortController2 = result.current.internal_toggleSendMessageOperation('pkey2', true);
688
+ });
689
+
690
+ expect(result.current.mainSendMessageOperations['pkey1']?.isLoading).toBe(true);
691
+ expect(result.current.mainSendMessageOperations['pkey2']?.isLoading).toBe(true);
692
+ expect(abortController1).not.toBe(abortController2);
693
+ });
694
+ });
695
+
696
+ describe('Send operation state update tests', () => {
697
+ it('should correctly update operation state', () => {
698
+ const { result } = renderHook(() => useChatStore());
699
+ const mockAbortController = new AbortController();
700
+
701
+ act(() => {
702
+ result.current.internal_updateSendMessageOperation('abc', {
703
+ isLoading: true,
704
+ abortController: mockAbortController,
705
+ inputSendErrorMsg: 'test error',
706
+ });
707
+ });
708
+
709
+ expect(result.current.mainSendMessageOperations['abc']).toEqual({
710
+ isLoading: true,
711
+ abortController: mockAbortController,
712
+ inputSendErrorMsg: 'test error',
713
+ });
714
+ });
715
+
716
+ it('should support partial update of operation state', () => {
717
+ const { result } = renderHook(() => useChatStore());
718
+ const initialController = new AbortController();
719
+
720
+ act(() => {
721
+ result.current.internal_updateSendMessageOperation('test-key', {
722
+ isLoading: true,
723
+ abortController: initialController,
724
+ });
725
+
726
+ // Only update error message
727
+ result.current.internal_updateSendMessageOperation('test-key', {
728
+ inputSendErrorMsg: 'new error',
729
+ });
730
+ });
731
+
732
+ expect(result.current.mainSendMessageOperations['test-key']).toEqual({
733
+ isLoading: true,
734
+ abortController: initialController,
735
+ inputSendErrorMsg: 'new error',
736
+ });
737
+ });
738
+ });
739
+
740
+ describe('Editor state recovery tests', () => {
741
+ it('should restore editor content when cancelling operation', () => {
742
+ const { result } = renderHook(() => useChatStore());
743
+ const mockSetJSONState = vi.fn();
744
+ const mockAbort = vi.fn();
745
+
746
+ act(() => {
747
+ useChatStore.setState({
748
+ activeId: 'session-1',
749
+ activeTopicId: 'topic-1',
750
+ mainSendMessageOperations: {
751
+ [messageMapKey('session-1', 'topic-1')]: {
752
+ isLoading: true,
753
+ abortController: { abort: mockAbort, signal: {} as any },
754
+ inputEditorTempState: { content: 'saved content' },
755
+ },
756
+ },
757
+ mainInputEditor: { setJSONState: mockSetJSONState } as any,
758
+ });
759
+ });
760
+
761
+ act(() => {
762
+ result.current.cancelSendMessageInServer();
763
+ });
764
+
765
+ expect(mockSetJSONState).toHaveBeenCalledWith({ content: 'saved content' });
766
+ });
767
+
768
+ it('should not restore when no saved editor state exists', () => {
769
+ const { result } = renderHook(() => useChatStore());
770
+ const mockSetJSONState = vi.fn();
771
+ const mockAbort = vi.fn();
772
+
773
+ act(() => {
774
+ useChatStore.setState({
775
+ activeId: 'session-1',
776
+ activeTopicId: 'topic-1',
777
+ mainSendMessageOperations: {
778
+ [messageMapKey('session-1', 'topic-1')]: {
779
+ isLoading: true,
780
+ abortController: { abort: mockAbort, signal: {} as any },
781
+ },
782
+ },
783
+ mainInputEditor: { setJSONState: mockSetJSONState } as any,
784
+ });
785
+ result.current.cancelSendMessageInServer();
786
+ });
787
+
788
+ expect(mockSetJSONState).not.toHaveBeenCalled();
789
+ });
790
+ });
446
791
  });
@@ -260,7 +260,8 @@ export const generateAIChatV2: StateCreator<
260
260
  // Only clear creating message state if it's the active session
261
261
  if (operationKey === messageMapKey(activeId, activeTopicId)) {
262
262
  const editorTempState = get().mainSendMessageOperations[operationKey]?.inputEditorTempState;
263
- get().mainInputEditor?.setJSONState(editorTempState);
263
+
264
+ if (editorTempState) get().mainInputEditor?.setJSONState(editorTempState);
264
265
  }
265
266
  },
266
267
  clearSendMessageError: () => {