@lobehub/lobehub 2.0.0-next.117 → 2.0.0-next.118

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 CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  # Changelog
4
4
 
5
+ ## [Version 2.0.0-next.118](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.117...v2.0.0-next.118)
6
+
7
+ <sup>Released on **2025-11-26**</sup>
8
+
9
+ #### 🐛 Bug Fixes
10
+
11
+ - **misc**: Showing compatibility with both new and old versions of Plugins.
12
+
13
+ <br/>
14
+
15
+ <details>
16
+ <summary><kbd>Improvements and Fixes</kbd></summary>
17
+
18
+ #### What's fixed
19
+
20
+ - **misc**: Showing compatibility with both new and old versions of Plugins, closes [#10418](https://github.com/lobehub/lobe-chat/issues/10418) ([64af7b1](https://github.com/lobehub/lobe-chat/commit/64af7b1))
21
+
22
+ </details>
23
+
24
+ <div align="right">
25
+
26
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
27
+
28
+ </div>
29
+
5
30
  ## [Version 2.0.0-next.117](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.116...v2.0.0-next.117)
6
31
 
7
32
  <sup>Released on **2025-11-25**</sup>
package/changelog/v1.json CHANGED
@@ -1,4 +1,13 @@
1
1
  [
2
+ {
3
+ "children": {
4
+ "fixes": [
5
+ "Showing compatibility with both new and old versions of Plugins."
6
+ ]
7
+ },
8
+ "date": "2025-11-26",
9
+ "version": "2.0.0-next.118"
10
+ },
2
11
  {
3
12
  "children": {
4
13
  "features": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobehub/lobehub",
3
- "version": "2.0.0-next.117",
3
+ "version": "2.0.0-next.118",
4
4
  "description": "LobeHub - an open-source,comprehensive AI Agent framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.",
5
5
  "keywords": [
6
6
  "framework",
@@ -0,0 +1,367 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { headersToRecord } from '../headers';
4
+
5
+ describe('headersToRecord', () => {
6
+ describe('undefined or null input', () => {
7
+ it('should return empty object when headersInit is undefined', () => {
8
+ // Arrange & Act
9
+ const result = headersToRecord(undefined);
10
+
11
+ // Assert
12
+ expect(result).toEqual({});
13
+ });
14
+
15
+ it('should return empty object when headersInit is not provided', () => {
16
+ // Arrange & Act
17
+ const result = headersToRecord();
18
+
19
+ // Assert
20
+ expect(result).toEqual({});
21
+ });
22
+ });
23
+
24
+ describe('Headers instance', () => {
25
+ it('should convert Headers instance to record', () => {
26
+ // Arrange
27
+ const headers = new Headers();
28
+ headers.append('content-type', 'application/json');
29
+ headers.append('authorization', 'Bearer token123');
30
+ headers.append('x-custom-header', 'custom-value');
31
+
32
+ // Act
33
+ const result = headersToRecord(headers);
34
+
35
+ // Assert
36
+ expect(result).toEqual({
37
+ 'content-type': 'application/json',
38
+ 'authorization': 'Bearer token123',
39
+ 'x-custom-header': 'custom-value',
40
+ });
41
+ });
42
+
43
+ it('should handle Headers instance with multiple values for same key', () => {
44
+ // Arrange
45
+ const headers = new Headers();
46
+ headers.append('accept', 'application/json');
47
+ headers.append('accept', 'text/html');
48
+
49
+ // Act
50
+ const result = headersToRecord(headers);
51
+
52
+ // Assert
53
+ expect(result).toHaveProperty('accept');
54
+ expect(typeof result.accept).toBe('string');
55
+ });
56
+
57
+ it('should handle empty Headers instance', () => {
58
+ // Arrange
59
+ const headers = new Headers();
60
+
61
+ // Act
62
+ const result = headersToRecord(headers);
63
+
64
+ // Assert
65
+ expect(result).toEqual({});
66
+ });
67
+ });
68
+
69
+ describe('Array format', () => {
70
+ it('should convert array of tuples to record', () => {
71
+ // Arrange
72
+ const headersArray: [string, string][] = [
73
+ ['content-type', 'application/json'],
74
+ ['authorization', 'Bearer token123'],
75
+ ['x-api-key', 'api-key-value'],
76
+ ];
77
+
78
+ // Act
79
+ const result = headersToRecord(headersArray);
80
+
81
+ // Assert
82
+ expect(result).toEqual({
83
+ 'content-type': 'application/json',
84
+ 'authorization': 'Bearer token123',
85
+ 'x-api-key': 'api-key-value',
86
+ });
87
+ });
88
+
89
+ it('should handle empty array', () => {
90
+ // Arrange
91
+ const headersArray: [string, string][] = [];
92
+
93
+ // Act
94
+ const result = headersToRecord(headersArray);
95
+
96
+ // Assert
97
+ expect(result).toEqual({});
98
+ });
99
+
100
+ it('should handle array with single header', () => {
101
+ // Arrange
102
+ const headersArray: [string, string][] = [['x-single-header', 'single-value']];
103
+
104
+ // Act
105
+ const result = headersToRecord(headersArray);
106
+
107
+ // Assert
108
+ expect(result).toEqual({
109
+ 'x-single-header': 'single-value',
110
+ });
111
+ });
112
+ });
113
+
114
+ describe('Plain object', () => {
115
+ it('should convert plain object to record', () => {
116
+ // Arrange
117
+ const headersObj = {
118
+ 'content-type': 'application/json',
119
+ 'authorization': 'Bearer token123',
120
+ 'x-custom': 'value',
121
+ };
122
+
123
+ // Act
124
+ const result = headersToRecord(headersObj);
125
+
126
+ // Assert
127
+ expect(result).toEqual({
128
+ 'content-type': 'application/json',
129
+ 'authorization': 'Bearer token123',
130
+ 'x-custom': 'value',
131
+ });
132
+ });
133
+
134
+ it('should handle empty object', () => {
135
+ // Arrange
136
+ const headersObj = {};
137
+
138
+ // Act
139
+ const result = headersToRecord(headersObj);
140
+
141
+ // Assert
142
+ expect(result).toEqual({});
143
+ });
144
+
145
+ it('should handle object with special characters in values', () => {
146
+ // Arrange
147
+ const headersObj = {
148
+ 'authorization': 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9',
149
+ 'x-special': 'value with spaces and symbols: !@#$%',
150
+ };
151
+
152
+ // Act
153
+ const result = headersToRecord(headersObj);
154
+
155
+ // Assert
156
+ expect(result).toEqual({
157
+ 'authorization': 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9',
158
+ 'x-special': 'value with spaces and symbols: !@#$%',
159
+ });
160
+ });
161
+ });
162
+
163
+ describe('Restricted headers filtering', () => {
164
+ it('should remove "host" header from Headers instance', () => {
165
+ // Arrange
166
+ const headers = new Headers();
167
+ headers.append('host', 'example.com');
168
+ headers.append('content-type', 'application/json');
169
+
170
+ // Act
171
+ const result = headersToRecord(headers);
172
+
173
+ // Assert
174
+ expect(result).not.toHaveProperty('host');
175
+ expect(result).toEqual({
176
+ 'content-type': 'application/json',
177
+ });
178
+ });
179
+
180
+ it('should remove "connection" header from array', () => {
181
+ // Arrange
182
+ const headersArray: [string, string][] = [
183
+ ['connection', 'keep-alive'],
184
+ ['authorization', 'Bearer token'],
185
+ ];
186
+
187
+ // Act
188
+ const result = headersToRecord(headersArray);
189
+
190
+ // Assert
191
+ expect(result).not.toHaveProperty('connection');
192
+ expect(result).toEqual({
193
+ authorization: 'Bearer token',
194
+ });
195
+ });
196
+
197
+ it('should remove "content-length" header from plain object', () => {
198
+ // Arrange
199
+ const headersObj = {
200
+ 'content-length': '1234',
201
+ 'content-type': 'application/json',
202
+ };
203
+
204
+ // Act
205
+ const result = headersToRecord(headersObj);
206
+
207
+ // Assert
208
+ expect(result).not.toHaveProperty('content-length');
209
+ expect(result).toEqual({
210
+ 'content-type': 'application/json',
211
+ });
212
+ });
213
+
214
+ it('should remove all restricted headers (host, connection, content-length)', () => {
215
+ // Arrange
216
+ const headersObj = {
217
+ 'host': 'example.com',
218
+ 'connection': 'keep-alive',
219
+ 'content-length': '1234',
220
+ 'authorization': 'Bearer token',
221
+ 'content-type': 'application/json',
222
+ };
223
+
224
+ // Act
225
+ const result = headersToRecord(headersObj);
226
+
227
+ // Assert
228
+ expect(result).not.toHaveProperty('host');
229
+ expect(result).not.toHaveProperty('connection');
230
+ expect(result).not.toHaveProperty('content-length');
231
+ expect(result).toEqual({
232
+ 'authorization': 'Bearer token',
233
+ 'content-type': 'application/json',
234
+ });
235
+ });
236
+
237
+ it('should handle case when only restricted headers are present', () => {
238
+ // Arrange
239
+ const headersObj = {
240
+ 'host': 'example.com',
241
+ 'connection': 'keep-alive',
242
+ 'content-length': '1234',
243
+ };
244
+
245
+ // Act
246
+ const result = headersToRecord(headersObj);
247
+
248
+ // Assert
249
+ expect(result).toEqual({});
250
+ });
251
+ });
252
+
253
+ describe('Edge cases', () => {
254
+ it('should handle headers with empty string values', () => {
255
+ // Arrange
256
+ const headersObj = {
257
+ 'x-empty': '',
258
+ 'x-normal': 'value',
259
+ };
260
+
261
+ // Act
262
+ const result = headersToRecord(headersObj);
263
+
264
+ // Assert
265
+ expect(result).toEqual({
266
+ 'x-empty': '',
267
+ 'x-normal': 'value',
268
+ });
269
+ });
270
+
271
+ it('should handle headers with numeric-like values as strings', () => {
272
+ // Arrange
273
+ const headersArray: [string, string][] = [
274
+ ['x-request-id', '12345'],
275
+ ['x-retry-count', '3'],
276
+ ];
277
+
278
+ // Act
279
+ const result = headersToRecord(headersArray);
280
+
281
+ // Assert
282
+ expect(result).toEqual({
283
+ 'x-request-id': '12345',
284
+ 'x-retry-count': '3',
285
+ });
286
+ });
287
+
288
+ it('should handle case-sensitive header names', () => {
289
+ // Arrange
290
+ const headersObj = {
291
+ 'Content-Type': 'application/json',
292
+ 'Authorization': 'Bearer token',
293
+ };
294
+
295
+ // Act
296
+ const result = headersToRecord(headersObj);
297
+
298
+ // Assert
299
+ expect(result).toEqual({
300
+ 'Content-Type': 'application/json',
301
+ 'Authorization': 'Bearer token',
302
+ });
303
+ });
304
+
305
+ it('should preserve header order from array input', () => {
306
+ // Arrange
307
+ const headersArray: [string, string][] = [
308
+ ['z-last', 'last'],
309
+ ['a-first', 'first'],
310
+ ['m-middle', 'middle'],
311
+ ];
312
+
313
+ // Act
314
+ const result = headersToRecord(headersArray);
315
+
316
+ // Assert
317
+ expect(Object.keys(result)).toEqual(['z-last', 'a-first', 'm-middle']);
318
+ });
319
+ });
320
+
321
+ describe('Real-world scenarios', () => {
322
+ it('should handle typical SSE request headers', () => {
323
+ // Arrange
324
+ const headers = new Headers();
325
+ headers.append('accept', 'text/event-stream');
326
+ headers.append('content-type', 'application/json');
327
+ headers.append('authorization', 'Bearer abc123');
328
+ headers.append('cache-control', 'no-cache');
329
+ headers.append('connection', 'keep-alive'); // Should be filtered
330
+
331
+ // Act
332
+ const result = headersToRecord(headers);
333
+
334
+ // Assert
335
+ expect(result).toEqual({
336
+ 'accept': 'text/event-stream',
337
+ 'content-type': 'application/json',
338
+ 'authorization': 'Bearer abc123',
339
+ 'cache-control': 'no-cache',
340
+ });
341
+ expect(result).not.toHaveProperty('connection');
342
+ });
343
+
344
+ it('should handle API request headers with custom fields', () => {
345
+ // Arrange
346
+ const headersObj = {
347
+ 'content-type': 'application/json',
348
+ 'x-api-key': 'secret-key',
349
+ 'x-request-id': 'req-123',
350
+ 'user-agent': 'MyApp/1.0',
351
+ 'host': 'api.example.com', // Should be filtered
352
+ 'content-length': '256', // Should be filtered
353
+ };
354
+
355
+ // Act
356
+ const result = headersToRecord(headersObj);
357
+
358
+ // Assert
359
+ expect(result).toEqual({
360
+ 'content-type': 'application/json',
361
+ 'x-api-key': 'secret-key',
362
+ 'x-request-id': 'req-123',
363
+ 'user-agent': 'MyApp/1.0',
364
+ });
365
+ });
366
+ });
367
+ });
@@ -1,6 +1,6 @@
1
1
  import { Block } from '@lobehub/ui';
2
2
  import { Empty } from 'antd';
3
- import Link from 'next/link';
3
+ import { Link } from 'react-router-dom';
4
4
  import { memo } from 'react';
5
5
  import { Flexbox } from 'react-layout-kit';
6
6
  import urlJoin from 'url-join';
@@ -20,11 +20,16 @@ const Plugin = memo(() => {
20
20
 
21
21
  return (
22
22
  <Flexbox gap={8}>
23
- {config?.plugins.map((item) => (
24
- <Link href={urlJoin('/discover/plugin', item)} key={item}>
25
- <PluginItem identifier={item} />
26
- </Link>
27
- ))}
23
+ {config?.plugins.map((item) => {
24
+ const identifier =
25
+ typeof item === 'string' ? item : (item as { identifier: string }).identifier;
26
+
27
+ return (
28
+ <Link key={identifier} to={urlJoin('/discover/plugin', identifier)}>
29
+ <PluginItem identifier={identifier} />
30
+ </Link>
31
+ );
32
+ })}
28
33
  </Flexbox>
29
34
  );
30
35
  });
@@ -5,6 +5,8 @@ exports[`MCPClient > Stdio Transport > should list tools via stdio 1`] = `
5
5
  {
6
6
  "description": "Echoes back a message with 'Hello' prefix",
7
7
  "inputSchema": {
8
+ "$schema": "http://json-schema.org/draft-07/schema#",
9
+ "additionalProperties": false,
8
10
  "properties": {
9
11
  "message": {
10
12
  "description": "The message to echo",
@@ -21,6 +23,7 @@ exports[`MCPClient > Stdio Transport > should list tools via stdio 1`] = `
21
23
  {
22
24
  "description": "Lists all available tools and methods",
23
25
  "inputSchema": {
26
+ "$schema": "http://json-schema.org/draft-07/schema#",
24
27
  "properties": {},
25
28
  "type": "object",
26
29
  },
@@ -29,6 +32,8 @@ exports[`MCPClient > Stdio Transport > should list tools via stdio 1`] = `
29
32
  {
30
33
  "description": "Adds two numbers",
31
34
  "inputSchema": {
35
+ "$schema": "http://json-schema.org/draft-07/schema#",
36
+ "additionalProperties": false,
32
37
  "properties": {
33
38
  "a": {
34
39
  "description": "The first number",
@@ -886,8 +886,48 @@ export class DiscoverService {
886
886
  const all = await this._getPluginList(locale);
887
887
  let raw = all.find((item) => item.identifier === identifier);
888
888
  if (!raw) {
889
- log('getPluginDetail: plugin not found for identifier=%s', identifier);
890
- return;
889
+ log('getPluginDetail: plugin not found in default store for identifier=%s, trying MCP plugin', identifier);
890
+ try {
891
+ const mcpDetail = await this.getMcpDetail({ identifier, locale });
892
+ const convertedMcp: Partial<DiscoverPluginDetail> = {
893
+ author:
894
+ typeof (mcpDetail as any).author === 'object'
895
+ ? (mcpDetail as any).author?.name || ''
896
+ : (mcpDetail as any).author || '',
897
+ avatar: (mcpDetail as any).icon || (mcpDetail as any).avatar || '',
898
+ category: (mcpDetail as any).category as any,
899
+ createdAt: (mcpDetail as any).createdAt || '',
900
+ description: mcpDetail.description || '',
901
+ homepage: mcpDetail.homepage || '',
902
+ identifier: mcpDetail.identifier,
903
+ manifest: undefined,
904
+ related: mcpDetail.related.map((item) => ({
905
+ author:
906
+ typeof (item as any).author === 'object'
907
+ ? (item as any).author?.name || ''
908
+ : (item as any).author || '',
909
+ avatar: (item as any).icon || (item as any).avatar || '',
910
+ category: (item as any).category as any,
911
+ createdAt: (item as any).createdAt || '',
912
+ description: (item as any).description || '',
913
+ homepage: (item as any).homepage || '',
914
+ identifier: item.identifier,
915
+ manifest: undefined,
916
+ schemaVersion: 1,
917
+ tags: (item as any).tags || [],
918
+ title: (item as any).name || item.identifier,
919
+ })) as unknown as DiscoverPluginItem[],
920
+ schemaVersion: 1,
921
+ tags: (mcpDetail as any).tags || [],
922
+ title: (mcpDetail as any).name || mcpDetail.identifier,
923
+ };
924
+ const plugin = merge(cloneDeep(DEFAULT_DISCOVER_PLUGIN_ITEM), convertedMcp);
925
+ log('getPluginDetail: returning converted MCP plugin');
926
+ return plugin as DiscoverPluginDetail;
927
+ } catch (error) {
928
+ log('getPluginDetail: MCP plugin not found for identifier=%s, error=%O', identifier, error);
929
+ return;
930
+ }
891
931
  }
892
932
 
893
933
  raw = merge(cloneDeep(DEFAULT_DISCOVER_PLUGIN_ITEM), raw);
@@ -13,9 +13,9 @@ const log = debug('lobe-mcp:content-processor');
13
13
  export type ProcessContentBlocksFn = (blocks: ToolCallContent[]) => Promise<ToolCallContent[]>;
14
14
 
15
15
  /**
16
- * 处理 MCP 返回的 content blocks
17
- * - 上传图片/音频到存储并替换 data 为代理 URL
18
- * - 保持其他类型的 block 不变
16
+ * Process content blocks returned by MCP
17
+ * - Upload images/audio to storage and replace data with proxy URL
18
+ * - Keep other types of blocks unchanged
19
19
  */
20
20
  export const processContentBlocks = async (
21
21
  blocks: ToolCallContent[],
@@ -64,10 +64,10 @@ export const processContentBlocks = async (
64
64
  };
65
65
 
66
66
  /**
67
- * content blocks 转换为字符串
68
- * - text: 提取 text 字段
69
- * - image/audio: 提取 data 字段(通常是上传后的代理 URL
70
- * - 其他: 返回空字符串
67
+ * Convert content blocks to string
68
+ * - text: Extract text field
69
+ * - image/audio: Extract data field (usually the proxy URL after upload)
70
+ * - others: Return empty string
71
71
  */
72
72
  export const contentBlocksToString = (blocks: ToolCallContent[] | null | undefined): string => {
73
73
  if (!blocks) return '';
@@ -195,13 +195,13 @@ class MCPSystemDepsCheckService {
195
195
  // Check if all system dependencies meet requirements
196
196
  const allDependenciesMet = systemDependenciesResults.every((dep) => dep.meetRequirement);
197
197
 
198
- // Check if configuration is required (有必填项)
198
+ // Check if configuration is required (has mandatory fields)
199
199
  const configSchema = option.connection?.configSchema;
200
200
  const needsConfig = Boolean(
201
201
  configSchema &&
202
- // 检查是否有 required 数组且不为空
202
+ // Check if there's a non-empty required array
203
203
  ((Array.isArray(configSchema.required) && configSchema.required.length > 0) ||
204
- // 检查 properties 中是否有字段标记为 required
204
+ // Check if any field in properties is marked as required
205
205
  (configSchema.properties &&
206
206
  Object.values(configSchema.properties).some((prop: any) => prop.required === true))),
207
207
  );
@@ -250,7 +250,7 @@ export class MCPService {
250
250
  } catch (error) {
251
251
  console.error(`Failed to initialize MCP client:`, error);
252
252
 
253
- // 保留完整的错误信息,特别是详细的 stderr 输出
253
+ // Preserve complete error information, especially detailed stderr output
254
254
  const errorMessage = error instanceof Error ? error.message : String(error);
255
255
 
256
256
  if (typeof error === 'object' && !!error && 'data' in error) {
@@ -261,7 +261,7 @@ export class MCPService {
261
261
  });
262
262
  }
263
263
 
264
- // 记录详细的错误信息用于调试
264
+ // Log detailed error information for debugging
265
265
  log('Detailed initialization error: %O', {
266
266
  error: errorMessage,
267
267
  params: this.sanitizeForLogging(params),
@@ -271,7 +271,7 @@ export class MCPService {
271
271
  throw new TRPCError({
272
272
  cause: error,
273
273
  code: 'INTERNAL_SERVER_ERROR',
274
- message: errorMessage, // 直接使用完整的错误信息
274
+ message: errorMessage, // Use complete error message directly
275
275
  });
276
276
  }
277
277
  }
@@ -307,12 +307,12 @@ export class MCPService {
307
307
  ): Promise<LobeChatPluginManifest> {
308
308
  const mcpParams = { name: identifier, type: 'http' as const, url };
309
309
 
310
- // 如果有认证信息,添加到参数中
310
+ // Add authentication info to parameters if available
311
311
  if (auth) {
312
312
  (mcpParams as any).auth = auth;
313
313
  }
314
314
 
315
- // 如果有 headers 信息,添加到参数中
315
+ // Add headers info to parameters if available
316
316
  if (headers) {
317
317
  (mcpParams as any).headers = headers;
318
318
  }
@@ -383,23 +383,23 @@ export class MCPService {
383
383
  log('Checking MCP plugin installation status: %O', loggableInput);
384
384
  const results = [];
385
385
 
386
- // 检查每个部署选项
386
+ // Check each deployment option
387
387
  for (const option of input.deploymentOptions) {
388
- // 使用系统依赖检查服务检查部署选项
388
+ // Use system dependency check service to check deployment option
389
389
  const result = await mcpSystemDepsCheckService.checkDeployOption(option);
390
390
  results.push(result);
391
391
  }
392
392
 
393
- // 找出推荐的或第一个可安装的选项
393
+ // Find the recommended or first installable option
394
394
  const recommendedResult = results.find((r) => r.isRecommended && r.allDependenciesMet);
395
395
  const firstInstallableResult = results.find((r) => r.allDependenciesMet);
396
396
 
397
- // 返回推荐的结果,或第一个可安装的结果,或第一个结果
397
+ // Return the recommended result, or the first installable result, or the first result
398
398
  const bestResult = recommendedResult || firstInstallableResult || results[0];
399
399
 
400
400
  log('Check completed, best result: %O', bestResult);
401
401
 
402
- // 构造返回结果,确保包含配置检查信息
402
+ // Construct return result, ensure configuration check information is included
403
403
  const checkResult: CheckMcpInstallResult = {
404
404
  ...bestResult,
405
405
  allOptions: results,
@@ -407,7 +407,7 @@ export class MCPService {
407
407
  success: true,
408
408
  };
409
409
 
410
- // 如果最佳结果需要配置,确保在顶层设置相关字段
410
+ // If the best result requires configuration, ensure related fields are set at the top level
411
411
  if (bestResult?.needsConfig) {
412
412
  checkResult.needsConfig = true;
413
413
  checkResult.configSchema = bestResult.configSchema;