@lobehub/lobehub 2.0.0-next.313 → 2.0.0-next.314

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.
Files changed (25) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/apps/desktop/src/main/appBrowsers.ts +4 -1
  3. package/apps/desktop/src/main/controllers/BrowserWindowsCtr.ts +15 -3
  4. package/apps/desktop/src/main/core/browser/Browser.ts +14 -4
  5. package/apps/desktop/src/main/core/browser/BrowserManager.ts +7 -2
  6. package/changelog/v1.json +9 -0
  7. package/e2e/src/steps/community/detail-pages.steps.ts +2 -2
  8. package/e2e/src/steps/community/interactions.steps.ts +6 -6
  9. package/e2e/src/steps/hooks.ts +19 -3
  10. package/package.json +1 -1
  11. package/packages/desktop-bridge/src/index.ts +5 -0
  12. package/packages/electron-client-ipc/src/types/window.ts +3 -2
  13. package/src/app/[variants]/(desktop)/desktop-onboarding/_layout/index.tsx +6 -3
  14. package/src/app/[variants]/(desktop)/desktop-onboarding/components/OnboardingFooterActions.tsx +38 -0
  15. package/src/app/[variants]/(desktop)/desktop-onboarding/features/DataModeStep.tsx +19 -14
  16. package/src/app/[variants]/(desktop)/desktop-onboarding/features/LoginStep.tsx +53 -19
  17. package/src/app/[variants]/(desktop)/desktop-onboarding/features/PermissionsStep.tsx +19 -14
  18. package/src/app/[variants]/(desktop)/desktop-onboarding/index.tsx +8 -7
  19. package/src/app/manifest.ts +1 -1
  20. package/src/features/Electron/titlebar/NavigationBar.tsx +1 -2
  21. package/src/server/manifest.ts +2 -2
  22. package/src/server/services/market/index.ts +13 -0
  23. package/src/server/services/sandbox/index.ts +84 -18
  24. package/src/server/services/toolExecution/serverRuntimes/cloudSandbox.ts +7 -0
  25. package/src/services/electron/system.ts +5 -5
package/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  # Changelog
4
4
 
5
+ ## [Version 2.0.0-next.314](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.313...v2.0.0-next.314)
6
+
7
+ <sup>Released on **2026-01-19**</sup>
8
+
9
+ #### ✨ Features
10
+
11
+ - **misc**: Improve desktop onboarding window management and footer actions.
12
+
13
+ <br/>
14
+
15
+ <details>
16
+ <summary><kbd>Improvements and Fixes</kbd></summary>
17
+
18
+ #### What's improved
19
+
20
+ - **misc**: Improve desktop onboarding window management and footer actions, closes [#11619](https://github.com/lobehub/lobe-chat/issues/11619) ([6ed280e](https://github.com/lobehub/lobe-chat/commit/6ed280e))
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.313](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.312...v2.0.0-next.313)
6
31
 
7
32
  <sup>Released on **2026-01-19**</sup>
@@ -1,3 +1,5 @@
1
+ import { APP_WINDOW_MIN_SIZE } from '@lobechat/desktop-bridge';
2
+
1
3
  import type { BrowserWindowOpts } from './core/browser/Browser';
2
4
 
3
5
  export const BrowsersIdentifiers = {
@@ -11,7 +13,8 @@ export const appBrowsers = {
11
13
  height: 800,
12
14
  identifier: 'app',
13
15
  keepAlive: true,
14
- minWidth: 400,
16
+ minHeight: APP_WINDOW_MIN_SIZE.height,
17
+ minWidth: APP_WINDOW_MIN_SIZE.width,
15
18
  path: '/',
16
19
  showOnInit: true,
17
20
  titleBarStyle: 'hidden',
@@ -1,7 +1,7 @@
1
1
  import type {
2
2
  InterceptRouteParams,
3
3
  OpenSettingsWindowOptions,
4
- WindowResizableParams,
4
+ WindowMinimumSizeParams,
5
5
  WindowSizeParams,
6
6
  } from '@lobechat/electron-client-ipc';
7
7
  import { findMatchingRoute } from '~common/routes';
@@ -81,9 +81,21 @@ export default class BrowserWindowsCtr extends ControllerModule {
81
81
  }
82
82
 
83
83
  @IpcMethod()
84
- setWindowResizable(params: WindowResizableParams) {
84
+ setWindowMinimumSize(params: WindowMinimumSizeParams) {
85
85
  this.withSenderIdentifier((identifier) => {
86
- this.app.browserManager.setWindowResizable(identifier, params.resizable);
86
+ const currentSize = this.app.browserManager.getWindowSize(identifier);
87
+ const nextWindowSize = {
88
+ ...currentSize,
89
+ };
90
+ if (params.height) {
91
+ nextWindowSize.height = Math.max(currentSize.height, params.height);
92
+ }
93
+ if (params.width) {
94
+ nextWindowSize.width = Math.max(currentSize.width, params.width);
95
+ }
96
+
97
+ this.app.browserManager.setWindowSize(identifier, nextWindowSize);
98
+ this.app.browserManager.setWindowMinimumSize(identifier, params);
87
99
  });
88
100
  }
89
101
 
@@ -1,4 +1,4 @@
1
- import { TITLE_BAR_HEIGHT } from '@lobechat/desktop-bridge';
1
+ import { APP_WINDOW_MIN_SIZE, TITLE_BAR_HEIGHT } from '@lobechat/desktop-bridge';
2
2
  import { MainBroadcastEventKey, MainBroadcastParams } from '@lobechat/electron-client-ipc';
3
3
  import {
4
4
  BrowserWindow,
@@ -291,9 +291,19 @@ export default class Browser {
291
291
  });
292
292
  }
293
293
 
294
- setWindowResizable(resizable: boolean): void {
295
- logger.debug(`[${this.identifier}] Setting window resizable: ${resizable}`);
296
- this._browserWindow?.setResizable(resizable);
294
+ setWindowMinimumSize(size: { height?: number; width?: number }): void {
295
+ logger.debug(`[${this.identifier}] Setting window minimum size: ${JSON.stringify(size)}`);
296
+
297
+ const currentMinimumSize = this._browserWindow?.getMinimumSize?.() ?? [0, 0];
298
+ const rawWidth = size.width ?? currentMinimumSize[0];
299
+ const rawHeight = size.height ?? currentMinimumSize[1];
300
+
301
+ // Electron doesn't "reset" minimum size with 0x0 reliably.
302
+ // Treat 0 / negative as fallback to app-level default preset.
303
+ const width = rawWidth > 0 ? rawWidth : APP_WINDOW_MIN_SIZE.width;
304
+ const height = rawHeight > 0 ? rawHeight : APP_WINDOW_MIN_SIZE.height;
305
+
306
+ this._browserWindow?.setMinimumSize?.(width, height);
297
307
  }
298
308
 
299
309
  // ==================== Window Position ====================
@@ -250,9 +250,14 @@ export class BrowserManager {
250
250
  browser?.setWindowSize(size);
251
251
  }
252
252
 
253
- setWindowResizable(identifier: string, resizable: boolean) {
253
+ getWindowSize(identifier: string) {
254
254
  const browser = this.browsers.get(identifier);
255
- browser?.setWindowResizable(resizable);
255
+ return browser?.browserWindow.getBounds();
256
+ }
257
+
258
+ setWindowMinimumSize(identifier: string, size: { height?: number; width?: number }) {
259
+ const browser = this.browsers.get(identifier);
260
+ browser?.setWindowMinimumSize(size);
256
261
  }
257
262
 
258
263
  getIdentifierByWebContents(webContents: WebContents): string | null {
package/changelog/v1.json CHANGED
@@ -1,4 +1,13 @@
1
1
  [
2
+ {
3
+ "children": {
4
+ "features": [
5
+ "Improve desktop onboarding window management and footer actions."
6
+ ]
7
+ },
8
+ "date": "2026-01-19",
9
+ "version": "2.0.0-next.314"
10
+ },
2
11
  {
3
12
  "children": {
4
13
  "fixes": [
@@ -68,7 +68,7 @@ Then('I should be on an assistant detail page', async function (this: CustomWorl
68
68
 
69
69
  const currentUrl = this.page.url();
70
70
  // Check if URL matches assistant detail page pattern
71
- const hasAssistantDetail = /\/community\/assistant\/[^#?]+/.test(currentUrl);
71
+ const hasAssistantDetail = /\/community\/agent\/[^#?]+/.test(currentUrl);
72
72
  expect(
73
73
  hasAssistantDetail,
74
74
  `Expected URL to match assistant detail page pattern, but got: ${currentUrl}`,
@@ -138,7 +138,7 @@ Then('I should be on the assistant list page', async function (this: CustomWorld
138
138
  // After back navigation, URL should be /community/agent or /community
139
139
  const isListPage =
140
140
  (currentUrl.includes('/community/agent') &&
141
- !/\/community\/assistant\/[\dA-Za-z-]+$/.test(currentUrl)) ||
141
+ !/\/community\/agent\/[\dA-Za-z-]+$/.test(currentUrl)) ||
142
142
  currentUrl.endsWith('/community') ||
143
143
  currentUrl.includes('/community#');
144
144
 
@@ -230,10 +230,10 @@ When('I click on the sort dropdown', async function (this: CustomWorld) {
230
230
  });
231
231
 
232
232
  When('I select a sort option', async function (this: CustomWorld) {
233
- await this.page.waitForTimeout(500);
233
+ await this.page.waitForTimeout(1000);
234
234
 
235
- // Find and click a sort option (assuming dropdown opens a menu)
236
- const sortOptions = this.page.locator('[role="option"], [role="menuitem"]');
235
+ // The sort dropdown uses checkbox items with role="menuitemcheckbox"
236
+ const sortOptions = this.page.locator('[role="menuitemcheckbox"]');
237
237
 
238
238
  // Wait for options to appear
239
239
  await sortOptions.first().waitFor({ state: 'visible', timeout: 30_000 });
@@ -381,7 +381,7 @@ Then('the URL should contain the category parameter', async function (this: Cust
381
381
  currentUrl.includes('category=') ||
382
382
  currentUrl.includes('tag=') ||
383
383
  // For path-based routing like /community/agent/category-name
384
- /\/community\/assistant\/[^/?]+/.test(currentUrl);
384
+ /\/community\/agent\/[^/?]+/.test(currentUrl);
385
385
 
386
386
  expect(
387
387
  hasCategory,
@@ -433,8 +433,8 @@ Then('I should be navigated to the assistant detail page', async function (this:
433
433
  await this.page.waitForLoadState('networkidle', { timeout: 30_000 });
434
434
 
435
435
  const currentUrl = this.page.url();
436
- // Verify that URL changed and contains /assistant/ followed by an identifier
437
- const hasAssistantDetail = /\/community\/assistant\/[^#?]+/.test(currentUrl);
436
+ // Verify that URL changed and contains /agent/ followed by an identifier
437
+ const hasAssistantDetail = /\/community\/agent\/[^#?]+/.test(currentUrl);
438
438
  const urlChanged = currentUrl !== this.testContext.previousUrl;
439
439
 
440
440
  expect(
@@ -45,10 +45,24 @@ BeforeAll({ timeout: 600_000 }, async function () {
45
45
  // Navigate to signin page
46
46
  await page.goto(`${baseUrl}/signin`, { waitUntil: 'networkidle' });
47
47
 
48
+ // Wait for the page to fully hydrate
49
+ await page.waitForTimeout(2000);
50
+
51
+ // Check if we can find the email input
52
+ const emailInput = page
53
+ .locator('input[id="email"], input[name="email"], input[type="text"]')
54
+ .first();
55
+ const emailInputVisible = await emailInput.isVisible().catch(() => false);
56
+
57
+ if (!emailInputVisible) {
58
+ console.log(
59
+ '⚠️ Login form not available, skipping authentication (tests requiring auth may fail)',
60
+ );
61
+ return;
62
+ }
63
+
48
64
  // Step 1: Enter email
49
65
  console.log(' Step 1: Entering email...');
50
- const emailInput = page.locator('input[id="email"]').first();
51
- await emailInput.waitFor({ state: 'visible', timeout: 30_000 });
52
66
  await emailInput.fill(TEST_USER.email);
53
67
 
54
68
  // Click the next button
@@ -57,7 +71,9 @@ BeforeAll({ timeout: 600_000 }, async function () {
57
71
 
58
72
  // Step 2: Wait for password step and enter password
59
73
  console.log(' Step 2: Entering password...');
60
- const passwordInput = page.locator('input[id="password"]').first();
74
+ const passwordInput = page
75
+ .locator('input[id="password"], input[name="password"], input[type="password"]')
76
+ .first();
61
77
  await passwordInput.waitFor({ state: 'visible', timeout: 30_000 });
62
78
  await passwordInput.fill(TEST_USER.password);
63
79
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobehub/lobehub",
3
- "version": "2.0.0-next.313",
3
+ "version": "2.0.0-next.314",
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",
@@ -10,3 +10,8 @@ export {
10
10
 
11
11
  // Desktop window constants
12
12
  export const TITLE_BAR_HEIGHT = 38;
13
+
14
+ export const APP_WINDOW_MIN_SIZE = {
15
+ height: 600,
16
+ width: 1000,
17
+ } as const;
@@ -3,6 +3,7 @@ export interface WindowSizeParams {
3
3
  width?: number;
4
4
  }
5
5
 
6
- export interface WindowResizableParams {
7
- resizable: boolean;
6
+ export interface WindowMinimumSizeParams {
7
+ height?: number;
8
+ width?: number;
8
9
  }
@@ -3,7 +3,7 @@
3
3
  import { TITLE_BAR_HEIGHT } from '@lobechat/desktop-bridge';
4
4
  import { Center, Flexbox, Text } from '@lobehub/ui';
5
5
  import { Divider } from 'antd';
6
- import { cx } from 'antd-style';
6
+ import { css, cx } from 'antd-style';
7
7
  import type { FC, PropsWithChildren } from 'react';
8
8
 
9
9
  import SimpleTitleBar from '@/features/Electron/titlebar/SimpleTitleBar';
@@ -13,6 +13,9 @@ import { useIsDark } from '@/hooks/useIsDark';
13
13
 
14
14
  import { styles } from './style';
15
15
 
16
+ const contentContainer = css`
17
+ overflow: auto;
18
+ `;
16
19
  const OnboardingContainer: FC<PropsWithChildren> = ({ children }) => {
17
20
  const isDarkMode = useIsDark();
18
21
  return (
@@ -44,9 +47,9 @@ const OnboardingContainer: FC<PropsWithChildren> = ({ children }) => {
44
47
  <ThemeButton placement={'bottomRight'} size={18} />
45
48
  </Flexbox>
46
49
  </Flexbox>
47
- <Center height={'100%'} padding={16} width={'100%'}>
50
+ <Flexbox align={'center'} className={cx(contentContainer)} height={'100%'} width={'100%'}>
48
51
  {children}
49
- </Center>
52
+ </Flexbox>
50
53
  <Center padding={24}>
51
54
  <Text align={'center'} type={'secondary'}>
52
55
  © 2025 LobeHub. All rights reserved.
@@ -0,0 +1,38 @@
1
+ import { Flexbox, type FlexboxProps } from '@lobehub/ui';
2
+ import { cssVar } from 'antd-style';
3
+ import { type ReactNode, memo } from 'react';
4
+
5
+ interface OnboardingFooterActionsProps extends Omit<FlexboxProps, 'children'> {
6
+ left?: ReactNode;
7
+ right?: ReactNode;
8
+ }
9
+
10
+ const OnboardingFooterActions = memo<OnboardingFooterActionsProps>(
11
+ ({ left, right, style, ...rest }) => {
12
+ return (
13
+ <Flexbox
14
+ align={'center'}
15
+ horizontal
16
+ justify={'space-between'}
17
+ style={{
18
+ background: cssVar.colorBgContainer,
19
+ bottom: 0,
20
+ marginTop: 'auto',
21
+ paddingTop: 16,
22
+ position: 'sticky',
23
+ width: '100%',
24
+ zIndex: 10,
25
+ ...style,
26
+ }}
27
+ {...rest}
28
+ >
29
+ <div>{left}</div>
30
+ <div>{right}</div>
31
+ </Flexbox>
32
+ );
33
+ },
34
+ );
35
+
36
+ OnboardingFooterActions.displayName = 'OnboardingFooterActions';
37
+
38
+ export default OnboardingFooterActions;
@@ -10,6 +10,7 @@ import { useUserStore } from '@/store/user';
10
10
  import { userGeneralSettingsSelectors } from '@/store/user/selectors';
11
11
 
12
12
  import LobeMessage from '../components/LobeMessage';
13
+ import OnboardingFooterActions from '../components/OnboardingFooterActions';
13
14
 
14
15
  type DataMode = 'share' | 'privacy';
15
16
 
@@ -48,7 +49,7 @@ const DataModeStep = memo<DataModeStepProps>(({ onBack, onNext }) => {
48
49
  );
49
50
 
50
51
  return (
51
- <Flexbox gap={16}>
52
+ <Flexbox gap={16} style={{ height: '100%', minHeight: '100%' }}>
52
53
  <Flexbox>
53
54
  <LobeMessage sentences={[t('screen4.title'), t('screen4.title2'), t('screen4.title3')]} />
54
55
  <Text as={'p'}>{t('screen4.description')}</Text>
@@ -113,19 +114,23 @@ const DataModeStep = memo<DataModeStepProps>(({ onBack, onNext }) => {
113
114
  <Text color={cssVar.colorTextSecondary} fontSize={12} style={{ marginTop: 16 }}>
114
115
  {t('screen4.footerNote')}
115
116
  </Text>
116
- <Flexbox horizontal justify={'space-between'} style={{ marginTop: 32 }}>
117
- <Button
118
- icon={Undo2Icon}
119
- onClick={onBack}
120
- style={{ color: cssVar.colorTextDescription }}
121
- type={'text'}
122
- >
123
- {t('back')}
124
- </Button>
125
- <Button onClick={onNext} type={'primary'}>
126
- {t('next')}
127
- </Button>
128
- </Flexbox>
117
+ <OnboardingFooterActions
118
+ left={
119
+ <Button
120
+ icon={Undo2Icon}
121
+ onClick={onBack}
122
+ style={{ color: cssVar.colorTextDescription }}
123
+ type={'text'}
124
+ >
125
+ {t('back')}
126
+ </Button>
127
+ }
128
+ right={
129
+ <Button onClick={onNext} type={'primary'}>
130
+ {t('next')}
131
+ </Button>
132
+ }
133
+ />
129
134
  </Flexbox>
130
135
  );
131
136
  });
@@ -1,6 +1,10 @@
1
1
  'use client';
2
2
 
3
- import { AuthorizationProgress, useWatchBroadcast } from '@lobechat/electron-client-ipc';
3
+ import {
4
+ AuthorizationPhase,
5
+ AuthorizationProgress,
6
+ useWatchBroadcast,
7
+ } from '@lobechat/electron-client-ipc';
4
8
  import { Alert, Button, Center, Flexbox, Icon, Input, Text } from '@lobehub/ui';
5
9
  import { Divider } from 'antd';
6
10
  import { cssVar } from 'antd-style';
@@ -9,6 +13,7 @@ import { memo, useEffect, useState } from 'react';
9
13
  import { useTranslation } from 'react-i18next';
10
14
 
11
15
  import { isDesktop } from '@/const/version';
16
+ import UserInfo from '@/features/User/UserInfo';
12
17
  import { remoteServerService } from '@/services/electron/remoteServer';
13
18
  import { useElectronStore } from '@/store/electron';
14
19
  import { setDesktopAutoOidcFirstOpenHandled } from '@/utils/electron/autoOidc';
@@ -21,6 +26,13 @@ type LoginMethod = 'cloud' | 'selfhost';
21
26
  // 登录状态类型
22
27
  type LoginStatus = 'idle' | 'loading' | 'success' | 'error';
23
28
 
29
+ const authorizationPhaseI18nKeyMap: Record<AuthorizationPhase, string> = {
30
+ browser_opened: 'screen5.auth.phase.browserOpened',
31
+ cancelled: 'screen5.actions.cancel',
32
+ verifying: 'screen5.auth.phase.verifying',
33
+ waiting_for_auth: 'screen5.auth.phase.waitingForAuth',
34
+ };
35
+
24
36
  const loginMethodMetas = {
25
37
  cloud: {
26
38
  descriptionKey: 'screen5.methods.cloud.description',
@@ -181,6 +193,7 @@ const LoginStep = memo<LoginStepProps>(({ onBack, onNext }) => {
181
193
  setAuthProgress(progress);
182
194
  if (progress.phase === 'cancelled') {
183
195
  setCloudLoginStatus('idle');
196
+ setSelfhostLoginStatus('idle');
184
197
  setAuthProgress(null);
185
198
  }
186
199
  });
@@ -188,6 +201,7 @@ const LoginStep = memo<LoginStepProps>(({ onBack, onNext }) => {
188
201
  const handleCancelAuth = async () => {
189
202
  await remoteServerService.cancelAuthorization();
190
203
  setCloudLoginStatus('idle');
204
+ setSelfhostLoginStatus('idle');
191
205
  setAuthProgress(null);
192
206
  };
193
207
 
@@ -195,13 +209,19 @@ const LoginStep = memo<LoginStepProps>(({ onBack, onNext }) => {
195
209
  const renderCloudContent = () => {
196
210
  if (cloudLoginStatus === 'success') {
197
211
  return (
198
- <Flexbox gap={12} style={{ width: '100%' }}>
212
+ <Flexbox gap={16} style={{ width: '100%' }}>
199
213
  <Alert
200
214
  description={t('authResult.success.desc')}
201
215
  style={{ width: '100%' }}
202
216
  title={t('authResult.success.title')}
203
217
  type={'success'}
204
218
  />
219
+ <UserInfo
220
+ style={{
221
+ background: cssVar.colorFillSecondary,
222
+ borderRadius: 8,
223
+ }}
224
+ />
205
225
  <Button
206
226
  block
207
227
  disabled={isSigningOut || isConnectingServer}
@@ -239,27 +259,35 @@ const LoginStep = memo<LoginStepProps>(({ onBack, onNext }) => {
239
259
  }
240
260
 
241
261
  if (cloudLoginStatus === 'loading') {
262
+ const phaseText = t(authorizationPhaseI18nKeyMap[authProgress?.phase ?? 'browser_opened'], {
263
+ defaultValue: t('screen5.actions.signingIn'),
264
+ });
265
+ const remainingSeconds = authProgress
266
+ ? Math.max(0, Math.ceil((authProgress.maxPollTime - authProgress.elapsed) / 1000))
267
+ : null;
268
+
242
269
  return (
243
270
  <Flexbox gap={8} style={{ width: '100%' }}>
244
271
  <Button block disabled={true} icon={Cloud} loading={true} size={'large'} type={'primary'}>
245
- {authProgress
246
- ? t(`screen5.auth.phase.${authProgress.phase}`, {
247
- defaultValue: t('screen5.actions.signingIn'),
248
- })
249
- : t('screen5.actions.signingIn')}
272
+ {t('screen5.actions.signingIn')}
250
273
  </Button>
251
- {authProgress && (
252
- <Flexbox align={'center'} horizontal justify={'space-between'}>
274
+ <Text style={{ color: cssVar.colorTextDescription }} type={'secondary'}>
275
+ {phaseText}
276
+ </Text>
277
+ <Flexbox align={'center'} horizontal justify={'space-between'}>
278
+ {remainingSeconds !== null ? (
253
279
  <Text style={{ color: cssVar.colorTextDescription }} type={'secondary'}>
254
280
  {t('screen5.auth.remaining', {
255
- time: Math.round((authProgress.maxPollTime - authProgress.elapsed) / 1000),
281
+ time: remainingSeconds,
256
282
  })}
257
283
  </Text>
258
- <Button onClick={handleCancelAuth} size={'small'} type={'text'}>
259
- {t('screen5.actions.cancel')}
260
- </Button>
261
- </Flexbox>
262
- )}
284
+ ) : (
285
+ <div />
286
+ )}
287
+ <Button onClick={handleCancelAuth} size={'small'} type={'text'}>
288
+ {t('screen5.actions.cancel')}
289
+ </Button>
290
+ </Flexbox>
263
291
  </Flexbox>
264
292
  );
265
293
  }
@@ -283,13 +311,19 @@ const LoginStep = memo<LoginStepProps>(({ onBack, onNext }) => {
283
311
  const renderSelfhostContent = () => {
284
312
  if (selfhostLoginStatus === 'success') {
285
313
  return (
286
- <Flexbox gap={12} style={{ width: '100%' }}>
314
+ <Flexbox gap={16} style={{ width: '100%' }}>
287
315
  <Alert
288
316
  description={t('authResult.success.desc')}
289
317
  style={{ width: '100%' }}
290
318
  title={t('authResult.success.title')}
291
319
  type={'success'}
292
320
  />
321
+ <UserInfo
322
+ style={{
323
+ background: cssVar.colorFillSecondary,
324
+ borderRadius: 8,
325
+ }}
326
+ />
293
327
  <Button
294
328
  block
295
329
  disabled={isSigningOut || isConnectingServer}
@@ -354,8 +388,8 @@ const LoginStep = memo<LoginStepProps>(({ onBack, onNext }) => {
354
388
  };
355
389
 
356
390
  return (
357
- <Flexbox gap={32}>
358
- <Flexbox>
391
+ <Center gap={32} style={{ height: '100%', minHeight: '100%' }}>
392
+ <Flexbox align={'flex-start'} justify={'flex-start'} style={{ width: '100%' }}>
359
393
  <LobeMessage sentences={[t('screen5.title'), t('screen5.title2'), t('screen5.title3')]} />
360
394
  <Text as={'p'}>{t('screen5.description')}</Text>
361
395
  </Flexbox>
@@ -401,7 +435,7 @@ const LoginStep = memo<LoginStepProps>(({ onBack, onNext }) => {
401
435
  </Button>
402
436
  </Flexbox>
403
437
  )}
404
- </Flexbox>
438
+ </Center>
405
439
  );
406
440
  });
407
441
 
@@ -18,6 +18,7 @@ import { useTranslation } from 'react-i18next';
18
18
  import { ensureElectronIpc } from '@/utils/electron/ipc';
19
19
 
20
20
  import LobeMessage from '../components/LobeMessage';
21
+ import OnboardingFooterActions from '../components/OnboardingFooterActions';
21
22
 
22
23
  type PermissionMeta = {
23
24
  descriptionKey: string;
@@ -154,7 +155,7 @@ const PermissionsStep = memo<PermissionsStepProps>(({ onBack, onNext }) => {
154
155
  };
155
156
 
156
157
  return (
157
- <Flexbox gap={16}>
158
+ <Flexbox gap={16} style={{ height: '100%', minHeight: '100%' }}>
158
159
  <Flexbox>
159
160
  <LobeMessage sentences={[t('screen3.title'), t('screen3.title2'), t('screen3.title3')]} />
160
161
  <Text as={'p'}>{t('screen3.description')}</Text>
@@ -207,19 +208,23 @@ const PermissionsStep = memo<PermissionsStepProps>(({ onBack, onNext }) => {
207
208
  </Block>
208
209
  ))}
209
210
  </Block>
210
- <Flexbox horizontal justify={'space-between'} style={{ marginTop: 32 }}>
211
- <Button
212
- icon={Undo2Icon}
213
- onClick={onBack}
214
- style={{ color: cssVar.colorTextDescription }}
215
- type={'text'}
216
- >
217
- {t('back')}
218
- </Button>
219
- <Button onClick={onNext} type={'primary'}>
220
- {t('next')}
221
- </Button>
222
- </Flexbox>
211
+ <OnboardingFooterActions
212
+ left={
213
+ <Button
214
+ icon={Undo2Icon}
215
+ onClick={onBack}
216
+ style={{ color: cssVar.colorTextDescription }}
217
+ type={'text'}
218
+ >
219
+ {t('back')}
220
+ </Button>
221
+ }
222
+ right={
223
+ <Button onClick={onNext} type={'primary'}>
224
+ {t('next')}
225
+ </Button>
226
+ }
227
+ />
223
228
  </Flexbox>
224
229
  );
225
230
  });
@@ -1,5 +1,6 @@
1
1
  'use client';
2
2
 
3
+ import { APP_WINDOW_MIN_SIZE } from '@lobechat/desktop-bridge';
3
4
  import { Flexbox, Skeleton } from '@lobehub/ui';
4
5
  import { Suspense, memo, useCallback, useEffect, useState } from 'react';
5
6
  import { useSearchParams } from 'react-router-dom';
@@ -50,12 +51,11 @@ const DesktopOnboardingPage = memo(() => {
50
51
 
51
52
  // 设置窗口大小和可调整性
52
53
  useEffect(() => {
53
- const fixedSize = { height: 900, width: 1400 };
54
+ const minimumSize = { height: 900, width: 1200 };
54
55
 
55
56
  const applyWindowSettings = async () => {
56
57
  try {
57
- await electronSystemService.setWindowSize(fixedSize);
58
- await electronSystemService.setWindowResizable({ resizable: false });
58
+ await electronSystemService.setWindowMinimumSize(minimumSize);
59
59
  } catch (error) {
60
60
  console.error('[DesktopOnboarding] Failed to apply window settings:', error);
61
61
  }
@@ -64,7 +64,8 @@ const DesktopOnboardingPage = memo(() => {
64
64
  applyWindowSettings();
65
65
 
66
66
  return () => {
67
- electronSystemService.setWindowResizable({ resizable: true }).catch((error) => {
67
+ // Restore to app-level default minimum size preset
68
+ electronSystemService.setWindowMinimumSize(APP_WINDOW_MIN_SIZE).catch((error) => {
68
69
  console.error('[DesktopOnboarding] Failed to restore window settings:', error);
69
70
  });
70
71
  };
@@ -127,9 +128,9 @@ const DesktopOnboardingPage = memo(() => {
127
128
  // 如果是第4步(LoginStep),完成 onboarding
128
129
  setDesktopOnboardingCompleted();
129
130
  clearDesktopOnboardingStep(); // Clear persisted step since onboarding is complete
130
- // Restore window resizable before hard reload (cleanup won't run due to hard navigation)
131
+ // Restore window minimum size before hard reload (cleanup won't run due to hard navigation)
131
132
  electronSystemService
132
- .setWindowResizable({ resizable: true })
133
+ .setWindowMinimumSize(APP_WINDOW_MIN_SIZE)
133
134
  .catch(console.error)
134
135
  .finally(() => {
135
136
  // Use hard reload instead of SPA navigation to ensure the app boots with the new desktop state.
@@ -196,7 +197,7 @@ const DesktopOnboardingPage = memo(() => {
196
197
 
197
198
  return (
198
199
  <OnboardingContainer>
199
- <Flexbox gap={24} style={{ maxWidth: 560, width: '100%' }}>
200
+ <Flexbox gap={24} style={{ maxWidth: 560, minHeight: '100%', width: '100%' }}>
200
201
  <Suspense
201
202
  fallback={
202
203
  <Flexbox gap={8}>
@@ -16,7 +16,7 @@ const manifest = async (): Promise<MetadataRoute.Manifest> => {
16
16
  ],
17
17
  name: 'LobeChat',
18
18
  short_name: 'LobeChat',
19
- start_url: '/agent',
19
+ start_url: '/',
20
20
  theme_color: '#000000',
21
21
  };
22
22
  }
@@ -53,13 +53,12 @@ const NavigationBar = memo(() => {
53
53
  return (
54
54
  <Flexbox
55
55
  align="center"
56
- className={electronStylish.nodrag}
57
56
  data-width={leftPanelWidth}
58
57
  horizontal
59
58
  justify="end"
60
59
  style={{ width: `${leftPanelWidth - 12}px` }}
61
60
  >
62
- <Flexbox align="center" gap={2} horizontal>
61
+ <Flexbox align="center" className={electronStylish.nodrag} gap={2} horizontal>
63
62
  <ActionIcon disabled={!canGoBack} icon={ArrowLeft} onClick={goBack} size="small" />
64
63
  <ActionIcon disabled={!canGoForward} icon={ArrowRight} onClick={goForward} size="small" />
65
64
  <Popover
@@ -63,10 +63,10 @@ export class Manifest {
63
63
  screenshots: screenshots.map((item) => this._getScreenshot(item)),
64
64
  short_name: name,
65
65
  splash_pages: null,
66
- start_url: '/agent',
66
+ start_url: '/',
67
67
  tab_strip: {
68
68
  new_tab_button: {
69
- url: '/agent',
69
+ url: '/',
70
70
  },
71
71
  },
72
72
  theme_color: color,
@@ -280,6 +280,19 @@ export class MarketService {
280
280
  return this.market.plugins.callCloudGateway(params, options);
281
281
  }
282
282
 
283
+ /**
284
+ * Export file from sandbox to upload URL
285
+ */
286
+ async exportFile(params: { path: string; topicId: string; uploadUrl: string; userId: string }) {
287
+ const { path, uploadUrl, topicId, userId } = params;
288
+
289
+ return this.market.plugins.runBuildInTool(
290
+ 'exportFile',
291
+ { path, uploadUrl },
292
+ { topicId, userId },
293
+ );
294
+ }
295
+
283
296
  /**
284
297
  * Get plugin manifest
285
298
  */
@@ -1,16 +1,20 @@
1
- import { type CodeInterpreterToolName } from '@lobehub/market-sdk';
2
1
  import {
3
2
  type ISandboxService,
4
3
  type SandboxCallToolResult,
5
4
  type SandboxExportFileResult,
6
5
  } from '@lobechat/builtin-tool-cloud-sandbox';
6
+ import { type CodeInterpreterToolName } from '@lobehub/market-sdk';
7
7
  import debug from 'debug';
8
+ import { sha256 } from 'js-sha256';
8
9
 
10
+ import { FileS3 } from '@/server/modules/S3';
11
+ import { type FileService } from '@/server/services/file';
9
12
  import { MarketService } from '@/server/services/market';
10
13
 
11
14
  const log = debug('lobe-server:sandbox-service');
12
15
 
13
16
  export interface ServerSandboxServiceOptions {
17
+ fileService: FileService;
14
18
  marketService: MarketService;
15
19
  topicId: string;
16
20
  userId: string;
@@ -28,11 +32,13 @@ export interface ServerSandboxServiceOptions {
28
32
  * - MarketService handles authentication via trustedClientToken
29
33
  */
30
34
  export class ServerSandboxService implements ISandboxService {
35
+ private fileService: FileService;
31
36
  private marketService: MarketService;
32
37
  private topicId: string;
33
38
  private userId: string;
34
39
 
35
40
  constructor(options: ServerSandboxServiceOptions) {
41
+ this.fileService = options.fileService;
36
42
  this.marketService = options.marketService;
37
43
  this.topicId = options.topicId;
38
44
  this.userId = options.userId;
@@ -45,11 +51,12 @@ export class ServerSandboxService implements ISandboxService {
45
51
  log('Calling sandbox tool: %s with params: %O, topicId: %s', toolName, params, this.topicId);
46
52
 
47
53
  try {
48
- const response = await this.marketService.getSDK().plugins.runBuildInTool(
49
- toolName as CodeInterpreterToolName,
50
- params as any,
51
- { topicId: this.topicId, userId: this.userId },
52
- );
54
+ const response = await this.marketService
55
+ .getSDK()
56
+ .plugins.runBuildInTool(toolName as CodeInterpreterToolName, params as any, {
57
+ topicId: this.topicId,
58
+ userId: this.userId,
59
+ });
53
60
 
54
61
  log('Sandbox tool %s response: %O', toolName, response);
55
62
 
@@ -86,26 +93,85 @@ export class ServerSandboxService implements ISandboxService {
86
93
  }
87
94
 
88
95
  /**
89
- * Export and upload a file from sandbox
96
+ * Export and upload a file from sandbox to S3
90
97
  *
91
- * Note: This is a simplified version for server-side use.
92
- * The full implementation with S3 upload is in the tRPC router.
98
+ * Steps:
99
+ * 1. Generate S3 pre-signed upload URL
100
+ * 2. Call sandbox exportFile tool to upload file
101
+ * 3. Verify upload success and get metadata
102
+ * 4. Create persistent file record
93
103
  */
94
104
  async exportAndUploadFile(path: string, filename: string): Promise<SandboxExportFileResult> {
95
105
  log('Exporting file: %s from path: %s, topicId: %s', filename, path, this.topicId);
96
106
 
97
- // For server-side, we need to call the exportFile tool
98
- // The full S3 upload logic should be handled separately
99
- // This is a basic implementation that can be extended
100
-
101
107
  try {
108
+ const s3 = new FileS3();
109
+
110
+ // Use date-based sharding for privacy compliance (GDPR, CCPA)
111
+ const today = new Date().toISOString().split('T')[0];
112
+
113
+ // Generate a unique key for the exported file
114
+ const key = `code-interpreter-exports/${today}/${this.topicId}/${filename}`;
115
+
116
+ // Step 1: Generate pre-signed upload URL
117
+ const uploadUrl = await s3.createPreSignedUrl(key);
118
+ log('Generated upload URL for key: %s', key);
119
+
120
+ // Step 2: Call sandbox's exportFile tool with the upload URL
121
+ const response = await this.marketService.exportFile({
122
+ path,
123
+ topicId: this.topicId,
124
+ uploadUrl,
125
+ userId: this.userId,
126
+ });
127
+
128
+ log('Sandbox exportFile response: %O', response);
129
+
130
+ if (!response.success) {
131
+ return {
132
+ error: { message: response.error?.message || 'Failed to export file from sandbox' },
133
+ filename,
134
+ success: false,
135
+ };
136
+ }
137
+
138
+ const result = response.data?.result;
139
+ const uploadSuccess = result?.success !== false;
140
+
141
+ if (!uploadSuccess) {
142
+ return {
143
+ error: { message: result?.error || 'Failed to upload file from sandbox' },
144
+ filename,
145
+ success: false,
146
+ };
147
+ }
148
+
149
+ // Step 3: Get file metadata from S3 to verify upload and get actual size
150
+ const metadata = await s3.getFileMetadata(key);
151
+ const fileSize = metadata.contentLength;
152
+ const mimeType = metadata.contentType || result?.mimeType || 'application/octet-stream';
153
+
154
+ // Step 4: Create persistent file record using FileService
155
+ // Generate a simple hash from the key (since we don't have the actual file content)
156
+ const fileHash = sha256(key + Date.now().toString());
157
+
158
+ const { fileId, url } = await this.fileService.createFileRecord({
159
+ fileHash,
160
+ fileType: mimeType,
161
+ name: filename,
162
+ size: fileSize,
163
+ url: key, // Store S3 key
164
+ });
165
+
166
+ log('Created file record: fileId=%s, url=%s', fileId, url);
167
+
102
168
  return {
103
- error: {
104
- message:
105
- 'Server-side file export not fully implemented. Use tRPC endpoint for file exports.',
106
- },
169
+ fileId,
107
170
  filename,
108
- success: false,
171
+ mimeType,
172
+ size: fileSize,
173
+ success: true,
174
+ url, // This is the permanent /f/:id URL
109
175
  };
110
176
  } catch (error) {
111
177
  log('Error exporting file: %O', error);
@@ -3,6 +3,7 @@ import {
3
3
  CloudSandboxIdentifier,
4
4
  } from '@lobechat/builtin-tool-cloud-sandbox';
5
5
 
6
+ import { FileService } from '@/server/services/file';
6
7
  import { MarketService } from '@/server/services/market';
7
8
  import { ServerSandboxService } from '@/server/services/sandbox';
8
9
 
@@ -18,8 +19,14 @@ export const cloudSandboxRuntime: ServerRuntimeRegistration = {
18
19
  throw new Error('userId and topicId are required for Cloud Sandbox execution');
19
20
  }
20
21
 
22
+ if (!context.serverDB) {
23
+ throw new Error('serverDB is required for Cloud Sandbox execution');
24
+ }
25
+
21
26
  const marketService = new MarketService({ userInfo: { userId: context.userId } });
27
+ const fileService = new FileService(context.serverDB, context.userId);
22
28
  const sandboxService = new ServerSandboxService({
29
+ fileService,
23
30
  marketService,
24
31
  topicId: context.topicId,
25
32
  userId: context.userId,
@@ -1,6 +1,6 @@
1
1
  import type {
2
2
  ElectronAppState,
3
- WindowResizableParams,
3
+ WindowMinimumSizeParams,
4
4
  WindowSizeParams,
5
5
  } from '@lobechat/electron-client-ipc';
6
6
 
@@ -36,14 +36,14 @@ class ElectronSystemService {
36
36
  return this.ipc.windows.minimizeWindow();
37
37
  }
38
38
 
39
- async setWindowResizable(params: WindowResizableParams): Promise<void> {
40
- return this.ipc.windows.setWindowResizable(params);
41
- }
42
-
43
39
  async setWindowSize(params: WindowSizeParams): Promise<void> {
44
40
  return this.ipc.windows.setWindowSize(params);
45
41
  }
46
42
 
43
+ async setWindowMinimumSize(params: WindowMinimumSizeParams): Promise<void> {
44
+ return this.ipc.windows.setWindowMinimumSize(params);
45
+ }
46
+
47
47
  async openExternalLink(url: string): Promise<void> {
48
48
  return this.ipc.system.openExternalLink(url);
49
49
  }