@lobehub/chat 1.106.3 → 1.106.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/apps/desktop/src/preload/routeInterceptor.ts +28 -0
  3. package/changelog/v1.json +21 -0
  4. package/locales/ar/models.json +164 -5
  5. package/locales/bg-BG/models.json +164 -5
  6. package/locales/de-DE/models.json +164 -5
  7. package/locales/en-US/models.json +164 -5
  8. package/locales/es-ES/models.json +164 -5
  9. package/locales/fa-IR/models.json +164 -5
  10. package/locales/fr-FR/models.json +164 -5
  11. package/locales/it-IT/models.json +164 -5
  12. package/locales/ja-JP/models.json +164 -5
  13. package/locales/ko-KR/models.json +164 -5
  14. package/locales/nl-NL/models.json +164 -5
  15. package/locales/pl-PL/models.json +164 -5
  16. package/locales/pt-BR/models.json +164 -5
  17. package/locales/ru-RU/models.json +164 -5
  18. package/locales/tr-TR/models.json +164 -5
  19. package/locales/vi-VN/models.json +164 -5
  20. package/locales/zh-CN/models.json +164 -5
  21. package/locales/zh-TW/models.json +164 -5
  22. package/package.json +1 -1
  23. package/src/features/Conversation/Messages/Assistant/Tool/Inspector/BuiltinPluginTitle.tsx +2 -9
  24. package/src/features/Conversation/Messages/Assistant/Tool/Inspector/PluginResultJSON.tsx +7 -2
  25. package/src/features/Conversation/Messages/Assistant/Tool/Inspector/ToolTitle.tsx +2 -2
  26. package/src/features/Conversation/Messages/Assistant/Tool/Inspector/index.tsx +5 -11
  27. package/src/features/Conversation/Messages/Assistant/Tool/Render/Arguments/index.tsx +37 -12
  28. package/src/features/Conversation/Messages/Assistant/Tool/Render/CustomRender.tsx +43 -34
  29. package/src/features/Conversation/Messages/Assistant/Tool/index.tsx +23 -6
  30. package/src/features/Conversation/Messages/Assistant/index.tsx +1 -0
  31. package/src/features/Conversation/components/VirtualizedList/index.tsx +0 -1
  32. package/src/server/services/mcp/index.test.ts +161 -0
  33. package/src/server/services/mcp/index.ts +4 -1
  34. package/src/store/chat/slices/aiChat/actions/generateAIChat.ts +10 -0
  35. package/src/store/chat/slices/aiChat/initialState.ts +2 -0
  36. package/src/store/chat/slices/message/selectors.ts +9 -0
  37. package/src/store/chat/slices/plugin/action.ts +2 -0
@@ -6,6 +6,7 @@ import { memo, useCallback, useEffect, useState } from 'react';
6
6
  import { useTranslation } from 'react-i18next';
7
7
  import { Flexbox } from 'react-layout-kit';
8
8
 
9
+ import PluginResult from '@/features/Conversation/Messages/Assistant/Tool/Inspector/PluginResultJSON';
9
10
  import PluginRender from '@/features/PluginsUI/Render';
10
11
  import { useChatStore } from '@/store/chat';
11
12
  import { chatSelectors } from '@/store/chat/selectors';
@@ -39,6 +40,7 @@ const CustomRender = memo<CustomRenderProps>(
39
40
  showPluginRender,
40
41
  setShowPluginRender,
41
42
  pluginError,
43
+ tool_call_id,
42
44
  }) => {
43
45
  const { t } = useTranslation(['tool', 'common']);
44
46
  const [loading] = useChatStore((s) => [chatSelectors.isPluginApiInvoking(id)(s)]);
@@ -80,9 +82,9 @@ const CustomRender = memo<CustomRenderProps>(
80
82
 
81
83
  if (loading) return <Arguments arguments={requestArgs} shine />;
82
84
 
83
- return (
84
- <Flexbox gap={12} id={id} width={'100%'}>
85
- {showPluginRender ? (
85
+ if (showPluginRender)
86
+ return (
87
+ <Flexbox gap={12} id={id} width={'100%'}>
86
88
  <PluginRender
87
89
  arguments={plugin?.arguments}
88
90
  content={content}
@@ -94,37 +96,44 @@ const CustomRender = memo<CustomRenderProps>(
94
96
  pluginState={pluginState}
95
97
  type={plugin?.type}
96
98
  />
97
- ) : isEditing ? (
98
- <KeyValueEditor
99
- initialValue={safeParseJson(requestArgs || '')}
100
- onCancel={handleCancel}
101
- onFinish={handleFinish}
102
- />
103
- ) : (
104
- <Arguments
105
- actions={
106
- <>
107
- <ActionIcon
108
- icon={Edit3Icon}
109
- onClick={() => {
110
- setIsEditing(true);
111
- }}
112
- size={'small'}
113
- title={t('edit', { ns: 'common' })}
114
- />
115
- <ActionIcon
116
- icon={PlayCircleIcon}
117
- onClick={async () => {
118
- await reInvokeToolMessage(id);
119
- }}
120
- size={'small'}
121
- title={t('run', { ns: 'common' })}
122
- />
123
- </>
124
- }
125
- arguments={requestArgs}
126
- />
127
- )}
99
+ </Flexbox>
100
+ );
101
+
102
+ if (isEditing)
103
+ return (
104
+ <KeyValueEditor
105
+ initialValue={safeParseJson(requestArgs || '')}
106
+ onCancel={handleCancel}
107
+ onFinish={handleFinish}
108
+ />
109
+ );
110
+
111
+ return (
112
+ <Flexbox gap={12} id={id} width={'100%'}>
113
+ <Arguments
114
+ actions={
115
+ <>
116
+ <ActionIcon
117
+ icon={Edit3Icon}
118
+ onClick={() => {
119
+ setIsEditing(true);
120
+ }}
121
+ size={'small'}
122
+ title={t('edit', { ns: 'common' })}
123
+ />
124
+ <ActionIcon
125
+ icon={PlayCircleIcon}
126
+ onClick={async () => {
127
+ await reInvokeToolMessage(id);
128
+ }}
129
+ size={'small'}
130
+ title={t('run', { ns: 'common' })}
131
+ />
132
+ </>
133
+ }
134
+ arguments={requestArgs}
135
+ />
136
+ {tool_call_id && <PluginResult toolCallId={tool_call_id} variant={'outlined'} />}
128
137
  </Flexbox>
129
138
  );
130
139
  },
@@ -1,7 +1,9 @@
1
- import { CSSProperties, memo, useState } from 'react';
1
+ import { CSSProperties, memo, useEffect, useState } from 'react';
2
2
  import { Flexbox } from 'react-layout-kit';
3
3
 
4
4
  import AnimatedCollapsed from '@/components/AnimatedCollapsed';
5
+ import { useChatStore } from '@/store/chat';
6
+ import { chatSelectors } from '@/store/chat/selectors';
5
7
 
6
8
  import Inspectors from './Inspector';
7
9
  import Render from './Render';
@@ -15,29 +17,44 @@ export interface InspectorProps {
15
17
  messageId: string;
16
18
  payload: object;
17
19
  style?: CSSProperties;
20
+ type?: string;
18
21
  }
19
22
 
20
23
  const Tool = memo<InspectorProps>(
21
- ({ arguments: requestArgs, apiName, messageId, id, index, identifier, style, payload }) => {
22
- const [showRender, setShowRender] = useState(true);
24
+ ({ arguments: requestArgs, apiName, messageId, id, index, identifier, style, payload, type }) => {
25
+ const [showDetail, setShowDetail] = useState(type !== 'mcp');
23
26
  const [showPluginRender, setShowPluginRender] = useState(false);
27
+ const isLoading = useChatStore(chatSelectors.isInToolsCalling(messageId, index));
28
+
29
+ useEffect(() => {
30
+ if (type !== 'mcp') return;
31
+
32
+ setTimeout(
33
+ () => {
34
+ setShowDetail(isLoading);
35
+ },
36
+ isLoading ? 1 : 1500,
37
+ );
38
+ }, [isLoading]);
24
39
 
25
40
  return (
26
41
  <Flexbox gap={8} style={style}>
27
42
  <Inspectors
28
43
  apiName={apiName}
29
44
  arguments={requestArgs}
45
+ // mcp don't have ui render
46
+ hidePluginUI={type === 'mcp'}
30
47
  id={id}
31
48
  identifier={identifier}
32
49
  index={index}
33
50
  messageId={messageId}
34
51
  payload={payload}
35
52
  setShowPluginRender={setShowPluginRender}
36
- setShowRender={setShowRender}
53
+ setShowRender={setShowDetail}
37
54
  showPluginRender={showPluginRender}
38
- showRender={showRender}
55
+ showRender={showDetail}
39
56
  />
40
- <AnimatedCollapsed open={showRender}>
57
+ <AnimatedCollapsed open={showDetail}>
41
58
  <Render
42
59
  messageId={messageId}
43
60
  requestArgs={requestArgs}
@@ -79,6 +79,7 @@ export const AssistantMessage = memo<
79
79
  key={toolCall.id}
80
80
  messageId={id}
81
81
  payload={toolCall}
82
+ type={toolCall.type}
82
83
  />
83
84
  ))}
84
85
  </Flexbox>
@@ -77,7 +77,6 @@ const VirtualizedList = memo<VirtualizedListProps>(({ mobile, dataSource, itemCo
77
77
  initialTopMostItemIndex={dataSource?.length - 1}
78
78
  isScrolling={setIsScrolling}
79
79
  itemContent={itemContent}
80
- overscan={overscan}
81
80
  ref={virtuosoRef}
82
81
  />
83
82
  <AutoScroll
@@ -0,0 +1,161 @@
1
+ import { TRPCError } from '@trpc/server';
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ // Mock 依赖
5
+ vi.mock('@/libs/mcp');
6
+
7
+ describe('MCPService', () => {
8
+ let mcpService: any;
9
+ let mockClient: any;
10
+
11
+ beforeEach(async () => {
12
+ vi.clearAllMocks();
13
+
14
+ // 动态导入服务实例
15
+ const { mcpService: importedService } = await import('./index');
16
+ mcpService = importedService;
17
+
18
+ // 创建 mock 客户端
19
+ mockClient = {
20
+ callTool: vi.fn(),
21
+ };
22
+
23
+ // Mock getClient 方法返回 mock 客户端
24
+ vi.spyOn(mcpService as any, 'getClient').mockResolvedValue(mockClient);
25
+ });
26
+
27
+ describe('callTool', () => {
28
+ const mockParams = {
29
+ name: 'test-mcp',
30
+ type: 'stdio' as const,
31
+ command: 'test-command',
32
+ args: ['--test'],
33
+ };
34
+
35
+ it('should return original data when content array is empty', async () => {
36
+ mockClient.callTool.mockResolvedValue({
37
+ content: [],
38
+ isError: false,
39
+ });
40
+
41
+ const result = await mcpService.callTool(mockParams, 'testTool', '{}');
42
+
43
+ expect(result).toEqual([]);
44
+ });
45
+
46
+ it('should return original data when content is null or undefined', async () => {
47
+ mockClient.callTool.mockResolvedValue({
48
+ content: null,
49
+ isError: false,
50
+ });
51
+
52
+ const result = await mcpService.callTool(mockParams, 'testTool', '{}');
53
+
54
+ expect(result).toBeNull();
55
+ });
56
+
57
+ it('should return parsed JSON when single element contains valid JSON', async () => {
58
+ const jsonData = { message: 'Hello World', status: 'success' };
59
+ mockClient.callTool.mockResolvedValue({
60
+ content: [{ type: 'text', text: JSON.stringify(jsonData) }],
61
+ isError: false,
62
+ });
63
+
64
+ const result = await mcpService.callTool(mockParams, 'testTool', '{}');
65
+
66
+ expect(result).toEqual(jsonData);
67
+ });
68
+
69
+ it('should return plain text when single element contains non-JSON text', async () => {
70
+ const textData = 'Hello World';
71
+ mockClient.callTool.mockResolvedValue({
72
+ content: [{ type: 'text', text: textData }],
73
+ isError: false,
74
+ });
75
+
76
+ const result = await mcpService.callTool(mockParams, 'testTool', '{}');
77
+
78
+ expect(result).toBe(textData);
79
+ });
80
+
81
+ it('should return original data when single element has no text', async () => {
82
+ const contentData = [{ type: 'text', text: '' }];
83
+ mockClient.callTool.mockResolvedValue({
84
+ content: contentData,
85
+ isError: false,
86
+ });
87
+
88
+ const result = await mcpService.callTool(mockParams, 'testTool', '{}');
89
+
90
+ expect(result).toEqual(contentData);
91
+ });
92
+
93
+ it('should return complete array when content has multiple elements', async () => {
94
+ const multipleContent = [
95
+ { type: 'text', text: 'First message' },
96
+ { type: 'text', text: 'Second message' },
97
+ { type: 'text', text: '{"json": "data"}' },
98
+ ];
99
+
100
+ mockClient.callTool.mockResolvedValue({
101
+ content: multipleContent,
102
+ isError: false,
103
+ });
104
+
105
+ const result = await mcpService.callTool(mockParams, 'testTool', '{}');
106
+
107
+ // 应该直接返回完整的数组,不进行任何处理
108
+ expect(result).toEqual(multipleContent);
109
+ });
110
+
111
+ it('should return complete array when content has two elements', async () => {
112
+ const twoContent = [
113
+ { type: 'text', text: 'First message' },
114
+ { type: 'text', text: 'Second message' },
115
+ ];
116
+
117
+ mockClient.callTool.mockResolvedValue({
118
+ content: twoContent,
119
+ isError: false,
120
+ });
121
+
122
+ const result = await mcpService.callTool(mockParams, 'testTool', '{}');
123
+
124
+ expect(result).toEqual(twoContent);
125
+ });
126
+
127
+ it('should return error result when isError is true', async () => {
128
+ const errorResult = {
129
+ content: [{ type: 'text', text: 'Error occurred' }],
130
+ isError: true,
131
+ };
132
+
133
+ mockClient.callTool.mockResolvedValue(errorResult);
134
+
135
+ const result = await mcpService.callTool(mockParams, 'testTool', '{}');
136
+
137
+ expect(result).toEqual(errorResult);
138
+ });
139
+
140
+ it('should throw TRPCError when client throws error', async () => {
141
+ const error = new Error('MCP client error');
142
+ mockClient.callTool.mockRejectedValue(error);
143
+
144
+ await expect(mcpService.callTool(mockParams, 'testTool', '{}')).rejects.toThrow(TRPCError);
145
+ });
146
+
147
+ it('should parse args string correctly', async () => {
148
+ const argsObject = { param1: 'value1', param2: 'value2' };
149
+ const argsString = JSON.stringify(argsObject);
150
+
151
+ mockClient.callTool.mockResolvedValue({
152
+ content: [{ type: 'text', text: 'result' }],
153
+ isError: false,
154
+ });
155
+
156
+ await mcpService.callTool(mockParams, 'testTool', argsString);
157
+
158
+ expect(mockClient.callTool).toHaveBeenCalledWith('testTool', argsObject);
159
+ });
160
+ });
161
+ });
@@ -166,8 +166,11 @@ class MCPService {
166
166
 
167
167
  const data = content as { text: string; type: 'text' }[];
168
168
 
169
- const text = data?.[0]?.text;
169
+ if (!data || data.length === 0) return data;
170
170
 
171
+ if (data.length > 1) return data;
172
+
173
+ const text = data[0]?.text;
171
174
  if (!text) return data;
172
175
 
173
176
  // try to get json object, which will be stringify in the client
@@ -108,6 +108,11 @@ export interface AIGenerateAction {
108
108
  id?: string,
109
109
  action?: Action,
110
110
  ) => AbortController | undefined;
111
+ internal_toggleMessageInToolsCalling: (
112
+ loading: boolean,
113
+ id?: string,
114
+ action?: Action,
115
+ ) => AbortController | undefined;
111
116
  /**
112
117
  * Controls the streaming state of tool calling processes, updating the UI accordingly
113
118
  */
@@ -445,6 +450,7 @@ export const generateAIChat: StateCreator<
445
450
 
446
451
  // if it's the function call message, trigger the function method
447
452
  if (isToolsCalling) {
453
+ get().internal_toggleMessageInToolsCalling(true, assistantId);
448
454
  await refreshMessages();
449
455
  await triggerToolCalls(assistantId, {
450
456
  threadId: params?.threadId,
@@ -467,6 +473,7 @@ export const generateAIChat: StateCreator<
467
473
 
468
474
  // 5. if it's the function call message, trigger the function method
469
475
  if (isFunctionCall) {
476
+ get().internal_toggleMessageInToolsCalling(true, assistantId);
470
477
  await refreshMessages();
471
478
  await triggerToolCalls(assistantId, {
472
479
  threadId: params?.threadId,
@@ -828,6 +835,9 @@ export const generateAIChat: StateCreator<
828
835
  internal_toggleChatLoading: (loading, id, action) => {
829
836
  return get().internal_toggleLoadingArrays('chatLoadingIds', loading, id, action);
830
837
  },
838
+ internal_toggleMessageInToolsCalling: (loading, id) => {
839
+ return get().internal_toggleLoadingArrays('messageInToolsCallingIds', loading, id);
840
+ },
831
841
  internal_toggleChatReasoning: (loading, id, action) => {
832
842
  return get().internal_toggleLoadingArrays('reasoningLoadingIds', loading, id, action);
833
843
  },
@@ -6,6 +6,7 @@ export interface ChatAIChatState {
6
6
  chatLoadingIdsAbortController?: AbortController;
7
7
  inputFiles: File[];
8
8
  inputMessage: string;
9
+ messageInToolsCallingIds: string[];
9
10
  /**
10
11
  * is the message is in RAG flow
11
12
  */
@@ -26,6 +27,7 @@ export const initialAiChatState: ChatAIChatState = {
26
27
  chatLoadingIds: [],
27
28
  inputFiles: [],
28
29
  inputMessage: '',
30
+ messageInToolsCallingIds: [],
29
31
  messageRAGLoadingIds: [],
30
32
  pluginApiLoadingIds: [],
31
33
  reasoningLoadingIds: [],
@@ -177,6 +177,14 @@ const isToolCallStreaming = (id: string, index: number) => (s: ChatStoreState) =
177
177
  return isLoading[index];
178
178
  };
179
179
 
180
+ const isInToolsCalling = (id: string, index: number) => (s: ChatStoreState) => {
181
+ const isStreamingToolsCalling = isToolCallStreaming(id, index)(s);
182
+
183
+ const isInvokingPluginApi = s.messageInToolsCallingIds.includes(id);
184
+
185
+ return isStreamingToolsCalling || isInvokingPluginApi;
186
+ };
187
+
180
188
  const isAIGenerating = (s: ChatStoreState) =>
181
189
  s.chatLoadingIds.some((id) => mainDisplayChatIDs(s).includes(id));
182
190
 
@@ -223,6 +231,7 @@ export const chatSelectors = {
223
231
  isCreatingMessage,
224
232
  isCurrentChatLoaded,
225
233
  isHasMessageLoading,
234
+ isInToolsCalling,
226
235
  isMessageEditing,
227
236
  isMessageGenerating,
228
237
  isMessageInChatReasoning,
@@ -283,6 +283,8 @@ export const chatPlugin: StateCreator<
283
283
 
284
284
  await Promise.all(messagePools);
285
285
 
286
+ await get().internal_toggleMessageInToolsCalling(false, assistantId);
287
+
286
288
  // only default type tool calls should trigger AI message
287
289
  if (!shouldCreateMessage) return;
288
290