@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.
- package/CHANGELOG.md +58 -0
- package/apps/desktop/src/preload/routeInterceptor.ts +28 -0
- package/changelog/v1.json +21 -0
- package/locales/ar/models.json +164 -5
- package/locales/bg-BG/models.json +164 -5
- package/locales/de-DE/models.json +164 -5
- package/locales/en-US/models.json +164 -5
- package/locales/es-ES/models.json +164 -5
- package/locales/fa-IR/models.json +164 -5
- package/locales/fr-FR/models.json +164 -5
- package/locales/it-IT/models.json +164 -5
- package/locales/ja-JP/models.json +164 -5
- package/locales/ko-KR/models.json +164 -5
- package/locales/nl-NL/models.json +164 -5
- package/locales/pl-PL/models.json +164 -5
- package/locales/pt-BR/models.json +164 -5
- package/locales/ru-RU/models.json +164 -5
- package/locales/tr-TR/models.json +164 -5
- package/locales/vi-VN/models.json +164 -5
- package/locales/zh-CN/models.json +164 -5
- package/locales/zh-TW/models.json +164 -5
- package/package.json +1 -1
- package/src/features/Conversation/Messages/Assistant/Tool/Inspector/BuiltinPluginTitle.tsx +2 -9
- package/src/features/Conversation/Messages/Assistant/Tool/Inspector/PluginResultJSON.tsx +7 -2
- package/src/features/Conversation/Messages/Assistant/Tool/Inspector/ToolTitle.tsx +2 -2
- package/src/features/Conversation/Messages/Assistant/Tool/Inspector/index.tsx +5 -11
- package/src/features/Conversation/Messages/Assistant/Tool/Render/Arguments/index.tsx +37 -12
- package/src/features/Conversation/Messages/Assistant/Tool/Render/CustomRender.tsx +43 -34
- package/src/features/Conversation/Messages/Assistant/Tool/index.tsx +23 -6
- package/src/features/Conversation/Messages/Assistant/index.tsx +1 -0
- package/src/features/Conversation/components/VirtualizedList/index.tsx +0 -1
- package/src/server/services/mcp/index.test.ts +161 -0
- package/src/server/services/mcp/index.ts +4 -1
- package/src/store/chat/slices/aiChat/actions/generateAIChat.ts +10 -0
- package/src/store/chat/slices/aiChat/initialState.ts +2 -0
- package/src/store/chat/slices/message/selectors.ts +9 -0
- 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
|
-
|
84
|
-
|
85
|
-
{
|
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
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
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 [
|
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={
|
53
|
+
setShowRender={setShowDetail}
|
37
54
|
showPluginRender={showPluginRender}
|
38
|
-
showRender={
|
55
|
+
showRender={showDetail}
|
39
56
|
/>
|
40
|
-
<AnimatedCollapsed open={
|
57
|
+
<AnimatedCollapsed open={showDetail}>
|
41
58
|
<Render
|
42
59
|
messageId={messageId}
|
43
60
|
requestArgs={requestArgs}
|
@@ -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
|
-
|
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
|
|