@lobehub/chat 1.133.6 → 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.
- package/CHANGELOG.md +25 -0
- package/apps/desktop/src/main/appBrowsers.ts +51 -0
- package/apps/desktop/src/main/controllers/BrowserWindowsCtr.ts +72 -1
- package/apps/desktop/src/main/core/browser/BrowserManager.ts +88 -18
- package/changelog/v1.json +9 -0
- package/package.json +1 -1
- package/packages/electron-client-ipc/src/events/windows.ts +39 -0
- package/src/app/[variants]/(main)/_layout/Desktop/DesktopLayoutContainer.tsx +4 -2
- package/src/app/[variants]/(main)/_layout/Desktop/SideBar/index.tsx +3 -1
- package/src/app/[variants]/(main)/_layout/Desktop/index.tsx +3 -1
- package/src/app/[variants]/(main)/chat/(workspace)/@topic/features/TopicListContent/TopicItem/TopicContent.tsx +25 -1
- package/src/app/[variants]/(main)/chat/@session/features/SessionListContent/List/Item/Actions.tsx +19 -2
- package/src/app/[variants]/(main)/chat/@session/features/SessionListContent/List/Item/index.tsx +27 -1
- package/src/app/[variants]/(main)/chat/_layout/Desktop/SessionPanel.tsx +11 -1
- package/src/app/[variants]/(main)/chat/features/TogglePanelButton.tsx +6 -0
- package/src/config/featureFlags/index.ts +2 -2
- package/src/config/featureFlags/schema.test.ts +165 -9
- package/src/config/featureFlags/schema.ts +68 -46
- package/src/features/ElectronTitlebar/Connection/index.tsx +0 -1
- package/src/hooks/useIsSingleMode.test.ts +66 -0
- package/src/hooks/useIsSingleMode.ts +29 -0
- package/src/server/featureFlags/index.ts +56 -0
- package/src/server/modules/EdgeConfig/index.ts +43 -4
- package/src/store/global/actions/general.ts +46 -0
package/src/app/[variants]/(main)/chat/@session/features/SessionListContent/List/Item/index.tsx
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { ModelTag } from '@lobehub/icons';
|
|
2
|
-
import { memo, useMemo, useState } from 'react';
|
|
2
|
+
import React, { memo, useMemo, useState } from 'react';
|
|
3
3
|
import { Flexbox } from 'react-layout-kit';
|
|
4
4
|
import { shallow } from 'zustand/shallow';
|
|
5
5
|
|
|
6
|
+
import { isDesktop } from '@/const/version';
|
|
6
7
|
import { useAgentStore } from '@/store/agent';
|
|
7
8
|
import { agentSelectors } from '@/store/agent/selectors';
|
|
8
9
|
import { useChatStore } from '@/store/chat';
|
|
9
10
|
import { chatSelectors } from '@/store/chat/selectors';
|
|
11
|
+
import { useGlobalStore } from '@/store/global';
|
|
10
12
|
import { useSessionStore } from '@/store/session';
|
|
11
13
|
import { sessionHelpers } from '@/store/session/helpers';
|
|
12
14
|
import { sessionMetaSelectors, sessionSelectors } from '@/store/session/selectors';
|
|
@@ -24,6 +26,8 @@ const SessionItem = memo<SessionItemProps>(({ id }) => {
|
|
|
24
26
|
const [createGroupModalOpen, setCreateGroupModalOpen] = useState(false);
|
|
25
27
|
const [defaultModel] = useAgentStore((s) => [agentSelectors.inboxAgentModel(s)]);
|
|
26
28
|
|
|
29
|
+
const openSessionInNewWindow = useGlobalStore((s) => s.openSessionInNewWindow);
|
|
30
|
+
|
|
27
31
|
const [active] = useSessionStore((s) => [s.activeId === id]);
|
|
28
32
|
const [loading] = useChatStore((s) => [chatSelectors.isAIGenerating(s) && id === s.activeId]);
|
|
29
33
|
|
|
@@ -46,6 +50,24 @@ const SessionItem = memo<SessionItemProps>(({ id }) => {
|
|
|
46
50
|
|
|
47
51
|
const showModel = model !== defaultModel;
|
|
48
52
|
|
|
53
|
+
const handleDoubleClick = () => {
|
|
54
|
+
if (isDesktop) {
|
|
55
|
+
openSessionInNewWindow(id);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const handleDragStart = (e: React.DragEvent) => {
|
|
60
|
+
// Set drag data to identify the session being dragged
|
|
61
|
+
e.dataTransfer.setData('text/plain', id);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const handleDragEnd = (e: React.DragEvent) => {
|
|
65
|
+
// If drag ends without being dropped in a valid target, open in new window
|
|
66
|
+
if (isDesktop && e.dataTransfer.dropEffect === 'none') {
|
|
67
|
+
openSessionInNewWindow(id);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
49
71
|
const actions = useMemo(
|
|
50
72
|
() => (
|
|
51
73
|
<Actions
|
|
@@ -78,8 +100,12 @@ const SessionItem = memo<SessionItemProps>(({ id }) => {
|
|
|
78
100
|
avatarBackground={avatarBackground}
|
|
79
101
|
date={updateAt?.valueOf()}
|
|
80
102
|
description={description}
|
|
103
|
+
draggable={isDesktop}
|
|
81
104
|
key={id}
|
|
82
105
|
loading={loading}
|
|
106
|
+
onDoubleClick={handleDoubleClick}
|
|
107
|
+
onDragEnd={handleDragEnd}
|
|
108
|
+
onDragStart={handleDragStart}
|
|
83
109
|
pin={pin}
|
|
84
110
|
showAction={open}
|
|
85
111
|
styles={{
|
|
@@ -7,6 +7,7 @@ import { PropsWithChildren, memo, useEffect, useMemo, useState } from 'react';
|
|
|
7
7
|
|
|
8
8
|
import { withSuspense } from '@/components/withSuspense';
|
|
9
9
|
import { FOLDER_WIDTH } from '@/const/layoutTokens';
|
|
10
|
+
import { useIsSingleMode } from '@/hooks/useIsSingleMode';
|
|
10
11
|
import { usePinnedAgentState } from '@/hooks/usePinnedAgentState';
|
|
11
12
|
import { useGlobalStore } from '@/store/global';
|
|
12
13
|
import { systemStatusSelectors } from '@/store/global/selectors';
|
|
@@ -33,11 +34,14 @@ export const useStyles = createStyles(({ css, token }) => ({
|
|
|
33
34
|
}));
|
|
34
35
|
|
|
35
36
|
const SessionPanel = memo<PropsWithChildren>(({ children }) => {
|
|
37
|
+
const isSingleMode = useIsSingleMode();
|
|
38
|
+
|
|
36
39
|
const { md = true } = useResponsive();
|
|
37
40
|
|
|
38
41
|
const [isPinned] = usePinnedAgentState();
|
|
39
42
|
|
|
40
43
|
const { styles } = useStyles();
|
|
44
|
+
|
|
41
45
|
const [sessionsWidth, sessionExpandable, updatePreference] = useGlobalStore((s) => [
|
|
42
46
|
systemStatusSelectors.sessionWidth(s),
|
|
43
47
|
systemStatusSelectors.showSessionPanel(s),
|
|
@@ -72,6 +76,12 @@ const SessionPanel = memo<PropsWithChildren>(({ children }) => {
|
|
|
72
76
|
const { appearance } = useThemeMode();
|
|
73
77
|
|
|
74
78
|
const SessionPanel = useMemo(() => {
|
|
79
|
+
if (isSingleMode) {
|
|
80
|
+
// 在单一模式下,仍然渲染 children 以确保 SessionHydration 等逻辑组件正常工作
|
|
81
|
+
// 但使用隐藏样式而不是 return null
|
|
82
|
+
return <div style={{ display: 'none' }}>{children}</div>;
|
|
83
|
+
}
|
|
84
|
+
|
|
75
85
|
return (
|
|
76
86
|
<DraggablePanel
|
|
77
87
|
className={styles.panel}
|
|
@@ -92,7 +102,7 @@ const SessionPanel = memo<PropsWithChildren>(({ children }) => {
|
|
|
92
102
|
</DraggablePanelContainer>
|
|
93
103
|
</DraggablePanel>
|
|
94
104
|
);
|
|
95
|
-
}, [sessionsWidth, md, isPinned, sessionExpandable, tmpWidth, appearance]);
|
|
105
|
+
}, [sessionsWidth, md, isPinned, sessionExpandable, tmpWidth, appearance, isSingleMode]);
|
|
96
106
|
|
|
97
107
|
return SessionPanel;
|
|
98
108
|
});
|
|
@@ -6,6 +6,7 @@ import { memo } from 'react';
|
|
|
6
6
|
import { useTranslation } from 'react-i18next';
|
|
7
7
|
|
|
8
8
|
import { DESKTOP_HEADER_ICON_SIZE } from '@/const/layoutTokens';
|
|
9
|
+
import { useIsSingleMode } from '@/hooks/useIsSingleMode';
|
|
9
10
|
import { useGlobalStore } from '@/store/global';
|
|
10
11
|
import { systemStatusSelectors } from '@/store/global/selectors';
|
|
11
12
|
import { useUserStore } from '@/store/user';
|
|
@@ -15,6 +16,7 @@ import { HotkeyEnum } from '@/types/hotkey';
|
|
|
15
16
|
export const TOOGLE_PANEL_BUTTON_ID = 'toggle-panel-button';
|
|
16
17
|
|
|
17
18
|
const TogglePanelButton = memo(() => {
|
|
19
|
+
const isSingleMode = useIsSingleMode();
|
|
18
20
|
const hotkey = useUserStore(settingsSelectors.getHotkeyById(HotkeyEnum.ToggleLeftPanel));
|
|
19
21
|
|
|
20
22
|
const { t } = useTranslation(['chat', 'hotkey']);
|
|
@@ -22,6 +24,10 @@ const TogglePanelButton = memo(() => {
|
|
|
22
24
|
const showSessionPanel = useGlobalStore(systemStatusSelectors.showSessionPanel);
|
|
23
25
|
const updateSystemStatus = useGlobalStore((s) => s.updateSystemStatus);
|
|
24
26
|
|
|
27
|
+
if (isSingleMode) {
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
|
|
25
31
|
return (
|
|
26
32
|
<Tooltip hotkey={hotkey} title={t('toggleLeftPanel.title', { ns: 'hotkey' })}>
|
|
27
33
|
<ActionIcon
|
|
@@ -22,10 +22,10 @@ export const getServerFeatureFlagsValue = () => {
|
|
|
22
22
|
return merge(DEFAULT_FEATURE_FLAGS, flags);
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
-
export const serverFeatureFlags = () => {
|
|
25
|
+
export const serverFeatureFlags = (userId?: string) => {
|
|
26
26
|
const serverConfig = getServerFeatureFlagsValue();
|
|
27
27
|
|
|
28
|
-
return mapFeatureFlagsEnvToState(serverConfig);
|
|
28
|
+
return mapFeatureFlagsEnvToState(serverConfig, userId);
|
|
29
29
|
};
|
|
30
30
|
|
|
31
31
|
export * from './schema';
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
|
|
3
|
-
import { FeatureFlagsSchema, mapFeatureFlagsEnvToState } from './schema';
|
|
3
|
+
import { FeatureFlagsSchema, evaluateFeatureFlag, mapFeatureFlagsEnvToState } from './schema';
|
|
4
4
|
|
|
5
5
|
describe('FeatureFlagsSchema', () => {
|
|
6
|
-
it('should validate correct feature flags', () => {
|
|
6
|
+
it('should validate correct feature flags with boolean values', () => {
|
|
7
7
|
const result = FeatureFlagsSchema.safeParse({
|
|
8
|
-
webrtc_sync: true,
|
|
9
8
|
language_model_settings: false,
|
|
10
9
|
openai_api_key: true,
|
|
11
10
|
openai_proxy_url: false,
|
|
@@ -18,20 +17,89 @@ describe('FeatureFlagsSchema', () => {
|
|
|
18
17
|
expect(result.success).toBe(true);
|
|
19
18
|
});
|
|
20
19
|
|
|
21
|
-
it('should
|
|
20
|
+
it('should validate correct feature flags with user ID arrays', () => {
|
|
22
21
|
const result = FeatureFlagsSchema.safeParse({
|
|
23
|
-
edit_agent: '
|
|
22
|
+
edit_agent: ['user-123', 'user-456'],
|
|
23
|
+
create_session: ['user-789'],
|
|
24
|
+
dalle: true,
|
|
25
|
+
ai_image: false,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
expect(result.success).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should validate mixed boolean and array values', () => {
|
|
32
|
+
const result = FeatureFlagsSchema.safeParse({
|
|
33
|
+
edit_agent: ['user-123'],
|
|
34
|
+
create_session: true,
|
|
35
|
+
dalle: false,
|
|
36
|
+
knowledge_base: ['user-456', 'user-789'],
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
expect(result.success).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should reject invalid feature flags with wrong types', () => {
|
|
43
|
+
const result = FeatureFlagsSchema.safeParse({
|
|
44
|
+
edit_agent: 'yes', // Invalid type, should be boolean or array
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
expect(result.success).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should reject invalid feature flags with non-string array elements', () => {
|
|
51
|
+
const result = FeatureFlagsSchema.safeParse({
|
|
52
|
+
edit_agent: [123, 456], // Invalid, array should contain strings
|
|
24
53
|
});
|
|
25
54
|
|
|
26
55
|
expect(result.success).toBe(false);
|
|
27
56
|
});
|
|
28
57
|
});
|
|
29
58
|
|
|
59
|
+
describe('evaluateFeatureFlag', () => {
|
|
60
|
+
it('should return true for boolean true value', () => {
|
|
61
|
+
expect(evaluateFeatureFlag(true)).toBe(true);
|
|
62
|
+
expect(evaluateFeatureFlag(true, 'user-123')).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should return false for boolean false value', () => {
|
|
66
|
+
expect(evaluateFeatureFlag(false)).toBe(false);
|
|
67
|
+
expect(evaluateFeatureFlag(false, 'user-123')).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should return undefined for undefined value', () => {
|
|
71
|
+
expect(evaluateFeatureFlag(undefined)).toBe(undefined);
|
|
72
|
+
expect(evaluateFeatureFlag(undefined, 'user-123')).toBe(undefined);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should return true if user ID is in the allowlist', () => {
|
|
76
|
+
const allowlist = ['user-123', 'user-456'];
|
|
77
|
+
expect(evaluateFeatureFlag(allowlist, 'user-123')).toBe(true);
|
|
78
|
+
expect(evaluateFeatureFlag(allowlist, 'user-456')).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should return false if user ID is not in the allowlist', () => {
|
|
82
|
+
const allowlist = ['user-123', 'user-456'];
|
|
83
|
+
expect(evaluateFeatureFlag(allowlist, 'user-789')).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should return false if no user ID provided with array value', () => {
|
|
87
|
+
const allowlist = ['user-123', 'user-456'];
|
|
88
|
+
expect(evaluateFeatureFlag(allowlist)).toBe(false);
|
|
89
|
+
expect(evaluateFeatureFlag(allowlist, undefined)).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should handle empty array', () => {
|
|
93
|
+
expect(evaluateFeatureFlag([], 'user-123')).toBe(false);
|
|
94
|
+
expect(evaluateFeatureFlag([])).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
30
98
|
describe('mapFeatureFlagsEnvToState', () => {
|
|
31
|
-
it('should correctly map feature flags to state', () => {
|
|
99
|
+
it('should correctly map boolean feature flags to state', () => {
|
|
32
100
|
const config = {
|
|
33
|
-
webrtc_sync: true,
|
|
34
101
|
language_model_settings: false,
|
|
102
|
+
provider_settings: true,
|
|
35
103
|
openai_api_key: true,
|
|
36
104
|
openai_proxy_url: false,
|
|
37
105
|
create_session: true,
|
|
@@ -40,22 +108,110 @@ describe('mapFeatureFlagsEnvToState', () => {
|
|
|
40
108
|
ai_image: true,
|
|
41
109
|
check_updates: true,
|
|
42
110
|
welcome_suggest: true,
|
|
111
|
+
plugins: true,
|
|
112
|
+
knowledge_base: false,
|
|
113
|
+
rag_eval: true,
|
|
114
|
+
clerk_sign_up: false,
|
|
115
|
+
market: true,
|
|
116
|
+
speech_to_text: true,
|
|
117
|
+
changelog: false,
|
|
118
|
+
pin_list: true,
|
|
119
|
+
api_key_manage: false,
|
|
120
|
+
cloud_promotion: true,
|
|
121
|
+
commercial_hide_github: false,
|
|
122
|
+
commercial_hide_docs: true,
|
|
43
123
|
};
|
|
44
124
|
|
|
45
|
-
const
|
|
125
|
+
const mappedState = mapFeatureFlagsEnvToState(config);
|
|
126
|
+
|
|
127
|
+
expect(mappedState).toMatchObject({
|
|
46
128
|
isAgentEditable: false,
|
|
47
129
|
showCreateSession: true,
|
|
48
130
|
showLLM: false,
|
|
131
|
+
showProvider: true,
|
|
49
132
|
showOpenAIApiKey: true,
|
|
50
133
|
showOpenAIProxyUrl: false,
|
|
51
134
|
showDalle: true,
|
|
52
135
|
showAiImage: true,
|
|
53
136
|
enableCheckUpdates: true,
|
|
54
137
|
showWelcomeSuggest: true,
|
|
138
|
+
enablePlugins: true,
|
|
139
|
+
enableKnowledgeBase: false,
|
|
140
|
+
enableRAGEval: true,
|
|
141
|
+
enableClerkSignUp: false,
|
|
142
|
+
showMarket: true,
|
|
143
|
+
enableSTT: true,
|
|
144
|
+
showChangelog: false,
|
|
145
|
+
showPinList: true,
|
|
146
|
+
showApiKeyManage: false,
|
|
147
|
+
showCloudPromotion: true,
|
|
148
|
+
hideGitHub: false,
|
|
149
|
+
hideDocs: true,
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('should correctly evaluate user-specific flags with allowlist', () => {
|
|
154
|
+
const userId = 'user-123';
|
|
155
|
+
const config = {
|
|
156
|
+
edit_agent: ['user-123', 'user-456'],
|
|
157
|
+
create_session: ['user-789'],
|
|
158
|
+
dalle: true,
|
|
159
|
+
knowledge_base: ['user-123'],
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const mappedState = mapFeatureFlagsEnvToState(config, userId);
|
|
163
|
+
|
|
164
|
+
expect(mappedState.isAgentEditable).toBe(true); // user-123 is in allowlist
|
|
165
|
+
expect(mappedState.showCreateSession).toBe(false); // user-123 is not in allowlist
|
|
166
|
+
expect(mappedState.showDalle).toBe(true); // boolean true
|
|
167
|
+
expect(mappedState.enableKnowledgeBase).toBe(true); // user-123 is in allowlist
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('should return false for array flags when user ID is not in allowlist', () => {
|
|
171
|
+
const userId = 'user-999';
|
|
172
|
+
const config = {
|
|
173
|
+
edit_agent: ['user-123', 'user-456'],
|
|
174
|
+
create_session: ['user-789'],
|
|
175
|
+
dalle: true,
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const mappedState = mapFeatureFlagsEnvToState(config, userId);
|
|
179
|
+
|
|
180
|
+
expect(mappedState.isAgentEditable).toBe(false);
|
|
181
|
+
expect(mappedState.showCreateSession).toBe(false);
|
|
182
|
+
expect(mappedState.showDalle).toBe(true);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('should return false for array flags when no user ID provided', () => {
|
|
186
|
+
const config = {
|
|
187
|
+
edit_agent: ['user-123', 'user-456'],
|
|
188
|
+
create_session: true,
|
|
55
189
|
};
|
|
56
190
|
|
|
57
191
|
const mappedState = mapFeatureFlagsEnvToState(config);
|
|
58
192
|
|
|
59
|
-
expect(mappedState).
|
|
193
|
+
expect(mappedState.isAgentEditable).toBe(false);
|
|
194
|
+
expect(mappedState.showCreateSession).toBe(true);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('should handle mixed boolean and array values correctly', () => {
|
|
198
|
+
const userId = 'user-123';
|
|
199
|
+
const config = {
|
|
200
|
+
edit_agent: ['user-123'],
|
|
201
|
+
create_session: true,
|
|
202
|
+
dalle: false,
|
|
203
|
+
ai_image: ['user-456'],
|
|
204
|
+
knowledge_base: ['user-123', 'user-789'],
|
|
205
|
+
rag_eval: true,
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const mappedState = mapFeatureFlagsEnvToState(config, userId);
|
|
209
|
+
|
|
210
|
+
expect(mappedState.isAgentEditable).toBe(true);
|
|
211
|
+
expect(mappedState.showCreateSession).toBe(true);
|
|
212
|
+
expect(mappedState.showDalle).toBe(false);
|
|
213
|
+
expect(mappedState.showAiImage).toBe(false);
|
|
214
|
+
expect(mappedState.enableKnowledgeBase).toBe(true);
|
|
215
|
+
expect(mappedState.enableRAGEval).toBe(true);
|
|
60
216
|
});
|
|
61
217
|
});
|
|
@@ -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:
|
|
6
|
-
pin_list:
|
|
8
|
+
check_updates: FeatureFlagValue.optional(),
|
|
9
|
+
pin_list: FeatureFlagValue.optional(),
|
|
7
10
|
|
|
8
11
|
// settings
|
|
9
|
-
language_model_settings:
|
|
10
|
-
provider_settings:
|
|
12
|
+
language_model_settings: FeatureFlagValue.optional(),
|
|
13
|
+
provider_settings: FeatureFlagValue.optional(),
|
|
11
14
|
|
|
12
|
-
openai_api_key:
|
|
13
|
-
openai_proxy_url:
|
|
15
|
+
openai_api_key: FeatureFlagValue.optional(),
|
|
16
|
+
openai_proxy_url: FeatureFlagValue.optional(),
|
|
14
17
|
|
|
15
18
|
// profile
|
|
16
|
-
api_key_manage:
|
|
19
|
+
api_key_manage: FeatureFlagValue.optional(),
|
|
17
20
|
|
|
18
|
-
create_session:
|
|
19
|
-
edit_agent:
|
|
21
|
+
create_session: FeatureFlagValue.optional(),
|
|
22
|
+
edit_agent: FeatureFlagValue.optional(),
|
|
20
23
|
|
|
21
|
-
plugins:
|
|
22
|
-
dalle:
|
|
23
|
-
ai_image:
|
|
24
|
-
speech_to_text:
|
|
25
|
-
token_counter:
|
|
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:
|
|
28
|
-
changelog:
|
|
30
|
+
welcome_suggest: FeatureFlagValue.optional(),
|
|
31
|
+
changelog: FeatureFlagValue.optional(),
|
|
29
32
|
|
|
30
|
-
clerk_sign_up:
|
|
33
|
+
clerk_sign_up: FeatureFlagValue.optional(),
|
|
31
34
|
|
|
32
|
-
market:
|
|
33
|
-
knowledge_base:
|
|
35
|
+
market: FeatureFlagValue.optional(),
|
|
36
|
+
knowledge_base: FeatureFlagValue.optional(),
|
|
34
37
|
|
|
35
|
-
rag_eval:
|
|
38
|
+
rag_eval: FeatureFlagValue.optional(),
|
|
36
39
|
|
|
37
40
|
// internal flag
|
|
38
|
-
cloud_promotion:
|
|
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:
|
|
44
|
-
commercial_hide_docs:
|
|
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>;
|
|
@@ -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
|
+
};
|