@lobehub/chat 1.133.5 → 1.134.0

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 +58 -0
  2. package/apps/desktop/src/main/appBrowsers.ts +51 -0
  3. package/apps/desktop/src/main/controllers/BrowserWindowsCtr.ts +72 -1
  4. package/apps/desktop/src/main/core/browser/BrowserManager.ts +88 -18
  5. package/changelog/v1.json +21 -0
  6. package/package.json +1 -1
  7. package/packages/database/src/models/__tests__/aiModel.test.ts +3 -0
  8. package/packages/database/src/models/aiModel.ts +18 -2
  9. package/packages/electron-client-ipc/src/events/windows.ts +39 -0
  10. package/packages/model-bank/src/aiModels/google.ts +64 -2
  11. package/packages/model-bank/src/types/aiModel.ts +13 -9
  12. package/packages/model-runtime/src/providers/google/createImage.ts +13 -4
  13. package/src/app/[variants]/(main)/_layout/Desktop/DesktopLayoutContainer.tsx +4 -2
  14. package/src/app/[variants]/(main)/_layout/Desktop/SideBar/index.tsx +3 -1
  15. package/src/app/[variants]/(main)/_layout/Desktop/index.tsx +3 -1
  16. package/src/app/[variants]/(main)/chat/(workspace)/@topic/features/TopicListContent/TopicItem/TopicContent.tsx +25 -1
  17. package/src/app/[variants]/(main)/chat/@session/features/SessionListContent/List/Item/Actions.tsx +19 -2
  18. package/src/app/[variants]/(main)/chat/@session/features/SessionListContent/List/Item/index.tsx +27 -1
  19. package/src/app/[variants]/(main)/chat/_layout/Desktop/SessionPanel.tsx +11 -1
  20. package/src/app/[variants]/(main)/chat/features/TogglePanelButton.tsx +6 -0
  21. package/src/app/[variants]/(main)/settings/provider/features/ModelList/ModelItem.tsx +6 -3
  22. package/src/config/featureFlags/index.ts +2 -2
  23. package/src/config/featureFlags/schema.test.ts +165 -9
  24. package/src/config/featureFlags/schema.ts +68 -46
  25. package/src/features/ElectronTitlebar/Connection/index.tsx +0 -1
  26. package/src/hooks/useIsSingleMode.test.ts +66 -0
  27. package/src/hooks/useIsSingleMode.ts +29 -0
  28. package/src/server/featureFlags/index.ts +56 -0
  29. package/src/server/modules/EdgeConfig/index.ts +43 -4
  30. package/src/server/routers/lambda/aiModel.test.ts +2 -0
  31. package/src/store/global/actions/general.ts +46 -0
@@ -1,51 +1,71 @@
1
1
  /* eslint-disable sort-keys-fix/sort-keys-fix */
2
2
  import { z } from 'zod';
3
3
 
4
+ // Define a union type for feature flag values: either boolean or array of user IDs
5
+ const FeatureFlagValue = z.union([z.boolean(), z.array(z.string())]);
6
+
4
7
  export const FeatureFlagsSchema = z.object({
5
- check_updates: z.boolean().optional(),
6
- pin_list: z.boolean().optional(),
8
+ check_updates: FeatureFlagValue.optional(),
9
+ pin_list: FeatureFlagValue.optional(),
7
10
 
8
11
  // settings
9
- language_model_settings: z.boolean().optional(),
10
- provider_settings: z.boolean().optional(),
12
+ language_model_settings: FeatureFlagValue.optional(),
13
+ provider_settings: FeatureFlagValue.optional(),
11
14
 
12
- openai_api_key: z.boolean().optional(),
13
- openai_proxy_url: z.boolean().optional(),
15
+ openai_api_key: FeatureFlagValue.optional(),
16
+ openai_proxy_url: FeatureFlagValue.optional(),
14
17
 
15
18
  // profile
16
- api_key_manage: z.boolean().optional(),
19
+ api_key_manage: FeatureFlagValue.optional(),
17
20
 
18
- create_session: z.boolean().optional(),
19
- edit_agent: z.boolean().optional(),
21
+ create_session: FeatureFlagValue.optional(),
22
+ edit_agent: FeatureFlagValue.optional(),
20
23
 
21
- plugins: z.boolean().optional(),
22
- dalle: z.boolean().optional(),
23
- ai_image: z.boolean().optional(),
24
- speech_to_text: z.boolean().optional(),
25
- token_counter: z.boolean().optional(),
24
+ plugins: FeatureFlagValue.optional(),
25
+ dalle: FeatureFlagValue.optional(),
26
+ ai_image: FeatureFlagValue.optional(),
27
+ speech_to_text: FeatureFlagValue.optional(),
28
+ token_counter: FeatureFlagValue.optional(),
26
29
 
27
- welcome_suggest: z.boolean().optional(),
28
- changelog: z.boolean().optional(),
30
+ welcome_suggest: FeatureFlagValue.optional(),
31
+ changelog: FeatureFlagValue.optional(),
29
32
 
30
- clerk_sign_up: z.boolean().optional(),
33
+ clerk_sign_up: FeatureFlagValue.optional(),
31
34
 
32
- market: z.boolean().optional(),
33
- knowledge_base: z.boolean().optional(),
35
+ market: FeatureFlagValue.optional(),
36
+ knowledge_base: FeatureFlagValue.optional(),
34
37
 
35
- rag_eval: z.boolean().optional(),
38
+ rag_eval: FeatureFlagValue.optional(),
36
39
 
37
40
  // internal flag
38
- cloud_promotion: z.boolean().optional(),
41
+ cloud_promotion: FeatureFlagValue.optional(),
39
42
 
40
43
  // the flags below can only be used with commercial license
41
44
  // if you want to use it in the commercial usage
42
45
  // please contact us for more information: hello@lobehub.com
43
- commercial_hide_github: z.boolean().optional(),
44
- commercial_hide_docs: z.boolean().optional(),
46
+ commercial_hide_github: FeatureFlagValue.optional(),
47
+ commercial_hide_docs: FeatureFlagValue.optional(),
45
48
  });
46
49
 
47
50
  export type IFeatureFlags = z.infer<typeof FeatureFlagsSchema>;
48
51
 
52
+ /**
53
+ * Evaluate a feature flag value against a user ID
54
+ * @param flagValue - The feature flag value (boolean or array of user IDs)
55
+ * @param userId - The current user ID
56
+ * @returns boolean indicating if the feature is enabled for the user
57
+ */
58
+ export const evaluateFeatureFlag = (
59
+ flagValue: boolean | string[] | undefined,
60
+ userId?: string,
61
+ ): boolean | undefined => {
62
+ if (typeof flagValue === 'boolean') return flagValue;
63
+
64
+ if (Array.isArray(flagValue)) {
65
+ return userId ? flagValue.includes(userId) : false;
66
+ }
67
+ };
68
+
49
69
  export const DEFAULT_FEATURE_FLAGS: IFeatureFlags = {
50
70
  pin_list: false,
51
71
 
@@ -86,39 +106,41 @@ export const DEFAULT_FEATURE_FLAGS: IFeatureFlags = {
86
106
  commercial_hide_docs: false,
87
107
  };
88
108
 
89
- export const mapFeatureFlagsEnvToState = (config: IFeatureFlags) => {
109
+ export const mapFeatureFlagsEnvToState = (config: IFeatureFlags, userId?: string) => {
90
110
  return {
91
- isAgentEditable: config.edit_agent,
111
+ isAgentEditable: evaluateFeatureFlag(config.edit_agent, userId),
92
112
 
93
- showCreateSession: config.create_session,
94
- showLLM: config.language_model_settings,
95
- showProvider: config.provider_settings,
96
- showPinList: config.pin_list,
113
+ showCreateSession: evaluateFeatureFlag(config.create_session, userId),
114
+ showLLM: evaluateFeatureFlag(config.language_model_settings, userId),
115
+ showProvider: evaluateFeatureFlag(config.provider_settings, userId),
116
+ showPinList: evaluateFeatureFlag(config.pin_list, userId),
97
117
 
98
- showOpenAIApiKey: config.openai_api_key,
99
- showOpenAIProxyUrl: config.openai_proxy_url,
118
+ showOpenAIApiKey: evaluateFeatureFlag(config.openai_api_key, userId),
119
+ showOpenAIProxyUrl: evaluateFeatureFlag(config.openai_proxy_url, userId),
100
120
 
101
- showApiKeyManage: config.api_key_manage,
121
+ showApiKeyManage: evaluateFeatureFlag(config.api_key_manage, userId),
102
122
 
103
- enablePlugins: config.plugins,
104
- showDalle: config.dalle,
105
- showAiImage: config.ai_image,
106
- showChangelog: config.changelog,
123
+ enablePlugins: evaluateFeatureFlag(config.plugins, userId),
124
+ showDalle: evaluateFeatureFlag(config.dalle, userId),
125
+ showAiImage: evaluateFeatureFlag(config.ai_image, userId),
126
+ showChangelog: evaluateFeatureFlag(config.changelog, userId),
107
127
 
108
- enableCheckUpdates: config.check_updates,
109
- showWelcomeSuggest: config.welcome_suggest,
128
+ enableCheckUpdates: evaluateFeatureFlag(config.check_updates, userId),
129
+ showWelcomeSuggest: evaluateFeatureFlag(config.welcome_suggest, userId),
110
130
 
111
- enableClerkSignUp: config.clerk_sign_up,
131
+ enableClerkSignUp: evaluateFeatureFlag(config.clerk_sign_up, userId),
112
132
 
113
- enableKnowledgeBase: config.knowledge_base,
114
- enableRAGEval: config.rag_eval,
133
+ enableKnowledgeBase: evaluateFeatureFlag(config.knowledge_base, userId),
134
+ enableRAGEval: evaluateFeatureFlag(config.rag_eval, userId),
115
135
 
116
- showCloudPromotion: config.cloud_promotion,
136
+ showCloudPromotion: evaluateFeatureFlag(config.cloud_promotion, userId),
117
137
 
118
- showMarket: config.market,
119
- enableSTT: config.speech_to_text,
138
+ showMarket: evaluateFeatureFlag(config.market, userId),
139
+ enableSTT: evaluateFeatureFlag(config.speech_to_text, userId),
120
140
 
121
- hideGitHub: config.commercial_hide_github,
122
- hideDocs: config.commercial_hide_docs,
141
+ hideGitHub: evaluateFeatureFlag(config.commercial_hide_github, userId),
142
+ hideDocs: evaluateFeatureFlag(config.commercial_hide_docs, userId),
123
143
  };
124
144
  };
145
+
146
+ export type IFeatureFlagsState = ReturnType<typeof mapFeatureFlagsEnvToState>;
@@ -20,7 +20,6 @@ const useStyles = createStyles(({ css }) => {
20
20
 
21
21
  const Connection = () => {
22
22
  const { styles, theme } = useStyles();
23
-
24
23
  const [isOpen, setIsOpen] = useState(false);
25
24
  const [isWaiting, setWaiting] = useState(false);
26
25
 
@@ -0,0 +1,66 @@
1
+ import { renderHook } from '@testing-library/react';
2
+ import { ReadonlyURLSearchParams } from 'next/navigation';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+
5
+ import { useIsSingleMode } from './useIsSingleMode';
6
+
7
+ // Mock next/navigation
8
+ const mockUseSearchParams = vi.hoisted(() => vi.fn());
9
+ vi.mock('next/navigation', () => ({
10
+ useSearchParams: mockUseSearchParams,
11
+ }));
12
+
13
+ describe('useIsSingleMode', () => {
14
+
15
+ it('should return false initially (during SSR)', () => {
16
+ const mockSearchParams = {
17
+ get: vi.fn(() => 'single'),
18
+ } as unknown as ReadonlyURLSearchParams;
19
+
20
+ mockUseSearchParams.mockReturnValue(mockSearchParams);
21
+
22
+ const { result } = renderHook(() => useIsSingleMode());
23
+
24
+ // In test environment, useEffect runs synchronously, so it will immediately detect single mode
25
+ expect(result.current).toBe(true);
26
+ });
27
+
28
+ it('should return true when mode=single', () => {
29
+ const mockSearchParams = {
30
+ get: vi.fn((key: string) => (key === 'mode' ? 'single' : null)),
31
+ } as unknown as ReadonlyURLSearchParams;
32
+
33
+ mockUseSearchParams.mockReturnValue(mockSearchParams);
34
+
35
+ const { result } = renderHook(() => useIsSingleMode());
36
+
37
+ // Should immediately detect single mode in test environment
38
+ expect(result.current).toBe(true);
39
+ });
40
+
41
+ it('should return false when mode is not single', () => {
42
+ const mockSearchParams = {
43
+ get: vi.fn((key: string) => (key === 'mode' ? 'normal' : null)),
44
+ } as unknown as ReadonlyURLSearchParams;
45
+
46
+ mockUseSearchParams.mockReturnValue(mockSearchParams);
47
+
48
+ const { result } = renderHook(() => useIsSingleMode());
49
+
50
+ // Should return false for non-single mode
51
+ expect(result.current).toBe(false);
52
+ });
53
+
54
+ it('should return false when no mode parameter exists', () => {
55
+ const mockSearchParams = {
56
+ get: vi.fn(() => null),
57
+ } as unknown as ReadonlyURLSearchParams;
58
+
59
+ mockUseSearchParams.mockReturnValue(mockSearchParams);
60
+
61
+ const { result } = renderHook(() => useIsSingleMode());
62
+
63
+ // Should return false when no mode parameter
64
+ expect(result.current).toBe(false);
65
+ });
66
+ });
@@ -0,0 +1,29 @@
1
+ 'use client';
2
+
3
+ import { useSearchParams } from 'next/navigation';
4
+ import { useEffect, useState } from 'react';
5
+
6
+ /**
7
+ * Hook to check if the current page is in single mode
8
+ * Single mode is used for standalone windows in desktop app
9
+ * @returns boolean indicating if the current page is in single mode
10
+ */
11
+ export const useIsSingleMode = (): boolean => {
12
+ const [isSingleMode, setIsSingleMode] = useState(false);
13
+ const [mounted, setMounted] = useState(false);
14
+
15
+ useEffect(() => {
16
+ setMounted(true);
17
+ }, []);
18
+
19
+ const searchParams = useSearchParams();
20
+
21
+ useEffect(() => {
22
+ if (mounted) {
23
+ setIsSingleMode(searchParams.get('mode') === 'single');
24
+ }
25
+ }, [searchParams, mounted]);
26
+
27
+ // Return false during SSR or before hydration
28
+ return mounted ? isSingleMode : false;
29
+ };
@@ -0,0 +1,56 @@
1
+ import createDebug from 'debug';
2
+
3
+ import {
4
+ DEFAULT_FEATURE_FLAGS,
5
+ getServerFeatureFlagsValue,
6
+ mapFeatureFlagsEnvToState,
7
+ } from '@/config/featureFlags';
8
+ import { merge } from '@/utils/merge';
9
+
10
+ import { EdgeConfig } from '../modules/EdgeConfig';
11
+
12
+ const debug = createDebug('lobe:featureFlags');
13
+
14
+ /**
15
+ * Get feature flags from EdgeConfig with fallback to environment variables
16
+ * @param userId - Optional user ID for user-specific feature flag evaluation
17
+ */
18
+ export const getServerFeatureFlagsFromEdgeConfig = async (userId?: string) => {
19
+ // Try to get feature flags from EdgeConfig first
20
+ if (EdgeConfig.isEnabled()) {
21
+ try {
22
+ const edgeConfig = new EdgeConfig();
23
+ const edgeFeatureFlags = await edgeConfig.getFeatureFlags();
24
+
25
+ if (edgeFeatureFlags && Object.keys(edgeFeatureFlags).length > 0) {
26
+ // Merge EdgeConfig flags with defaults
27
+ const mergedFlags = merge(DEFAULT_FEATURE_FLAGS, edgeFeatureFlags);
28
+ debug('Using EdgeConfig flags for user: %s', userId || 'anonymous');
29
+ return mergedFlags;
30
+ } else {
31
+ debug('EdgeConfig returned empty/null/undefined, falling back to environment variables');
32
+ }
33
+ } catch (error) {
34
+ console.error(
35
+ '[FeatureFlags] Failed to fetch feature flags from EdgeConfig, falling back to environment variables:',
36
+ error,
37
+ );
38
+ }
39
+ } else {
40
+ debug('EdgeConfig not enabled, using environment variables');
41
+ }
42
+
43
+ // Fallback to environment variable-based feature flags
44
+ const envFlags = getServerFeatureFlagsValue();
45
+ debug('Using environment variable flags for user: %s', userId || 'anonymous');
46
+ return envFlags;
47
+ };
48
+
49
+ /**
50
+ * Get server feature flags from EdgeConfig and map them to state with user ID
51
+ * @param userId - Optional user ID for user-specific feature flag evaluation
52
+ */
53
+ export const getServerFeatureFlagsStateFromEdgeConfig = async (userId?: string) => {
54
+ const flags = await getServerFeatureFlagsFromEdgeConfig(userId);
55
+ return mapFeatureFlagsEnvToState(flags, userId);
56
+ };
@@ -1,8 +1,24 @@
1
1
  import { EdgeConfigClient, createClient } from '@vercel/edge-config';
2
+ import createDebug from 'debug';
2
3
 
3
4
  import { appEnv } from '@/envs/app';
4
5
 
5
- import type { EdgeConfigData } from './types';
6
+ const debug = createDebug('lobe-server:edge-config');
7
+
8
+ const EdgeConfigKeys = {
9
+ /**
10
+ * Assistant whitelist
11
+ */
12
+ AssistantBlacklist: 'assistant_blacklist',
13
+ /**
14
+ * Assistant whitelist
15
+ */
16
+ AssistantWhitelist: 'assistant_whitelist',
17
+ /**
18
+ * Feature flags configuration
19
+ */
20
+ FeatureFlags: 'feature_flags',
21
+ };
6
22
 
7
23
  export class EdgeConfig {
8
24
  get client(): EdgeConfigClient {
@@ -16,12 +32,35 @@ export class EdgeConfig {
16
32
  * Check if Edge Config is enabled
17
33
  */
18
34
  static isEnabled() {
19
- return !!appEnv.VERCEL_EDGE_CONFIG;
35
+ const isEnabled = !!appEnv.VERCEL_EDGE_CONFIG;
36
+ debug('VERCEL_EDGE_CONFIG env var: %s', appEnv.VERCEL_EDGE_CONFIG ? 'SET' : 'NOT SET');
37
+ debug('EdgeConfig enabled: %s', isEnabled);
38
+ return isEnabled;
20
39
  }
21
40
 
22
41
  getAgentRestrictions = async () => {
23
42
  const { assistant_blacklist: blacklist, assistant_whitelist: whitelist } =
24
- await this.client.getAll<EdgeConfigData>(['assistant_whitelist', 'assistant_blacklist']);
25
- return { blacklist, whitelist };
43
+ await this.client.getAll([
44
+ EdgeConfigKeys.AssistantWhitelist,
45
+ EdgeConfigKeys.AssistantBlacklist,
46
+ ]);
47
+
48
+ return { blacklist, whitelist } as {
49
+ blacklist: string[] | undefined;
50
+ whitelist: string[] | undefined;
51
+ };
52
+ };
53
+
54
+ getFlagByKey = async (key: string) => {
55
+ const value = await this.client.get(key);
56
+ return value;
57
+ };
58
+
59
+ getFeatureFlags = async () => {
60
+ const featureFlags = await this.client.get(EdgeConfigKeys.FeatureFlags);
61
+ debug('Feature flags retrieved: %O', featureFlags);
62
+ return featureFlags as Record<string, boolean | string[]> | undefined;
26
63
  };
27
64
  }
65
+
66
+ export { EdgeConfigKeys };
@@ -151,12 +151,14 @@ describe('aiModelRouter', () => {
151
151
  id: 'model-1',
152
152
  providerId: 'provider-1',
153
153
  enabled: true,
154
+ type: 'embedding',
154
155
  });
155
156
 
156
157
  expect(mockToggle).toHaveBeenCalledWith({
157
158
  id: 'model-1',
158
159
  providerId: 'provider-1',
159
160
  enabled: true,
161
+ type: 'embedding',
160
162
  });
161
163
  });
162
164
 
@@ -20,6 +20,8 @@ import type { GlobalStore } from '../store';
20
20
  const n = setNamespace('g');
21
21
 
22
22
  export interface GlobalGeneralAction {
23
+ openSessionInNewWindow: (sessionId: string) => Promise<void>;
24
+ openTopicInNewWindow: (sessionId: string, topicId: string) => Promise<void>;
23
25
  switchLocale: (locale: LocaleMode) => void;
24
26
  switchThemeMode: (themeMode: ThemeMode, params?: { skipBroadcast?: boolean }) => void;
25
27
  updateSystemStatus: (status: Partial<SystemStatus>, action?: any) => void;
@@ -33,6 +35,50 @@ export const generalActionSlice: StateCreator<
33
35
  [],
34
36
  GlobalGeneralAction
35
37
  > = (set, get) => ({
38
+ openSessionInNewWindow: async (sessionId: string) => {
39
+ if (!isDesktop) return;
40
+
41
+ try {
42
+ const { dispatch } = await import('@lobechat/electron-client-ipc');
43
+
44
+ const url = `/chat?session=${sessionId}&mode=single`;
45
+
46
+ const result = await dispatch('createMultiInstanceWindow', {
47
+ path: url,
48
+ templateId: 'chatSingle',
49
+ uniqueId: `chat_${sessionId}`,
50
+ });
51
+
52
+ if (!result.success) {
53
+ console.error('Failed to open session in new window:', result.error);
54
+ }
55
+ } catch (error) {
56
+ console.error('Error opening session in new window:', error);
57
+ }
58
+ },
59
+
60
+ openTopicInNewWindow: async (sessionId: string, topicId: string) => {
61
+ if (!isDesktop) return;
62
+
63
+ try {
64
+ const { dispatch } = await import('@lobechat/electron-client-ipc');
65
+
66
+ const url = `/chat?session=${sessionId}&topic=${topicId}&mode=single`;
67
+
68
+ const result = await dispatch('createMultiInstanceWindow', {
69
+ path: url,
70
+ templateId: 'chatSingle',
71
+ uniqueId: `chat_${sessionId}_${topicId}`,
72
+ });
73
+
74
+ if (!result.success) {
75
+ console.error('Failed to open topic in new window:', result.error);
76
+ }
77
+ } catch (error) {
78
+ console.error('Error opening topic in new window:', error);
79
+ }
80
+ },
81
+
36
82
  switchLocale: (locale) => {
37
83
  get().updateSystemStatus({ language: locale });
38
84