@lobehub/chat 1.81.5 → 1.81.6
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 +25 -0
- package/changelog/v1.json +9 -0
- package/locales/ar/auth.json +1 -1
- package/locales/ar/hotkey.json +4 -0
- package/locales/bg-BG/auth.json +1 -1
- package/locales/bg-BG/hotkey.json +4 -0
- package/locales/de-DE/auth.json +1 -1
- package/locales/de-DE/hotkey.json +4 -0
- package/locales/en-US/auth.json +1 -1
- package/locales/en-US/hotkey.json +4 -0
- package/locales/es-ES/auth.json +1 -1
- package/locales/es-ES/hotkey.json +4 -0
- package/locales/fa-IR/auth.json +1 -1
- package/locales/fa-IR/hotkey.json +4 -0
- package/locales/fr-FR/auth.json +1 -1
- package/locales/fr-FR/hotkey.json +4 -0
- package/locales/it-IT/auth.json +1 -1
- package/locales/it-IT/hotkey.json +4 -0
- package/locales/ja-JP/auth.json +1 -1
- package/locales/ja-JP/hotkey.json +4 -0
- package/locales/ko-KR/auth.json +1 -1
- package/locales/ko-KR/hotkey.json +4 -0
- package/locales/nl-NL/auth.json +1 -1
- package/locales/nl-NL/hotkey.json +4 -0
- package/locales/pl-PL/auth.json +1 -1
- package/locales/pl-PL/hotkey.json +4 -0
- package/locales/pt-BR/auth.json +1 -1
- package/locales/pt-BR/hotkey.json +4 -0
- package/locales/ru-RU/auth.json +1 -1
- package/locales/ru-RU/hotkey.json +4 -0
- package/locales/tr-TR/auth.json +1 -1
- package/locales/tr-TR/hotkey.json +4 -0
- package/locales/vi-VN/auth.json +1 -1
- package/locales/vi-VN/hotkey.json +4 -0
- package/locales/zh-CN/auth.json +1 -1
- package/locales/zh-CN/changelog.json +1 -1
- package/locales/zh-CN/clerk.json +1 -1
- package/locales/zh-CN/discover.json +1 -1
- package/locales/zh-CN/file.json +1 -1
- package/locales/zh-CN/hotkey.json +4 -0
- package/locales/zh-CN/knowledgeBase.json +1 -1
- package/locales/zh-CN/metadata.json +1 -1
- package/locales/zh-CN/migration.json +1 -1
- package/locales/zh-CN/ragEval.json +1 -1
- package/locales/zh-CN/thread.json +1 -1
- package/locales/zh-CN/welcome.json +1 -1
- package/locales/zh-TW/auth.json +1 -1
- package/locales/zh-TW/hotkey.json +4 -0
- package/package.json +5 -3
- package/src/config/aiModels/github.ts +2 -4
- package/src/config/aiModels/google.ts +3 -4
- package/src/config/aiModels/sensenova.ts +4 -5
- package/src/const/hotkeys.ts +6 -0
- package/src/features/ChatInput/ActionBar/Clear.tsx +18 -8
- package/src/hooks/useHotkeys/chatScope.ts +7 -0
- package/src/libs/agent-runtime/google/index.ts +1 -1
- package/src/libs/agent-runtime/sensenova/index.ts +20 -27
- package/src/libs/agent-runtime/utils/sensenovaHelpers.test.ts +24 -33
- package/src/libs/agent-runtime/utils/sensenovaHelpers.ts +2 -3
- package/src/locales/default/hotkey.ts +4 -0
- package/src/server/modules/MCPClient/__tests__/__snapshots__/index.test.ts.snap +113 -0
- package/src/server/modules/MCPClient/__tests__/index.test.ts +81 -0
- package/src/server/modules/MCPClient/index.ts +80 -0
- package/src/types/hotkey.ts +1 -0
@@ -7,19 +7,28 @@ import { useTranslation } from 'react-i18next';
|
|
7
7
|
import { useIsMobile } from '@/hooks/useIsMobile';
|
8
8
|
import { useChatStore } from '@/store/chat';
|
9
9
|
import { useFileStore } from '@/store/file';
|
10
|
+
import { useUserStore } from '@/store/user';
|
11
|
+
import { settingsSelectors } from '@/store/user/selectors';
|
12
|
+
import { HotkeyEnum } from '@/types/hotkey';
|
13
|
+
|
14
|
+
export const useClearCurrentMessages = () => {
|
15
|
+
const clearMessage = useChatStore((s) => s.clearMessage);
|
16
|
+
const clearImageList = useFileStore((s) => s.clearChatUploadFileList);
|
17
|
+
|
18
|
+
return useCallback(async () => {
|
19
|
+
await clearMessage();
|
20
|
+
clearImageList();
|
21
|
+
}, [clearImageList, clearMessage]);
|
22
|
+
};
|
10
23
|
|
11
24
|
const Clear = memo(() => {
|
12
25
|
const { t } = useTranslation('setting');
|
13
|
-
const
|
14
|
-
|
26
|
+
const hotkey = useUserStore(settingsSelectors.getHotkeyById(HotkeyEnum.ClearCurrentMessages));
|
27
|
+
|
28
|
+
const clearCurrentMessages = useClearCurrentMessages();
|
15
29
|
const [confirmOpened, updateConfirmOpened] = useState(false);
|
16
30
|
const mobile = useIsMobile();
|
17
31
|
|
18
|
-
const resetConversation = useCallback(async () => {
|
19
|
-
await clearMessage();
|
20
|
-
clearImageList();
|
21
|
-
}, []);
|
22
|
-
|
23
32
|
const actionTitle: any = confirmOpened ? void 0 : t('clearCurrentMessages', { ns: 'chat' });
|
24
33
|
|
25
34
|
const popconfirmPlacement = mobile ? 'top' : 'topRight';
|
@@ -28,7 +37,7 @@ const Clear = memo(() => {
|
|
28
37
|
<Popconfirm
|
29
38
|
arrow={false}
|
30
39
|
okButtonProps={{ danger: true, type: 'primary' }}
|
31
|
-
onConfirm={
|
40
|
+
onConfirm={clearCurrentMessages}
|
32
41
|
onOpenChange={updateConfirmOpened}
|
33
42
|
open={confirmOpened}
|
34
43
|
placement={popconfirmPlacement}
|
@@ -45,6 +54,7 @@ const Clear = memo(() => {
|
|
45
54
|
root: { maxWidth: 'none' },
|
46
55
|
}}
|
47
56
|
title={actionTitle}
|
57
|
+
tooltipHotkey={hotkey}
|
48
58
|
/>
|
49
59
|
</Popconfirm>
|
50
60
|
);
|
@@ -3,6 +3,7 @@ import { parseAsBoolean, useQueryState } from 'nuqs';
|
|
3
3
|
import { useEffect } from 'react';
|
4
4
|
import { useHotkeysContext } from 'react-hotkeys-hook';
|
5
5
|
|
6
|
+
import { useClearCurrentMessages } from '@/features/ChatInput/ActionBar/Clear';
|
6
7
|
import { useSendMessage } from '@/features/ChatInput/useSend';
|
7
8
|
import { useOpenChatSettings } from '@/hooks/useInterceptingRoutes';
|
8
9
|
import { useActionSWR } from '@/libs/swr';
|
@@ -78,6 +79,11 @@ export const useAddUserMessageHotkey = () => {
|
|
78
79
|
return useHotkeyById(HotkeyEnum.AddUserMessage, () => send({ onlyAddUserMessage: true }));
|
79
80
|
};
|
80
81
|
|
82
|
+
export const useClearCurrentMessagesHotkey = () => {
|
83
|
+
const clearCurrentMessages = useClearCurrentMessages();
|
84
|
+
return useHotkeyById(HotkeyEnum.ClearCurrentMessages, () => clearCurrentMessages());
|
85
|
+
};
|
86
|
+
|
81
87
|
// 注册聚合
|
82
88
|
|
83
89
|
export const useRegisterChatHotkeys = () => {
|
@@ -95,6 +101,7 @@ export const useRegisterChatHotkeys = () => {
|
|
95
101
|
useRegenerateMessageHotkey();
|
96
102
|
useSaveTopicHotkey();
|
97
103
|
useAddUserMessageHotkey();
|
104
|
+
useClearCurrentMessagesHotkey();
|
98
105
|
|
99
106
|
useEffect(() => {
|
100
107
|
enableScope(HotkeyScopeEnum.Chat);
|
@@ -368,7 +368,7 @@ export class LobeGoogleAI implements LobeRuntimeAI {
|
|
368
368
|
payload?: ChatStreamPayload,
|
369
369
|
): GoogleFunctionCallTool[] | undefined {
|
370
370
|
// 目前 Tools (例如 googleSearch) 无法与其他 FunctionCall 同时使用
|
371
|
-
if (payload?.messages?.some(m => m.tool_calls?.length)) {
|
371
|
+
if (payload?.messages?.some((m) => m.tool_calls?.length)) {
|
372
372
|
return; // 若历史消息中已有 function calling,则不再注入任何 Tools
|
373
373
|
}
|
374
374
|
if (payload?.enabledSearch) {
|
@@ -1,10 +1,9 @@
|
|
1
|
+
import type { ChatModelCard } from '@/types/llm';
|
2
|
+
|
1
3
|
import { ModelProvider } from '../types';
|
2
4
|
import { LobeOpenAICompatibleFactory } from '../utils/openaiCompatibleFactory';
|
3
|
-
|
4
5
|
import { convertSenseNovaMessage } from '../utils/sensenovaHelpers';
|
5
6
|
|
6
|
-
import type { ChatModelCard } from '@/types/llm';
|
7
|
-
|
8
7
|
export interface SenseNovaModelCard {
|
9
8
|
id: string;
|
10
9
|
}
|
@@ -21,10 +20,10 @@ export const LobeSenseNovaAI = LobeOpenAICompatibleFactory({
|
|
21
20
|
frequency_penalty !== undefined && frequency_penalty > 0 && frequency_penalty <= 2
|
22
21
|
? frequency_penalty
|
23
22
|
: undefined,
|
24
|
-
messages: messages.map((message) =>
|
23
|
+
messages: messages.map((message) =>
|
25
24
|
message.role !== 'user' || !/^Sense(Nova-V6|Chat-Vision)/.test(model)
|
26
25
|
? message
|
27
|
-
: { ...message, content: convertSenseNovaMessage(message.content) }
|
26
|
+
: { ...message, content: convertSenseNovaMessage(message.content) },
|
28
27
|
) as any[],
|
29
28
|
model,
|
30
29
|
stream: true,
|
@@ -42,46 +41,40 @@ export const LobeSenseNovaAI = LobeOpenAICompatibleFactory({
|
|
42
41
|
models: async ({ client }) => {
|
43
42
|
const { LOBE_DEFAULT_MODEL_LIST } = await import('@/config/aiModels');
|
44
43
|
|
45
|
-
const functionCallKeywords = [
|
46
|
-
'sensechat-5',
|
47
|
-
];
|
44
|
+
const functionCallKeywords = ['sensechat-5'];
|
48
45
|
|
49
|
-
const visionKeywords = [
|
50
|
-
'vision',
|
51
|
-
'sensenova-v6',
|
52
|
-
];
|
46
|
+
const visionKeywords = ['vision', 'sensenova-v6'];
|
53
47
|
|
54
|
-
const reasoningKeywords = [
|
55
|
-
'deepseek-r1',
|
56
|
-
'sensenova-v6',
|
57
|
-
];
|
48
|
+
const reasoningKeywords = ['deepseek-r1', 'sensenova-v6'];
|
58
49
|
|
59
50
|
client.baseURL = 'https://api.sensenova.cn/v1/llm';
|
60
51
|
|
61
|
-
const modelsPage = await client.models.list() as any;
|
52
|
+
const modelsPage = (await client.models.list()) as any;
|
62
53
|
const modelList: SenseNovaModelCard[] = modelsPage.data;
|
63
54
|
|
64
55
|
return modelList
|
65
56
|
.map((model) => {
|
66
|
-
const knownModel = LOBE_DEFAULT_MODEL_LIST.find(
|
57
|
+
const knownModel = LOBE_DEFAULT_MODEL_LIST.find(
|
58
|
+
(m) => model.id.toLowerCase() === m.id.toLowerCase(),
|
59
|
+
);
|
67
60
|
|
68
61
|
return {
|
69
62
|
contextWindowTokens: knownModel?.contextWindowTokens ?? undefined,
|
70
63
|
displayName: knownModel?.displayName ?? undefined,
|
71
64
|
enabled: knownModel?.enabled || false,
|
72
65
|
functionCall:
|
73
|
-
functionCallKeywords.some(keyword => model.id.toLowerCase().includes(keyword))
|
74
|
-
|
75
|
-
|
66
|
+
functionCallKeywords.some((keyword) => model.id.toLowerCase().includes(keyword)) ||
|
67
|
+
knownModel?.abilities?.functionCall ||
|
68
|
+
false,
|
76
69
|
id: model.id,
|
77
70
|
reasoning:
|
78
|
-
reasoningKeywords.some(keyword => model.id.toLowerCase().includes(keyword))
|
79
|
-
|
80
|
-
|
71
|
+
reasoningKeywords.some((keyword) => model.id.toLowerCase().includes(keyword)) ||
|
72
|
+
knownModel?.abilities?.reasoning ||
|
73
|
+
false,
|
81
74
|
vision:
|
82
|
-
visionKeywords.some(keyword => model.id.toLowerCase().includes(keyword))
|
83
|
-
|
84
|
-
|
75
|
+
visionKeywords.some((keyword) => model.id.toLowerCase().includes(keyword)) ||
|
76
|
+
knownModel?.abilities?.vision ||
|
77
|
+
false,
|
85
78
|
};
|
86
79
|
})
|
87
80
|
.filter(Boolean) as ChatModelCard[];
|
@@ -1,4 +1,5 @@
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
2
|
+
|
2
3
|
import { convertSenseNovaMessage } from './sensenovaHelpers';
|
3
4
|
|
4
5
|
describe('convertSenseNovaMessage', () => {
|
@@ -10,9 +11,7 @@ describe('convertSenseNovaMessage', () => {
|
|
10
11
|
});
|
11
12
|
|
12
13
|
it('should handle array content with text type', () => {
|
13
|
-
const content = [
|
14
|
-
{ type: 'text', text: 'Hello world' }
|
15
|
-
];
|
14
|
+
const content = [{ type: 'text', text: 'Hello world' }];
|
16
15
|
const result = convertSenseNovaMessage(content);
|
17
16
|
|
18
17
|
expect(result).toEqual([{ type: 'text', text: 'Hello world' }]);
|
@@ -20,38 +19,32 @@ describe('convertSenseNovaMessage', () => {
|
|
20
19
|
|
21
20
|
it('should convert image_url with base64 format to image_base64', () => {
|
22
21
|
const content = [
|
23
|
-
{ type: 'image_url', image_url: { url: 'data:image/jpeg;base64,ABCDEF123456' } }
|
22
|
+
{ type: 'image_url', image_url: { url: 'data:image/jpeg;base64,ABCDEF123456' } },
|
24
23
|
];
|
25
24
|
const result = convertSenseNovaMessage(content);
|
26
25
|
|
27
|
-
expect(result).toEqual([
|
28
|
-
{ type: 'image_base64', image_base64: 'ABCDEF123456' }
|
29
|
-
]);
|
26
|
+
expect(result).toEqual([{ type: 'image_base64', image_base64: 'ABCDEF123456' }]);
|
30
27
|
});
|
31
28
|
|
32
29
|
it('should keep image_url format for non-base64 urls', () => {
|
33
|
-
const content = [
|
34
|
-
{ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } }
|
35
|
-
];
|
30
|
+
const content = [{ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } }];
|
36
31
|
const result = convertSenseNovaMessage(content);
|
37
32
|
|
38
|
-
expect(result).toEqual([
|
39
|
-
{ type: 'image_url', image_url: 'https://example.com/image.jpg' }
|
40
|
-
]);
|
33
|
+
expect(result).toEqual([{ type: 'image_url', image_url: 'https://example.com/image.jpg' }]);
|
41
34
|
});
|
42
35
|
|
43
36
|
it('should handle mixed content types', () => {
|
44
37
|
const content = [
|
45
38
|
{ type: 'text', text: 'Hello world' },
|
46
39
|
{ type: 'image_url', image_url: { url: 'data:image/jpeg;base64,ABCDEF123456' } },
|
47
|
-
{ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } }
|
40
|
+
{ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } },
|
48
41
|
];
|
49
42
|
const result = convertSenseNovaMessage(content);
|
50
43
|
|
51
44
|
expect(result).toEqual([
|
52
45
|
{ type: 'text', text: 'Hello world' },
|
53
46
|
{ type: 'image_base64', image_base64: 'ABCDEF123456' },
|
54
|
-
{ type: 'image_url', image_url: 'https://example.com/image.jpg' }
|
47
|
+
{ type: 'image_url', image_url: 'https://example.com/image.jpg' },
|
55
48
|
]);
|
56
49
|
});
|
57
50
|
|
@@ -59,13 +52,11 @@ describe('convertSenseNovaMessage', () => {
|
|
59
52
|
const content = [
|
60
53
|
{ type: 'text', text: 'Hello world' },
|
61
54
|
{ type: 'unknown', value: 'should be filtered' },
|
62
|
-
{ type: 'image_url', image_url: { notUrl: 'missing url field' } }
|
55
|
+
{ type: 'image_url', image_url: { notUrl: 'missing url field' } },
|
63
56
|
];
|
64
57
|
const result = convertSenseNovaMessage(content);
|
65
58
|
|
66
|
-
expect(result).toEqual([
|
67
|
-
{ type: 'text', text: 'Hello world' }
|
68
|
-
]);
|
59
|
+
expect(result).toEqual([{ type: 'text', text: 'Hello world' }]);
|
69
60
|
});
|
70
61
|
|
71
62
|
it('should handle the example input format correctly', () => {
|
@@ -73,36 +64,36 @@ describe('convertSenseNovaMessage', () => {
|
|
73
64
|
{
|
74
65
|
content: [
|
75
66
|
{
|
76
|
-
content:
|
77
|
-
role:
|
67
|
+
content: 'Hi',
|
68
|
+
role: 'user',
|
78
69
|
},
|
79
70
|
{
|
80
71
|
image_url: {
|
81
|
-
detail:
|
82
|
-
url:
|
72
|
+
detail: 'auto',
|
73
|
+
url: 'data:image/jpeg;base64,ABCDEF123456',
|
83
74
|
},
|
84
|
-
type:
|
85
|
-
}
|
75
|
+
type: 'image_url',
|
76
|
+
},
|
86
77
|
],
|
87
|
-
role:
|
88
|
-
}
|
78
|
+
role: 'user',
|
79
|
+
},
|
89
80
|
];
|
90
81
|
|
91
82
|
// This is simulating how you might use convertSenseNovaMessage with the example input
|
92
83
|
// Note: The actual function only converts the content part, not the entire messages array
|
93
84
|
const content = messages[0].content;
|
94
|
-
|
85
|
+
|
95
86
|
// This is how the function would be expected to handle a mixed array like this
|
96
|
-
// However, the actual test would need to be adjusted based on how your function
|
87
|
+
// However, the actual test would need to be adjusted based on how your function
|
97
88
|
// is intended to handle this specific format with nested content objects
|
98
89
|
const result = convertSenseNovaMessage([
|
99
|
-
{ type: 'text', text:
|
100
|
-
{ type: 'image_url', image_url: { url:
|
90
|
+
{ type: 'text', text: 'Hi' },
|
91
|
+
{ type: 'image_url', image_url: { url: 'data:image/jpeg;base64,ABCDEF123456' } },
|
101
92
|
]);
|
102
93
|
|
103
94
|
expect(result).toEqual([
|
104
|
-
{ type: 'text', text:
|
105
|
-
{ type: 'image_base64', image_base64:
|
95
|
+
{ type: 'text', text: 'Hi' },
|
96
|
+
{ type: 'image_base64', image_base64: 'ABCDEF123456' },
|
106
97
|
]);
|
107
98
|
});
|
108
99
|
});
|
@@ -1,5 +1,4 @@
|
|
1
1
|
export const convertSenseNovaMessage = (content: any) => {
|
2
|
-
|
3
2
|
// 如果为单条 string 类 content,则格式转换为 text 类
|
4
3
|
if (typeof content === 'string') {
|
5
4
|
return [{ text: content, type: 'text' }];
|
@@ -16,8 +15,8 @@ export const convertSenseNovaMessage = (content: any) => {
|
|
16
15
|
const url = item.image_url.url;
|
17
16
|
|
18
17
|
// 如果 image_url 为 base64 格式,则返回 image_base64 类,否则返回 image_url 类
|
19
|
-
return url.startsWith('data:image/jpeg;base64')
|
20
|
-
? {
|
18
|
+
return url.startsWith('data:image/jpeg;base64')
|
19
|
+
? {
|
21
20
|
image_base64: url.split(',')[1],
|
22
21
|
type: 'image_base64',
|
23
22
|
}
|
@@ -0,0 +1,113 @@
|
|
1
|
+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
2
|
+
|
3
|
+
exports[`MCPClient > Stdio Transport (using SDK Mock Server) > should list tools via stdio 1`] = `
|
4
|
+
[
|
5
|
+
{
|
6
|
+
"description": "Echoes back a message with 'Hello' prefix",
|
7
|
+
"inputSchema": {
|
8
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
9
|
+
"additionalProperties": false,
|
10
|
+
"properties": {
|
11
|
+
"message": {
|
12
|
+
"description": "The message to echo",
|
13
|
+
"type": "string",
|
14
|
+
},
|
15
|
+
},
|
16
|
+
"required": [
|
17
|
+
"message",
|
18
|
+
],
|
19
|
+
"type": "object",
|
20
|
+
},
|
21
|
+
"name": "echo",
|
22
|
+
},
|
23
|
+
{
|
24
|
+
"description": "Lists all available tools and methods",
|
25
|
+
"inputSchema": {
|
26
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
27
|
+
"additionalProperties": false,
|
28
|
+
"properties": {},
|
29
|
+
"type": "object",
|
30
|
+
},
|
31
|
+
"name": "debug",
|
32
|
+
},
|
33
|
+
{
|
34
|
+
"description": "Adds two numbers",
|
35
|
+
"inputSchema": {
|
36
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
37
|
+
"additionalProperties": false,
|
38
|
+
"properties": {
|
39
|
+
"a": {
|
40
|
+
"description": "The first number",
|
41
|
+
"type": "number",
|
42
|
+
},
|
43
|
+
"b": {
|
44
|
+
"description": "The second number",
|
45
|
+
"type": "number",
|
46
|
+
},
|
47
|
+
},
|
48
|
+
"required": [
|
49
|
+
"a",
|
50
|
+
"b",
|
51
|
+
],
|
52
|
+
"type": "object",
|
53
|
+
},
|
54
|
+
"name": "add",
|
55
|
+
},
|
56
|
+
]
|
57
|
+
`;
|
58
|
+
|
59
|
+
exports[`MCPClient > Stdio Transport > should list tools via stdio 1`] = `
|
60
|
+
[
|
61
|
+
{
|
62
|
+
"description": "Echoes back a message with 'Hello' prefix",
|
63
|
+
"inputSchema": {
|
64
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
65
|
+
"additionalProperties": false,
|
66
|
+
"properties": {
|
67
|
+
"message": {
|
68
|
+
"description": "The message to echo",
|
69
|
+
"type": "string",
|
70
|
+
},
|
71
|
+
},
|
72
|
+
"required": [
|
73
|
+
"message",
|
74
|
+
],
|
75
|
+
"type": "object",
|
76
|
+
},
|
77
|
+
"name": "echo",
|
78
|
+
},
|
79
|
+
{
|
80
|
+
"description": "Lists all available tools and methods",
|
81
|
+
"inputSchema": {
|
82
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
83
|
+
"additionalProperties": false,
|
84
|
+
"properties": {},
|
85
|
+
"type": "object",
|
86
|
+
},
|
87
|
+
"name": "debug",
|
88
|
+
},
|
89
|
+
{
|
90
|
+
"description": "Adds two numbers",
|
91
|
+
"inputSchema": {
|
92
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
93
|
+
"additionalProperties": false,
|
94
|
+
"properties": {
|
95
|
+
"a": {
|
96
|
+
"description": "The first number",
|
97
|
+
"type": "number",
|
98
|
+
},
|
99
|
+
"b": {
|
100
|
+
"description": "The second number",
|
101
|
+
"type": "number",
|
102
|
+
},
|
103
|
+
},
|
104
|
+
"required": [
|
105
|
+
"a",
|
106
|
+
"b",
|
107
|
+
],
|
108
|
+
"type": "object",
|
109
|
+
},
|
110
|
+
"name": "add",
|
111
|
+
},
|
112
|
+
]
|
113
|
+
`;
|
@@ -0,0 +1,81 @@
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
2
|
+
|
3
|
+
import { MCPClient } from '../index';
|
4
|
+
|
5
|
+
describe('MCPClient', () => {
|
6
|
+
// --- Updated Stdio Transport tests ---
|
7
|
+
describe('Stdio Transport', () => {
|
8
|
+
let mcpClient: MCPClient;
|
9
|
+
const stdioConnection = {
|
10
|
+
id: 'mcp-hello-world',
|
11
|
+
name: 'Stdio SDK Test Connection',
|
12
|
+
type: 'stdio' as const,
|
13
|
+
command: 'npx', // Use node to run the compiled mock server
|
14
|
+
args: ['mcp-hello-world@1.1.2'], // Use the path to the compiled JS file
|
15
|
+
};
|
16
|
+
|
17
|
+
beforeEach(async () => {
|
18
|
+
// args are now set directly in the connection object
|
19
|
+
mcpClient = new MCPClient(stdioConnection);
|
20
|
+
// Initialize the client - this starts the stdio process
|
21
|
+
await mcpClient.initialize();
|
22
|
+
// Add a small delay to allow the server process to fully start (optional, but can help)
|
23
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
24
|
+
});
|
25
|
+
|
26
|
+
afterEach(async () => {
|
27
|
+
// Assume SDK client/transport handles process termination gracefully
|
28
|
+
// If processes leak, more explicit cleanup might be needed here
|
29
|
+
});
|
30
|
+
|
31
|
+
it('should create and initialize an instance with stdio transport', () => {
|
32
|
+
expect(mcpClient).toBeInstanceOf(MCPClient);
|
33
|
+
});
|
34
|
+
|
35
|
+
it('should list tools via stdio', async () => {
|
36
|
+
const result = await mcpClient.listTools();
|
37
|
+
|
38
|
+
// Check exact length if no other tools are expected
|
39
|
+
expect(result.tools).toHaveLength(3);
|
40
|
+
|
41
|
+
// Expect the tools defined in mock-sdk-server.ts
|
42
|
+
expect(result.tools).toMatchSnapshot();
|
43
|
+
});
|
44
|
+
|
45
|
+
it('should call the "echo" tool via stdio', async () => {
|
46
|
+
const toolName = 'echo';
|
47
|
+
const toolArgs = { message: 'hello stdio' };
|
48
|
+
// Expect the result format defined in mock-sdk-server.ts
|
49
|
+
const expectedResult = {
|
50
|
+
content: [{ type: 'text', text: 'You said: hello stdio' }],
|
51
|
+
};
|
52
|
+
|
53
|
+
const result = await mcpClient.callTool(toolName, toolArgs);
|
54
|
+
expect(result).toEqual(expectedResult);
|
55
|
+
});
|
56
|
+
|
57
|
+
it('should call the "add" tool via stdio', async () => {
|
58
|
+
const toolName = 'add';
|
59
|
+
const toolArgs = { a: 5, b: 7 };
|
60
|
+
|
61
|
+
const result = await mcpClient.callTool(toolName, toolArgs);
|
62
|
+
expect(result).toEqual({
|
63
|
+
content: [{ type: 'text', text: 'The sum is: 12' }],
|
64
|
+
});
|
65
|
+
});
|
66
|
+
});
|
67
|
+
|
68
|
+
// Error Handling tests remain the same...
|
69
|
+
describe('Error Handling', () => {
|
70
|
+
it('should throw error for unsupported connection type', () => {
|
71
|
+
const connection = {
|
72
|
+
id: 'invalid-test',
|
73
|
+
name: 'Invalid Test Connection',
|
74
|
+
type: 'invalid' as any,
|
75
|
+
};
|
76
|
+
expect(() => new MCPClient(connection as any)).toThrow(
|
77
|
+
'Unsupported MCP connection type: invalid',
|
78
|
+
);
|
79
|
+
});
|
80
|
+
});
|
81
|
+
});
|
@@ -0,0 +1,80 @@
|
|
1
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
2
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
3
|
+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
4
|
+
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.d.ts';
|
5
|
+
import debug from 'debug';
|
6
|
+
|
7
|
+
const log = debug('lobe-mcp:client');
|
8
|
+
|
9
|
+
interface MCPConnectionBase {
|
10
|
+
id: string;
|
11
|
+
name: string;
|
12
|
+
type: 'http' | 'stdio';
|
13
|
+
}
|
14
|
+
|
15
|
+
interface HttpMCPConnection extends MCPConnectionBase {
|
16
|
+
type: 'http';
|
17
|
+
url: string;
|
18
|
+
}
|
19
|
+
|
20
|
+
interface StdioMCPConnection extends MCPConnectionBase {
|
21
|
+
args: string[];
|
22
|
+
command: string;
|
23
|
+
type: 'stdio';
|
24
|
+
}
|
25
|
+
type MCPConnection = HttpMCPConnection | StdioMCPConnection;
|
26
|
+
|
27
|
+
export class MCPClient {
|
28
|
+
private mcp: Client;
|
29
|
+
private transport: Transport;
|
30
|
+
|
31
|
+
constructor(connection: MCPConnection) {
|
32
|
+
log('Creating MCPClient with connection: %O', connection);
|
33
|
+
this.mcp = new Client({ name: 'lobehub-mcp-client', version: '1.0.0' });
|
34
|
+
|
35
|
+
switch (connection.type) {
|
36
|
+
case 'http': {
|
37
|
+
log('Using HTTP transport with url: %s', connection.url);
|
38
|
+
this.transport = new StreamableHTTPClientTransport(new URL(connection.url));
|
39
|
+
break;
|
40
|
+
}
|
41
|
+
case 'stdio': {
|
42
|
+
log(
|
43
|
+
'Using Stdio transport with command: %s and args: %O',
|
44
|
+
connection.command,
|
45
|
+
connection.args,
|
46
|
+
);
|
47
|
+
this.transport = new StdioClientTransport({
|
48
|
+
args: connection.args,
|
49
|
+
command: connection.command,
|
50
|
+
});
|
51
|
+
break;
|
52
|
+
}
|
53
|
+
default: {
|
54
|
+
const err = new Error(`Unsupported MCP connection type: ${(connection as any).type}`);
|
55
|
+
log('Error creating client: %O', err);
|
56
|
+
throw err;
|
57
|
+
}
|
58
|
+
}
|
59
|
+
}
|
60
|
+
|
61
|
+
async initialize() {
|
62
|
+
log('Initializing MCP connection...');
|
63
|
+
await this.mcp.connect(this.transport);
|
64
|
+
log('MCP connection initialized.');
|
65
|
+
}
|
66
|
+
|
67
|
+
async listTools() {
|
68
|
+
log('Listing tools...');
|
69
|
+
const tools = await this.mcp.listTools();
|
70
|
+
log('Listed tools: %O', tools);
|
71
|
+
return tools;
|
72
|
+
}
|
73
|
+
|
74
|
+
async callTool(toolName: string, args: any) {
|
75
|
+
log('Calling tool: %s with args: %O', toolName, args);
|
76
|
+
const result = await this.mcp.callTool({ arguments: args, name: toolName });
|
77
|
+
log('Tool call result: %O', result);
|
78
|
+
return result;
|
79
|
+
}
|
80
|
+
}
|
package/src/types/hotkey.ts
CHANGED