@lobehub/lobehub 2.0.0-next.292 → 2.0.0-next.294
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/.github/workflows/release-desktop-beta.yml +6 -6
- package/.github/workflows/release-desktop-stable.yml +22 -11
- package/CHANGELOG.md +52 -0
- package/apps/desktop/electron.vite.config.ts +0 -1
- package/apps/desktop/src/main/controllers/McpCtr.ts +50 -18
- package/apps/desktop/src/main/libs/mcp/client.ts +54 -2
- package/changelog/v1.json +10 -0
- package/package.json +1 -1
- package/packages/const/src/cacheControl.ts +1 -0
- package/src/app/(backend)/api/desktop/latest/route.ts +115 -0
- package/src/app/(backend)/middleware/validate/createValidator.test.ts +61 -0
- package/src/app/(backend)/middleware/validate/createValidator.ts +79 -0
- package/src/app/(backend)/middleware/validate/index.ts +3 -0
- package/src/app/[variants]/(main)/agent/_layout/AgentIdSync.tsx +12 -1
- package/src/app/[variants]/(main)/group/_layout/GroupIdSync.tsx +12 -1
- package/src/features/MCP/MCPInstallProgress/InstallError/ErrorDetails.tsx +61 -83
- package/src/features/PluginDevModal/MCPManifestForm/index.tsx +30 -3
- package/src/libs/mcp/types.ts +31 -0
- package/src/server/services/desktopRelease/index.test.ts +65 -0
- package/src/server/services/desktopRelease/index.ts +208 -0
- package/src/store/tool/slices/mcpStore/action.ts +26 -11
|
@@ -4,7 +4,7 @@ name: Release Desktop Beta
|
|
|
4
4
|
# Beta/Nightly 频道发版工作流
|
|
5
5
|
# ============================================
|
|
6
6
|
# 触发条件: 发布包含 pre-release 标识的 release
|
|
7
|
-
# 如: v2.0.0-beta.1, v2.0.0-alpha.1, v2.0.0-rc.1, v2.0.0-nightly.xxx
|
|
7
|
+
# 如: v2.0.0-beta.1, v2.0.0-alpha.1, v2.0.0-rc.1, v2.0.0-nightly.xxx, v2.0.0-next.292
|
|
8
8
|
#
|
|
9
9
|
# 注意: Stable 版本 (如 v2.0.0) 由 release-desktop-stable.yml 处理
|
|
10
10
|
# ============================================
|
|
@@ -24,10 +24,10 @@ env:
|
|
|
24
24
|
|
|
25
25
|
jobs:
|
|
26
26
|
# ============================================
|
|
27
|
-
# 检查是否为 Beta/Nightly 版本 (排除 Stable)
|
|
27
|
+
# 检查是否为 Beta/Nightly/Next 版本 (排除 Stable)
|
|
28
28
|
# ============================================
|
|
29
29
|
check-beta:
|
|
30
|
-
name: Check if Beta/Nightly Release
|
|
30
|
+
name: Check if Beta/Nightly/Next Release
|
|
31
31
|
runs-on: ubuntu-latest
|
|
32
32
|
outputs:
|
|
33
33
|
is_beta: ${{ steps.check.outputs.is_beta }}
|
|
@@ -40,10 +40,10 @@ jobs:
|
|
|
40
40
|
version="${version#v}"
|
|
41
41
|
echo "version=${version}" >> $GITHUB_OUTPUT
|
|
42
42
|
|
|
43
|
-
# Beta/Nightly 版本包含 beta/alpha/rc/nightly
|
|
44
|
-
if [[ "$version" == *"beta"* ]] || [[ "$version" == *"alpha"* ]] || [[ "$version" == *"rc"* ]] || [[ "$version" == *"nightly"* ]]; then
|
|
43
|
+
# Beta/Nightly/Next 版本包含 beta/alpha/rc/nightly/next
|
|
44
|
+
if [[ "$version" == *"beta"* ]] || [[ "$version" == *"alpha"* ]] || [[ "$version" == *"rc"* ]] || [[ "$version" == *"nightly"* ]] || [[ "$version" == *"next"* ]]; then
|
|
45
45
|
echo "is_beta=true" >> $GITHUB_OUTPUT
|
|
46
|
-
echo "✅ Beta/Nightly release detected: $version"
|
|
46
|
+
echo "✅ Beta/Nightly/Next release detected: $version"
|
|
47
47
|
else
|
|
48
48
|
echo "is_beta=false" >> $GITHUB_OUTPUT
|
|
49
49
|
echo "⏭️ Skipping: $version is a stable release (handled by release-desktop-stable.yml)"
|
|
@@ -3,10 +3,10 @@ name: Release Desktop Stable
|
|
|
3
3
|
# ============================================
|
|
4
4
|
# Stable 频道发版工作流
|
|
5
5
|
# ============================================
|
|
6
|
-
# 触发条件: 发布不含 pre-release
|
|
6
|
+
# 触发条件: 发布不含 pre-release 后缀的 release (如 v2.0.0)
|
|
7
7
|
#
|
|
8
8
|
# 与 Beta 的区别:
|
|
9
|
-
# 1. 仅响应 stable 版本 tag (
|
|
9
|
+
# 1. 仅响应 stable 版本 tag (不含任何 '-' 后缀)
|
|
10
10
|
# 2. 使用 STABLE 专用的 Umami 配置
|
|
11
11
|
# 3. 额外上传到 S3 更新服务器
|
|
12
12
|
# 4. 构建时注入 UPDATE_SERVER_URL 让客户端从 S3 检查更新
|
|
@@ -34,6 +34,11 @@ on:
|
|
|
34
34
|
required: false
|
|
35
35
|
type: boolean
|
|
36
36
|
default: true
|
|
37
|
+
build_mac_intel:
|
|
38
|
+
description: 'Build macOS (Intel x64)'
|
|
39
|
+
required: false
|
|
40
|
+
type: boolean
|
|
41
|
+
default: true
|
|
37
42
|
build_windows:
|
|
38
43
|
description: 'Build Windows'
|
|
39
44
|
required: false
|
|
@@ -84,8 +89,8 @@ jobs:
|
|
|
84
89
|
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
|
|
85
90
|
# 手动触发: 使用输入的版本号
|
|
86
91
|
version="${{ inputs.version }}"
|
|
92
|
+
version="${version#v}"
|
|
87
93
|
echo "is_manual=true" >> $GITHUB_OUTPUT
|
|
88
|
-
echo "is_stable=true" >> $GITHUB_OUTPUT
|
|
89
94
|
echo "version=${version}" >> $GITHUB_OUTPUT
|
|
90
95
|
echo "release_notes=" >> $GITHUB_OUTPUT
|
|
91
96
|
echo "🔧 Manual trigger: version=${version}"
|
|
@@ -101,15 +106,15 @@ jobs:
|
|
|
101
106
|
printf '%s\n' "$release_body"
|
|
102
107
|
echo "EOF"
|
|
103
108
|
} >> $GITHUB_OUTPUT
|
|
109
|
+
fi
|
|
104
110
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
fi
|
|
111
|
+
# 检查是否为 stable 版本 (不含任何 '-' 后缀)
|
|
112
|
+
if [[ "$version" == *"-"* ]]; then
|
|
113
|
+
echo "is_stable=false" >> $GITHUB_OUTPUT
|
|
114
|
+
echo "⏭️ Skipping: $version is not a stable release"
|
|
115
|
+
else
|
|
116
|
+
echo "is_stable=true" >> $GITHUB_OUTPUT
|
|
117
|
+
echo "✅ Stable release detected: $version"
|
|
113
118
|
fi
|
|
114
119
|
|
|
115
120
|
# ============================================
|
|
@@ -147,6 +152,12 @@ jobs:
|
|
|
147
152
|
static_matrix=$(echo "$static_matrix" | jq -c --argjson entry "$arm_entry" '. + [$entry]')
|
|
148
153
|
fi
|
|
149
154
|
|
|
155
|
+
if [[ "${{ github.event_name }}" != "workflow_dispatch" ]] || [[ "${{ inputs.build_mac_intel }}" == "true" ]]; then
|
|
156
|
+
echo "Using GitHub-Hosted Runner for macOS Intel x64"
|
|
157
|
+
intel_entry='{"os": "macos-15-intel", "name": "macos-intel"}'
|
|
158
|
+
static_matrix=$(echo "$static_matrix" | jq -c --argjson entry "$intel_entry" '. + [$entry]')
|
|
159
|
+
fi
|
|
160
|
+
|
|
150
161
|
# 输出
|
|
151
162
|
echo "matrix={\"include\":$static_matrix}" >> $GITHUB_OUTPUT
|
|
152
163
|
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,58 @@
|
|
|
2
2
|
|
|
3
3
|
# Changelog
|
|
4
4
|
|
|
5
|
+
## [Version 2.0.0-next.294](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.293...v2.0.0-next.294)
|
|
6
|
+
|
|
7
|
+
<sup>Released on **2026-01-15**</sup>
|
|
8
|
+
|
|
9
|
+
#### 🐛 Bug Fixes
|
|
10
|
+
|
|
11
|
+
- **chat**: Reset activeTopicId when switching agent/group.
|
|
12
|
+
- **mcp**: Fix installation check hanging issue in desktop app.
|
|
13
|
+
|
|
14
|
+
<br/>
|
|
15
|
+
|
|
16
|
+
<details>
|
|
17
|
+
<summary><kbd>Improvements and Fixes</kbd></summary>
|
|
18
|
+
|
|
19
|
+
#### What's fixed
|
|
20
|
+
|
|
21
|
+
- **chat**: Reset activeTopicId when switching agent/group, closes [#11523](https://github.com/lobehub/lobe-chat/issues/11523) ([fde54b0](https://github.com/lobehub/lobe-chat/commit/fde54b0))
|
|
22
|
+
- **mcp**: Fix installation check hanging issue in desktop app, closes [#11524](https://github.com/lobehub/lobe-chat/issues/11524) ([b9341c3](https://github.com/lobehub/lobe-chat/commit/b9341c3))
|
|
23
|
+
|
|
24
|
+
</details>
|
|
25
|
+
|
|
26
|
+
<div align="right">
|
|
27
|
+
|
|
28
|
+
[](#readme-top)
|
|
29
|
+
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
## [Version 2.0.0-next.293](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.292...v2.0.0-next.293)
|
|
33
|
+
|
|
34
|
+
<sup>Released on **2026-01-15**</sup>
|
|
35
|
+
|
|
36
|
+
#### ✨ Features
|
|
37
|
+
|
|
38
|
+
- **desktop**: Add desktop release service and API endpoint.
|
|
39
|
+
|
|
40
|
+
<br/>
|
|
41
|
+
|
|
42
|
+
<details>
|
|
43
|
+
<summary><kbd>Improvements and Fixes</kbd></summary>
|
|
44
|
+
|
|
45
|
+
#### What's improved
|
|
46
|
+
|
|
47
|
+
- **desktop**: Add desktop release service and API endpoint, closes [#11520](https://github.com/lobehub/lobe-chat/issues/11520) ([e3dc5be](https://github.com/lobehub/lobe-chat/commit/e3dc5be))
|
|
48
|
+
|
|
49
|
+
</details>
|
|
50
|
+
|
|
51
|
+
<div align="right">
|
|
52
|
+
|
|
53
|
+
[](#readme-top)
|
|
54
|
+
|
|
55
|
+
</div>
|
|
56
|
+
|
|
5
57
|
## [Version 2.0.0-next.292](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.291...v2.0.0-next.292)
|
|
6
58
|
|
|
7
59
|
<sup>Released on **2026-01-15**</sup>
|
|
@@ -7,7 +7,7 @@ import superjson from 'superjson';
|
|
|
7
7
|
import FileService from '@/services/fileSrv';
|
|
8
8
|
import { createLogger } from '@/utils/logger';
|
|
9
9
|
|
|
10
|
-
import { MCPClient } from '../libs/mcp/client';
|
|
10
|
+
import { MCPClient, MCPConnectionError } from '../libs/mcp/client';
|
|
11
11
|
import type { MCPClientParams, ToolCallContent, ToolCallResult } from '../libs/mcp/types';
|
|
12
12
|
import { ControllerModule, IpcMethod } from './index';
|
|
13
13
|
|
|
@@ -228,8 +228,9 @@ export default class McpCtr extends ControllerModule {
|
|
|
228
228
|
type: 'stdio',
|
|
229
229
|
};
|
|
230
230
|
|
|
231
|
-
|
|
231
|
+
let client: MCPClient | undefined;
|
|
232
232
|
try {
|
|
233
|
+
client = await this.createClient(params);
|
|
233
234
|
const manifest = await client.listManifests();
|
|
234
235
|
const identifier = input.name;
|
|
235
236
|
|
|
@@ -257,8 +258,25 @@ export default class McpCtr extends ControllerModule {
|
|
|
257
258
|
mcpParams: params,
|
|
258
259
|
type: 'mcp' as any,
|
|
259
260
|
});
|
|
261
|
+
} catch (error) {
|
|
262
|
+
// If it's an MCPConnectionError with stderr logs, enhance the error message
|
|
263
|
+
if (error instanceof MCPConnectionError && error.stderrLogs.length > 0) {
|
|
264
|
+
const stderrOutput = error.stderrLogs.join('\n');
|
|
265
|
+
const enhancedError = new Error(
|
|
266
|
+
`${error.message}\n\n--- STDIO Process Output ---\n${stderrOutput}`,
|
|
267
|
+
);
|
|
268
|
+
enhancedError.name = error.name;
|
|
269
|
+
logger.error('getStdioMcpServerManifest failed with STDIO logs:', {
|
|
270
|
+
message: error.message,
|
|
271
|
+
stderrLogs: error.stderrLogs,
|
|
272
|
+
});
|
|
273
|
+
throw enhancedError;
|
|
274
|
+
}
|
|
275
|
+
throw error;
|
|
260
276
|
} finally {
|
|
261
|
-
|
|
277
|
+
if (client) {
|
|
278
|
+
await client.disconnect();
|
|
279
|
+
}
|
|
262
280
|
}
|
|
263
281
|
}
|
|
264
282
|
|
|
@@ -313,8 +331,9 @@ export default class McpCtr extends ControllerModule {
|
|
|
313
331
|
type: 'stdio',
|
|
314
332
|
};
|
|
315
333
|
|
|
316
|
-
|
|
334
|
+
let client: MCPClient | undefined;
|
|
317
335
|
try {
|
|
336
|
+
client = await this.createClient(params);
|
|
318
337
|
const args = safeParseToRecord(input.args);
|
|
319
338
|
|
|
320
339
|
const raw = (await client.callTool(input.toolName, args)) as ToolCallResult;
|
|
@@ -328,10 +347,25 @@ export default class McpCtr extends ControllerModule {
|
|
|
328
347
|
success: true,
|
|
329
348
|
});
|
|
330
349
|
} catch (error) {
|
|
350
|
+
// If it's an MCPConnectionError with stderr logs, enhance the error message
|
|
351
|
+
if (error instanceof MCPConnectionError && error.stderrLogs.length > 0) {
|
|
352
|
+
const stderrOutput = error.stderrLogs.join('\n');
|
|
353
|
+
const enhancedError = new Error(
|
|
354
|
+
`${error.message}\n\n--- STDIO Process Output ---\n${stderrOutput}`,
|
|
355
|
+
);
|
|
356
|
+
enhancedError.name = error.name;
|
|
357
|
+
logger.error('callTool failed with STDIO logs:', {
|
|
358
|
+
message: error.message,
|
|
359
|
+
stderrLogs: error.stderrLogs,
|
|
360
|
+
});
|
|
361
|
+
throw enhancedError;
|
|
362
|
+
}
|
|
331
363
|
logger.error('callTool failed:', error);
|
|
332
364
|
throw error;
|
|
333
365
|
} finally {
|
|
334
|
-
|
|
366
|
+
if (client) {
|
|
367
|
+
await client.disconnect();
|
|
368
|
+
}
|
|
335
369
|
}
|
|
336
370
|
}
|
|
337
371
|
|
|
@@ -361,8 +395,9 @@ export default class McpCtr extends ControllerModule {
|
|
|
361
395
|
}
|
|
362
396
|
|
|
363
397
|
private async checkSystemDependency(dependency: any) {
|
|
398
|
+
const checkCommand = dependency.checkCommand || `${dependency.name} --version`;
|
|
399
|
+
|
|
364
400
|
try {
|
|
365
|
-
const checkCommand = dependency.checkCommand || `${dependency.name} --version`;
|
|
366
401
|
const { stdout, stderr } = await execPromise(checkCommand);
|
|
367
402
|
|
|
368
403
|
if (stderr && !stdout) {
|
|
@@ -444,22 +479,19 @@ export default class McpCtr extends ControllerModule {
|
|
|
444
479
|
const packageName = details?.packageName;
|
|
445
480
|
if (!packageName) return { installed: false };
|
|
446
481
|
|
|
482
|
+
// Only check global npm list - do NOT use npx as it may download packages
|
|
447
483
|
try {
|
|
448
484
|
const { stdout } = await execPromise(`npm list -g ${packageName} --depth=0`);
|
|
449
|
-
if (!stdout.includes('(empty)') && stdout.includes(packageName))
|
|
485
|
+
if (!stdout.includes('(empty)') && stdout.includes(packageName)) {
|
|
486
|
+
return { installed: true };
|
|
487
|
+
}
|
|
450
488
|
} catch {
|
|
451
|
-
// ignore
|
|
489
|
+
// ignore - package not found in global list
|
|
452
490
|
}
|
|
453
491
|
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
} catch (error) {
|
|
458
|
-
return {
|
|
459
|
-
error: error instanceof Error ? error.message : 'Unknown error',
|
|
460
|
-
installed: false,
|
|
461
|
-
};
|
|
462
|
-
}
|
|
492
|
+
// For npm packages, we don't require pre-installation
|
|
493
|
+
// npx will handle downloading and running on-demand during actual MCP connection
|
|
494
|
+
return { installed: false };
|
|
463
495
|
}
|
|
464
496
|
|
|
465
497
|
if (installationMethod === 'python') {
|
|
@@ -553,7 +585,7 @@ export default class McpCtr extends ControllerModule {
|
|
|
553
585
|
const bestResult = recommendedResult || firstInstallableResult || results[0];
|
|
554
586
|
|
|
555
587
|
const checkResult: CheckMcpInstallResult = {
|
|
556
|
-
...
|
|
588
|
+
...bestResult,
|
|
557
589
|
allOptions: results as any,
|
|
558
590
|
platform: process.platform,
|
|
559
591
|
success: true,
|
|
@@ -6,15 +6,31 @@ import {
|
|
|
6
6
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
7
7
|
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
|
8
8
|
import type { Progress } from '@modelcontextprotocol/sdk/types.js';
|
|
9
|
+
import type { Readable } from 'node:stream';
|
|
9
10
|
|
|
10
11
|
import { getDesktopEnv } from '@/env';
|
|
11
12
|
|
|
12
13
|
import type { MCPClientParams, McpPrompt, McpResource, McpTool, ToolCallResult } from './types';
|
|
13
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Custom error class for MCP connection errors that includes STDIO logs
|
|
17
|
+
*/
|
|
18
|
+
export class MCPConnectionError extends Error {
|
|
19
|
+
readonly stderrLogs: string[];
|
|
20
|
+
|
|
21
|
+
constructor(message: string, stderrLogs: string[] = []) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = 'MCPConnectionError';
|
|
24
|
+
this.stderrLogs = stderrLogs;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
14
28
|
export class MCPClient {
|
|
15
29
|
private readonly mcp: Client;
|
|
16
30
|
|
|
17
31
|
private transport: Transport;
|
|
32
|
+
private stderrLogs: string[] = [];
|
|
33
|
+
private isStdio: boolean = false;
|
|
18
34
|
|
|
19
35
|
constructor(params: MCPClientParams) {
|
|
20
36
|
this.mcp = new Client({ name: 'lobehub-desktop-mcp-client', version: '1.0.0' });
|
|
@@ -40,14 +56,21 @@ export class MCPClient {
|
|
|
40
56
|
}
|
|
41
57
|
|
|
42
58
|
case 'stdio': {
|
|
43
|
-
this.
|
|
59
|
+
this.isStdio = true;
|
|
60
|
+
const stdioTransport = new StdioClientTransport({
|
|
44
61
|
args: params.args,
|
|
45
62
|
command: params.command,
|
|
46
63
|
env: {
|
|
47
64
|
...getDefaultEnvironment(),
|
|
48
65
|
...params.env,
|
|
49
66
|
},
|
|
67
|
+
stderr: 'pipe', // Capture stderr for better error messages
|
|
50
68
|
});
|
|
69
|
+
|
|
70
|
+
// Listen to stderr stream to collect logs
|
|
71
|
+
this.setupStderrListener(stdioTransport);
|
|
72
|
+
|
|
73
|
+
this.transport = stdioTransport;
|
|
51
74
|
break;
|
|
52
75
|
}
|
|
53
76
|
|
|
@@ -60,16 +83,45 @@ export class MCPClient {
|
|
|
60
83
|
}
|
|
61
84
|
}
|
|
62
85
|
|
|
86
|
+
private setupStderrListener(transport: StdioClientTransport) {
|
|
87
|
+
const stderr = transport.stderr as Readable | null;
|
|
88
|
+
if (stderr) {
|
|
89
|
+
stderr.on('data', (chunk: Buffer) => {
|
|
90
|
+
const text = chunk.toString('utf8');
|
|
91
|
+
// Split by newlines and filter empty lines
|
|
92
|
+
const lines = text.split('\n').filter((line) => line.trim());
|
|
93
|
+
this.stderrLogs.push(...lines);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Get collected stderr logs from the STDIO process
|
|
100
|
+
*/
|
|
101
|
+
getStderrLogs(): string[] {
|
|
102
|
+
return this.stderrLogs;
|
|
103
|
+
}
|
|
104
|
+
|
|
63
105
|
private isMethodNotFoundError(error: unknown) {
|
|
64
106
|
const err = error as any;
|
|
65
107
|
if (!err) return false;
|
|
108
|
+
// eslint-disable-next-line unicorn/numeric-separators-style
|
|
66
109
|
if (err.code === -32601) return true;
|
|
67
110
|
if (typeof err.message === 'string' && err.message.includes('Method not found')) return true;
|
|
68
111
|
return false;
|
|
69
112
|
}
|
|
70
113
|
|
|
71
114
|
async initialize(options: { onProgress?: (progress: Progress) => void } = {}) {
|
|
72
|
-
|
|
115
|
+
try {
|
|
116
|
+
await this.mcp.connect(this.transport, { onprogress: options.onProgress });
|
|
117
|
+
} catch (error) {
|
|
118
|
+
// If this is a STDIO connection and we have stderr logs, enhance the error
|
|
119
|
+
if (this.isStdio && this.stderrLogs.length > 0) {
|
|
120
|
+
const originalMessage = error instanceof Error ? error.message : String(error);
|
|
121
|
+
throw new MCPConnectionError(originalMessage, this.stderrLogs);
|
|
122
|
+
}
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
73
125
|
}
|
|
74
126
|
|
|
75
127
|
async disconnect() {
|
package/changelog/v1.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lobehub/lobehub",
|
|
3
|
-
"version": "2.0.0-next.
|
|
3
|
+
"version": "2.0.0-next.294",
|
|
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,115 @@
|
|
|
1
|
+
import debug from 'debug';
|
|
2
|
+
import { NextResponse } from 'next/server';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
import { zodValidator } from '@/app/(backend)/middleware/validate';
|
|
6
|
+
import {
|
|
7
|
+
type DesktopDownloadType,
|
|
8
|
+
getLatestDesktopReleaseFromGithub,
|
|
9
|
+
getStableDesktopReleaseInfoFromUpdateServer,
|
|
10
|
+
resolveDesktopDownload,
|
|
11
|
+
resolveDesktopDownloadFromUrls,
|
|
12
|
+
} from '@/server/services/desktopRelease';
|
|
13
|
+
|
|
14
|
+
const log = debug('api-route:desktop:latest');
|
|
15
|
+
|
|
16
|
+
const SupportedTypes = ['mac-arm', 'mac-intel', 'windows', 'linux'] as const;
|
|
17
|
+
|
|
18
|
+
const truthyStringToBoolean = z.preprocess((value) => {
|
|
19
|
+
if (!value) return undefined;
|
|
20
|
+
if (typeof value === 'boolean') return value;
|
|
21
|
+
if (typeof value !== 'string') return undefined;
|
|
22
|
+
|
|
23
|
+
const v = value.trim().toLowerCase();
|
|
24
|
+
if (!v) return undefined;
|
|
25
|
+
|
|
26
|
+
return v === '1' || v === 'true' || v === 'yes' || v === 'y';
|
|
27
|
+
}, z.boolean());
|
|
28
|
+
|
|
29
|
+
const downloadTypeSchema = z.preprocess((value) => {
|
|
30
|
+
if (typeof value !== 'string') return value;
|
|
31
|
+
return value;
|
|
32
|
+
}, z.enum(SupportedTypes));
|
|
33
|
+
|
|
34
|
+
const querySchema = z
|
|
35
|
+
.object({
|
|
36
|
+
asJson: truthyStringToBoolean.optional(),
|
|
37
|
+
as_json: truthyStringToBoolean.optional(),
|
|
38
|
+
type: downloadTypeSchema.optional(),
|
|
39
|
+
})
|
|
40
|
+
.strip()
|
|
41
|
+
.transform((value) => ({
|
|
42
|
+
asJson: value.as_json ?? value.asJson ?? false,
|
|
43
|
+
type: value.type,
|
|
44
|
+
}))
|
|
45
|
+
.superRefine((value, ctx) => {
|
|
46
|
+
if (!value.asJson && !value.type) {
|
|
47
|
+
ctx.addIssue({
|
|
48
|
+
code: z.ZodIssueCode.custom,
|
|
49
|
+
message: '`type` is required when `as_json` is false',
|
|
50
|
+
path: ['type'],
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
export const GET = zodValidator(querySchema)(async (req, _context, query) => {
|
|
56
|
+
try {
|
|
57
|
+
const { asJson, type } = query;
|
|
58
|
+
|
|
59
|
+
const stableInfo = await getStableDesktopReleaseInfoFromUpdateServer();
|
|
60
|
+
|
|
61
|
+
if (!type) {
|
|
62
|
+
if (stableInfo) {
|
|
63
|
+
return NextResponse.json({
|
|
64
|
+
links: {
|
|
65
|
+
'linux': resolveDesktopDownloadFromUrls({ ...stableInfo, type: 'linux' }),
|
|
66
|
+
'mac-arm': resolveDesktopDownloadFromUrls({ ...stableInfo, type: 'mac-arm' }),
|
|
67
|
+
'mac-intel': resolveDesktopDownloadFromUrls({ ...stableInfo, type: 'mac-intel' }),
|
|
68
|
+
'windows': resolveDesktopDownloadFromUrls({ ...stableInfo, type: 'windows' }),
|
|
69
|
+
},
|
|
70
|
+
tag: stableInfo.tag,
|
|
71
|
+
version: stableInfo.version,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const release = await getLatestDesktopReleaseFromGithub();
|
|
76
|
+
const resolveOne = (t: DesktopDownloadType) => resolveDesktopDownload(release, t);
|
|
77
|
+
|
|
78
|
+
return NextResponse.json({
|
|
79
|
+
links: {
|
|
80
|
+
'linux': resolveOne('linux'),
|
|
81
|
+
'mac-arm': resolveOne('mac-arm'),
|
|
82
|
+
'mac-intel': resolveOne('mac-intel'),
|
|
83
|
+
'windows': resolveOne('windows'),
|
|
84
|
+
},
|
|
85
|
+
tag: release.tag_name,
|
|
86
|
+
version: release.tag_name.replace(/^v/i, ''),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const s3Resolved = stableInfo ? resolveDesktopDownloadFromUrls({ ...stableInfo, type }) : null;
|
|
91
|
+
if (s3Resolved) {
|
|
92
|
+
if (asJson) return NextResponse.json(s3Resolved);
|
|
93
|
+
return NextResponse.redirect(s3Resolved.url, { status: 302 });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const release = await getLatestDesktopReleaseFromGithub();
|
|
97
|
+
const resolved = resolveDesktopDownload(release, type);
|
|
98
|
+
if (!resolved) {
|
|
99
|
+
return NextResponse.json(
|
|
100
|
+
{ error: 'No matched asset for type', supportedTypes: SupportedTypes, type },
|
|
101
|
+
{ status: 404 },
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (asJson) return NextResponse.json(resolved);
|
|
106
|
+
|
|
107
|
+
return NextResponse.redirect(resolved.url, { status: 302 });
|
|
108
|
+
} catch (e) {
|
|
109
|
+
log('Failed to resolve latest desktop download: %O', e);
|
|
110
|
+
return NextResponse.json(
|
|
111
|
+
{ error: 'Failed to resolve latest desktop download' },
|
|
112
|
+
{ status: 500 },
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { NextRequest } from 'next/server';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
import { createValidator } from './createValidator';
|
|
6
|
+
|
|
7
|
+
describe('createValidator', () => {
|
|
8
|
+
it('should validate query for GET and pass parsed data to handler', async () => {
|
|
9
|
+
const validate = createValidator({
|
|
10
|
+
errorStatus: 422,
|
|
11
|
+
stopOnFirstError: true,
|
|
12
|
+
omitNotShapeField: true,
|
|
13
|
+
});
|
|
14
|
+
const schema = z.object({ type: z.enum(['a', 'b']) });
|
|
15
|
+
|
|
16
|
+
const handler = validate(schema)(async (_req: Request, _ctx: unknown, data: any) => {
|
|
17
|
+
return new Response(JSON.stringify({ ok: true, data }), { status: 200 });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const res = await handler(new NextRequest('https://example.com/api?type=a'));
|
|
21
|
+
expect(res.status).toBe(200);
|
|
22
|
+
expect(await res.json()).toEqual({ ok: true, data: { type: 'a' } });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should return 422 with one issue when stopOnFirstError', async () => {
|
|
26
|
+
const validate = createValidator({
|
|
27
|
+
errorStatus: 422,
|
|
28
|
+
stopOnFirstError: true,
|
|
29
|
+
omitNotShapeField: true,
|
|
30
|
+
});
|
|
31
|
+
const schema = z.object({
|
|
32
|
+
foo: z.string().min(2),
|
|
33
|
+
type: z.enum(['a', 'b']),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const handler = validate(schema)(async () => new Response('ok'));
|
|
37
|
+
const res = await handler(new NextRequest('https://example.com/api?foo=x&type=c'));
|
|
38
|
+
expect(res.status).toBe(422);
|
|
39
|
+
const body = await res.json();
|
|
40
|
+
expect(body.error).toBe('Invalid request');
|
|
41
|
+
expect(Array.isArray(body.issues)).toBe(true);
|
|
42
|
+
expect(body.issues).toHaveLength(1);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should omit unknown fields when omitNotShapeField enabled', async () => {
|
|
46
|
+
const validate = createValidator({
|
|
47
|
+
errorStatus: 422,
|
|
48
|
+
stopOnFirstError: true,
|
|
49
|
+
omitNotShapeField: true,
|
|
50
|
+
});
|
|
51
|
+
const schema = z.object({ type: z.enum(['a', 'b']) });
|
|
52
|
+
|
|
53
|
+
const handler = validate(schema)(async (_req: Request, _ctx: unknown, data: any) => {
|
|
54
|
+
return new Response(JSON.stringify(data), { status: 200 });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const res = await handler(new NextRequest('https://example.com/api?type=a&extra=1'));
|
|
58
|
+
expect(res.status).toBe(200);
|
|
59
|
+
expect(await res.json()).toEqual({ type: 'a' });
|
|
60
|
+
});
|
|
61
|
+
});
|