@lobehub/chat 1.84.12 → 1.84.14

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 (31) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/apps/desktop/build/icon-beta.ico +0 -0
  3. package/apps/desktop/build/icon-nightly.ico +0 -0
  4. package/apps/desktop/build/icon.ico +0 -0
  5. package/apps/desktop/electron-builder.js +2 -2
  6. package/apps/desktop/resources/error.html +0 -1
  7. package/apps/desktop/resources/tray-icon.png +0 -0
  8. package/apps/desktop/src/main/controllers/BrowserWindowsCtr.ts +16 -0
  9. package/apps/desktop/src/main/controllers/TrayMenuCtr.ts +109 -0
  10. package/apps/desktop/src/main/core/App.ts +22 -3
  11. package/apps/desktop/src/main/core/Browser.ts +2 -6
  12. package/apps/desktop/src/main/core/BrowserManager.ts +36 -1
  13. package/apps/desktop/src/main/core/Tray.ts +231 -0
  14. package/apps/desktop/src/main/core/TrayManager.ts +131 -0
  15. package/apps/desktop/src/main/types/ipcClientEvent.ts +3 -0
  16. package/changelog/v1.json +18 -0
  17. package/package.json +1 -1
  18. package/packages/electron-client-ipc/src/events/index.ts +3 -1
  19. package/packages/electron-client-ipc/src/events/system.ts +3 -0
  20. package/packages/electron-client-ipc/src/events/tray.ts +31 -0
  21. package/packages/electron-client-ipc/src/types/index.ts +1 -0
  22. package/packages/electron-client-ipc/src/types/tray.ts +39 -0
  23. package/src/app/[variants]/(main)/chat/(workspace)/@conversation/features/ChatInput/Mobile/Files/FileItem/File.tsx +1 -4
  24. package/src/app/[variants]/(main)/chat/(workspace)/@conversation/features/ChatInput/Mobile/Files/FileItem/Image.tsx +6 -17
  25. package/src/app/[variants]/(main)/chat/(workspace)/_layout/Desktop/TopicPanel.tsx +1 -0
  26. package/src/app/[variants]/(main)/settings/provider/features/ProviderConfig/Checker.tsx +4 -0
  27. package/src/features/ElectronTitlebar/WinControl/index.tsx +91 -0
  28. package/src/features/ElectronTitlebar/const.ts +1 -0
  29. package/src/features/ElectronTitlebar/index.tsx +19 -5
  30. package/src/services/electron/system.ts +12 -0
  31. package/apps/desktop/build/favicon.ico +0 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,56 @@
2
2
 
3
3
  # Changelog
4
4
 
5
+ ### [Version 1.84.14](https://github.com/lobehub/lobe-chat/compare/v1.84.13...v1.84.14)
6
+
7
+ <sup>Released on **2025-05-01**</sup>
8
+
9
+ #### 💄 Styles
10
+
11
+ - **misc**: Add windows control and tray.
12
+
13
+ <br/>
14
+
15
+ <details>
16
+ <summary><kbd>Improvements and Fixes</kbd></summary>
17
+
18
+ #### Styles
19
+
20
+ - **misc**: Add windows control and tray, closes [#7665](https://github.com/lobehub/lobe-chat/issues/7665) ([c5f3d13](https://github.com/lobehub/lobe-chat/commit/c5f3d13))
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
+
30
+ ### [Version 1.84.13](https://github.com/lobehub/lobe-chat/compare/v1.84.12...v1.84.13)
31
+
32
+ <sup>Released on **2025-05-01**</sup>
33
+
34
+ #### 💄 Styles
35
+
36
+ - **misc**: Fix style issues.
37
+
38
+ <br/>
39
+
40
+ <details>
41
+ <summary><kbd>Improvements and Fixes</kbd></summary>
42
+
43
+ #### Styles
44
+
45
+ - **misc**: Fix style issues, closes [#7659](https://github.com/lobehub/lobe-chat/issues/7659) ([3da871b](https://github.com/lobehub/lobe-chat/commit/3da871b))
46
+
47
+ </details>
48
+
49
+ <div align="right">
50
+
51
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
52
+
53
+ </div>
54
+
5
55
  ### [Version 1.84.12](https://github.com/lobehub/lobe-chat/compare/v1.84.11...v1.84.12)
6
56
 
7
57
  <sup>Released on **2025-04-30**</sup>
Binary file
Binary file
Binary file
@@ -79,10 +79,10 @@ const config = {
79
79
  },
80
80
  npmRebuild: true,
81
81
  nsis: {
82
+ allowToChangeInstallationDirectory: true,
82
83
  artifactName: '${productName}-${version}-setup.${ext}',
83
84
  createDesktopShortcut: 'always',
84
- // allowToChangeInstallationDirectory: true,
85
- // oneClick: false,
85
+ oneClick: false,
86
86
  shortcutName: '${productName}',
87
87
  uninstallDisplayName: '${productName}',
88
88
  },
@@ -23,7 +23,6 @@
23
23
  @media (prefers-color-scheme: dark) {
24
24
  body {
25
25
  color: #f5f5f5;
26
- background-color: #121212;
27
26
  }
28
27
  .error-message {
29
28
  color: #f5f5f5;
@@ -2,6 +2,7 @@ import { InterceptRouteParams } from '@lobechat/electron-client-ipc';
2
2
  import { extractSubPath, findMatchingRoute } from '~common/routes';
3
3
 
4
4
  import { AppBrowsersIdentifiers, BrowsersIdentifiers } from '@/appBrowsers';
5
+ import { IpcClientEventSender } from '@/types/ipcClientEvent';
5
6
 
6
7
  import { ControllerModule, ipcClientEvent, shortcut } from './index';
7
8
 
@@ -26,6 +27,21 @@ export default class BrowserWindowsCtr extends ControllerModule {
26
27
  }
27
28
  }
28
29
 
30
+ @ipcClientEvent('closeWindow')
31
+ closeWindow(data: undefined, sender: IpcClientEventSender) {
32
+ this.app.browserManager.closeWindow(sender.identifier);
33
+ }
34
+
35
+ @ipcClientEvent('minimizeWindow')
36
+ minimizeWindow(data: undefined, sender: IpcClientEventSender) {
37
+ this.app.browserManager.minimizeWindow(sender.identifier);
38
+ }
39
+
40
+ @ipcClientEvent('maximizeWindow')
41
+ maximizeWindow(data: undefined, sender: IpcClientEventSender) {
42
+ this.app.browserManager.maximizeWindow(sender.identifier);
43
+ }
44
+
29
45
  /**
30
46
  * Handle route interception requests
31
47
  * Responsible for handling route interception requests from the renderer process
@@ -0,0 +1,109 @@
1
+ import {
2
+ ShowTrayNotificationParams,
3
+ UpdateTrayIconParams,
4
+ UpdateTrayTooltipParams,
5
+ } from '@lobechat/electron-client-ipc';
6
+
7
+ import { createLogger } from '@/utils/logger';
8
+
9
+ import { ControllerModule, ipcClientEvent, shortcut } from './index';
10
+
11
+ // 创建日志记录器
12
+ const logger = createLogger('controllers:TrayMenuCtr');
13
+
14
+ export default class TrayMenuCtr extends ControllerModule {
15
+ /**
16
+ * 使用快捷键切换窗口可见性
17
+ */
18
+ @shortcut('toggleMainWindow')
19
+ async toggleMainWindow() {
20
+ logger.debug('通过快捷键切换主窗口可见性');
21
+ const mainWindow = this.app.browserManager.getMainWindow();
22
+ mainWindow.toggleVisible();
23
+ }
24
+
25
+ /**
26
+ * 显示托盘气泡通知
27
+ * @param options 气泡选项
28
+ * @returns 操作结果
29
+ */
30
+ @ipcClientEvent('showTrayNotification')
31
+ async showNotification(options: ShowTrayNotificationParams) {
32
+ logger.debug('显示托盘气泡通知');
33
+
34
+ if (process.platform === 'win32') {
35
+ const mainTray = this.app.trayManager.getMainTray();
36
+
37
+ if (mainTray) {
38
+ mainTray.displayBalloon({
39
+ content: options.content,
40
+ iconType: options.iconType || 'info',
41
+ title: options.title,
42
+ });
43
+
44
+ return { success: true };
45
+ }
46
+ }
47
+
48
+ return {
49
+ error: '托盘通知仅在 Windows 平台支持',
50
+ success: false
51
+ };
52
+ }
53
+
54
+ /**
55
+ * 更新托盘图标
56
+ * @param options 图标选项
57
+ * @returns 操作结果
58
+ */
59
+ @ipcClientEvent('updateTrayIcon')
60
+ async updateTrayIcon(options: UpdateTrayIconParams) {
61
+ logger.debug('更新托盘图标');
62
+
63
+ if (process.platform === 'win32') {
64
+ const mainTray = this.app.trayManager.getMainTray();
65
+
66
+ if (mainTray && options.iconPath) {
67
+ try {
68
+ mainTray.updateIcon(options.iconPath);
69
+ return { success: true };
70
+ } catch (error) {
71
+ logger.error('更新托盘图标失败:', error);
72
+ return {
73
+ error: String(error),
74
+ success: false
75
+ };
76
+ }
77
+ }
78
+ }
79
+
80
+ return {
81
+ error: '托盘功能仅在 Windows 平台支持',
82
+ success: false
83
+ };
84
+ }
85
+
86
+ /**
87
+ * 更新托盘提示文本
88
+ * @param options 提示文本选项
89
+ * @returns 操作结果
90
+ */
91
+ @ipcClientEvent('updateTrayTooltip')
92
+ async updateTrayTooltip(options: UpdateTrayTooltipParams) {
93
+ logger.debug('更新托盘提示文本');
94
+
95
+ if (process.platform === 'win32') {
96
+ const mainTray = this.app.trayManager.getMainTray();
97
+
98
+ if (mainTray && options.tooltip) {
99
+ mainTray.updateTooltip(options.tooltip);
100
+ return { success: true };
101
+ }
102
+ }
103
+
104
+ return {
105
+ error: '托盘功能仅在 Windows 平台支持',
106
+ success: false
107
+ };
108
+ }
109
+ }
@@ -9,6 +9,7 @@ import { buildDir, nextStandaloneDir } from '@/const/dir';
9
9
  import { isDev } from '@/const/env';
10
10
  import { IControlModule } from '@/controllers';
11
11
  import { IServiceModule } from '@/services';
12
+ import { IpcClientEventSender } from '@/types/ipcClientEvent';
12
13
  import { createLogger } from '@/utils/logger';
13
14
  import { CustomRequestHandler, createHandler } from '@/utils/next-electron-rsc';
14
15
 
@@ -18,6 +19,7 @@ import { IoCContainer } from './IoCContainer';
18
19
  import MenuManager from './MenuManager';
19
20
  import { ShortcutManager } from './ShortcutManager';
20
21
  import { StoreManager } from './StoreManager';
22
+ import TrayManager from './TrayManager';
21
23
  import { UpdaterManager } from './UpdaterManager';
22
24
 
23
25
  const logger = createLogger('core:App');
@@ -38,6 +40,7 @@ export class App {
38
40
  storeManager: StoreManager;
39
41
  updaterManager: UpdaterManager;
40
42
  shortcutManager: ShortcutManager;
43
+ trayManager: TrayManager;
41
44
 
42
45
  /**
43
46
  * whether app is in quiting
@@ -92,6 +95,7 @@ export class App {
92
95
  this.menuManager = new MenuManager(this);
93
96
  this.updaterManager = new UpdaterManager(this);
94
97
  this.shortcutManager = new ShortcutManager(this);
98
+ this.trayManager = new TrayManager(this);
95
99
 
96
100
  // register the schema to interceptor url
97
101
  // it should register before app ready
@@ -130,6 +134,11 @@ export class App {
130
134
 
131
135
  this.browserManager.initializeBrowsers();
132
136
 
137
+ // Initialize tray manager
138
+ if (process.platform === 'win32') {
139
+ this.trayManager.initializeTrays();
140
+ }
141
+
133
142
  // Initialize updater manager
134
143
  await this.updaterManager.initialize();
135
144
 
@@ -340,9 +349,13 @@ export class App {
340
349
  this.ipcClientEventMap.forEach((eventInfo, key) => {
341
350
  const { controller, methodName } = eventInfo;
342
351
 
343
- ipcMain.handle(key, async (e, ...data) => {
352
+ ipcMain.handle(key, async (e, data) => {
353
+ // 从 WebContents 获取对应的 BrowserWindow id
354
+ const senderIdentifier = this.browserManager.getIdentifierByWebContents(e.sender);
344
355
  try {
345
- return await controller[methodName](...data);
356
+ return await controller[methodName](data, {
357
+ identifier: senderIdentifier,
358
+ } as IpcClientEventSender);
346
359
  } catch (error) {
347
360
  logger.error(`Error handling IPC event ${key}:`, error);
348
361
  return { error: error.message };
@@ -370,7 +383,13 @@ export class App {
370
383
 
371
384
  // 新增 before-quit 处理函数
372
385
  private handleBeforeQuit = () => {
373
- this.isQuiting = true; // 首先设置标志
386
+ logger.info('Application is preparing to quit');
387
+ this.isQuiting = true;
388
+
389
+ // 销毁托盘
390
+ if (process.platform === 'win32') {
391
+ this.trayManager.destroyAll();
392
+ }
374
393
 
375
394
  // 执行清理操作
376
395
  this.unregisterAllRequestHandlers();
@@ -184,12 +184,11 @@ export default class Browser {
184
184
 
185
185
  const browserWindow = new BrowserWindow({
186
186
  ...res,
187
-
188
187
  height: savedState?.height || height,
189
188
 
189
+ // Always create hidden first
190
190
  show: false,
191
191
 
192
- // Always create hidden first
193
192
  title,
194
193
 
195
194
  transparent: true,
@@ -199,11 +198,7 @@ export default class Browser {
199
198
  // https://www.electronjs.org/docs/tutorial/context-isolation
200
199
  contextIsolation: true,
201
200
  preload: join(preloadDir, 'index.js'),
202
- // devTools: isDev,
203
201
  },
204
- // Use saved state if available, otherwise use options. Do not set x/y
205
- // x: savedState?.x, // Don't restore x
206
- // y: savedState?.y, // Don't restore y
207
202
  width: savedState?.width || width,
208
203
  });
209
204
 
@@ -215,6 +210,7 @@ export default class Browser {
215
210
  session: browserWindow.webContents.session,
216
211
  });
217
212
 
213
+ console.log('platform:',process.platform);
218
214
  // Windows 11 can use this new API
219
215
  if (process.platform === 'win32' && browserWindow.setBackgroundMaterial) {
220
216
  logger.debug(`[${this.identifier}] Setting window background material for Windows 11`);
@@ -1,4 +1,5 @@
1
1
  import { MainBroadcastEventKey, MainBroadcastParams } from '@lobechat/electron-client-ipc';
2
+ import { WebContents } from 'electron';
2
3
 
3
4
  import { createLogger } from '@/utils/logger';
4
5
 
@@ -15,6 +16,8 @@ export default class BrowserManager {
15
16
 
16
17
  browsers: Map<AppBrowsersIdentifiers, Browser> = new Map();
17
18
 
19
+ private webContentsMap = new Map<WebContents, AppBrowsersIdentifiers>();
20
+
18
21
  constructor(app: App) {
19
22
  logger.debug('Initializing BrowserManager');
20
23
  this.app = app;
@@ -147,8 +150,40 @@ export default class BrowserManager {
147
150
  logger.debug(`Creating new browser: ${options.identifier}`);
148
151
  browser = new Browser(options, this.app);
149
152
 
150
- this.browsers.set(options.identifier as AppBrowsersIdentifiers, browser);
153
+ const identifier = options.identifier as AppBrowsersIdentifiers;
154
+ this.browsers.set(identifier, browser);
155
+
156
+ // 记录 WebContents 和 identifier 的映射
157
+ this.webContentsMap.set(browser.browserWindow.webContents, identifier);
158
+
159
+ // 当窗口关闭时清理映射
160
+ browser.browserWindow.on('closed', () => {
161
+ this.webContentsMap.delete(browser.browserWindow.webContents);
162
+ });
151
163
 
152
164
  return browser;
153
165
  }
166
+
167
+ closeWindow(identifier: string) {
168
+ const browser = this.browsers.get(identifier as AppBrowsersIdentifiers);
169
+ browser?.close();
170
+ }
171
+
172
+ minimizeWindow(identifier: string) {
173
+ const browser = this.browsers.get(identifier as AppBrowsersIdentifiers);
174
+ browser?.browserWindow.minimize();
175
+ }
176
+
177
+ maximizeWindow(identifier: string) {
178
+ const browser = this.browsers.get(identifier as AppBrowsersIdentifiers);
179
+ if (browser.browserWindow.isMaximized()) {
180
+ browser?.browserWindow.unmaximize();
181
+ } else {
182
+ browser?.browserWindow.maximize();
183
+ }
184
+ }
185
+
186
+ getIdentifierByWebContents(webContents: WebContents): AppBrowsersIdentifiers | null {
187
+ return this.webContentsMap.get(webContents) || null;
188
+ }
154
189
  }
@@ -0,0 +1,231 @@
1
+ import { MainBroadcastEventKey, MainBroadcastParams } from '@lobechat/electron-client-ipc';
2
+ import {
3
+ DisplayBalloonOptions,
4
+ Tray as ElectronTray,
5
+ Menu,
6
+ MenuItemConstructorOptions,
7
+ app,
8
+ nativeImage,
9
+ } from 'electron';
10
+ import { join } from 'node:path';
11
+
12
+ import { resourcesDir } from '@/const/dir';
13
+ import { createLogger } from '@/utils/logger';
14
+
15
+ import type { App } from './App';
16
+
17
+ // 创建日志记录器
18
+ const logger = createLogger('core:Tray');
19
+
20
+ export interface TrayOptions {
21
+ /**
22
+ * 托盘图标路径(相对于资源目录)
23
+ */
24
+ iconPath: string;
25
+
26
+ /**
27
+ * 托盘标识符
28
+ */
29
+ identifier: string;
30
+
31
+ /**
32
+ * 托盘提示文本
33
+ */
34
+ tooltip?: string;
35
+ }
36
+
37
+ export default class Tray {
38
+ private app: App;
39
+
40
+ /**
41
+ * 内部 Electron 托盘
42
+ */
43
+ private _tray?: ElectronTray;
44
+
45
+ /**
46
+ * 标识符
47
+ */
48
+ identifier: string;
49
+
50
+ /**
51
+ * 创建时的选项
52
+ */
53
+ options: TrayOptions;
54
+
55
+ /**
56
+ * 获取托盘实例
57
+ */
58
+ get tray() {
59
+ return this.retrieveOrInitialize();
60
+ }
61
+
62
+ /**
63
+ * 构造托盘对象
64
+ * @param options 托盘选项
65
+ * @param application 应用实例
66
+ */
67
+ constructor(options: TrayOptions, application: App) {
68
+ logger.debug(`创建托盘实例: ${options.identifier}`);
69
+ logger.debug(`托盘选项: ${JSON.stringify(options)}`);
70
+ this.app = application;
71
+ this.identifier = options.identifier;
72
+ this.options = options;
73
+
74
+ // 初始化
75
+ this.retrieveOrInitialize();
76
+ }
77
+
78
+ /**
79
+ * 初始化托盘
80
+ */
81
+ retrieveOrInitialize() {
82
+ // 如果托盘已存在且未被销毁,则返回
83
+ if (this._tray) {
84
+ logger.debug(`[${this.identifier}] 返回现有托盘实例`);
85
+ return this._tray;
86
+ }
87
+
88
+ const { iconPath, tooltip } = this.options;
89
+
90
+ // 加载托盘图标
91
+ logger.info(`创建新的托盘实例: ${this.identifier}`);
92
+ const iconFile = join(resourcesDir, iconPath);
93
+ logger.debug(`[${this.identifier}] 加载图标: ${iconFile}`);
94
+
95
+ try {
96
+ const icon = nativeImage.createFromPath(iconFile);
97
+ this._tray = new ElectronTray(icon);
98
+
99
+ // 设置工具提示
100
+ if (tooltip) {
101
+ logger.debug(`[${this.identifier}] 设置提示文本: ${tooltip}`);
102
+ this._tray.setToolTip(tooltip);
103
+ }
104
+
105
+ // 设置默认上下文菜单
106
+ this.setContextMenu();
107
+
108
+ // 设置点击事件
109
+ this._tray.on('click', () => {
110
+ logger.debug(`[${this.identifier}] 托盘被点击`);
111
+ this.onClick();
112
+ });
113
+
114
+ logger.debug(`[${this.identifier}] 托盘实例创建完成`);
115
+ return this._tray;
116
+ } catch (error) {
117
+ logger.error(`[${this.identifier}] 创建托盘失败:`, error);
118
+ throw error;
119
+ }
120
+ }
121
+
122
+ /**
123
+ * 设置托盘上下文菜单
124
+ * @param template 菜单模板,如果未提供则使用默认模板
125
+ */
126
+ setContextMenu(template?: MenuItemConstructorOptions[]) {
127
+ logger.debug(`[${this.identifier}] 设置托盘上下文菜单`);
128
+
129
+ // 如果未提供模板,使用默认菜单
130
+ const defaultTemplate: MenuItemConstructorOptions[] = template || [
131
+ {
132
+ click: () => {
133
+ logger.debug(`[${this.identifier}] 菜单项 "显示主窗口" 被点击`);
134
+ this.app.browserManager.showMainWindow();
135
+ },
136
+ label: '显示主窗口',
137
+ },
138
+ { type: 'separator' },
139
+ {
140
+ click: () => {
141
+ logger.debug(`[${this.identifier}] 菜单项 "退出" 被点击`);
142
+ app.quit();
143
+ },
144
+ label: '退出',
145
+ },
146
+ ];
147
+
148
+ const contextMenu = Menu.buildFromTemplate(defaultTemplate);
149
+ this._tray?.setContextMenu(contextMenu);
150
+ logger.debug(`[${this.identifier}] 托盘上下文菜单已设置`);
151
+ }
152
+
153
+ /**
154
+ * 处理托盘点击事件
155
+ */
156
+ onClick() {
157
+ logger.debug(`[${this.identifier}] 处理托盘点击事件`);
158
+ const mainWindow = this.app.browserManager.getMainWindow();
159
+
160
+ if (mainWindow) {
161
+ if (mainWindow.browserWindow.isVisible() && mainWindow.browserWindow.isFocused()) {
162
+ logger.debug(`[${this.identifier}] 主窗口已可见且聚焦,现在隐藏它`);
163
+ mainWindow.hide();
164
+ } else {
165
+ logger.debug(`[${this.identifier}] 显示并聚焦主窗口`);
166
+ mainWindow.show();
167
+ mainWindow.browserWindow.focus();
168
+ }
169
+ }
170
+ }
171
+
172
+ /**
173
+ * 更新托盘图标
174
+ * @param iconPath 新图标路径(相对于资源目录)
175
+ */
176
+ updateIcon(iconPath: string) {
177
+ logger.debug(`[${this.identifier}] 更新图标: ${iconPath}`);
178
+ try {
179
+ const iconFile = join(resourcesDir, iconPath);
180
+ const icon = nativeImage.createFromPath(iconFile);
181
+ this._tray?.setImage(icon);
182
+ this.options.iconPath = iconPath;
183
+ logger.debug(`[${this.identifier}] 图标已更新`);
184
+ } catch (error) {
185
+ logger.error(`[${this.identifier}] 更新图标失败:`, error);
186
+ }
187
+ }
188
+
189
+ /**
190
+ * 更新提示文本
191
+ * @param tooltip 新提示文本
192
+ */
193
+ updateTooltip(tooltip: string) {
194
+ logger.debug(`[${this.identifier}] 更新提示文本: ${tooltip}`);
195
+ this._tray?.setToolTip(tooltip);
196
+ this.options.tooltip = tooltip;
197
+ }
198
+
199
+ /**
200
+ * 显示气泡通知(仅在 Windows 上支持)
201
+ * @param options 气泡选项
202
+ */
203
+ displayBalloon(options: DisplayBalloonOptions) {
204
+ if (process.platform === 'win32' && this._tray) {
205
+ logger.debug(`[${this.identifier}] 显示气泡通知: ${JSON.stringify(options)}`);
206
+ this._tray.displayBalloon(options);
207
+ } else {
208
+ logger.debug(`[${this.identifier}] 气泡通知仅在 Windows 上支持`);
209
+ }
210
+ }
211
+
212
+ /**
213
+ * 广播事件
214
+ */
215
+ broadcast = <T extends MainBroadcastEventKey>(channel: T, data?: MainBroadcastParams<T>) => {
216
+ logger.debug(`向托盘 ${this.identifier} 广播, 频道: ${channel}`);
217
+ // 可以通过 App 实例的 browserManager 将消息转发到主窗口
218
+ this.app.browserManager.getMainWindow()?.broadcast(channel, data);
219
+ };
220
+
221
+ /**
222
+ * 销毁托盘实例
223
+ */
224
+ destroy() {
225
+ logger.debug(`销毁托盘实例: ${this.identifier}`);
226
+ if (this._tray) {
227
+ this._tray.destroy();
228
+ this._tray = undefined;
229
+ }
230
+ }
231
+ }
@@ -0,0 +1,131 @@
1
+ import { MainBroadcastEventKey, MainBroadcastParams } from '@lobechat/electron-client-ipc';
2
+
3
+ import { name } from '@/../../package.json';
4
+ import { createLogger } from '@/utils/logger';
5
+
6
+ import type { App } from './App';
7
+ import Tray, { TrayOptions } from './Tray';
8
+
9
+ // 创建日志记录器
10
+ const logger = createLogger('core:TrayManager');
11
+
12
+ /**
13
+ * 托盘标识符类型
14
+ */
15
+ export type TrayIdentifiers = 'main';
16
+
17
+ export default class TrayManager {
18
+ app: App;
19
+
20
+ /**
21
+ * 存储所有托盘实例
22
+ */
23
+ trays: Map<TrayIdentifiers, Tray> = new Map();
24
+
25
+ /**
26
+ * 构造方法
27
+ * @param app 应用实例
28
+ */
29
+ constructor(app: App) {
30
+ logger.debug('初始化 TrayManager');
31
+ this.app = app;
32
+ }
33
+
34
+ /**
35
+ * 初始化所有托盘
36
+ */
37
+ initializeTrays() {
38
+ logger.debug('初始化应用托盘');
39
+
40
+ // 初始化主托盘
41
+ this.initializeMainTray();
42
+ }
43
+
44
+ /**
45
+ * 获取主托盘
46
+ */
47
+ getMainTray() {
48
+ return this.retrieveByIdentifier('main');
49
+ }
50
+
51
+ /**
52
+ * 初始化主托盘
53
+ */
54
+ initializeMainTray() {
55
+ logger.debug('初始化主托盘');
56
+ return this.retrieveOrInitialize({
57
+ iconPath: 'tray-icon.png',
58
+ identifier: 'main', // 使用应用图标,需要确保资源目录中有此文件
59
+ tooltip: name, // 可以使用 app.getName() 或本地化字符串
60
+ });
61
+ }
62
+
63
+ /**
64
+ * 通过标识符获取托盘实例
65
+ * @param identifier 托盘标识符
66
+ */
67
+ retrieveByIdentifier(identifier: TrayIdentifiers) {
68
+ logger.debug(`通过标识符获取托盘: ${identifier}`);
69
+ return this.trays.get(identifier);
70
+ }
71
+
72
+ /**
73
+ * 向所有托盘广播消息
74
+ * @param event 事件名称
75
+ * @param data 事件数据
76
+ */
77
+ broadcastToAllTrays = <T extends MainBroadcastEventKey>(
78
+ event: T,
79
+ data: MainBroadcastParams<T>,
80
+ ) => {
81
+ logger.debug(`向所有托盘广播事件 ${event}`);
82
+ this.trays.forEach((tray) => {
83
+ tray.broadcast(event, data);
84
+ });
85
+ };
86
+
87
+ /**
88
+ * 向指定托盘广播消息
89
+ * @param identifier 托盘标识符
90
+ * @param event 事件名称
91
+ * @param data 事件数据
92
+ */
93
+ broadcastToTray = <T extends MainBroadcastEventKey>(
94
+ identifier: TrayIdentifiers,
95
+ event: T,
96
+ data: MainBroadcastParams<T>,
97
+ ) => {
98
+ logger.debug(`向托盘 ${identifier} 广播事件 ${event}`);
99
+ this.trays.get(identifier)?.broadcast(event, data);
100
+ };
101
+
102
+ /**
103
+ * 获取或创建托盘实例
104
+ * @param options 托盘选项
105
+ */
106
+ private retrieveOrInitialize(options: TrayOptions) {
107
+ let tray = this.trays.get(options.identifier as TrayIdentifiers);
108
+ if (tray) {
109
+ logger.debug(`获取现有托盘: ${options.identifier}`);
110
+ return tray;
111
+ }
112
+
113
+ logger.debug(`创建新托盘: ${options.identifier}`);
114
+ tray = new Tray(options, this.app);
115
+
116
+ this.trays.set(options.identifier as TrayIdentifiers, tray);
117
+
118
+ return tray;
119
+ }
120
+
121
+ /**
122
+ * 销毁所有托盘
123
+ */
124
+ destroyAll() {
125
+ logger.debug('销毁所有托盘');
126
+ this.trays.forEach((tray) => {
127
+ tray.destroy();
128
+ });
129
+ this.trays.clear();
130
+ }
131
+ }
@@ -0,0 +1,3 @@
1
+ export interface IpcClientEventSender {
2
+ identifier: string;
3
+ }
package/changelog/v1.json CHANGED
@@ -1,4 +1,22 @@
1
1
  [
2
+ {
3
+ "children": {
4
+ "improvements": [
5
+ "Add windows control and tray."
6
+ ]
7
+ },
8
+ "date": "2025-05-01",
9
+ "version": "1.84.14"
10
+ },
11
+ {
12
+ "children": {
13
+ "improvements": [
14
+ "Fix style issues."
15
+ ]
16
+ },
17
+ "date": "2025-05-01",
18
+ "version": "1.84.13"
19
+ },
2
20
  {
3
21
  "children": {
4
22
  "improvements": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobehub/chat",
3
- "version": "1.84.12",
3
+ "version": "1.84.14",
4
4
  "description": "Lobe Chat - an open-source, high-performance chatbot 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",
@@ -3,6 +3,7 @@ import { MenuDispatchEvents } from './menu';
3
3
  import { RemoteServerBroadcastEvents, RemoteServerDispatchEvents } from './remoteServer';
4
4
  import { ShortcutDispatchEvents } from './shortcut';
5
5
  import { SystemDispatchEvents } from './system';
6
+ import { TrayDispatchEvents } from './tray';
6
7
  import { AutoUpdateBroadcastEvents, AutoUpdateDispatchEvents } from './update';
7
8
  import { UploadFilesDispatchEvents } from './upload';
8
9
  import { WindowsDispatchEvents } from './windows';
@@ -19,7 +20,8 @@ export interface ClientDispatchEvents
19
20
  AutoUpdateDispatchEvents,
20
21
  ShortcutDispatchEvents,
21
22
  RemoteServerDispatchEvents,
22
- UploadFilesDispatchEvents {}
23
+ UploadFilesDispatchEvents,
24
+ TrayDispatchEvents {}
23
25
 
24
26
  export type ClientDispatchEventKey = keyof ClientDispatchEvents;
25
27
 
@@ -2,7 +2,10 @@ import { ElectronAppState } from '../types';
2
2
 
3
3
  export interface SystemDispatchEvents {
4
4
  checkSystemAccessibility: () => boolean | undefined;
5
+ closeWindow: () => void;
5
6
  getDesktopAppState: () => ElectronAppState;
7
+ maximizeWindow: () => void;
8
+ minimizeWindow: () => void;
6
9
  openExternalLink: (url: string) => void;
7
10
  /**
8
11
  * 更新应用语言设置
@@ -0,0 +1,31 @@
1
+ import {
2
+ ShowTrayNotificationParams,
3
+ UpdateTrayIconParams,
4
+ UpdateTrayTooltipParams,
5
+ } from '../types';
6
+
7
+ export interface TrayDispatchEvents {
8
+ /**
9
+ * 显示托盘通知
10
+ * @param params 通知参数
11
+ * @returns 操作结果
12
+ */
13
+ showTrayNotification: (params: ShowTrayNotificationParams) => {
14
+ error?: string;
15
+ success: boolean;
16
+ };
17
+
18
+ /**
19
+ * 更新托盘图标
20
+ * @param params 图标参数
21
+ * @returns 操作结果
22
+ */
23
+ updateTrayIcon: (params: UpdateTrayIconParams) => { error?: string; success: boolean };
24
+
25
+ /**
26
+ * 更新托盘提示文本
27
+ * @param params 提示文本参数
28
+ * @returns 操作结果
29
+ */
30
+ updateTrayTooltip: (params: UpdateTrayTooltipParams) => { error?: string; success: boolean };
31
+ }
@@ -5,5 +5,6 @@ export * from './proxyTRPCRequest';
5
5
  export * from './route';
6
6
  export * from './shortcut';
7
7
  export * from './system';
8
+ export * from './tray';
8
9
  export * from './update';
9
10
  export * from './upload';
@@ -0,0 +1,39 @@
1
+ /**
2
+ * 显示托盘通知的参数
3
+ */
4
+ export interface ShowTrayNotificationParams {
5
+ /**
6
+ * 通知内容
7
+ */
8
+ content: string;
9
+
10
+ /**
11
+ * 图标类型
12
+ */
13
+ iconType?: 'info' | 'warning' | 'error' | 'none';
14
+
15
+ /**
16
+ * 通知标题
17
+ */
18
+ title: string;
19
+ }
20
+
21
+ /**
22
+ * 更新托盘图标的参数
23
+ */
24
+ export interface UpdateTrayIconParams {
25
+ /**
26
+ * 图标路径(相对于资源目录)
27
+ */
28
+ iconPath: string;
29
+ }
30
+
31
+ /**
32
+ * 更新托盘提示文本的参数
33
+ */
34
+ export interface UpdateTrayTooltipParams {
35
+ /**
36
+ * 提示文本
37
+ */
38
+ tooltip: string;
39
+ }
@@ -64,10 +64,7 @@ const FileItem = memo<FileItemProps>(({ id, onRemove, file, status, uploadState,
64
64
  e.stopPropagation();
65
65
  onRemove?.();
66
66
  }}
67
- style={{
68
- blockSize: '32px',
69
- fontSize: '20px',
70
- }}
67
+ size={'small'}
71
68
  />
72
69
  </Flexbox>
73
70
  );
@@ -3,8 +3,6 @@ import { createStyles } from 'antd-style';
3
3
  import { Trash } from 'lucide-react';
4
4
  import { memo } from 'react';
5
5
 
6
- import { usePlatform } from '@/hooks/usePlatform';
7
-
8
6
  import { MIN_IMAGE_SIZE } from './style';
9
7
 
10
8
  const useStyles = createStyles(({ css, token }) => ({
@@ -21,15 +19,9 @@ const useStyles = createStyles(({ css, token }) => ({
21
19
  box-shadow: 0 0 0 1px ${token.colorFill} inset;
22
20
  `,
23
21
  image: css`
22
+ width: 64px !important;
23
+ height: 64px !important;
24
24
  margin-block: 0 !important;
25
-
26
- .ant-image {
27
- height: 100% !important;
28
-
29
- img {
30
- height: 100% !important;
31
- }
32
- }
33
25
  `,
34
26
  }));
35
27
 
@@ -43,7 +35,6 @@ interface FileItemProps {
43
35
  const FileItem = memo<FileItemProps>(({ alt, onRemove, src, loading }) => {
44
36
  const IMAGE_SIZE = MIN_IMAGE_SIZE;
45
37
  const { styles, cx } = useStyles();
46
- const { isSafari } = usePlatform();
47
38
 
48
39
  return (
49
40
  <Image
@@ -56,19 +47,17 @@ const FileItem = memo<FileItemProps>(({ alt, onRemove, src, loading }) => {
56
47
  e.stopPropagation();
57
48
  onRemove?.();
58
49
  }}
59
- style={{
60
- blockSize: '28px',
61
- fontSize: '20px',
62
- }}
50
+ size={'small'}
63
51
  />
64
52
  }
65
53
  alt={alt || ''}
66
54
  alwaysShowActions
67
- height={isSafari ? 'auto' : '100%'}
55
+ height={64}
68
56
  isLoading={loading}
57
+ objectFit={'cover'}
69
58
  size={IMAGE_SIZE as any}
70
59
  src={src}
71
- style={{ height: isSafari ? 'auto' : '100%' }}
60
+ width={64}
72
61
  wrapperClassName={cx(styles.image, styles.editableImage)}
73
62
  />
74
63
  );
@@ -19,6 +19,7 @@ const useStyles = createStyles(({ css, token }) => ({
19
19
  `,
20
20
  drawer: css`
21
21
  z-index: 20;
22
+ background: ${token.colorBgContainerSecondary};
22
23
  `,
23
24
  header: css`
24
25
  border-block-end: 1px solid ${token.colorBorderSecondary};
@@ -144,6 +144,10 @@ const Checker = memo<ConnectionCheckerProps>(
144
144
  );
145
145
  }}
146
146
  options={totalModels.map((id) => ({ label: id, value: id }))}
147
+ style={{
148
+ flex: 1,
149
+ overflow: 'hidden',
150
+ }}
147
151
  suffixIcon={isProviderConfigUpdating && <Icon icon={Loader2Icon} spin />}
148
152
  value={checkModel}
149
153
  virtual
@@ -0,0 +1,91 @@
1
+ import { Icon } from '@lobehub/ui';
2
+ import { createStyles } from 'antd-style';
3
+ import { Minus, Square, XIcon } from 'lucide-react';
4
+
5
+ import { electronSystemService } from '@/services/electron/system';
6
+
7
+ import { TITLE_BAR_HEIGHT } from '../const';
8
+
9
+ const useStyles = createStyles(({ css, cx, token }) => {
10
+ const icon = css`
11
+ display: flex;
12
+ align-items: center;
13
+ justify-content: center;
14
+
15
+ width: 64px;
16
+ min-height: ${TITLE_BAR_HEIGHT}px;
17
+
18
+ color: ${token.colorTextSecondary};
19
+
20
+ transition: all ease-in-out 100ms;
21
+
22
+ -webkit-app-region: no-drag;
23
+
24
+ &:hover {
25
+ background: ${token.colorFillTertiary};
26
+ }
27
+
28
+ &:active {
29
+ background: ${token.colorFillSecondary};
30
+ }
31
+ `;
32
+ return {
33
+ close: cx(
34
+ icon,
35
+ css`
36
+ &:hover {
37
+ color: ${token.colorTextLightSolid};
38
+
39
+ /* win11 的色值,亮暗色均不变 */
40
+ background: #d33328;
41
+ }
42
+
43
+ &:active {
44
+ color: ${token.colorTextLightSolid};
45
+
46
+ /* win11 的色值 */
47
+ background: #8b2b25;
48
+ }
49
+ `,
50
+ ),
51
+ container: css`
52
+ cursor: pointer;
53
+ display: flex;
54
+ `,
55
+ icon,
56
+ };
57
+ });
58
+
59
+ const WinControl = () => {
60
+ const { styles } = useStyles();
61
+ return (
62
+ <div className={styles.container}>
63
+ <div
64
+ className={styles.icon}
65
+ onClick={() => {
66
+ electronSystemService.minimizeWindow();
67
+ }}
68
+ >
69
+ <Icon icon={Minus} style={{ fontSize: 18 }} />
70
+ </div>
71
+ <div
72
+ className={styles.icon}
73
+ onClick={() => {
74
+ electronSystemService.maximizeWindow();
75
+ }}
76
+ >
77
+ <Icon icon={Square} />
78
+ </div>
79
+ <div
80
+ className={styles.close}
81
+ onClick={() => {
82
+ electronSystemService.closeWindow();
83
+ }}
84
+ >
85
+ <Icon icon={XIcon} style={{ fontSize: 18 }} />
86
+ </div>
87
+ </div>
88
+ );
89
+ };
90
+
91
+ export default WinControl;
@@ -0,0 +1 @@
1
+ export const TITLE_BAR_HEIGHT = 36;
@@ -1,14 +1,18 @@
1
+ import { Divider } from 'antd';
1
2
  import { memo } from 'react';
2
3
  import { Flexbox } from 'react-layout-kit';
3
4
 
4
5
  import { useElectronStore } from '@/store/electron';
5
6
  import { electronStylish } from '@/styles/electron';
7
+ import { isMacOS } from '@/utils/platform';
6
8
 
7
9
  import Connection from './Connection';
8
10
  import { UpdateModal } from './UpdateModal';
9
11
  import { UpdateNotification } from './UpdateNotification';
12
+ import WinControl from './WinControl';
13
+ import { TITLE_BAR_HEIGHT } from './const';
10
14
 
11
- export const TITLE_BAR_HEIGHT = 36;
15
+ const isMac = isMacOS();
12
16
 
13
17
  const TitleBar = memo(() => {
14
18
  const initElectronAppState = useElectronStore((s) => s.useInitElectronAppState);
@@ -22,16 +26,24 @@ const TitleBar = memo(() => {
22
26
  height={TITLE_BAR_HEIGHT}
23
27
  horizontal
24
28
  justify={'space-between'}
25
- paddingInline={12}
29
+ paddingInline={isMac ? 12 : '12px 0'}
26
30
  style={{ minHeight: TITLE_BAR_HEIGHT }}
27
31
  width={'100%'}
28
32
  >
29
33
  <div />
30
34
  <div>{/* TODO */}</div>
31
35
 
32
- <Flexbox className={electronStylish.nodrag} gap={8} horizontal>
33
- <UpdateNotification />
34
- <Connection />
36
+ <Flexbox align={'center'} gap={4} horizontal>
37
+ <Flexbox className={electronStylish.nodrag} gap={8} horizontal>
38
+ <UpdateNotification />
39
+ <Connection />
40
+ </Flexbox>
41
+ {!isMac && (
42
+ <>
43
+ <Divider type={'vertical'} />
44
+ <WinControl />
45
+ </>
46
+ )}
35
47
  </Flexbox>
36
48
  <UpdateModal />
37
49
  </Flexbox>
@@ -39,3 +51,5 @@ const TitleBar = memo(() => {
39
51
  });
40
52
 
41
53
  export default TitleBar;
54
+
55
+ export { TITLE_BAR_HEIGHT } from './const';
@@ -14,6 +14,18 @@ class ElectronSystemService {
14
14
  return dispatch('getDesktopAppState');
15
15
  }
16
16
 
17
+ async closeWindow(): Promise<void> {
18
+ return dispatch('closeWindow');
19
+ }
20
+
21
+ async maximizeWindow(): Promise<void> {
22
+ return dispatch('maximizeWindow');
23
+ }
24
+
25
+ async minimizeWindow(): Promise<void> {
26
+ return dispatch('minimizeWindow');
27
+ }
28
+
17
29
  // Add other system-related service methods here if needed in the future
18
30
  }
19
31
 
Binary file