@lobehub/lobehub 2.0.0-next.215 → 2.0.0-next.217
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -0
- package/apps/desktop/src/main/core/browser/Browser.ts +78 -14
- package/apps/desktop/src/main/core/browser/__tests__/Browser.test.ts +51 -0
- package/changelog/v1.json +14 -0
- package/package.json +1 -1
- package/packages/utils/src/server/index.ts +0 -1
- package/src/app/[variants]/(main)/image/features/PromptInput/index.tsx +44 -0
- package/src/features/CommandMenu/AskAIMenu.tsx +47 -14
- package/src/features/ModelSwitchPanel/index.tsx +21 -1
- package/packages/utils/src/server/__tests__/geo.test.ts +0 -116
- package/packages/utils/src/server/geo.ts +0 -60
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,56 @@
|
|
|
2
2
|
|
|
3
3
|
# Changelog
|
|
4
4
|
|
|
5
|
+
## [Version 2.0.0-next.217](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.216...v2.0.0-next.217)
|
|
6
|
+
|
|
7
|
+
<sup>Released on **2026-01-05**</sup>
|
|
8
|
+
|
|
9
|
+
#### ♻ Code Refactoring
|
|
10
|
+
|
|
11
|
+
- **utils**: Remove unused geo server utilities.
|
|
12
|
+
|
|
13
|
+
<br/>
|
|
14
|
+
|
|
15
|
+
<details>
|
|
16
|
+
<summary><kbd>Improvements and Fixes</kbd></summary>
|
|
17
|
+
|
|
18
|
+
#### Code refactoring
|
|
19
|
+
|
|
20
|
+
- **utils**: Remove unused geo server utilities, closes [#11243](https://github.com/lobehub/lobe-chat/issues/11243) ([ee474cc](https://github.com/lobehub/lobe-chat/commit/ee474cc))
|
|
21
|
+
|
|
22
|
+
</details>
|
|
23
|
+
|
|
24
|
+
<div align="right">
|
|
25
|
+
|
|
26
|
+
[](#readme-top)
|
|
27
|
+
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
## [Version 2.0.0-next.216](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.215...v2.0.0-next.216)
|
|
31
|
+
|
|
32
|
+
<sup>Released on **2026-01-05**</sup>
|
|
33
|
+
|
|
34
|
+
#### 🐛 Bug Fixes
|
|
35
|
+
|
|
36
|
+
- **misc**: Restore window position safely.
|
|
37
|
+
|
|
38
|
+
<br/>
|
|
39
|
+
|
|
40
|
+
<details>
|
|
41
|
+
<summary><kbd>Improvements and Fixes</kbd></summary>
|
|
42
|
+
|
|
43
|
+
#### What's fixed
|
|
44
|
+
|
|
45
|
+
- **misc**: Restore window position safely ([e0b555e](https://github.com/lobehub/lobe-chat/commit/e0b555e))
|
|
46
|
+
|
|
47
|
+
</details>
|
|
48
|
+
|
|
49
|
+
<div align="right">
|
|
50
|
+
|
|
51
|
+
[](#readme-top)
|
|
52
|
+
|
|
53
|
+
</div>
|
|
54
|
+
|
|
5
55
|
## [Version 2.0.0-next.215](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.214...v2.0.0-next.215)
|
|
6
56
|
|
|
7
57
|
<sup>Released on **2026-01-05**</sup>
|
|
@@ -42,6 +42,13 @@ export interface BrowserWindowOpts extends BrowserWindowConstructorOptions {
|
|
|
42
42
|
width?: number;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
interface WindowState {
|
|
46
|
+
height?: number;
|
|
47
|
+
width?: number;
|
|
48
|
+
x?: number;
|
|
49
|
+
y?: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
45
52
|
export default class Browser {
|
|
46
53
|
private app: App;
|
|
47
54
|
private _browserWindow?: BrowserWindow;
|
|
@@ -152,6 +159,46 @@ export default class Browser {
|
|
|
152
159
|
this._browserWindow.setTitleBarOverlay(config.titleBarOverlay);
|
|
153
160
|
}
|
|
154
161
|
|
|
162
|
+
private clampNumber(value: number, min: number, max: number) {
|
|
163
|
+
return Math.min(Math.max(value, min), max);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private resolveWindowState(
|
|
167
|
+
savedState: WindowState | undefined,
|
|
168
|
+
fallbackState: { height?: number; width?: number },
|
|
169
|
+
): WindowState {
|
|
170
|
+
const width = savedState?.width ?? fallbackState.width;
|
|
171
|
+
const height = savedState?.height ?? fallbackState.height;
|
|
172
|
+
const resolvedState: WindowState = { height, width };
|
|
173
|
+
|
|
174
|
+
const hasPosition = Number.isFinite(savedState?.x) && Number.isFinite(savedState?.y);
|
|
175
|
+
if (!hasPosition) return resolvedState;
|
|
176
|
+
|
|
177
|
+
const x = savedState?.x as number;
|
|
178
|
+
const y = savedState?.y as number;
|
|
179
|
+
|
|
180
|
+
const targetDisplay = screen.getDisplayMatching({
|
|
181
|
+
height: height ?? 0,
|
|
182
|
+
width: width ?? 0,
|
|
183
|
+
x,
|
|
184
|
+
y,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const workArea = targetDisplay?.workArea ?? screen.getPrimaryDisplay().workArea;
|
|
188
|
+
const resolvedWidth = typeof width === 'number' ? Math.min(width, workArea.width) : width;
|
|
189
|
+
const resolvedHeight = typeof height === 'number' ? Math.min(height, workArea.height) : height;
|
|
190
|
+
|
|
191
|
+
const maxX = workArea.x + Math.max(0, workArea.width - (resolvedWidth ?? 0));
|
|
192
|
+
const maxY = workArea.y + Math.max(0, workArea.height - (resolvedHeight ?? 0));
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
height: resolvedHeight,
|
|
196
|
+
width: resolvedWidth,
|
|
197
|
+
x: this.clampNumber(x, workArea.x, maxX),
|
|
198
|
+
y: this.clampNumber(y, workArea.y, maxY),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
155
202
|
private cleanupThemeListener(): void {
|
|
156
203
|
if (this.themeListenerSetup) {
|
|
157
204
|
// Note: nativeTheme listeners are global, consider using a centralized theme manager
|
|
@@ -323,12 +370,17 @@ export default class Browser {
|
|
|
323
370
|
|
|
324
371
|
// Load window state
|
|
325
372
|
const savedState = this.app.storeManager.get(this.windowStateKey as any) as
|
|
326
|
-
|
|
|
327
|
-
| undefined;
|
|
373
|
+
| WindowState
|
|
374
|
+
| undefined;
|
|
328
375
|
logger.info(`Creating new BrowserWindow instance: ${this.identifier}`);
|
|
329
376
|
logger.debug(`[${this.identifier}] Options for new window: ${JSON.stringify(this.options)}`);
|
|
330
377
|
logger.debug(
|
|
331
|
-
`[${this.identifier}] Saved window state
|
|
378
|
+
`[${this.identifier}] Saved window state: ${JSON.stringify(savedState)}`,
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
const resolvedState = this.resolveWindowState(savedState, { height, width });
|
|
382
|
+
logger.debug(
|
|
383
|
+
`[${this.identifier}] Resolved window state: ${JSON.stringify(resolvedState)}`,
|
|
332
384
|
);
|
|
333
385
|
|
|
334
386
|
const browserWindow = new BrowserWindow({
|
|
@@ -337,7 +389,7 @@ export default class Browser {
|
|
|
337
389
|
backgroundColor: '#00000000',
|
|
338
390
|
darkTheme: this.isDarkMode,
|
|
339
391
|
frame: false,
|
|
340
|
-
height:
|
|
392
|
+
height: resolvedState.height,
|
|
341
393
|
show: false,
|
|
342
394
|
title,
|
|
343
395
|
vibrancy: 'sidebar',
|
|
@@ -348,7 +400,9 @@ export default class Browser {
|
|
|
348
400
|
preload: join(preloadDir, 'index.js'),
|
|
349
401
|
sandbox: false,
|
|
350
402
|
},
|
|
351
|
-
width:
|
|
403
|
+
width: resolvedState.width,
|
|
404
|
+
x: resolvedState.x,
|
|
405
|
+
y: resolvedState.y,
|
|
352
406
|
...this.getPlatformThemeConfig(),
|
|
353
407
|
});
|
|
354
408
|
|
|
@@ -405,12 +459,17 @@ export default class Browser {
|
|
|
405
459
|
logger.debug(`[${this.identifier}] App is quitting, allowing window to close naturally.`);
|
|
406
460
|
// Save state before quitting
|
|
407
461
|
try {
|
|
408
|
-
const
|
|
409
|
-
const sizeState = {
|
|
462
|
+
const bounds = browserWindow.getBounds();
|
|
463
|
+
const sizeState = {
|
|
464
|
+
height: bounds.height,
|
|
465
|
+
width: bounds.width,
|
|
466
|
+
x: bounds.x,
|
|
467
|
+
y: bounds.y,
|
|
468
|
+
};
|
|
410
469
|
logger.debug(
|
|
411
|
-
`[${this.identifier}] Saving window
|
|
470
|
+
`[${this.identifier}] Saving window state on quit: ${JSON.stringify(sizeState)}`,
|
|
412
471
|
);
|
|
413
|
-
this.app.storeManager.set(this.windowStateKey as any, sizeState);
|
|
472
|
+
this.app.storeManager.set(this.windowStateKey as any, sizeState);
|
|
414
473
|
} catch (error) {
|
|
415
474
|
logger.error(`[${this.identifier}] Failed to save window state on quit:`, error);
|
|
416
475
|
}
|
|
@@ -437,15 +496,20 @@ export default class Browser {
|
|
|
437
496
|
} else {
|
|
438
497
|
// Window is actually closing (not keepAlive)
|
|
439
498
|
logger.debug(
|
|
440
|
-
`[${this.identifier}] keepAlive is false, allowing window to close. Saving
|
|
499
|
+
`[${this.identifier}] keepAlive is false, allowing window to close. Saving state...`,
|
|
441
500
|
);
|
|
442
501
|
try {
|
|
443
|
-
const
|
|
444
|
-
const sizeState = {
|
|
502
|
+
const bounds = browserWindow.getBounds();
|
|
503
|
+
const sizeState = {
|
|
504
|
+
height: bounds.height,
|
|
505
|
+
width: bounds.width,
|
|
506
|
+
x: bounds.x,
|
|
507
|
+
y: bounds.y,
|
|
508
|
+
};
|
|
445
509
|
logger.debug(
|
|
446
|
-
`[${this.identifier}] Saving window
|
|
510
|
+
`[${this.identifier}] Saving window state on close: ${JSON.stringify(sizeState)}`,
|
|
447
511
|
);
|
|
448
|
-
this.app.storeManager.set(this.windowStateKey as any, sizeState);
|
|
512
|
+
this.app.storeManager.set(this.windowStateKey as any, sizeState);
|
|
449
513
|
} catch (error) {
|
|
450
514
|
logger.error(`[${this.identifier}] Failed to save window state on close:`, error);
|
|
451
515
|
}
|
|
@@ -56,9 +56,15 @@ const { mockBrowserWindow, mockNativeTheme, mockIpcMain, mockScreen, MockBrowser
|
|
|
56
56
|
themeSource: 'system',
|
|
57
57
|
},
|
|
58
58
|
mockScreen: {
|
|
59
|
+
getDisplayMatching: vi.fn().mockReturnValue({
|
|
60
|
+
workArea: { height: 1080, width: 1920, x: 0, y: 0 },
|
|
61
|
+
}),
|
|
59
62
|
getDisplayNearestPoint: vi.fn().mockReturnValue({
|
|
60
63
|
workArea: { height: 1080, width: 1920, x: 0, y: 0 },
|
|
61
64
|
}),
|
|
65
|
+
getPrimaryDisplay: vi.fn().mockReturnValue({
|
|
66
|
+
workArea: { height: 1080, width: 1920, x: 0, y: 0 },
|
|
67
|
+
}),
|
|
62
68
|
},
|
|
63
69
|
};
|
|
64
70
|
});
|
|
@@ -240,6 +246,47 @@ describe('Browser', () => {
|
|
|
240
246
|
);
|
|
241
247
|
});
|
|
242
248
|
|
|
249
|
+
it('should restore window position from store and clamp within display', () => {
|
|
250
|
+
mockStoreManagerGet.mockImplementation((key: string) => {
|
|
251
|
+
if (key === 'windowSize_test-window') {
|
|
252
|
+
return { height: 700, width: 900, x: 1800, y: 900 };
|
|
253
|
+
}
|
|
254
|
+
return undefined;
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
new Browser(defaultOptions, mockApp);
|
|
258
|
+
|
|
259
|
+
expect(MockBrowserWindow).toHaveBeenCalledWith(
|
|
260
|
+
expect.objectContaining({
|
|
261
|
+
height: 700,
|
|
262
|
+
width: 900,
|
|
263
|
+
x: 1020,
|
|
264
|
+
y: 380,
|
|
265
|
+
}),
|
|
266
|
+
);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('should clamp saved size when it exceeds current display bounds', () => {
|
|
270
|
+
mockScreen.getDisplayMatching.mockReturnValueOnce({
|
|
271
|
+
workArea: { height: 800, width: 1200, x: 0, y: 0 },
|
|
272
|
+
});
|
|
273
|
+
mockStoreManagerGet.mockImplementation((key: string) => {
|
|
274
|
+
if (key === 'windowSize_test-window') {
|
|
275
|
+
return { height: 1200, width: 2000, x: 0, y: 0 };
|
|
276
|
+
}
|
|
277
|
+
return undefined;
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
new Browser(defaultOptions, mockApp);
|
|
281
|
+
|
|
282
|
+
expect(MockBrowserWindow).toHaveBeenCalledWith(
|
|
283
|
+
expect.objectContaining({
|
|
284
|
+
height: 800,
|
|
285
|
+
width: 1200,
|
|
286
|
+
}),
|
|
287
|
+
);
|
|
288
|
+
});
|
|
289
|
+
|
|
243
290
|
it('should use default size when no saved state', () => {
|
|
244
291
|
mockStoreManagerGet.mockReturnValue(undefined);
|
|
245
292
|
|
|
@@ -541,6 +588,8 @@ describe('Browser', () => {
|
|
|
541
588
|
expect(mockStoreManagerSet).toHaveBeenCalledWith('windowSize_test-window', {
|
|
542
589
|
height: 600,
|
|
543
590
|
width: 800,
|
|
591
|
+
x: 0,
|
|
592
|
+
y: 0,
|
|
544
593
|
});
|
|
545
594
|
expect(mockEvent.preventDefault).not.toHaveBeenCalled();
|
|
546
595
|
});
|
|
@@ -572,6 +621,8 @@ describe('Browser', () => {
|
|
|
572
621
|
expect(mockStoreManagerSet).toHaveBeenCalledWith('windowSize_test-window', {
|
|
573
622
|
height: 600,
|
|
574
623
|
width: 800,
|
|
624
|
+
x: 0,
|
|
625
|
+
y: 0,
|
|
575
626
|
});
|
|
576
627
|
});
|
|
577
628
|
});
|
package/changelog/v1.json
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"children": {},
|
|
4
|
+
"date": "2026-01-05",
|
|
5
|
+
"version": "2.0.0-next.217"
|
|
6
|
+
},
|
|
7
|
+
{
|
|
8
|
+
"children": {
|
|
9
|
+
"fixes": [
|
|
10
|
+
"Restore window position safely."
|
|
11
|
+
]
|
|
12
|
+
},
|
|
13
|
+
"date": "2026-01-05",
|
|
14
|
+
"version": "2.0.0-next.216"
|
|
15
|
+
},
|
|
2
16
|
{
|
|
3
17
|
"children": {
|
|
4
18
|
"fixes": [
|
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.217",
|
|
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",
|
|
@@ -5,11 +5,13 @@ import { Button, Flexbox, TextArea } from '@lobehub/ui';
|
|
|
5
5
|
import { createStaticStyles, cx } from 'antd-style';
|
|
6
6
|
import { Sparkles } from 'lucide-react';
|
|
7
7
|
import type { KeyboardEvent } from 'react';
|
|
8
|
+
import { useEffect, useRef } from 'react';
|
|
8
9
|
import { useTranslation } from 'react-i18next';
|
|
9
10
|
|
|
10
11
|
import { loginRequired } from '@/components/Error/loginRequiredNotification';
|
|
11
12
|
import { useGeminiChineseWarning } from '@/hooks/useGeminiChineseWarning';
|
|
12
13
|
import { useIsDark } from '@/hooks/useIsDark';
|
|
14
|
+
import { useQueryState } from '@/hooks/useQueryParam';
|
|
13
15
|
import { useImageStore } from '@/store/image';
|
|
14
16
|
import { createImageSelectors } from '@/store/image/selectors';
|
|
15
17
|
import { useGenerationConfigParam } from '@/store/image/slices/generationConfig/hooks';
|
|
@@ -49,6 +51,10 @@ const PromptInput = ({ showTitle = false }: PromptInputProps) => {
|
|
|
49
51
|
const isLogin = useUserStore(authSelectors.isLogin);
|
|
50
52
|
const checkGeminiChineseWarning = useGeminiChineseWarning();
|
|
51
53
|
|
|
54
|
+
// Read prompt from query parameter
|
|
55
|
+
const [promptParam, setPromptParam] = useQueryState('prompt');
|
|
56
|
+
const hasProcessedPrompt = useRef(false);
|
|
57
|
+
|
|
52
58
|
const handleGenerate = async () => {
|
|
53
59
|
if (!isLogin) {
|
|
54
60
|
loginRequired.redirect({ timeout: 2000 });
|
|
@@ -66,6 +72,44 @@ const PromptInput = ({ showTitle = false }: PromptInputProps) => {
|
|
|
66
72
|
await createImage();
|
|
67
73
|
};
|
|
68
74
|
|
|
75
|
+
// Auto-fill and auto-send when prompt query parameter is present
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (promptParam && !hasProcessedPrompt.current && isLogin) {
|
|
78
|
+
// Decode the prompt parameter
|
|
79
|
+
const decodedPrompt = decodeURIComponent(promptParam);
|
|
80
|
+
|
|
81
|
+
// Set the prompt value in the store
|
|
82
|
+
setValue(decodedPrompt);
|
|
83
|
+
|
|
84
|
+
// Mark as processed to avoid running this effect again
|
|
85
|
+
hasProcessedPrompt.current = true;
|
|
86
|
+
|
|
87
|
+
// Clear the query parameter
|
|
88
|
+
setPromptParam(null);
|
|
89
|
+
|
|
90
|
+
// Auto-trigger generation after a short delay to ensure state is updated
|
|
91
|
+
setTimeout(async () => {
|
|
92
|
+
const shouldContinue = await checkGeminiChineseWarning({
|
|
93
|
+
model: currentModel,
|
|
94
|
+
prompt: decodedPrompt,
|
|
95
|
+
scenario: 'image',
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
if (shouldContinue) {
|
|
99
|
+
await createImage();
|
|
100
|
+
}
|
|
101
|
+
}, 100);
|
|
102
|
+
}
|
|
103
|
+
}, [
|
|
104
|
+
promptParam,
|
|
105
|
+
isLogin,
|
|
106
|
+
setValue,
|
|
107
|
+
setPromptParam,
|
|
108
|
+
checkGeminiChineseWarning,
|
|
109
|
+
currentModel,
|
|
110
|
+
createImage,
|
|
111
|
+
]);
|
|
112
|
+
|
|
69
113
|
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
70
114
|
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
|
|
71
115
|
e.preventDefault();
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { DEFAULT_AVATAR, DEFAULT_INBOX_AVATAR } from '@lobechat/const';
|
|
2
2
|
import { Avatar } from '@lobehub/ui';
|
|
3
|
+
import { GroupBotSquareIcon } from '@lobehub/ui/icons';
|
|
3
4
|
import { Command } from 'cmdk';
|
|
4
|
-
import { Image } from 'lucide-react';
|
|
5
|
+
import { Bot, Image } from 'lucide-react';
|
|
5
6
|
import { memo } from 'react';
|
|
6
7
|
import { useTranslation } from 'react-i18next';
|
|
7
8
|
import { useNavigate } from 'react-router-dom';
|
|
@@ -10,11 +11,12 @@ import { useHomeStore } from '@/store/home';
|
|
|
10
11
|
import { homeAgentListSelectors } from '@/store/home/selectors';
|
|
11
12
|
|
|
12
13
|
import { useCommandMenuContext } from './CommandMenuContext';
|
|
14
|
+
import { CommandItem } from './components';
|
|
13
15
|
import { styles } from './styles';
|
|
14
16
|
import { useCommandMenu } from './useCommandMenu';
|
|
15
17
|
|
|
16
18
|
const AskAIMenu = memo(() => {
|
|
17
|
-
const { t } = useTranslation('common');
|
|
19
|
+
const { t } = useTranslation(['common', 'chat', 'home']);
|
|
18
20
|
const navigate = useNavigate();
|
|
19
21
|
const { handleAskLobeAI, handleAIPainting, closeCommandMenu } = useCommandMenu();
|
|
20
22
|
const { search } = useCommandMenuContext();
|
|
@@ -27,6 +29,24 @@ const AskAIMenu = memo(() => {
|
|
|
27
29
|
? t('cmdk.askAIHeading', { query: `"${search.trim()}"` })
|
|
28
30
|
: t('cmdk.askAIHeadingEmpty');
|
|
29
31
|
|
|
32
|
+
const handleAgentBuilder = () => {
|
|
33
|
+
const trimmedSearch = search.trim();
|
|
34
|
+
closeCommandMenu(); // Close immediately
|
|
35
|
+
if (trimmedSearch) {
|
|
36
|
+
// Use sendAsAgent to create a blank agent and open agent builder
|
|
37
|
+
useHomeStore.getState().sendAsAgent(trimmedSearch);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const handleGroupBuilder = () => {
|
|
42
|
+
const trimmedSearch = search.trim();
|
|
43
|
+
closeCommandMenu(); // Close immediately
|
|
44
|
+
if (trimmedSearch) {
|
|
45
|
+
// Use sendAsGroup to create a blank group and open group builder
|
|
46
|
+
useHomeStore.getState().sendAsGroup(trimmedSearch);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
30
50
|
const handleAgentSelect = (agentId: string) => {
|
|
31
51
|
if (search.trim()) {
|
|
32
52
|
const message = encodeURIComponent(search.trim());
|
|
@@ -45,6 +65,18 @@ const AskAIMenu = memo(() => {
|
|
|
45
65
|
<div className={styles.itemLabel}>Lobe AI</div>
|
|
46
66
|
</div>
|
|
47
67
|
</Command.Item>
|
|
68
|
+
<Command.Item onSelect={handleAgentBuilder} value="agent-builder">
|
|
69
|
+
<Bot className={styles.icon} />
|
|
70
|
+
<div className={styles.itemContent}>
|
|
71
|
+
<div className={styles.itemLabel}>{t('agentBuilder.title', { ns: 'chat' })}</div>
|
|
72
|
+
</div>
|
|
73
|
+
</Command.Item>
|
|
74
|
+
<Command.Item onSelect={handleGroupBuilder} value="group-builder">
|
|
75
|
+
<GroupBotSquareIcon className={styles.icon} />
|
|
76
|
+
<div className={styles.itemContent}>
|
|
77
|
+
<div className={styles.itemLabel}>{t('starter.createGroup', { ns: 'home' })}</div>
|
|
78
|
+
</div>
|
|
79
|
+
</Command.Item>
|
|
48
80
|
<Command.Item onSelect={handleAIPainting} value="ai-painting">
|
|
49
81
|
<Image className={styles.icon} />
|
|
50
82
|
<div className={styles.itemContent}>
|
|
@@ -53,21 +85,22 @@ const AskAIMenu = memo(() => {
|
|
|
53
85
|
</Command.Item>
|
|
54
86
|
|
|
55
87
|
{agents.map((agent) => (
|
|
56
|
-
<
|
|
88
|
+
<CommandItem
|
|
89
|
+
icon={
|
|
90
|
+
<Avatar
|
|
91
|
+
avatar={typeof agent.avatar === 'string' ? agent.avatar : DEFAULT_AVATAR}
|
|
92
|
+
emojiScaleWithBackground
|
|
93
|
+
shape="square"
|
|
94
|
+
size={18}
|
|
95
|
+
/>
|
|
96
|
+
}
|
|
57
97
|
key={agent.id}
|
|
58
98
|
onSelect={() => handleAgentSelect(agent.id)}
|
|
99
|
+
title={agent.title || t('defaultAgent')}
|
|
100
|
+
trailingLabel={t('cmdk.search.agent')}
|
|
59
101
|
value={`agent-${agent.id}`}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
avatar={typeof agent.avatar === 'string' ? agent.avatar : DEFAULT_AVATAR}
|
|
63
|
-
emojiScaleWithBackground
|
|
64
|
-
shape="square"
|
|
65
|
-
size={18}
|
|
66
|
-
/>
|
|
67
|
-
<div className={styles.itemContent}>
|
|
68
|
-
<div className={styles.itemLabel}>{agent.title || t('defaultAgent')}</div>
|
|
69
|
-
</div>
|
|
70
|
-
</Command.Item>
|
|
102
|
+
variant="detailed"
|
|
103
|
+
/>
|
|
71
104
|
))}
|
|
72
105
|
</Command.Group>
|
|
73
106
|
);
|
|
@@ -165,7 +165,7 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
|
|
|
165
165
|
|
|
166
166
|
display: flex;
|
|
167
167
|
align-items: center;
|
|
168
|
-
justify-content:
|
|
168
|
+
justify-content: space-between;
|
|
169
169
|
|
|
170
170
|
padding-block: 6px;
|
|
171
171
|
padding-inline: 8px;
|
|
@@ -173,6 +173,14 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
|
|
|
173
173
|
|
|
174
174
|
background: ${cssVar.colorBgElevated};
|
|
175
175
|
`,
|
|
176
|
+
toolbarModelName: css`
|
|
177
|
+
overflow: hidden;
|
|
178
|
+
|
|
179
|
+
font-size: 12px;
|
|
180
|
+
color: ${cssVar.colorTextSecondary};
|
|
181
|
+
text-overflow: ellipsis;
|
|
182
|
+
white-space: nowrap;
|
|
183
|
+
`,
|
|
176
184
|
}));
|
|
177
185
|
|
|
178
186
|
const menuKey = (provider: string, model: string) => `${provider}-${model}`;
|
|
@@ -381,6 +389,17 @@ const ModelSwitchPanel = memo<ModelSwitchPanelProps>(
|
|
|
381
389
|
|
|
382
390
|
const activeKey = menuKey(provider, model);
|
|
383
391
|
|
|
392
|
+
// Find current model's display name
|
|
393
|
+
const currentModelName = useMemo(() => {
|
|
394
|
+
for (const providerItem of enabledList) {
|
|
395
|
+
const modelItem = providerItem.children.find((m) => m.id === model);
|
|
396
|
+
if (modelItem) {
|
|
397
|
+
return modelItem.displayName || modelItem.id;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return model;
|
|
401
|
+
}, [enabledList, model]);
|
|
402
|
+
|
|
384
403
|
const renderVirtualItem = useCallback(
|
|
385
404
|
(item: VirtualItem) => {
|
|
386
405
|
switch (item.type) {
|
|
@@ -661,6 +680,7 @@ const ModelSwitchPanel = memo<ModelSwitchPanelProps>(
|
|
|
661
680
|
style={{ position: 'relative' }}
|
|
662
681
|
>
|
|
663
682
|
<div className={styles.toolbar}>
|
|
683
|
+
<div className={styles.toolbarModelName}>{currentModelName}</div>
|
|
664
684
|
<Segmented
|
|
665
685
|
onChange={(value) => {
|
|
666
686
|
const mode = value as GroupMode;
|
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
import { geolocation } from '@vercel/functions';
|
|
2
|
-
import { getCountry } from 'countries-and-timezones';
|
|
3
|
-
import { NextRequest } from 'next/server';
|
|
4
|
-
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
-
|
|
6
|
-
import { parseDefaultThemeFromCountry } from '../geo';
|
|
7
|
-
|
|
8
|
-
vi.mock('@vercel/functions', () => ({
|
|
9
|
-
geolocation: vi.fn(),
|
|
10
|
-
}));
|
|
11
|
-
|
|
12
|
-
vi.mock('countries-and-timezones', () => ({
|
|
13
|
-
getCountry: vi.fn(),
|
|
14
|
-
}));
|
|
15
|
-
|
|
16
|
-
describe('parseDefaultThemeFromCountry', () => {
|
|
17
|
-
const mockRequest = (headers: Record<string, string> = {}) => {
|
|
18
|
-
return {
|
|
19
|
-
headers: {
|
|
20
|
-
get: (key: string) => headers[key],
|
|
21
|
-
},
|
|
22
|
-
} as NextRequest;
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
it('should return light theme when no country code is found', () => {
|
|
26
|
-
vi.mocked(geolocation).mockReturnValue({});
|
|
27
|
-
const request = mockRequest();
|
|
28
|
-
expect(parseDefaultThemeFromCountry(request)).toBe('light');
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it('should return light theme when country has no timezone', () => {
|
|
32
|
-
vi.mocked(geolocation).mockReturnValue({ country: 'US' });
|
|
33
|
-
vi.mocked(getCountry).mockReturnValue({
|
|
34
|
-
id: 'US',
|
|
35
|
-
name: 'United States',
|
|
36
|
-
timezones: [],
|
|
37
|
-
});
|
|
38
|
-
const request = mockRequest();
|
|
39
|
-
expect(parseDefaultThemeFromCountry(request)).toBe('light');
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it('should return light theme when country has invalid timezone', () => {
|
|
43
|
-
vi.mocked(geolocation).mockReturnValue({ country: 'US' });
|
|
44
|
-
vi.mocked(getCountry).mockReturnValue({
|
|
45
|
-
id: 'US',
|
|
46
|
-
name: 'United States',
|
|
47
|
-
// @ts-ignore
|
|
48
|
-
timezones: ['America/Invalid'],
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
const mockDate = new Date('2025-04-01T12:00:00');
|
|
52
|
-
vi.setSystemTime(mockDate);
|
|
53
|
-
|
|
54
|
-
const request = mockRequest();
|
|
55
|
-
expect(parseDefaultThemeFromCountry(request)).toBe('light');
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it('should return light theme during daytime hours', () => {
|
|
59
|
-
vi.mocked(geolocation).mockReturnValue({ country: 'US' });
|
|
60
|
-
vi.mocked(getCountry).mockReturnValue({
|
|
61
|
-
id: 'US',
|
|
62
|
-
name: 'United States',
|
|
63
|
-
timezones: ['America/New_York'],
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
// 设置UTC时间16:00,这样在纽约时区(EDT,UTC-4)就是12:00
|
|
67
|
-
const mockDate = new Date('2025-04-01T16:00:00.000Z');
|
|
68
|
-
vi.setSystemTime(mockDate);
|
|
69
|
-
|
|
70
|
-
const request = mockRequest();
|
|
71
|
-
const result = parseDefaultThemeFromCountry(request);
|
|
72
|
-
expect(result).toBe('light');
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it('should return dark theme during night hours', () => {
|
|
76
|
-
vi.mocked(geolocation).mockReturnValue({ country: 'US' });
|
|
77
|
-
vi.mocked(getCountry).mockReturnValue({
|
|
78
|
-
id: 'US',
|
|
79
|
-
name: 'United States',
|
|
80
|
-
timezones: ['America/New_York'],
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
// 设置UTC时间02:00,这样在纽约时区(EDT,UTC-4)就是22:00
|
|
84
|
-
const mockDate = new Date('2025-04-01T02:00:00.000Z');
|
|
85
|
-
vi.setSystemTime(mockDate);
|
|
86
|
-
|
|
87
|
-
const request = mockRequest();
|
|
88
|
-
expect(parseDefaultThemeFromCountry(request)).toBe('dark');
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it('should try different header sources for country code', () => {
|
|
92
|
-
vi.mocked(geolocation).mockReturnValue({});
|
|
93
|
-
vi.mocked(getCountry).mockReturnValue({
|
|
94
|
-
id: 'US',
|
|
95
|
-
name: 'United States',
|
|
96
|
-
timezones: ['America/New_York'],
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
const headers = {
|
|
100
|
-
'x-vercel-ip-country': 'US',
|
|
101
|
-
'cf-ipcountry': 'CA',
|
|
102
|
-
'x-zeabur-ip-country': 'UK',
|
|
103
|
-
'x-country-code': 'FR',
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
const request = mockRequest(headers);
|
|
107
|
-
parseDefaultThemeFromCountry(request);
|
|
108
|
-
|
|
109
|
-
expect(getCountry).toHaveBeenCalledWith('US');
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
afterEach(() => {
|
|
113
|
-
vi.useRealTimers();
|
|
114
|
-
vi.clearAllMocks();
|
|
115
|
-
});
|
|
116
|
-
});
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
import { geolocation } from '@vercel/functions';
|
|
2
|
-
import { getCountry } from 'countries-and-timezones';
|
|
3
|
-
import { NextRequest } from 'next/server';
|
|
4
|
-
|
|
5
|
-
const getLocalTime = (timeZone: string) => {
|
|
6
|
-
return new Date().toLocaleString('en-US', {
|
|
7
|
-
hour: 'numeric',
|
|
8
|
-
hour12: false,
|
|
9
|
-
timeZone,
|
|
10
|
-
});
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
const isValidTimeZone = (timeZone: string) => {
|
|
14
|
-
try {
|
|
15
|
-
getLocalTime(timeZone);
|
|
16
|
-
return true; // If no exception is thrown, the timezone is valid
|
|
17
|
-
} catch (e) {
|
|
18
|
-
// If a RangeError is caught, the timezone is invalid
|
|
19
|
-
if (e instanceof RangeError) {
|
|
20
|
-
return false;
|
|
21
|
-
}
|
|
22
|
-
// If it's another error, better to re-throw it
|
|
23
|
-
throw e;
|
|
24
|
-
}
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
export const parseDefaultThemeFromCountry = (request: NextRequest) => {
|
|
28
|
-
// 1. Get country code from request headers
|
|
29
|
-
const geo = geolocation(request);
|
|
30
|
-
|
|
31
|
-
const countryCode =
|
|
32
|
-
geo?.country ||
|
|
33
|
-
request.headers.get('x-vercel-ip-country') || // Vercel
|
|
34
|
-
request.headers.get('cf-ipcountry') || // Cloudflare
|
|
35
|
-
request.headers.get('x-zeabur-ip-country') || // Zeabur
|
|
36
|
-
request.headers.get('x-country-code'); // Netlify
|
|
37
|
-
|
|
38
|
-
// If no country code is obtained, return light theme directly
|
|
39
|
-
if (!countryCode) return 'light';
|
|
40
|
-
|
|
41
|
-
// 2. Get timezone information for the country
|
|
42
|
-
const country = getCountry(countryCode);
|
|
43
|
-
|
|
44
|
-
// If country information is not found or the country has no timezone information, return light theme
|
|
45
|
-
if (!country?.timezones?.length) return 'light';
|
|
46
|
-
|
|
47
|
-
const timeZone = country.timezones.find((tz) => isValidTimeZone(tz));
|
|
48
|
-
if (!timeZone) return 'light';
|
|
49
|
-
|
|
50
|
-
// 3. Get the current time in the country's first timezone
|
|
51
|
-
const localTime = getLocalTime(timeZone);
|
|
52
|
-
|
|
53
|
-
// 4. Parse the hour and determine the theme
|
|
54
|
-
const localHour = parseInt(localTime);
|
|
55
|
-
// console.log(
|
|
56
|
-
// `[theme] Country: ${countryCode}, Timezone: ${country.timezones[0]}, LocalHour: ${localHour}`,
|
|
57
|
-
// );
|
|
58
|
-
|
|
59
|
-
return localHour >= 6 && localHour < 18 ? 'light' : 'dark';
|
|
60
|
-
};
|