@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 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
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#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
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#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
- | { height?: number; width?: number }
327
- | undefined; // Keep type for now, but only use w/h
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 (only size used): ${JSON.stringify(savedState)}`,
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: savedState?.height || 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: savedState?.width || 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 { width, height } = browserWindow.getBounds(); // Get only width and height
409
- const sizeState = { height, width };
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 size on quit: ${JSON.stringify(sizeState)}`,
470
+ `[${this.identifier}] Saving window state on quit: ${JSON.stringify(sizeState)}`,
412
471
  );
413
- this.app.storeManager.set(this.windowStateKey as any, sizeState); // Save only size
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 size...`, // Updated log message
499
+ `[${this.identifier}] keepAlive is false, allowing window to close. Saving state...`,
441
500
  );
442
501
  try {
443
- const { width, height } = browserWindow.getBounds(); // Get only width and height
444
- const sizeState = { height, width };
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 size on close: ${JSON.stringify(sizeState)}`,
510
+ `[${this.identifier}] Saving window state on close: ${JSON.stringify(sizeState)}`,
447
511
  );
448
- this.app.storeManager.set(this.windowStateKey as any, sizeState); // Save only size
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.215",
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",
@@ -1,6 +1,5 @@
1
1
  export * from './auth';
2
2
  export * from './correctOIDCUrl';
3
- export * from './geo';
4
3
  export * from './response';
5
4
  export * from './responsive';
6
5
  export * from './sse';
@@ -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
- <Command.Item
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
- <Avatar
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: flex-end;
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
- };