@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.
@@ -0,0 +1,79 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { z } from 'zod';
3
+
4
+ export interface ValidatorOptions {
5
+ errorStatus?: number;
6
+ omitNotShapeField?: boolean;
7
+ stopOnFirstError?: boolean;
8
+ }
9
+
10
+ type InferInput<TSchema extends z.ZodTypeAny> = z.input<TSchema>;
11
+ type InferOutput<TSchema extends z.ZodTypeAny> = z.output<TSchema>;
12
+ type MaybePromise<T> = T | Promise<T>;
13
+
14
+ const getRequestInput = async (req: Request): Promise<Record<string, unknown>> => {
15
+ const method = req.method?.toUpperCase?.() ?? 'GET';
16
+ if (method === 'GET' || method === 'HEAD') {
17
+ return Object.fromEntries(new URL(req.url).searchParams.entries());
18
+ }
19
+
20
+ const contentType = req.headers.get('content-type') || '';
21
+ if (contentType.includes('application/json')) {
22
+ try {
23
+ return (await req.json()) as any;
24
+ } catch {
25
+ return {};
26
+ }
27
+ }
28
+
29
+ try {
30
+ return (await (req as any).json?.()) as any;
31
+ } catch {
32
+ return Object.fromEntries(new URL(req.url).searchParams.entries());
33
+ }
34
+ };
35
+
36
+ const applyOptionsToSchema = <TSchema extends z.ZodTypeAny>(
37
+ schema: TSchema,
38
+ options: ValidatorOptions,
39
+ ): z.ZodTypeAny => {
40
+ if (!options.omitNotShapeField) return schema;
41
+ if (schema instanceof z.ZodObject) return schema.strip();
42
+ return schema;
43
+ };
44
+
45
+ export const createValidator =
46
+ (options: ValidatorOptions = {}) =>
47
+ <TSchema extends z.ZodTypeAny>(schema: TSchema) => {
48
+ const errorStatus = options.errorStatus ?? 422;
49
+ const effectiveSchema = applyOptionsToSchema(schema, options) as z.ZodType<
50
+ InferOutput<TSchema>
51
+ >;
52
+
53
+ return <TReq extends NextRequest, TContext>(
54
+ handler: (
55
+ req: TReq,
56
+ context: TContext,
57
+ data: InferOutput<TSchema>,
58
+ ) => MaybePromise<Response>,
59
+ ) =>
60
+ async (req: TReq, context?: TContext) => {
61
+ const input = (await getRequestInput(req)) as InferInput<TSchema>;
62
+ const result = effectiveSchema.safeParse(input);
63
+
64
+ if (!result.success) {
65
+ const issues = options.stopOnFirstError
66
+ ? result.error.issues.slice(0, 1)
67
+ : result.error.issues;
68
+ return NextResponse.json({ error: 'Invalid request', issues }, { status: errorStatus });
69
+ }
70
+
71
+ return handler(req, context as TContext, result.data);
72
+ };
73
+ };
74
+
75
+ export const zodValidator = createValidator({
76
+ errorStatus: 422,
77
+ omitNotShapeField: true,
78
+ stopOnFirstError: true,
79
+ });
@@ -0,0 +1,3 @@
1
+ export type { ValidatorOptions } from './createValidator';
2
+ export { createValidator } from './createValidator';
3
+ export { zodValidator } from './createValidator';
@@ -1,4 +1,5 @@
1
- import { useUnmount } from 'ahooks';
1
+ import { usePrevious, useUnmount } from 'ahooks';
2
+ import { useEffect } from 'react';
2
3
  import { useParams } from 'react-router-dom';
3
4
  import { createStoreUpdater } from 'zustand-utils';
4
5
 
@@ -9,10 +10,20 @@ const AgentIdSync = () => {
9
10
  const useStoreUpdater = createStoreUpdater(useAgentStore);
10
11
  const useChatStoreUpdater = createStoreUpdater(useChatStore);
11
12
  const params = useParams<{ aid?: string }>();
13
+ const prevAgentId = usePrevious(params.aid);
12
14
 
13
15
  useStoreUpdater('activeAgentId', params.aid);
14
16
  useChatStoreUpdater('activeAgentId', params.aid ?? '');
15
17
 
18
+ // Reset activeTopicId when switching to a different agent
19
+ // This prevents messages from being saved to the wrong topic bucket
20
+ useEffect(() => {
21
+ // Only reset topic when switching between agents (not on initial mount)
22
+ if (prevAgentId !== undefined && prevAgentId !== params.aid) {
23
+ useChatStore.getState().switchTopic(null, { skipRefreshMessage: true });
24
+ }
25
+ }, [params.aid, prevAgentId]);
26
+
16
27
  // Clear activeAgentId when unmounting (leaving chat page)
17
28
  useUnmount(() => {
18
29
  useAgentStore.setState({ activeAgentId: undefined });
@@ -1,4 +1,5 @@
1
- import { useUnmount } from 'ahooks';
1
+ import { usePrevious, useUnmount } from 'ahooks';
2
+ import { useEffect } from 'react';
2
3
  import { useParams } from 'react-router-dom';
3
4
  import { createStoreUpdater } from 'zustand-utils';
4
5
 
@@ -10,6 +11,7 @@ const GroupIdSync = () => {
10
11
  const useAgentGroupStoreUpdater = createStoreUpdater(useAgentGroupStore);
11
12
  const useChatStoreUpdater = createStoreUpdater(useChatStore);
12
13
  const params = useParams<{ gid?: string }>();
14
+ const prevGroupId = usePrevious(params.gid);
13
15
  const router = useQueryRoute();
14
16
 
15
17
  // Sync groupId to agentGroupStore and chatStore
@@ -19,6 +21,15 @@ const GroupIdSync = () => {
19
21
  // Inject router to agentGroupStore for navigation
20
22
  useAgentGroupStoreUpdater('router', router);
21
23
 
24
+ // Reset activeTopicId when switching to a different group
25
+ // This prevents messages from being saved to the wrong topic bucket
26
+ useEffect(() => {
27
+ // Only reset topic when switching between groups (not on initial mount)
28
+ if (prevGroupId !== undefined && prevGroupId !== params.gid) {
29
+ useChatStore.getState().switchTopic(null, { skipRefreshMessage: true });
30
+ }
31
+ }, [params.gid, prevGroupId]);
32
+
22
33
  // Clear activeGroupId when unmounting (leaving group page)
23
34
  useUnmount(() => {
24
35
  useAgentGroupStore.setState({ activeGroupId: undefined, router: undefined });
@@ -1,8 +1,7 @@
1
- import { Button, Flexbox, Highlighter, Icon, Tag } from '@lobehub/ui';
1
+ import { Flexbox, Highlighter, Tag } from '@lobehub/ui';
2
2
  import { cssVar } from 'antd-style';
3
- import { ChevronDown, ChevronRight } from 'lucide-react';
4
3
  import * as motion from 'motion/react-m';
5
- import { memo, useState } from 'react';
4
+ import { memo } from 'react';
6
5
  import { useTranslation } from 'react-i18next';
7
6
 
8
7
  import { type MCPErrorInfoMetadata } from '@/types/plugins';
@@ -12,94 +11,73 @@ const ErrorDetails = memo<{
12
11
  errorMessage?: string;
13
12
  }>(({ errorInfo, errorMessage }) => {
14
13
  const { t } = useTranslation('plugin');
15
- const [expanded, setExpanded] = useState(false);
16
14
 
17
15
  return (
18
16
  <Flexbox gap={8}>
19
- <Button
20
- color={'default'}
21
- icon={<Icon icon={expanded ? ChevronDown : ChevronRight} />}
22
- onClick={() => setExpanded(!expanded)}
23
- size="small"
24
- style={{
25
- fontSize: '12px',
26
- padding: '0 4px',
27
- }}
28
- variant="filled"
17
+ <motion.div
18
+ animate={{ height: 'auto', opacity: 1 }}
19
+ initial={{ height: 0, opacity: 0 }}
20
+ style={{ overflow: 'hidden' }}
29
21
  >
30
- {expanded
31
- ? t('mcpInstall.errorDetails.hideDetails')
32
- : t('mcpInstall.errorDetails.showDetails')}
33
- </Button>
34
-
35
- {expanded && (
36
- <motion.div
37
- animate={{ height: 'auto', opacity: 1 }}
38
- initial={{ height: 0, opacity: 0 }}
39
- style={{ overflow: 'hidden' }}
22
+ <Flexbox
23
+ gap={8}
24
+ style={{
25
+ backgroundColor: cssVar.colorFillQuaternary,
26
+ borderRadius: 8,
27
+ fontFamily: 'monospace',
28
+ fontSize: '11px',
29
+ padding: '8px 12px',
30
+ }}
40
31
  >
41
- <Flexbox
42
- gap={8}
43
- style={{
44
- backgroundColor: cssVar.colorFillQuaternary,
45
- borderRadius: 8,
46
- fontFamily: 'monospace',
47
- fontSize: '11px',
48
- padding: '8px 12px',
49
- }}
50
- >
51
- {errorInfo.params && (
52
- <Flexbox gap={4}>
53
- <div>
54
- <Tag color="blue" variant={'filled'}>
55
- {t('mcpInstall.errorDetails.connectionParams')}
56
- </Tag>
57
- </div>
58
- <div style={{ marginTop: 4, wordBreak: 'break-all' }}>
59
- {errorInfo.params.command && (
60
- <div>
61
- {t('mcpInstall.errorDetails.command')}: {errorInfo.params.command}
62
- </div>
63
- )}
64
- {errorInfo.params.args && (
65
- <div>
66
- {t('mcpInstall.errorDetails.args')}: {errorInfo.params.args.join(' ')}
67
- </div>
68
- )}
69
- </div>
70
- </Flexbox>
71
- )}
72
-
73
- {errorInfo.errorLog && (
74
- <Flexbox gap={4}>
75
- <div>
76
- <Tag color="red" variant={'filled'}>
77
- {t('mcpInstall.errorDetails.errorOutput')}
78
- </Tag>
79
- </div>
80
- <Highlighter
81
- language={'log'}
82
- style={{
83
- maxHeight: 200,
84
- overflow: 'auto',
85
- }}
86
- >
87
- {errorInfo.errorLog}
88
- </Highlighter>
89
- </Flexbox>
90
- )}
32
+ {errorInfo.params && (
33
+ <Flexbox gap={4}>
34
+ <div>
35
+ <Tag color="blue" variant={'filled'}>
36
+ {t('mcpInstall.errorDetails.connectionParams')}
37
+ </Tag>
38
+ </div>
39
+ <div style={{ marginTop: 4, wordBreak: 'break-all' }}>
40
+ {errorInfo.params.command && (
41
+ <div>
42
+ {t('mcpInstall.errorDetails.command')}: {errorInfo.params.command}
43
+ </div>
44
+ )}
45
+ {errorInfo.params.args && (
46
+ <div>
47
+ {t('mcpInstall.errorDetails.args')}: {errorInfo.params.args.join(' ')}
48
+ </div>
49
+ )}
50
+ </div>
51
+ </Flexbox>
52
+ )}
91
53
 
92
- {errorInfo.originalError && errorInfo.originalError !== errorMessage && (
54
+ {errorInfo.errorLog && (
55
+ <Flexbox gap={4}>
93
56
  <div>
94
- <Tag color="orange">{t('mcpInstall.errorDetails.originalError')}</Tag>
95
- <div style={{ marginTop: 4, wordBreak: 'break-all' }}>
96
- {errorInfo.originalError}
97
- </div>
57
+ <Tag color="red" variant={'filled'}>
58
+ {t('mcpInstall.errorDetails.errorOutput')}
59
+ </Tag>
98
60
  </div>
99
- )}
100
- </Flexbox>
101
- </motion.div>
102
- )}
61
+ <Highlighter
62
+ language={'log'}
63
+ style={{
64
+ maxHeight: 200,
65
+ overflow: 'auto',
66
+ }}
67
+ >
68
+ {errorInfo.errorLog}
69
+ </Highlighter>
70
+ </Flexbox>
71
+ )}
72
+
73
+ {errorInfo.originalError && errorInfo.originalError !== errorMessage && (
74
+ <div>
75
+ <Tag color="orange">{t('mcpInstall.errorDetails.originalError')}</Tag>
76
+ <div style={{ marginTop: 4, wordBreak: 'break-all' }}>{errorInfo.originalError}</div>
77
+ </div>
78
+ )}
79
+ </Flexbox>
80
+ </motion.div>
103
81
  </Flexbox>
104
82
  );
105
83
  });
@@ -6,8 +6,10 @@ import { useTranslation } from 'react-i18next';
6
6
 
7
7
  import KeyValueEditor from '@/components/KeyValueEditor';
8
8
  import MCPStdioCommandInput from '@/components/MCPStdioCommandInput';
9
+ import ErrorDetails from '@/features/MCP/MCPInstallProgress/InstallError/ErrorDetails';
9
10
  import { useToolStore } from '@/store/tool';
10
11
  import { mcpStoreSelectors, pluginSelectors } from '@/store/tool/selectors';
12
+ import { type MCPErrorInfoMetadata } from '@/types/plugins';
11
13
 
12
14
  import ArgsInput from './ArgsInput';
13
15
  import CollapsibleSection from './CollapsibleSection';
@@ -46,10 +48,12 @@ const MCPManifestForm = ({ form, isEditMode }: MCPManifestFormProps) => {
46
48
  const testState = useToolStore(mcpStoreSelectors.getMCPConnectionTestState(identifier), isEqual);
47
49
 
48
50
  const [connectionError, setConnectionError] = useState<string | null>(null);
51
+ const [errorMetadata, setErrorMetadata] = useState<MCPErrorInfoMetadata | null>(null);
49
52
 
50
53
  const handleTestConnection = async () => {
51
54
  setIsTesting(true);
52
55
  setConnectionError(null);
56
+ setErrorMetadata(null);
53
57
 
54
58
  // Manually trigger validation for fields needed for the test
55
59
  let isValid = false;
@@ -97,12 +101,29 @@ const MCPManifestForm = ({ form, isEditMode }: MCPManifestFormProps) => {
97
101
  // Be careful about overwriting user input if not desired
98
102
  form.setFieldsValue({ manifest: result.manifest });
99
103
  setConnectionError(null); // 清除本地错误状态
104
+ setErrorMetadata(null);
100
105
  } else if (result.error) {
101
106
  // Store 已经处理了错误状态,这里可以选择显示额外的用户友好提示
102
107
  const errorMessage = t('error.testConnectionFailed', {
103
108
  error: result.error,
104
109
  });
105
110
  setConnectionError(errorMessage);
111
+
112
+ // Build error metadata for detailed display
113
+ if (result.errorLog || mcpType === 'stdio') {
114
+ setErrorMetadata({
115
+ errorLog: result.errorLog,
116
+ params:
117
+ mcpType === 'stdio'
118
+ ? {
119
+ args: mcp?.args,
120
+ command: mcp?.command,
121
+ type: 'stdio',
122
+ }
123
+ : undefined,
124
+ timestamp: Date.now(),
125
+ });
126
+ }
106
127
  }
107
128
  } catch (error) {
108
129
  // Handle unexpected errors
@@ -121,7 +142,10 @@ const MCPManifestForm = ({ form, isEditMode }: MCPManifestFormProps) => {
121
142
  <QuickImportSection
122
143
  form={form}
123
144
  isEditMode={isEditMode}
124
- onClearConnectionError={() => setConnectionError(null)}
145
+ onClearConnectionError={() => {
146
+ setConnectionError(null);
147
+ setErrorMetadata(null);
148
+ }}
125
149
  />
126
150
  <Form form={form} layout={'vertical'}>
127
151
  <Flexbox>
@@ -270,9 +294,12 @@ const MCPManifestForm = ({ form, isEditMode }: MCPManifestFormProps) => {
270
294
  {(connectionError || testState.error) && (
271
295
  <Alert
272
296
  closable
273
- onClose={() => setConnectionError(null)}
297
+ extra={errorMetadata ? <ErrorDetails errorInfo={errorMetadata} /> : undefined}
298
+ onClose={() => {
299
+ setConnectionError(null);
300
+ setErrorMetadata(null);
301
+ }}
274
302
  showIcon
275
- style={{ marginBottom: 16 }}
276
303
  title={connectionError || testState.error}
277
304
  type="error"
278
305
  />
@@ -240,3 +240,34 @@ export function createMCPError(
240
240
 
241
241
  return error;
242
242
  }
243
+
244
+ /**
245
+ * STDIO Process Output separator used in enhanced error messages
246
+ */
247
+ const STDIO_OUTPUT_SEPARATOR = '--- STDIO Process Output ---';
248
+
249
+ /**
250
+ * Parse error message to extract STDIO process output logs
251
+ * The enhanced error format from desktop is:
252
+ * "Original message\n\n--- STDIO Process Output ---\nlogs..."
253
+ */
254
+ export interface ParsedStdioError {
255
+ errorLog?: string;
256
+ originalMessage: string;
257
+ }
258
+
259
+ export function parseStdioErrorMessage(errorMessage: string): ParsedStdioError {
260
+ const separatorIndex = errorMessage.indexOf(STDIO_OUTPUT_SEPARATOR);
261
+
262
+ if (separatorIndex === -1) {
263
+ return { originalMessage: errorMessage };
264
+ }
265
+
266
+ const originalMessage = errorMessage.slice(0, separatorIndex).trim();
267
+ const errorLog = errorMessage.slice(separatorIndex + STDIO_OUTPUT_SEPARATOR.length).trim();
268
+
269
+ return {
270
+ errorLog: errorLog || undefined,
271
+ originalMessage: originalMessage || errorMessage,
272
+ };
273
+ }
@@ -0,0 +1,65 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import {
4
+ type DesktopDownloadType,
5
+ resolveDesktopDownload,
6
+ resolveDesktopDownloadFromUrls,
7
+ } from './index';
8
+
9
+ const mockRelease = {
10
+ assets: [
11
+ {
12
+ browser_download_url: 'https://example.com/LobeHub-2.0.0-arm64.dmg',
13
+ name: 'LobeHub-2.0.0-arm64.dmg',
14
+ },
15
+ {
16
+ browser_download_url: 'https://example.com/LobeHub-2.0.0-x64.dmg',
17
+ name: 'LobeHub-2.0.0-x64.dmg',
18
+ },
19
+ {
20
+ browser_download_url: 'https://example.com/LobeHub-2.0.0-setup.exe',
21
+ name: 'LobeHub-2.0.0-setup.exe',
22
+ },
23
+ {
24
+ browser_download_url: 'https://example.com/LobeHub-2.0.0.AppImage',
25
+ name: 'LobeHub-2.0.0.AppImage',
26
+ },
27
+ ],
28
+ published_at: '2026-01-01T00:00:00.000Z',
29
+ tag_name: 'v2.0.0',
30
+ };
31
+
32
+ describe('desktopRelease', () => {
33
+ it.each([
34
+ ['mac-arm', 'LobeHub-2.0.0-arm64.dmg'],
35
+ ['mac-intel', 'LobeHub-2.0.0-x64.dmg'],
36
+ ['windows', 'LobeHub-2.0.0-setup.exe'],
37
+ ['linux', 'LobeHub-2.0.0.AppImage'],
38
+ ] as Array<[DesktopDownloadType, string]>)(
39
+ 'resolveDesktopDownload(%s)',
40
+ (type, expectedAssetName) => {
41
+ const resolved = resolveDesktopDownload(mockRelease as any, type);
42
+ expect(resolved?.assetName).toBe(expectedAssetName);
43
+ expect(resolved?.version).toBe('2.0.0');
44
+ expect(resolved?.tag).toBe('v2.0.0');
45
+ expect(resolved?.type).toBe(type);
46
+ expect(resolved?.url).toContain(expectedAssetName);
47
+ },
48
+ );
49
+
50
+ it('resolveDesktopDownloadFromUrls should match basename', () => {
51
+ const resolved = resolveDesktopDownloadFromUrls({
52
+ publishedAt: '2026-01-01T00:00:00.000Z',
53
+ tag: 'v2.0.0',
54
+ type: 'windows',
55
+ urls: [
56
+ 'https://releases.example.com/stable/2.0.0/LobeHub-2.0.0-setup.exe?download=1',
57
+ 'https://releases.example.com/stable/2.0.0/LobeHub-2.0.0-x64.dmg',
58
+ ],
59
+ version: '2.0.0',
60
+ });
61
+
62
+ expect(resolved?.assetName).toBe('LobeHub-2.0.0-setup.exe');
63
+ expect(resolved?.url).toContain('setup.exe');
64
+ });
65
+ });