@lobehub/lobehub 2.0.0-next.221 → 2.0.0-next.223

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 (130) hide show
  1. package/.github/workflows/claude-auto-testing.yml +6 -3
  2. package/.github/workflows/claude-dedupe-issues.yml +8 -1
  3. package/.github/workflows/claude-issue-triage.yml +8 -14
  4. package/.github/workflows/claude-translate-comments.yml +6 -3
  5. package/.github/workflows/claude-translator.yml +12 -14
  6. package/.github/workflows/claude.yml +10 -20
  7. package/.github/workflows/test.yml +26 -0
  8. package/CHANGELOG.md +58 -0
  9. package/changelog/v1.json +18 -0
  10. package/e2e/package.json +1 -1
  11. package/e2e/src/mocks/index.ts +2 -2
  12. package/e2e/src/steps/{discover → community}/detail-pages.steps.ts +8 -8
  13. package/e2e/src/steps/{discover → community}/interactions.steps.ts +4 -4
  14. package/locales/zh-CN/components.json +1 -0
  15. package/package.json +3 -3
  16. package/packages/const/src/index.ts +0 -1
  17. package/packages/memory-user-memory/package.json +1 -0
  18. package/packages/memory-user-memory/src/extractors/context.test.ts +3 -2
  19. package/packages/memory-user-memory/src/extractors/experience.test.ts +3 -2
  20. package/packages/memory-user-memory/src/extractors/identity.test.ts +23 -6
  21. package/packages/memory-user-memory/src/extractors/preference.test.ts +3 -2
  22. package/packages/memory-user-memory/vitest.config.ts +4 -0
  23. package/packages/model-runtime/src/providers/replicate/index.ts +1 -1
  24. package/packages/ssrf-safe-fetch/index.test.ts +2 -2
  25. package/packages/ssrf-safe-fetch/package.json +3 -2
  26. package/packages/types/src/serverConfig.ts +2 -0
  27. package/packages/utils/package.json +1 -1
  28. package/packages/utils/src/client/xor-obfuscation.test.ts +32 -32
  29. package/packages/utils/src/client/xor-obfuscation.ts +3 -4
  30. package/packages/utils/src/imageToBase64.ts +1 -1
  31. package/packages/utils/src/server/__tests__/auth.test.ts +1 -1
  32. package/packages/utils/src/server/auth.ts +1 -1
  33. package/packages/utils/src/server/correctOIDCUrl.test.ts +80 -19
  34. package/packages/utils/src/server/correctOIDCUrl.ts +89 -24
  35. package/packages/utils/src/server/index.ts +1 -0
  36. package/packages/utils/src/server/xor.test.ts +9 -7
  37. package/packages/utils/src/server/xor.ts +1 -1
  38. package/packages/web-crawler/package.json +1 -1
  39. package/packages/web-crawler/src/crawImpl/__tests__/naive.test.ts +1 -1
  40. package/packages/web-crawler/src/crawImpl/naive.ts +1 -1
  41. package/scripts/prebuild.mts +58 -1
  42. package/src/app/(backend)/api/auth/[...all]/route.ts +2 -1
  43. package/src/app/(backend)/middleware/auth/index.ts +3 -3
  44. package/src/app/(backend)/middleware/auth/utils.test.ts +1 -1
  45. package/src/app/(backend)/middleware/auth/utils.ts +1 -1
  46. package/src/app/(backend)/oidc/callback/desktop/route.ts +7 -36
  47. package/src/app/(backend)/webapi/chat/[provider]/route.test.ts +2 -2
  48. package/src/app/(backend)/webapi/models/[provider]/route.test.ts +1 -1
  49. package/src/app/(backend)/webapi/plugin/gateway/route.ts +1 -1
  50. package/src/app/(backend)/webapi/proxy/route.ts +1 -1
  51. package/src/app/[variants]/(auth)/login/[[...login]]/page.tsx +1 -1
  52. package/src/app/[variants]/(auth)/reset-password/layout.tsx +1 -1
  53. package/src/app/[variants]/(auth)/signin/layout.tsx +1 -1
  54. package/src/app/[variants]/(auth)/signin/useSignIn.ts +2 -2
  55. package/src/app/[variants]/(auth)/signup/[[...signup]]/page.tsx +1 -1
  56. package/src/app/[variants]/(auth)/signup/[[...signup]]/useSignUp.tsx +12 -6
  57. package/src/app/[variants]/(auth)/verify-email/layout.tsx +1 -1
  58. package/src/app/[variants]/(main)/settings/profile/features/AvatarRow.tsx +1 -1
  59. package/src/app/[variants]/(main)/settings/security/index.tsx +1 -1
  60. package/src/app/[variants]/(mobile)/me/(home)/__tests__/UserBanner.test.tsx +1 -1
  61. package/src/app/[variants]/(mobile)/me/(home)/__tests__/useCategory.test.tsx +1 -1
  62. package/src/app/[variants]/(mobile)/me/(home)/features/UserBanner.tsx +1 -1
  63. package/src/app/[variants]/(mobile)/settings/_layout/Header.tsx +1 -1
  64. package/src/components/ModelSelect/index.tsx +103 -72
  65. package/src/envs/auth.ts +30 -9
  66. package/src/features/Conversation/Messages/AssistantGroup/components/EditState.tsx +15 -32
  67. package/src/features/Conversation/Messages/AssistantGroup/index.tsx +9 -7
  68. package/src/features/EditorModal/EditorCanvas.tsx +31 -50
  69. package/src/features/EditorModal/TextareCanvas.tsx +3 -1
  70. package/src/features/EditorModal/index.tsx +14 -4
  71. package/src/features/ModelSwitchPanel/components/Footer.tsx +42 -0
  72. package/src/features/ModelSwitchPanel/components/List/MultipleProvidersModelItem.tsx +103 -0
  73. package/src/features/ModelSwitchPanel/components/List/SingleProviderModelItem.tsx +24 -0
  74. package/src/features/ModelSwitchPanel/components/List/VirtualItemRenderer.tsx +180 -0
  75. package/src/features/ModelSwitchPanel/components/List/index.tsx +99 -0
  76. package/src/features/ModelSwitchPanel/components/PanelContent.tsx +77 -0
  77. package/src/features/ModelSwitchPanel/components/Toolbar.tsx +54 -0
  78. package/src/features/ModelSwitchPanel/const.ts +29 -0
  79. package/src/features/ModelSwitchPanel/hooks/useBuildVirtualItems.ts +122 -0
  80. package/src/features/ModelSwitchPanel/hooks/useCurrentModelName.ts +18 -0
  81. package/src/features/ModelSwitchPanel/hooks/useDelayedRender.ts +18 -0
  82. package/src/features/ModelSwitchPanel/hooks/useModelAndProvider.ts +14 -0
  83. package/src/features/ModelSwitchPanel/hooks/usePanelHandlers.ts +33 -0
  84. package/src/features/ModelSwitchPanel/hooks/usePanelSize.ts +33 -0
  85. package/src/features/ModelSwitchPanel/hooks/usePanelState.ts +20 -0
  86. package/src/features/ModelSwitchPanel/index.tsx +25 -706
  87. package/src/features/ModelSwitchPanel/styles.ts +58 -0
  88. package/src/features/ModelSwitchPanel/types.ts +73 -0
  89. package/src/features/ModelSwitchPanel/utils.ts +24 -0
  90. package/src/features/User/UserPanel/PanelContent.tsx +1 -1
  91. package/src/features/User/__tests__/PanelContent.test.tsx +1 -1
  92. package/src/features/User/__tests__/UserAvatar.test.tsx +1 -1
  93. package/src/features/User/__tests__/useMenu.test.tsx +1 -1
  94. package/src/layout/GlobalProvider/StoreInitialization.tsx +2 -1
  95. package/src/libs/better-auth/auth-client.ts +7 -3
  96. package/src/libs/better-auth/define-config.ts +2 -2
  97. package/src/libs/next/proxy/define-config.ts +9 -6
  98. package/src/libs/oidc-provider/provider.test.ts +1 -1
  99. package/src/libs/trpc/async/context.ts +1 -1
  100. package/src/libs/trpc/lambda/context.ts +7 -8
  101. package/src/libs/trpc/middleware/userAuth.ts +1 -1
  102. package/src/libs/trusted-client/getSessionUser.ts +1 -1
  103. package/src/locales/default/components.ts +1 -0
  104. package/src/server/globalConfig/index.ts +2 -0
  105. package/src/server/routers/async/caller.ts +1 -1
  106. package/src/server/routers/lambda/__tests__/user.test.ts +2 -2
  107. package/src/server/routers/lambda/user.ts +2 -1
  108. package/src/services/_auth.ts +3 -3
  109. package/src/services/chat/index.ts +1 -1
  110. package/src/services/chat/mecha/contextEngineering.ts +1 -1
  111. package/src/store/global/initialState.ts +10 -0
  112. package/src/store/global/selectors/systemStatus.ts +5 -0
  113. package/src/store/serverConfig/selectors.ts +5 -1
  114. package/src/store/tool/slices/mcpStore/action.ts +74 -75
  115. package/src/store/user/slices/auth/action.test.ts +1 -1
  116. package/src/store/user/slices/auth/action.ts +1 -1
  117. package/src/store/user/slices/auth/initialState.ts +1 -1
  118. package/src/store/user/slices/auth/selectors.test.ts +1 -1
  119. package/src/store/user/slices/auth/selectors.ts +1 -1
  120. package/src/store/user/slices/common/action.ts +1 -1
  121. package/src/store/userMemory/slices/context/action.ts +6 -6
  122. package/packages/const/src/auth.ts +0 -14
  123. /package/e2e/src/features/{discover → community}/detail-pages.feature +0 -0
  124. /package/e2e/src/features/{discover → community}/interactions.feature +0 -0
  125. /package/e2e/src/features/{discover → community}/smoke.feature +0 -0
  126. /package/e2e/src/mocks/{discover → community}/data.ts +0 -0
  127. /package/e2e/src/mocks/{discover → community}/handlers.ts +0 -0
  128. /package/e2e/src/mocks/{discover → community}/index.ts +0 -0
  129. /package/e2e/src/mocks/{discover → community}/types.ts +0 -0
  130. /package/e2e/src/steps/{discover → community}/smoke.steps.ts +0 -0
@@ -0,0 +1,42 @@
1
+ import { Block, Flexbox, Icon } from '@lobehub/ui';
2
+ import { cssVar } from 'antd-style';
3
+ import { LucideArrowRight, LucideBolt } from 'lucide-react';
4
+ import type { FC } from 'react';
5
+ import { useTranslation } from 'react-i18next';
6
+ import { useNavigate } from 'react-router-dom';
7
+
8
+ import { styles } from '../styles';
9
+
10
+ interface FooterProps {
11
+ onClose: () => void;
12
+ }
13
+
14
+ export const Footer: FC<FooterProps> = ({ onClose }) => {
15
+ const { t } = useTranslation('components');
16
+ const navigate = useNavigate();
17
+
18
+ return (
19
+ <Flexbox className={styles.footer} padding={4}>
20
+ <Block
21
+ clickable
22
+ gap={8}
23
+ horizontal
24
+ onClick={() => {
25
+ navigate('/settings/provider/all');
26
+ onClose();
27
+ }}
28
+ paddingBlock={8}
29
+ paddingInline={12}
30
+ variant={'borderless'}
31
+ >
32
+ <Flexbox align={'center'} gap={8} horizontal style={{ flex: 1 }}>
33
+ <Icon icon={LucideBolt} size={'small'} />
34
+ {t('ModelSwitchPanel.manageProvider')}
35
+ </Flexbox>
36
+ <Icon color={cssVar.colorTextDescription} icon={LucideArrowRight} size={'small'} />
37
+ </Block>
38
+ </Flexbox>
39
+ );
40
+ };
41
+
42
+ Footer.displayName = 'Footer';
@@ -0,0 +1,103 @@
1
+ import { ActionIcon, Dropdown } from '@lobehub/ui';
2
+ import { MenuItemType } from 'antd/es/menu/interface';
3
+ import { LucideBolt } from 'lucide-react';
4
+ import { memo, useMemo } from 'react';
5
+ import { useTranslation } from 'react-i18next';
6
+ import { useNavigate } from 'react-router-dom';
7
+ import urlJoin from 'url-join';
8
+
9
+ import { ModelItemRender, ProviderItemRender } from '@/components/ModelSelect';
10
+
11
+ import { styles } from '../../styles';
12
+ import type { ModelWithProviders } from '../../types';
13
+ import { menuKey } from '../../utils';
14
+
15
+ interface MultipleProvidersModelItemProps {
16
+ activeKey: string;
17
+ data: ModelWithProviders;
18
+ newLabel: string;
19
+ onClose: () => void;
20
+ onModelChange: (modelId: string, providerId: string) => Promise<void>;
21
+ }
22
+
23
+ export const MultipleProvidersModelItem = memo<MultipleProvidersModelItemProps>(
24
+ ({ activeKey, data, newLabel, onModelChange, onClose }) => {
25
+ const { t } = useTranslation('components');
26
+ const navigate = useNavigate();
27
+
28
+ const items = useMemo(
29
+ () =>
30
+ [
31
+ {
32
+ key: 'header',
33
+ label: t('ModelSwitchPanel.useModelFrom'),
34
+ type: 'group',
35
+ },
36
+ ...data.providers.map((p) => {
37
+ return {
38
+ extra: (
39
+ <ActionIcon
40
+ className={'settings-icon'}
41
+ icon={LucideBolt}
42
+ onClick={(e) => {
43
+ e.preventDefault();
44
+ e.stopPropagation();
45
+ const url = urlJoin('/settings/provider', p.id || 'all');
46
+ if (e.ctrlKey || e.metaKey) {
47
+ window.open(url, '_blank');
48
+ } else {
49
+ navigate(url);
50
+ }
51
+ }}
52
+ size={'small'}
53
+ title={t('ModelSwitchPanel.goToSettings')}
54
+ />
55
+ ),
56
+ key: menuKey(p.id, data.model.id),
57
+ label: (
58
+ <ProviderItemRender
59
+ logo={p.logo}
60
+ name={p.name}
61
+ provider={p.id}
62
+ size={20}
63
+ source={p.source}
64
+ type={'avatar'}
65
+ />
66
+ ),
67
+ onClick: async () => {
68
+ onModelChange(data.model.id, p.id);
69
+ onClose();
70
+ },
71
+ };
72
+ }),
73
+ ] as MenuItemType[],
74
+ [data.model.id, data.providers, navigate, onModelChange, onClose, t],
75
+ );
76
+
77
+ return (
78
+ <Dropdown
79
+ align={{ offset: [12, -48] }}
80
+ arrow={false}
81
+ classNames={{
82
+ item: styles.menuItem,
83
+ }}
84
+ menu={{
85
+ items,
86
+ selectedKeys: [activeKey],
87
+ }}
88
+ // @ts-ignore
89
+ placement="rightTop"
90
+ >
91
+ <ModelItemRender
92
+ {...data.model}
93
+ {...data.model.abilities}
94
+ infoTagTooltip={false}
95
+ newBadgeLabel={newLabel}
96
+ showInfoTag={true}
97
+ />
98
+ </Dropdown>
99
+ );
100
+ },
101
+ );
102
+
103
+ MultipleProvidersModelItem.displayName = 'MultipleProvidersModelItem';
@@ -0,0 +1,24 @@
1
+ import { memo } from 'react';
2
+
3
+ import { ModelItemRender } from '@/components/ModelSelect';
4
+
5
+ import type { ModelWithProviders } from '../../types';
6
+
7
+ interface SingleProviderModelItemProps {
8
+ data: ModelWithProviders;
9
+ newLabel: string;
10
+ }
11
+
12
+ export const SingleProviderModelItem = memo<SingleProviderModelItemProps>(({ data, newLabel }) => {
13
+ return (
14
+ <ModelItemRender
15
+ {...data.model}
16
+ {...data.model.abilities}
17
+ infoTagTooltip={false}
18
+ newBadgeLabel={newLabel}
19
+ showInfoTag={true}
20
+ />
21
+ );
22
+ });
23
+
24
+ SingleProviderModelItem.displayName = 'SingleProviderModelItem';
@@ -0,0 +1,180 @@
1
+ import { ActionIcon, Block, Flexbox, Icon } from '@lobehub/ui';
2
+ import { cssVar } from 'antd-style';
3
+ import { LucideArrowRight, LucideBolt } from 'lucide-react';
4
+ import { memo } from 'react';
5
+ import { useTranslation } from 'react-i18next';
6
+ import { useNavigate } from 'react-router-dom';
7
+ import urlJoin from 'url-join';
8
+
9
+ import { ModelItemRender, ProviderItemRender } from '@/components/ModelSelect';
10
+
11
+ import { styles } from '../../styles';
12
+ import type { VirtualItem } from '../../types';
13
+ import { menuKey } from '../../utils';
14
+ import { MultipleProvidersModelItem } from './MultipleProvidersModelItem';
15
+ import { SingleProviderModelItem } from './SingleProviderModelItem';
16
+
17
+ interface VirtualItemRendererProps {
18
+ activeKey: string;
19
+ item: VirtualItem;
20
+ newLabel: string;
21
+ onClose: () => void;
22
+ onModelChange: (modelId: string, providerId: string) => Promise<void>;
23
+ }
24
+
25
+ export const VirtualItemRenderer = memo<VirtualItemRendererProps>(
26
+ ({ activeKey, item, newLabel, onModelChange, onClose }) => {
27
+ const { t } = useTranslation('components');
28
+ const navigate = useNavigate();
29
+
30
+ switch (item.type) {
31
+ case 'no-provider': {
32
+ return (
33
+ <Block
34
+ className={styles.menuItem}
35
+ clickable
36
+ gap={8}
37
+ horizontal
38
+ key="no-provider"
39
+ onClick={() => navigate('/settings/provider/all')}
40
+ style={{ color: cssVar.colorTextTertiary }}
41
+ variant={'borderless'}
42
+ >
43
+ {t('ModelSwitchPanel.emptyProvider')}
44
+ <Icon icon={LucideArrowRight} />
45
+ </Block>
46
+ );
47
+ }
48
+
49
+ case 'group-header': {
50
+ return (
51
+ <Flexbox
52
+ className={styles.groupHeader}
53
+ horizontal
54
+ justify="space-between"
55
+ key={`header-${item.provider.id}`}
56
+ paddingBlock={'12px 4px'}
57
+ paddingInline={'12px 8px'}
58
+ >
59
+ <ProviderItemRender
60
+ logo={item.provider.logo}
61
+ name={item.provider.name}
62
+ provider={item.provider.id}
63
+ source={item.provider.source}
64
+ />
65
+ <ActionIcon
66
+ className="settings-icon"
67
+ icon={LucideBolt}
68
+ onClick={(e) => {
69
+ e.preventDefault();
70
+ e.stopPropagation();
71
+ const url = urlJoin('/settings/provider', item.provider.id || 'all');
72
+ if (e.ctrlKey || e.metaKey) {
73
+ window.open(url, '_blank');
74
+ } else {
75
+ navigate(url);
76
+ }
77
+ }}
78
+ size={'small'}
79
+ title={t('ModelSwitchPanel.goToSettings')}
80
+ />
81
+ </Flexbox>
82
+ );
83
+ }
84
+
85
+ case 'empty-model': {
86
+ return (
87
+ <Flexbox
88
+ className={styles.menuItem}
89
+ gap={8}
90
+ horizontal
91
+ key={`empty-${item.provider.id}`}
92
+ onClick={() => navigate(`/settings/provider/${item.provider.id}`)}
93
+ style={{ color: cssVar.colorTextTertiary }}
94
+ >
95
+ {t('ModelSwitchPanel.emptyModel')}
96
+ <Icon icon={LucideArrowRight} />
97
+ </Flexbox>
98
+ );
99
+ }
100
+
101
+ case 'provider-model-item': {
102
+ const key = menuKey(item.provider.id, item.model.id);
103
+ const isActive = key === activeKey;
104
+
105
+ return (
106
+ <Block
107
+ className={styles.menuItem}
108
+ clickable
109
+ key={key}
110
+ onClick={async () => {
111
+ onModelChange(item.model.id, item.provider.id);
112
+ onClose();
113
+ }}
114
+ variant={isActive ? 'filled' : 'borderless'}
115
+ >
116
+ <ModelItemRender
117
+ {...item.model}
118
+ {...item.model.abilities}
119
+ infoTagTooltip={false}
120
+ newBadgeLabel={newLabel}
121
+ showInfoTag
122
+ />
123
+ </Block>
124
+ );
125
+ }
126
+
127
+ case 'model-item-single': {
128
+ const singleProvider = item.data.providers[0];
129
+ const key = menuKey(singleProvider.id, item.data.model.id);
130
+ const isActive = key === activeKey;
131
+
132
+ return (
133
+ <Block
134
+ className={styles.menuItem}
135
+ clickable
136
+ key={key}
137
+ onClick={async () => {
138
+ onModelChange(item.data.model.id, singleProvider.id);
139
+ onClose();
140
+ }}
141
+ variant={isActive ? 'filled' : 'borderless'}
142
+ >
143
+ <SingleProviderModelItem data={item.data} newLabel={newLabel} />
144
+ </Block>
145
+ );
146
+ }
147
+
148
+ case 'model-item-multiple': {
149
+ // Check if any provider of this model is active
150
+ const activeProvider = item.data.providers.find(
151
+ (p) => menuKey(p.id, item.data.model.id) === activeKey,
152
+ );
153
+ const isActive = !!activeProvider;
154
+
155
+ return (
156
+ <Block
157
+ className={styles.menuItem}
158
+ clickable
159
+ key={item.data.displayName}
160
+ variant={isActive ? 'filled' : 'borderless'}
161
+ >
162
+ <MultipleProvidersModelItem
163
+ activeKey={activeKey}
164
+ data={item.data}
165
+ newLabel={newLabel}
166
+ onClose={onClose}
167
+ onModelChange={onModelChange}
168
+ />
169
+ </Block>
170
+ );
171
+ }
172
+
173
+ default: {
174
+ return null;
175
+ }
176
+ }
177
+ },
178
+ );
179
+
180
+ VirtualItemRenderer.displayName = 'VirtualItemRenderer';
@@ -0,0 +1,99 @@
1
+ import { Flexbox } from '@lobehub/ui';
2
+ import type { FC } from 'react';
3
+ import { useMemo } from 'react';
4
+ import { useTranslation } from 'react-i18next';
5
+
6
+ import { useEnabledChatModels } from '@/hooks/useEnabledChatModels';
7
+
8
+ import {
9
+ FOOTER_HEIGHT,
10
+ INITIAL_RENDER_COUNT,
11
+ ITEM_HEIGHT,
12
+ MAX_PANEL_HEIGHT,
13
+ TOOLBAR_HEIGHT,
14
+ } from '../../const';
15
+ import { useBuildVirtualItems } from '../../hooks/useBuildVirtualItems';
16
+ import { useDelayedRender } from '../../hooks/useDelayedRender';
17
+ import { useModelAndProvider } from '../../hooks/useModelAndProvider';
18
+ import { usePanelHandlers } from '../../hooks/usePanelHandlers';
19
+ import { styles } from '../../styles';
20
+ import type { GroupMode } from '../../types';
21
+ import { getVirtualItemKey, menuKey } from '../../utils';
22
+ import { VirtualItemRenderer } from './VirtualItemRenderer';
23
+
24
+ interface ListProps {
25
+ groupMode: GroupMode;
26
+ isOpen: boolean;
27
+ model?: string;
28
+ onModelChange?: (params: { model: string; provider: string }) => Promise<void>;
29
+ onOpenChange?: (open: boolean) => void;
30
+ provider?: string;
31
+ searchKeyword?: string;
32
+ }
33
+
34
+ export const List: FC<ListProps> = ({
35
+ groupMode,
36
+ isOpen,
37
+ model: modelProp,
38
+ onModelChange: onModelChangeProp,
39
+ onOpenChange,
40
+ provider: providerProp,
41
+ searchKeyword = '',
42
+ }) => {
43
+ const { t: tCommon } = useTranslation('common');
44
+ const newLabel = tCommon('new');
45
+
46
+ // Get enabled models list
47
+ const enabledList = useEnabledChatModels();
48
+
49
+ // Get delayed render state
50
+ const renderAll = useDelayedRender(isOpen);
51
+
52
+ // Get model and provider
53
+ const { model, provider } = useModelAndProvider(modelProp, providerProp);
54
+
55
+ // Get handlers
56
+ const { handleModelChange, handleClose } = usePanelHandlers({
57
+ onModelChange: onModelChangeProp,
58
+ onOpenChange,
59
+ });
60
+
61
+ // Build virtual items
62
+ const virtualItems = useBuildVirtualItems(enabledList, groupMode, searchKeyword);
63
+
64
+ // Calculate panel height
65
+ const panelHeight = useMemo(
66
+ () =>
67
+ enabledList.length === 0
68
+ ? TOOLBAR_HEIGHT + ITEM_HEIGHT['no-provider'] + FOOTER_HEIGHT
69
+ : MAX_PANEL_HEIGHT,
70
+ [enabledList.length],
71
+ );
72
+
73
+ // Calculate active key
74
+ const activeKey = menuKey(provider, model);
75
+
76
+ return (
77
+ <Flexbox
78
+ className={styles.list}
79
+ flex={1}
80
+ style={{
81
+ height: panelHeight - TOOLBAR_HEIGHT - FOOTER_HEIGHT,
82
+ paddingBlock: groupMode === 'byModel' ? 8 : 0,
83
+ }}
84
+ >
85
+ {virtualItems.slice(0, renderAll ? virtualItems.length : INITIAL_RENDER_COUNT).map((item) => (
86
+ <VirtualItemRenderer
87
+ activeKey={activeKey}
88
+ item={item}
89
+ key={getVirtualItemKey(item)}
90
+ newLabel={newLabel}
91
+ onClose={handleClose}
92
+ onModelChange={handleModelChange}
93
+ />
94
+ ))}
95
+ </Flexbox>
96
+ );
97
+ };
98
+
99
+ List.displayName = 'List';
@@ -0,0 +1,77 @@
1
+ import type { FC } from 'react';
2
+ import { useState } from 'react';
3
+ import { Rnd } from 'react-rnd';
4
+
5
+ import { useEnabledChatModels } from '@/hooks/useEnabledChatModels';
6
+
7
+ import { ENABLE_RESIZING, MAX_WIDTH, MIN_WIDTH } from '../const';
8
+ import { usePanelHandlers } from '../hooks/usePanelHandlers';
9
+ import { usePanelSize } from '../hooks/usePanelSize';
10
+ import { usePanelState } from '../hooks/usePanelState';
11
+ import { Footer } from './Footer';
12
+ import { List } from './List';
13
+ import { Toolbar } from './Toolbar';
14
+
15
+ interface PanelContentProps {
16
+ isOpen: boolean;
17
+ model?: string;
18
+ onModelChange?: (params: { model: string; provider: string }) => Promise<void>;
19
+ onOpenChange?: (open: boolean) => void;
20
+ provider?: string;
21
+ }
22
+
23
+ export const PanelContent: FC<PanelContentProps> = ({
24
+ isOpen,
25
+ model: modelProp,
26
+ onModelChange: onModelChangeProp,
27
+ onOpenChange,
28
+ provider: providerProp,
29
+ }) => {
30
+ // Get enabled models list
31
+ const enabledList = useEnabledChatModels();
32
+
33
+ // Search keyword state
34
+ const [searchKeyword, setSearchKeyword] = useState('');
35
+
36
+ // Hooks for state management
37
+ const { groupMode, handleGroupModeChange } = usePanelState();
38
+ const { panelHeight, panelWidth, handlePanelWidthChange } = usePanelSize(enabledList.length);
39
+ const { handleClose } = usePanelHandlers({
40
+ onModelChange: onModelChangeProp,
41
+ onOpenChange,
42
+ });
43
+
44
+ return (
45
+ <Rnd
46
+ disableDragging
47
+ enableResizing={ENABLE_RESIZING}
48
+ maxWidth={MAX_WIDTH}
49
+ minWidth={MIN_WIDTH}
50
+ onResizeStop={(_e, _direction, ref) => {
51
+ handlePanelWidthChange(ref.offsetWidth);
52
+ }}
53
+ position={{ x: 0, y: 0 }}
54
+ size={{ height: panelHeight, width: panelWidth }}
55
+ style={{ display: 'flex', flexDirection: 'column', position: 'relative' }}
56
+ >
57
+ <Toolbar
58
+ groupMode={groupMode}
59
+ onGroupModeChange={handleGroupModeChange}
60
+ onSearchKeywordChange={setSearchKeyword}
61
+ searchKeyword={searchKeyword}
62
+ />
63
+ <List
64
+ groupMode={groupMode}
65
+ isOpen={isOpen}
66
+ model={modelProp}
67
+ onModelChange={onModelChangeProp}
68
+ onOpenChange={onOpenChange}
69
+ provider={providerProp}
70
+ searchKeyword={searchKeyword}
71
+ />
72
+ <Footer onClose={handleClose} />
73
+ </Rnd>
74
+ );
75
+ };
76
+
77
+ PanelContent.displayName = 'PanelContent';
@@ -0,0 +1,54 @@
1
+ import { Flexbox, Icon, SearchBar, Segmented } from '@lobehub/ui';
2
+ import { ProviderIcon } from '@lobehub/ui/icons';
3
+ import { Brain } from 'lucide-react';
4
+ import { memo } from 'react';
5
+ import { useTranslation } from 'react-i18next';
6
+
7
+ import { styles } from '../styles';
8
+ import type { GroupMode } from '../types';
9
+
10
+ interface ToolbarProps {
11
+ groupMode: GroupMode;
12
+ onGroupModeChange: (mode: GroupMode) => void;
13
+ onSearchKeywordChange: (keyword: string) => void;
14
+ searchKeyword: string;
15
+ }
16
+
17
+ export const Toolbar = memo<ToolbarProps>(
18
+ ({ groupMode, onGroupModeChange, searchKeyword, onSearchKeywordChange }) => {
19
+ const { t } = useTranslation('components');
20
+
21
+ return (
22
+ <Flexbox className={styles.toolbar} gap={4} horizontal paddingBlock={8} paddingInline={8}>
23
+ <SearchBar
24
+ allowClear
25
+ onChange={(e) => onSearchKeywordChange(e.target.value)}
26
+ placeholder={t('ModelSwitchPanel.searchPlaceholder')}
27
+ size="small"
28
+ style={{ flex: 1 }}
29
+ value={searchKeyword}
30
+ variant="borderless"
31
+ />
32
+ <Segmented
33
+ onChange={(value) => onGroupModeChange(value as GroupMode)}
34
+ options={[
35
+ {
36
+ icon: <Icon icon={Brain} />,
37
+ title: t('ModelSwitchPanel.byModel'),
38
+ value: 'byModel',
39
+ },
40
+ {
41
+ icon: <Icon icon={ProviderIcon} />,
42
+ title: t('ModelSwitchPanel.byProvider'),
43
+ value: 'byProvider',
44
+ },
45
+ ]}
46
+ size="small"
47
+ value={groupMode}
48
+ />
49
+ </Flexbox>
50
+ );
51
+ },
52
+ );
53
+
54
+ Toolbar.displayName = 'Toolbar';
@@ -0,0 +1,29 @@
1
+ export const STORAGE_KEY = 'MODEL_SWITCH_PANEL_WIDTH';
2
+ export const STORAGE_KEY_MODE = 'MODEL_SWITCH_PANEL_MODE';
3
+ export const DEFAULT_WIDTH = 430;
4
+ export const MIN_WIDTH = 280;
5
+ export const MAX_WIDTH = 600;
6
+ export const MAX_PANEL_HEIGHT = 460;
7
+ export const TOOLBAR_HEIGHT = 40;
8
+ export const FOOTER_HEIGHT = 48;
9
+
10
+ export const INITIAL_RENDER_COUNT = 15;
11
+ export const RENDER_ALL_DELAY_MS = 500;
12
+
13
+ export const ITEM_HEIGHT = {
14
+ 'empty-model': 32,
15
+ 'group-header': 32,
16
+ 'model-item': 32,
17
+ 'no-provider': 32,
18
+ } as const;
19
+
20
+ export const ENABLE_RESIZING = {
21
+ bottom: false,
22
+ bottomLeft: false,
23
+ bottomRight: false,
24
+ left: false,
25
+ right: true,
26
+ top: false,
27
+ topLeft: false,
28
+ topRight: false,
29
+ } as const;