@lobehub/chat 1.64.1 → 1.64.3

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 (44) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +1 -1
  3. package/changelog/v1.json +18 -0
  4. package/locales/ar/models.json +9 -0
  5. package/locales/bg-BG/models.json +9 -0
  6. package/locales/de-DE/models.json +9 -0
  7. package/locales/en-US/models.json +9 -0
  8. package/locales/es-ES/models.json +9 -0
  9. package/locales/fa-IR/models.json +3 -0
  10. package/locales/fr-FR/models.json +9 -0
  11. package/locales/it-IT/models.json +9 -0
  12. package/locales/ja-JP/models.json +9 -0
  13. package/locales/ko-KR/models.json +9 -0
  14. package/locales/nl-NL/models.json +9 -0
  15. package/locales/pl-PL/models.json +9 -0
  16. package/locales/pt-BR/models.json +9 -0
  17. package/locales/ru-RU/models.json +9 -0
  18. package/locales/tr-TR/models.json +9 -0
  19. package/locales/vi-VN/models.json +9 -0
  20. package/locales/zh-CN/models.json +9 -0
  21. package/locales/zh-TW/models.json +9 -0
  22. package/package.json +1 -1
  23. package/src/components/WebFavicon/index.tsx +26 -0
  24. package/src/config/aiModels/anthropic.ts +23 -0
  25. package/src/config/aiModels/bedrock.ts +23 -2
  26. package/src/config/modelProviders/anthropic.ts +34 -0
  27. package/src/config/modelProviders/bedrock.ts +51 -0
  28. package/src/features/Conversation/Messages/Assistant/Tool/Render/CustomRender.tsx +1 -0
  29. package/src/features/Conversation/Messages/Assistant/Tool/Render/index.tsx +6 -0
  30. package/src/store/chat/slices/aiChat/actions/generateAIChat.ts +1 -1
  31. package/src/store/chat/slices/builtinTool/{action.test.ts → actions/dalle.test.ts} +2 -2
  32. package/src/store/chat/slices/builtinTool/actions/dalle.ts +126 -0
  33. package/src/store/chat/slices/builtinTool/actions/index.ts +18 -0
  34. package/src/store/chat/slices/builtinTool/actions/searXNG.test.ts +288 -0
  35. package/src/store/chat/slices/builtinTool/{action.ts → actions/searXNG.ts} +15 -116
  36. package/src/store/chat/slices/message/action.ts +1 -1
  37. package/src/store/chat/store.ts +1 -1
  38. package/src/tools/web-browsing/Portal/ResultList/SearchItem/TitleExtra.tsx +5 -1
  39. package/src/tools/web-browsing/Portal/ResultList/SearchItem/Video.tsx +6 -1
  40. package/src/tools/web-browsing/Portal/ResultList/SearchItem/index.tsx +8 -3
  41. package/src/tools/web-browsing/Render/SearchQuery/index.tsx +2 -2
  42. package/src/tools/web-browsing/Render/SearchResult/SearchResultItem.tsx +2 -9
  43. package/src/tools/web-browsing/Render/index.tsx +1 -1
  44. package/src/tools/web-browsing/const.ts +10 -9
@@ -22,6 +22,26 @@ const bedrockChatModels: AIChatModelCard[] = [
22
22
  tokens: 32_000,
23
23
  },
24
24
  */
25
+ {
26
+ abilities: {
27
+ functionCall: true,
28
+ reasoning: true,
29
+ vision: true,
30
+ },
31
+ contextWindowTokens: 200_000,
32
+ description:
33
+ 'Claude 3.7 sonnet 是 Anthropic 最快的下一代模型。与 Claude 3 Haiku 相比,Claude 3.7 Sonnet 在各项技能上都有所提升,并在许多智力基准测试中超越了上一代最大的模型 Claude 3 Opus。',
34
+ displayName: 'Claude 3.7 Sonnet',
35
+ enabled: true,
36
+ id: 'anthropic.claude-3-7-sonnet-20250219-v1:0',
37
+ maxOutput: 8192,
38
+ pricing: {
39
+ input: 3,
40
+ output: 15,
41
+ },
42
+ releasedAt: '2025-02-24',
43
+ type: 'chat',
44
+ },
25
45
  {
26
46
  abilities: {
27
47
  functionCall: true,
@@ -33,6 +53,7 @@ const bedrockChatModels: AIChatModelCard[] = [
33
53
  displayName: 'Claude 3.5 Sonnet',
34
54
  enabled: true,
35
55
  id: 'anthropic.claude-3-5-sonnet-20241022-v2:0',
56
+ maxOutput: 8192,
36
57
  pricing: {
37
58
  input: 3,
38
59
  output: 15,
@@ -51,7 +72,7 @@ const bedrockChatModels: AIChatModelCard[] = [
51
72
  displayName: 'Claude 3.5 Sonnet v2 (Inference profile)',
52
73
  enabled: true,
53
74
  id: 'us.anthropic.claude-3-5-sonnet-20241022-v2:0',
54
- maxOutput: 4096,
75
+ maxOutput: 8192,
55
76
  pricing: {
56
77
  input: 3,
57
78
  output: 15,
@@ -69,8 +90,8 @@ const bedrockChatModels: AIChatModelCard[] = [
69
90
  'Claude 3.5 Sonnet 提升了行业标准,性能超过竞争对手模型和 Claude 3 Opus,在广泛的评估中表现出色,同时具有我们中等层级模型的速度和成本。',
70
91
  displayName: 'Claude 3.5 Sonnet 0620',
71
92
  enabled: true,
72
-
73
93
  id: 'anthropic.claude-3-5-sonnet-20240620-v1:0',
94
+ maxOutput: 8192,
74
95
  pricing: {
75
96
  input: 3,
76
97
  output: 15,
@@ -3,6 +3,40 @@ import { ModelProviderCard } from '@/types/llm';
3
3
  // ref: https://docs.anthropic.com/en/docs/about-claude/models#model-names
4
4
  const Anthropic: ModelProviderCard = {
5
5
  chatModels: [
6
+ {
7
+ contextWindowTokens: 200_000,
8
+ description:
9
+ 'Claude 3.7 sonnet 是 Anthropic 最快的下一代模型。与 Claude 3 Haiku 相比,Claude 3.7 Sonnet 在各项技能上都有所提升,并在许多智力基准测试中超越了上一代最大的模型 Claude 3 Opus。',
10
+ displayName: 'Claude 3.7 Sonnet',
11
+ enabled: true,
12
+ functionCall: true,
13
+ id: 'claude-3-7-sonnet-20250219',
14
+ maxOutput: 8192,
15
+ pricing: {
16
+ cachedInput: 0.1,
17
+ input: 1,
18
+ output: 5,
19
+ writeCacheInput: 1.25,
20
+ },
21
+ releasedAt: '2025-02-24',
22
+ },
23
+ {
24
+ contextWindowTokens: 200_000,
25
+ description:
26
+ 'Claude 3.7 sonnet Extended thinking 是 Anthropic 最快的下一代模型。与 Claude 3 Haiku 相比,Claude 3.7 Sonnet 在各项技能上都有所提升,并在许多智力基准测试中超越了上一代最大的模型 Claude 3 Opus。',
27
+ displayName: 'Claude 3.7 Sonnet Extended thinking',
28
+ enabled: true,
29
+ functionCall: true,
30
+ id: 'claude-3-7-sonnet-20250219',
31
+ maxOutput: 64_000,
32
+ pricing: {
33
+ cachedInput: 0.1,
34
+ input: 1,
35
+ output: 5,
36
+ writeCacheInput: 1.25,
37
+ },
38
+ releasedAt: '2025-02-24',
39
+ },
6
40
  {
7
41
  contextWindowTokens: 200_000,
8
42
  description:
@@ -26,6 +26,57 @@ const Bedrock: ModelProviderCard = {
26
26
  tokens: 32_000,
27
27
  },
28
28
  */
29
+ {
30
+ contextWindowTokens: 200_000,
31
+ description:
32
+ 'Claude 3.7 sonnet 是 Anthropic 最快的下一代模型。与 Claude 3 Haiku 相比,Claude 3.7 Sonnet 在各项技能上都有所提升,并在许多智力基准测试中超越了上一代最大的模型 Claude 3 Opus。',
33
+ displayName: 'Claude 3.7 Sonnet',
34
+ enabled: true,
35
+ functionCall: true,
36
+ id: 'anthropic.claude-3-7-sonnet-20250219-v1:0',
37
+ maxOutput: 8192,
38
+ pricing: {
39
+ cachedInput: 0.1,
40
+ input: 1,
41
+ output: 5,
42
+ writeCacheInput: 1.25,
43
+ },
44
+ releasedAt: '2025-02-24',
45
+ },
46
+ {
47
+ contextWindowTokens: 200_000,
48
+ description:
49
+ 'Claude 3.7 sonnet Extended thinking 是 Anthropic 最快的下一代模型。与 Claude 3 Haiku 相比,Claude 3.7 Sonnet 在各项技能上都有所提升,并在许多智力基准测试中超越了上一代最大的模型 Claude 3 Opus。',
50
+ displayName: 'Claude 3.7 Sonnet Extended thinking',
51
+ enabled: true,
52
+ functionCall: true,
53
+ id: 'anthropic.claude-3-7-sonnet-20250219-v1:0',
54
+ maxOutput: 64_000,
55
+ pricing: {
56
+ cachedInput: 0.1,
57
+ input: 1,
58
+ output: 5,
59
+ writeCacheInput: 1.25,
60
+ },
61
+ releasedAt: '2025-02-24',
62
+ },
63
+ {
64
+ contextWindowTokens: 200_000,
65
+ description:
66
+ 'Claude 3.5 Haiku 是 Anthropic 最快的下一代模型。与 Claude 3 Haiku 相比,Claude 3.5 Haiku 在各项技能上都有所提升,并在许多智力基准测试中超越了上一代最大的模型 Claude 3 Opus。',
67
+ displayName: 'Claude 3.5 Haiku',
68
+ enabled: true,
69
+ functionCall: true,
70
+ id: 'anthropic.claude-3-5-haiku-20241022-v1:0',
71
+ maxOutput: 8192,
72
+ pricing: {
73
+ cachedInput: 0.1,
74
+ input: 1,
75
+ output: 5,
76
+ writeCacheInput: 1.25,
77
+ },
78
+ releasedAt: '2024-11-05',
79
+ },
29
80
  {
30
81
  contextWindowTokens: 200_000,
31
82
  description:
@@ -38,6 +38,7 @@ const CustomRender = memo<
38
38
  const { t } = useTranslation('plugin');
39
39
 
40
40
  const theme = useTheme();
41
+
41
42
  useEffect(() => {
42
43
  if (!plugin?.type || loading) return;
43
44
 
@@ -1,5 +1,6 @@
1
1
  import { Suspense, memo } from 'react';
2
2
 
3
+ import { LOADING_FLAT } from '@/const/message';
3
4
  import ErrorResponse from '@/features/Conversation/Messages/Assistant/Tool/Render/ErrorResponse';
4
5
  import { useChatStore } from '@/store/chat';
5
6
  import { chatSelectors } from '@/store/chat/selectors';
@@ -28,6 +29,11 @@ const Render = memo<RenderProps>(
28
29
  return <ErrorResponse {...toolMessage.error} id={messageId} plugin={toolMessage.plugin} />;
29
30
  }
30
31
 
32
+ // 如果是 LOADING_FLAT 则说明还在加载中
33
+ // 而 standalone 模式的插件 content 应该始终是 LOADING_FLAT
34
+ if (toolMessage.content === LOADING_FLAT && toolMessage.plugin?.type !== 'standalone')
35
+ return <Arguments arguments={requestArgs} shine />;
36
+
31
37
  return (
32
38
  <Suspense fallback={<Arguments arguments={requestArgs} shine />}>
33
39
  <CustomRender
@@ -626,7 +626,7 @@ export const generateAIChat: StateCreator<
626
626
  },
627
627
 
628
628
  false,
629
- 'toggleToolCallingStreaming',
629
+ `toggleToolCallingStreaming/${!!streaming ? 'start' : 'end'}`,
630
630
  );
631
631
  },
632
632
  });
@@ -10,9 +10,9 @@ import { chatSelectors } from '@/store/chat/selectors';
10
10
  import { ChatMessage } from '@/types/message';
11
11
  import { DallEImageItem } from '@/types/tool/dalle';
12
12
 
13
- import { useChatStore } from '../../store';
13
+ import { useChatStore } from '../../../store';
14
14
 
15
- describe('chatToolSlice', () => {
15
+ describe('chatToolSlice - dalle', () => {
16
16
  describe('generateImageFromPrompts', () => {
17
17
  it('should generate images from prompts, update items, and upload images', async () => {
18
18
  const { result } = renderHook(() => useChatStore());
@@ -0,0 +1,126 @@
1
+ import { produce } from 'immer';
2
+ import pMap from 'p-map';
3
+ import { SWRResponse } from 'swr';
4
+ import { StateCreator } from 'zustand/vanilla';
5
+
6
+ import { useClientDataSWR } from '@/libs/swr';
7
+ import { fileService } from '@/services/file';
8
+ import { imageGenerationService } from '@/services/textToImage';
9
+ import { uploadService } from '@/services/upload';
10
+ import { chatSelectors } from '@/store/chat/selectors';
11
+ import { ChatStore } from '@/store/chat/store';
12
+ import { useFileStore } from '@/store/file';
13
+ import { DallEImageItem } from '@/types/tool/dalle';
14
+
15
+
16
+ import { setNamespace } from '@/utils/storeDebug';
17
+
18
+ const n = setNamespace('tool');
19
+
20
+ const SWR_FETCH_KEY = 'FetchImageItem';
21
+
22
+ export interface ChatDallEAction {
23
+ generateImageFromPrompts: (items: DallEImageItem[], id: string) => Promise<void>;
24
+ text2image: (id: string, data: DallEImageItem[]) => Promise<void>;
25
+ toggleDallEImageLoading: (key: string, value: boolean) => void;
26
+ updateImageItem: (id: string, updater: (data: DallEImageItem[]) => void) => Promise<void>;
27
+ useFetchDalleImageItem: (id: string) => SWRResponse;
28
+ }
29
+
30
+ export const dalleSlice: StateCreator<
31
+ ChatStore,
32
+ [['zustand/devtools', never]],
33
+ [],
34
+ ChatDallEAction
35
+ > = (set, get) => ({
36
+ generateImageFromPrompts: async (items, messageId) => {
37
+ const { toggleDallEImageLoading, updateImageItem } = get();
38
+ // eslint-disable-next-line unicorn/consistent-function-scoping
39
+ const getMessageById = (id: string) => chatSelectors.getMessageById(id)(get());
40
+
41
+ const message = getMessageById(messageId);
42
+ if (!message) return;
43
+
44
+ const parent = getMessageById(message!.parentId!);
45
+ const originPrompt = parent?.content;
46
+ let errorArray: any[] = [];
47
+
48
+ await pMap(items, async (params, index) => {
49
+ toggleDallEImageLoading(messageId + params.prompt, true);
50
+
51
+ let url = '';
52
+ try {
53
+ url = await imageGenerationService.generateImage(params);
54
+ } catch (e) {
55
+ toggleDallEImageLoading(messageId + params.prompt, false);
56
+ errorArray[index] = e;
57
+
58
+ await get().updatePluginState(messageId, { error: errorArray });
59
+ }
60
+
61
+ if (!url) return;
62
+
63
+ await updateImageItem(messageId, (draft) => {
64
+ draft[index].previewUrl = url;
65
+ });
66
+
67
+ toggleDallEImageLoading(messageId + params.prompt, false);
68
+ const imageFile = await uploadService.getImageFileByUrlWithCORS(
69
+ url,
70
+ `${originPrompt || params.prompt}_${index}.png`,
71
+ );
72
+
73
+ const data = await useFileStore.getState().uploadWithProgress({
74
+ file: imageFile,
75
+ });
76
+
77
+ if (!data) return;
78
+
79
+ await updateImageItem(messageId, (draft) => {
80
+ draft[index].imageId = data.id;
81
+ draft[index].previewUrl = undefined;
82
+ });
83
+ });
84
+ },
85
+ text2image: async (id, data) => {
86
+ // const isAutoGen = settingsSelectors.isDalleAutoGenerating(useGlobalStore.getState());
87
+ // if (!isAutoGen) return;
88
+
89
+ await get().generateImageFromPrompts(data, id);
90
+ },
91
+
92
+ toggleDallEImageLoading: (key, value) => {
93
+ set(
94
+ { dalleImageLoading: { ...get().dalleImageLoading, [key]: value } },
95
+ false,
96
+ n('toggleDallEImageLoading'),
97
+ );
98
+ },
99
+
100
+ updateImageItem: async (id, updater) => {
101
+ const message = chatSelectors.getMessageById(id)(get());
102
+ if (!message) return;
103
+
104
+ const data: DallEImageItem[] = JSON.parse(message.content);
105
+
106
+ const nextContent = produce(data, updater);
107
+ await get().internal_updateMessageContent(id, JSON.stringify(nextContent));
108
+ },
109
+
110
+ useFetchDalleImageItem: (id) =>
111
+ useClientDataSWR([SWR_FETCH_KEY, id], async () => {
112
+ const item = await fileService.getFile(id);
113
+
114
+ set(
115
+ produce((draft) => {
116
+ if (draft.dalleImageMap[id]) return;
117
+
118
+ draft.dalleImageMap[id] = item;
119
+ }),
120
+ false,
121
+ n('useFetchFile'),
122
+ );
123
+
124
+ return item;
125
+ }),
126
+ });
@@ -0,0 +1,18 @@
1
+ import { StateCreator } from 'zustand/vanilla';
2
+
3
+ import { ChatStore } from '@/store/chat/store';
4
+
5
+ import { ChatDallEAction, dalleSlice } from './dalle';
6
+ import { SearchAction, searchSlice } from './searXNG';
7
+
8
+ export interface ChatBuiltinToolAction extends ChatDallEAction, SearchAction {}
9
+
10
+ export const chatToolSlice: StateCreator<
11
+ ChatStore,
12
+ [['zustand/devtools', never]],
13
+ [],
14
+ ChatBuiltinToolAction
15
+ > = (...params) => ({
16
+ ...dalleSlice(...params),
17
+ ...searchSlice(...params),
18
+ });
@@ -0,0 +1,288 @@
1
+ import { act, renderHook } from '@testing-library/react';
2
+ import { Mock, beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import { searchService } from '@/services/search';
5
+ import { useChatStore } from '@/store/chat';
6
+ import { chatSelectors } from '@/store/chat/selectors';
7
+ import { ChatMessage } from '@/types/message';
8
+ import { SearchContent, SearchQuery, SearchResponse } from '@/types/tool/search';
9
+
10
+ // Mock services
11
+ vi.mock('@/services/search', () => ({
12
+ searchService: {
13
+ search: vi.fn(),
14
+ },
15
+ }));
16
+
17
+ vi.mock('@/store/chat/selectors', () => ({
18
+ chatSelectors: {
19
+ getMessageById: vi.fn(),
20
+ },
21
+ }));
22
+
23
+ describe('searXNG actions', () => {
24
+ beforeEach(() => {
25
+ vi.clearAllMocks();
26
+ useChatStore.setState({
27
+ activeId: 'session-id',
28
+ activeTopicId: 'topic-id',
29
+ searchLoading: {},
30
+ internal_updateMessageContent: vi.fn(),
31
+ internal_updateMessagePluginError: vi.fn(),
32
+ updatePluginArguments: vi.fn(),
33
+ updatePluginState: vi.fn(),
34
+ internal_createMessage: vi.fn(),
35
+ internal_addToolToAssistantMessage: vi.fn(),
36
+ openToolUI: vi.fn(),
37
+ });
38
+ });
39
+
40
+ describe('searchWithSearXNG', () => {
41
+ it('should handle successful search', async () => {
42
+ const mockResponse: SearchResponse = {
43
+ results: [
44
+ {
45
+ title: 'Test Result',
46
+ content: 'Test Content',
47
+ url: 'https://test.com',
48
+ category: 'general',
49
+ engine: 'google',
50
+ engines: ['google'],
51
+ parsed_url: ['test.com'],
52
+ positions: [1],
53
+ score: 1,
54
+ template: 'default',
55
+ },
56
+ ],
57
+ answers: [],
58
+ corrections: [],
59
+ infoboxes: [],
60
+ number_of_results: 1,
61
+ query: 'test',
62
+ suggestions: [],
63
+ unresponsive_engines: [],
64
+ };
65
+
66
+ (searchService.search as Mock).mockResolvedValue(mockResponse);
67
+
68
+ const { result } = renderHook(() => useChatStore());
69
+ const { searchWithSearXNG } = result.current;
70
+
71
+ const messageId = 'test-message-id';
72
+ const query: SearchQuery = {
73
+ query: 'test query',
74
+ searchEngines: ['google'],
75
+ };
76
+
77
+ await act(async () => {
78
+ await searchWithSearXNG(messageId, query);
79
+ });
80
+
81
+ const expectedContent: SearchContent[] = [
82
+ {
83
+ content: 'Test Content',
84
+ title: 'Test Result',
85
+ url: 'https://test.com',
86
+ },
87
+ ];
88
+
89
+ expect(searchService.search).toHaveBeenCalledWith('test query', ['google']);
90
+ expect(result.current.searchLoading[messageId]).toBe(false);
91
+ expect(result.current.internal_updateMessageContent).toHaveBeenCalledWith(
92
+ messageId,
93
+ JSON.stringify(expectedContent),
94
+ );
95
+ });
96
+
97
+ it('should handle empty search results and retry with default engine', async () => {
98
+ const emptyResponse: SearchResponse = {
99
+ results: [],
100
+ answers: [],
101
+ corrections: [],
102
+ infoboxes: [],
103
+ number_of_results: 0,
104
+ query: 'test',
105
+ suggestions: [],
106
+ unresponsive_engines: [],
107
+ };
108
+
109
+ const retryResponse: SearchResponse = {
110
+ results: [
111
+ {
112
+ title: 'Retry Result',
113
+ content: 'Retry Content',
114
+ url: 'https://retry.com',
115
+ category: 'general',
116
+ engine: 'google',
117
+ engines: ['google'],
118
+ parsed_url: ['retry.com'],
119
+ positions: [1],
120
+ score: 1,
121
+ template: 'default',
122
+ },
123
+ ],
124
+ answers: [],
125
+ corrections: [],
126
+ infoboxes: [],
127
+ number_of_results: 1,
128
+ query: 'test',
129
+ suggestions: [],
130
+ unresponsive_engines: [],
131
+ };
132
+
133
+ (searchService.search as Mock)
134
+ .mockResolvedValueOnce(emptyResponse)
135
+ .mockResolvedValueOnce(retryResponse);
136
+
137
+ const { result } = renderHook(() => useChatStore());
138
+ const { searchWithSearXNG } = result.current;
139
+
140
+ const messageId = 'test-message-id';
141
+ const query: SearchQuery = {
142
+ query: 'test query',
143
+ searchEngines: ['custom-engine'],
144
+ };
145
+
146
+ await act(async () => {
147
+ await searchWithSearXNG(messageId, query);
148
+ });
149
+
150
+ expect(searchService.search).toHaveBeenCalledTimes(2);
151
+ expect(searchService.search).toHaveBeenNthCalledWith(1, 'test query', ['custom-engine']);
152
+ expect(searchService.search).toHaveBeenNthCalledWith(2, 'test query');
153
+ expect(result.current.updatePluginArguments).toHaveBeenCalledWith(messageId, {
154
+ query: 'test query',
155
+ searchEngines: undefined,
156
+ });
157
+ });
158
+
159
+ it('should handle search error', async () => {
160
+ const error = new Error('Search failed');
161
+ (searchService.search as Mock).mockRejectedValue(error);
162
+
163
+ const { result } = renderHook(() => useChatStore());
164
+ const { searchWithSearXNG } = result.current;
165
+
166
+ const messageId = 'test-message-id';
167
+ const query: SearchQuery = {
168
+ query: 'test query',
169
+ };
170
+
171
+ await act(async () => {
172
+ await searchWithSearXNG(messageId, query);
173
+ });
174
+
175
+ expect(result.current.internal_updateMessagePluginError).toHaveBeenCalledWith(messageId, {
176
+ body: error,
177
+ message: 'Search failed',
178
+ type: 'PluginServerError',
179
+ });
180
+ expect(result.current.searchLoading[messageId]).toBe(false);
181
+ });
182
+ });
183
+
184
+ describe('reSearchWithSearXNG', () => {
185
+ it('should update arguments and perform search', async () => {
186
+ const { result } = renderHook(() => useChatStore());
187
+ const spy = vi.spyOn(result.current, 'searchWithSearXNG');
188
+ const { reSearchWithSearXNG } = result.current;
189
+
190
+ const messageId = 'test-message-id';
191
+ const query: SearchQuery = {
192
+ query: 'test query',
193
+ };
194
+
195
+ await act(async () => {
196
+ await reSearchWithSearXNG(messageId, query, { aiSummary: true });
197
+ });
198
+
199
+ expect(result.current.updatePluginArguments).toHaveBeenCalledWith(messageId, query);
200
+ expect(spy).toHaveBeenCalledWith(messageId, query, true);
201
+ });
202
+ });
203
+
204
+ describe('saveSearXNGSearchResult', () => {
205
+ it('should save search result as tool message', async () => {
206
+ const messageId = 'test-message-id';
207
+ const parentId = 'parent-message-id';
208
+ const mockMessage: Partial<ChatMessage> = {
209
+ id: messageId,
210
+ parentId,
211
+ content: 'test content',
212
+ plugin: {
213
+ identifier: 'search',
214
+ arguments: '{}',
215
+ apiName: 'search',
216
+ type: 'default',
217
+ },
218
+ pluginState: {},
219
+ role: 'assistant',
220
+ createdAt: Date.now(),
221
+ updatedAt: Date.now(),
222
+ meta: {},
223
+ };
224
+
225
+ vi.spyOn(chatSelectors, 'getMessageById').mockImplementation(
226
+ () => () => mockMessage as ChatMessage,
227
+ );
228
+
229
+ const { result } = renderHook(() => useChatStore());
230
+ const { saveSearXNGSearchResult } = result.current;
231
+
232
+ await act(async () => {
233
+ await saveSearXNGSearchResult(messageId);
234
+ });
235
+
236
+ expect(result.current.internal_createMessage).toHaveBeenCalledWith(
237
+ expect.objectContaining({
238
+ content: 'test content',
239
+ parentId,
240
+ plugin: mockMessage.plugin,
241
+ pluginState: mockMessage.pluginState,
242
+ role: 'tool',
243
+ }),
244
+ );
245
+
246
+ expect(result.current.internal_addToolToAssistantMessage).toHaveBeenCalledWith(
247
+ parentId,
248
+ expect.objectContaining({
249
+ identifier: 'search',
250
+ type: 'default',
251
+ }),
252
+ );
253
+ });
254
+
255
+ it('should not save if message not found', async () => {
256
+ vi.spyOn(chatSelectors, 'getMessageById').mockImplementation(() => () => undefined);
257
+
258
+ const { result } = renderHook(() => useChatStore());
259
+ const { saveSearXNGSearchResult } = result.current;
260
+
261
+ await act(async () => {
262
+ await saveSearXNGSearchResult('non-existent-id');
263
+ });
264
+
265
+ expect(result.current.internal_createMessage).not.toHaveBeenCalled();
266
+ expect(result.current.internal_addToolToAssistantMessage).not.toHaveBeenCalled();
267
+ });
268
+ });
269
+
270
+ describe('toggleSearchLoading', () => {
271
+ it('should toggle search loading state', () => {
272
+ const { result } = renderHook(() => useChatStore());
273
+ const messageId = 'test-message-id';
274
+
275
+ act(() => {
276
+ result.current.toggleSearchLoading(messageId, true);
277
+ });
278
+
279
+ expect(result.current.searchLoading[messageId]).toBe(true);
280
+
281
+ act(() => {
282
+ result.current.toggleSearchLoading(messageId, false);
283
+ });
284
+
285
+ expect(result.current.searchLoading[messageId]).toBe(false);
286
+ });
287
+ });
288
+ });