@lobehub/lobehub 2.0.0-next.324 → 2.0.0-next.325

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 (66) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/CLAUDE.md +4 -0
  3. package/apps/desktop/src/main/core/browser/Browser.ts +40 -1
  4. package/apps/desktop/src/main/core/infrastructure/I18nManager.ts +0 -11
  5. package/changelog/v1.json +5 -0
  6. package/package.json +2 -2
  7. package/packages/database/src/models/__tests__/session.test.ts +0 -29
  8. package/src/app/[variants]/(main)/agent/features/Conversation/AgentWelcome/OpeningQuestions.tsx +0 -2
  9. package/src/app/[variants]/(main)/community/(detail)/agent/features/Sidebar/TocList/index.tsx +0 -36
  10. package/src/app/[variants]/(main)/community/(list)/_layout/Header.tsx +0 -2
  11. package/src/app/[variants]/(main)/group/_layout/Sidebar/GroupConfig/GroupMember.tsx +0 -4
  12. package/src/app/[variants]/(main)/group/features/Conversation/ConversationArea.tsx +0 -7
  13. package/src/app/[variants]/(main)/group/features/Conversation/MainChatInput/GroupChat.tsx +0 -2
  14. package/src/app/[variants]/(main)/home/_layout/Body/index.tsx +0 -2
  15. package/src/app/[variants]/(main)/page/_layout/Body/List/Item/useDropdownMenu.tsx +0 -6
  16. package/src/app/[variants]/(main)/page/_layout/Body/useDropdownMenu.tsx +0 -15
  17. package/src/app/[variants]/(main)/resource/features/DndContextWrapper.tsx +0 -5
  18. package/src/app/[variants]/(main)/settings/provider/features/ModelList/CreateNewModelModal/index.tsx +0 -1
  19. package/src/app/[variants]/(main)/settings/provider/features/ModelList/ModelItem.tsx +0 -10
  20. package/src/app/[variants]/(main)/settings/provider/features/ProviderConfig/Checker.tsx +1 -1
  21. package/src/app/[variants]/(mobile)/(home)/features/SessionListContent/List/Item/Actions.tsx +0 -1
  22. package/src/app/[variants]/layout.tsx +0 -2
  23. package/src/envs/__tests__/app.test.ts +0 -6
  24. package/src/features/ChatInput/ActionBar/Knowledge/useControls.tsx +0 -22
  25. package/src/features/ChatInput/store/action.ts +0 -2
  26. package/src/features/Conversation/Messages/Task/TaskDetailPanel/index.tsx +1 -13
  27. package/src/features/DataImporter/ImportDetail.tsx +0 -20
  28. package/src/features/DevPanel/features/Table/TableCell.tsx +1 -36
  29. package/src/features/DevPanel/index.tsx +0 -9
  30. package/src/features/ModelSwitchPanel/__mocks__/mockEnabledChatModels.ts +159 -0
  31. package/src/features/ModelSwitchPanel/components/List/{VirtualItemRenderer.tsx → ListItemRenderer.tsx} +15 -25
  32. package/src/features/ModelSwitchPanel/components/List/MultipleProvidersModelItem.tsx +95 -69
  33. package/src/features/ModelSwitchPanel/components/List/index.tsx +39 -40
  34. package/src/features/ModelSwitchPanel/components/PanelContent.tsx +0 -8
  35. package/src/features/ModelSwitchPanel/hooks/{useBuildVirtualItems.ts → useBuildListItems.ts} +7 -17
  36. package/src/features/ModelSwitchPanel/index.tsx +24 -23
  37. package/src/features/ModelSwitchPanel/styles.ts +3 -0
  38. package/src/features/ModelSwitchPanel/types.ts +3 -8
  39. package/src/features/ModelSwitchPanel/utils.ts +2 -2
  40. package/src/features/NavPanel/SideBarDrawer.tsx +12 -2
  41. package/src/features/Portal/GroupThread/Body/index.tsx +0 -6
  42. package/src/features/ResourceManager/components/Header/AddButton.tsx +0 -16
  43. package/src/features/ShareModal/ShareImage/index.tsx +0 -8
  44. package/src/hooks/useProviderName.ts +0 -1
  45. package/src/layout/GlobalProvider/Locale.tsx +0 -12
  46. package/src/layout/GlobalProvider/index.tsx +0 -1
  47. package/src/libs/better-auth/sso/helpers.ts +0 -1
  48. package/src/libs/next/config/define-config.ts +5 -0
  49. package/src/locales/create.ts +0 -17
  50. package/src/services/aiChat.ts +0 -4
  51. package/src/services/debug.ts +1 -34
  52. package/src/services/models.ts +0 -15
  53. package/src/store/chat/agents/GroupOrchestration/createGroupOrchestrationExecutors.ts +0 -9
  54. package/src/store/chat/slices/aiChat/actions/conversationLifecycle.ts +0 -3
  55. package/src/store/chat/slices/aiChat/actions/index.ts +1 -3
  56. package/src/store/file/slices/chat/action.test.ts +0 -89
  57. package/src/store/file/slices/chunk/selectors.ts +0 -1
  58. package/src/store/file/slices/fileManager/selectors.ts +0 -1
  59. package/src/store/file/slices/tts/selectors.ts +0 -2
  60. package/src/store/tool/slices/customPlugin/index.ts +0 -1
  61. package/src/store/tool/slices/mcpStore/index.ts +0 -1
  62. package/src/store/tool/slices/oldStore/index.ts +0 -1
  63. package/src/store/tool/slices/plugin/index.ts +0 -1
  64. package/src/styles/global.ts +6 -0
  65. package/src/utils/router.tsx +1 -7
  66. package/src/utils/server/parseModels.ts +0 -1
@@ -0,0 +1,159 @@
1
+ import type { AiModelForSelect } from 'model-bank';
2
+
3
+ import type { EnabledProviderWithModels } from '@/types/aiProvider';
4
+
5
+ /**
6
+ * Mock data for testing ModelSwitchPanel
7
+ *
8
+ * This data includes:
9
+ * - Multiple providers (OpenAI, Azure, Ollama)
10
+ * - Same model provided by multiple providers (gpt-4o -> model-item-multiple)
11
+ * - Single provider model (llama3 -> model-item-single)
12
+ */
13
+ export const mockEnabledChatModels: EnabledProviderWithModels[] = [
14
+ {
15
+ children: [
16
+ {
17
+ abilities: {
18
+ functionCall: true,
19
+ reasoning: false,
20
+ vision: true,
21
+ },
22
+ contextWindowTokens: 128_000,
23
+ displayName: 'GPT-4o',
24
+ id: 'gpt-4o',
25
+ maxOutput: 16_384,
26
+ releasedAt: '2024-05-13',
27
+ type: 'chat',
28
+ } as AiModelForSelect,
29
+ {
30
+ abilities: {
31
+ functionCall: true,
32
+ reasoning: false,
33
+ vision: true,
34
+ },
35
+ contextWindowTokens: 128_000,
36
+ displayName: 'GPT-4o Mini',
37
+ id: 'gpt-4o-mini',
38
+ maxOutput: 16_384,
39
+ releasedAt: '2024-07-18',
40
+ type: 'chat',
41
+ } as AiModelForSelect,
42
+ {
43
+ abilities: {
44
+ functionCall: true,
45
+ reasoning: true,
46
+ vision: false,
47
+ },
48
+ contextWindowTokens: 200_000,
49
+ displayName: 'o1',
50
+ id: 'o1',
51
+ maxOutput: 100_000,
52
+ releasedAt: '2024-12-17',
53
+ type: 'chat',
54
+ } as AiModelForSelect,
55
+ ],
56
+ id: 'openai',
57
+ logo: 'https://registry.npmmirror.com/@lobehub/icons-static-png/1.45.0/files/dark/openai.png',
58
+ name: 'OpenAI',
59
+ source: 'builtin',
60
+ },
61
+ {
62
+ children: [
63
+ {
64
+ // Same displayName as OpenAI's gpt-4o -> will create model-item-multiple
65
+ abilities: {
66
+ functionCall: true,
67
+ reasoning: false,
68
+ vision: true,
69
+ },
70
+ contextWindowTokens: 128_000,
71
+ displayName: 'GPT-4o',
72
+ id: 'gpt-4o',
73
+ maxOutput: 16_384,
74
+ type: 'chat',
75
+ } as AiModelForSelect,
76
+ {
77
+ // Same displayName as OpenAI's gpt-4o-mini -> will create model-item-multiple
78
+ abilities: {
79
+ functionCall: true,
80
+ reasoning: false,
81
+ vision: true,
82
+ },
83
+ contextWindowTokens: 128_000,
84
+ displayName: 'GPT-4o Mini',
85
+ id: 'gpt-4o-mini',
86
+ maxOutput: 16_384,
87
+ type: 'chat',
88
+ } as AiModelForSelect,
89
+ ],
90
+ id: 'azure',
91
+ logo: 'https://registry.npmmirror.com/@lobehub/icons-static-png/1.45.0/files/dark/azure.png',
92
+ name: 'Azure OpenAI',
93
+ source: 'builtin',
94
+ },
95
+ {
96
+ children: [
97
+ {
98
+ // Unique model -> will create model-item-single
99
+ abilities: {
100
+ functionCall: true,
101
+ reasoning: false,
102
+ vision: false,
103
+ },
104
+ contextWindowTokens: 128_000,
105
+ displayName: 'Llama 3.3 70B',
106
+ id: 'llama3.3:70b',
107
+ maxOutput: 8192,
108
+ type: 'chat',
109
+ } as AiModelForSelect,
110
+ {
111
+ abilities: {
112
+ functionCall: false,
113
+ reasoning: false,
114
+ vision: true,
115
+ },
116
+ contextWindowTokens: 128_000,
117
+ displayName: 'Llava',
118
+ id: 'llava:latest',
119
+ maxOutput: 4096,
120
+ type: 'chat',
121
+ } as AiModelForSelect,
122
+ ],
123
+ id: 'ollama',
124
+ logo: 'https://registry.npmmirror.com/@lobehub/icons-static-png/1.45.0/files/dark/ollama.png',
125
+ name: 'Ollama',
126
+ source: 'builtin',
127
+ },
128
+ {
129
+ children: [
130
+ {
131
+ // Same as OpenAI's o1 -> will create model-item-multiple
132
+ abilities: {
133
+ functionCall: true,
134
+ reasoning: true,
135
+ vision: false,
136
+ },
137
+ contextWindowTokens: 200_000,
138
+ displayName: 'o1',
139
+ id: 'o1',
140
+ maxOutput: 100_000,
141
+ type: 'chat',
142
+ } as AiModelForSelect,
143
+ ],
144
+ id: 'openrouter',
145
+ logo: 'https://registry.npmmirror.com/@lobehub/icons-static-png/1.45.0/files/dark/openrouter.png',
146
+ name: 'OpenRouter',
147
+ source: 'builtin',
148
+ },
149
+ ];
150
+
151
+ /**
152
+ * Expected result when groupMode = 'byModel':
153
+ *
154
+ * - GPT-4o (model-item-multiple) -> OpenAI, Azure
155
+ * - GPT-4o Mini (model-item-multiple) -> OpenAI, Azure
156
+ * - Llama 3.3 70B (model-item-single) -> Ollama
157
+ * - Llava (model-item-single) -> Ollama
158
+ * - o1 (model-item-multiple) -> OpenAI, OpenRouter
159
+ */
@@ -9,21 +9,22 @@ import urlJoin from 'url-join';
9
9
  import { ModelItemRender, ProviderItemRender } from '@/components/ModelSelect';
10
10
 
11
11
  import { styles } from '../../styles';
12
- import type { VirtualItem } from '../../types';
12
+ import type { ListItem } from '../../types';
13
13
  import { menuKey } from '../../utils';
14
14
  import { MultipleProvidersModelItem } from './MultipleProvidersModelItem';
15
15
  import { SingleProviderModelItem } from './SingleProviderModelItem';
16
16
 
17
- interface VirtualItemRendererProps {
17
+ interface ListItemRendererProps {
18
18
  activeKey: string;
19
- item: VirtualItem;
19
+ isScrolling: boolean;
20
+ item: ListItem;
20
21
  newLabel: string;
21
22
  onClose: () => void;
22
23
  onModelChange: (modelId: string, providerId: string) => Promise<void>;
23
24
  }
24
25
 
25
- export const VirtualItemRenderer = memo<VirtualItemRendererProps>(
26
- ({ activeKey, item, newLabel, onModelChange, onClose }) => {
26
+ export const ListItemRenderer = memo<ListItemRendererProps>(
27
+ ({ activeKey, isScrolling, item, newLabel, onModelChange, onClose }) => {
27
28
  const { t } = useTranslation('components');
28
29
  const navigate = useNavigate();
29
30
 
@@ -145,27 +146,16 @@ export const VirtualItemRenderer = memo<VirtualItemRendererProps>(
145
146
  }
146
147
 
147
148
  case 'model-item-multiple': {
148
- // Check if any provider of this model is active
149
- const activeProvider = item.data.providers.find(
150
- (p) => menuKey(p.id, item.data.model.id) === activeKey,
151
- );
152
- const isActive = !!activeProvider;
153
-
154
149
  return (
155
- <Block
156
- className={styles.menuItem}
157
- clickable
150
+ <MultipleProvidersModelItem
151
+ activeKey={activeKey}
152
+ data={item.data}
153
+ isScrolling={isScrolling}
158
154
  key={item.data.displayName}
159
- variant={isActive ? 'filled' : 'borderless'}
160
- >
161
- <MultipleProvidersModelItem
162
- activeKey={activeKey}
163
- data={item.data}
164
- newLabel={newLabel}
165
- onClose={onClose}
166
- onModelChange={onModelChange}
167
- />
168
- </Block>
155
+ newLabel={newLabel}
156
+ onClose={onClose}
157
+ onModelChange={onModelChange}
158
+ />
169
159
  );
170
160
  }
171
161
 
@@ -176,4 +166,4 @@ export const VirtualItemRenderer = memo<VirtualItemRendererProps>(
176
166
  },
177
167
  );
178
168
 
179
- VirtualItemRenderer.displayName = 'VirtualItemRenderer';
169
+ ListItemRenderer.displayName = 'ListItemRenderer';
@@ -1,6 +1,21 @@
1
- import { ActionIcon, type DropdownItem, DropdownMenu } from '@lobehub/ui';
1
+ import {
2
+ ActionIcon,
3
+ DropdownMenuGroup,
4
+ DropdownMenuGroupLabel,
5
+ DropdownMenuItem,
6
+ DropdownMenuItemExtra,
7
+ DropdownMenuItemIcon,
8
+ DropdownMenuItemLabel,
9
+ DropdownMenuPopup,
10
+ DropdownMenuPortal,
11
+ DropdownMenuPositioner,
12
+ DropdownMenuSubmenuRoot,
13
+ DropdownMenuSubmenuTrigger,
14
+ menuSharedStyles,
15
+ } from '@lobehub/ui';
16
+ import { cx } from 'antd-style';
2
17
  import { Check, LucideBolt } from 'lucide-react';
3
- import { memo, useMemo } from 'react';
18
+ import { memo, useEffect, useState } from 'react';
4
19
  import { useTranslation } from 'react-i18next';
5
20
  import { useNavigate } from 'react-router-dom';
6
21
  import urlJoin from 'url-join';
@@ -14,85 +29,96 @@ import { menuKey } from '../../utils';
14
29
  interface MultipleProvidersModelItemProps {
15
30
  activeKey: string;
16
31
  data: ModelWithProviders;
32
+ isScrolling: boolean;
17
33
  newLabel: string;
18
34
  onClose: () => void;
19
35
  onModelChange: (modelId: string, providerId: string) => Promise<void>;
20
36
  }
21
37
 
22
38
  export const MultipleProvidersModelItem = memo<MultipleProvidersModelItemProps>(
23
- ({ activeKey, data, newLabel, onModelChange, onClose }) => {
39
+ ({ activeKey, data, isScrolling, newLabel, onModelChange, onClose }) => {
24
40
  const { t } = useTranslation('components');
25
41
  const navigate = useNavigate();
42
+ const [submenuOpen, setSubmenuOpen] = useState(false);
26
43
 
27
- const items = useMemo(
28
- () =>
29
- [
30
- {
31
- key: 'header',
32
- label: t('ModelSwitchPanel.useModelFrom'),
33
- type: 'group',
34
- },
35
- ...data.providers.map((p) => {
36
- const key = menuKey(p.id, data.model.id);
44
+ useEffect(() => {
45
+ if (isScrolling) {
46
+ setSubmenuOpen(false);
47
+ }
48
+ }, [isScrolling]);
37
49
 
38
- return {
39
- extra: (
40
- <ActionIcon
41
- className={'settings-icon'}
42
- icon={LucideBolt}
43
- onClick={(e) => {
44
- e.preventDefault();
45
- e.stopPropagation();
46
- const url = urlJoin('/settings/provider', p.id || 'all');
47
- if (e.ctrlKey || e.metaKey) {
48
- window.open(url, '_blank');
49
- } else {
50
- navigate(url);
51
- }
52
- }}
53
- size={'small'}
54
- title={t('ModelSwitchPanel.goToSettings')}
55
- />
56
- ),
57
- icon: activeKey === key ? Check : undefined,
58
- key,
59
- label: (
60
- <ProviderItemRender
61
- logo={p.logo}
62
- name={p.name}
63
- provider={p.id}
64
- size={20}
65
- source={p.source}
66
- type={'avatar'}
67
- />
68
- ),
69
- onClick: async () => {
70
- onModelChange(data.model.id, p.id);
71
- onClose();
72
- },
73
- };
74
- }),
75
- ] as DropdownItem[],
76
- [activeKey, data.model.id, data.providers, navigate, onModelChange, onClose, t],
77
- );
50
+ const isActive = data.providers.some((p) => menuKey(p.id, data.model.id) === activeKey);
78
51
 
79
52
  return (
80
- <DropdownMenu
81
- items={items}
82
- placement="rightTop"
83
- popupProps={{ className: styles.dropdownMenu }}
84
- positionerProps={{
85
- alignOffset: -48,
86
- sideOffset: 12,
87
- }}
88
- >
89
- <ModelItemRender
90
- {...data.model}
91
- {...data.model.abilities}
92
- newBadgeLabel={newLabel}
93
- showInfoTag={true}
94
- />
95
- </DropdownMenu>
53
+ <DropdownMenuSubmenuRoot onOpenChange={setSubmenuOpen} open={submenuOpen}>
54
+ <DropdownMenuSubmenuTrigger
55
+ className={cx(menuSharedStyles.item, isActive && styles.menuItemActive)}
56
+ >
57
+ <ModelItemRender
58
+ {...data.model}
59
+ {...data.model.abilities}
60
+ newBadgeLabel={newLabel}
61
+ showInfoTag={true}
62
+ />
63
+ </DropdownMenuSubmenuTrigger>
64
+ <DropdownMenuPortal>
65
+ <DropdownMenuPositioner anchor={null} placement="rightTop" sideOffset={-4}>
66
+ <DropdownMenuPopup className={styles.dropdownMenu}>
67
+ <DropdownMenuGroup>
68
+ <DropdownMenuGroupLabel>
69
+ {t('ModelSwitchPanel.useModelFrom')}
70
+ </DropdownMenuGroupLabel>
71
+ {data.providers.map((p) => {
72
+ const key = menuKey(p.id, data.model.id);
73
+ const isProviderActive = activeKey === key;
74
+
75
+ return (
76
+ <DropdownMenuItem
77
+ key={key}
78
+ onClick={async () => {
79
+ await onModelChange(data.model.id, p.id);
80
+ onClose();
81
+ }}
82
+ >
83
+ <DropdownMenuItemIcon>
84
+ {isProviderActive ? <Check size={16} /> : null}
85
+ </DropdownMenuItemIcon>
86
+ <DropdownMenuItemLabel>
87
+ <ProviderItemRender
88
+ logo={p.logo}
89
+ name={p.name}
90
+ provider={p.id}
91
+ size={20}
92
+ source={p.source}
93
+ type={'avatar'}
94
+ />
95
+ </DropdownMenuItemLabel>
96
+ <DropdownMenuItemExtra>
97
+ <ActionIcon
98
+ className={'settings-icon'}
99
+ icon={LucideBolt}
100
+ onClick={(e) => {
101
+ e.preventDefault();
102
+ e.stopPropagation();
103
+ const url = urlJoin('/settings/provider', p.id || 'all');
104
+ if (e.ctrlKey || e.metaKey) {
105
+ window.open(url, '_blank');
106
+ } else {
107
+ navigate(url);
108
+ }
109
+ }}
110
+ size={'small'}
111
+ title={t('ModelSwitchPanel.goToSettings')}
112
+ />
113
+ </DropdownMenuItemExtra>
114
+ </DropdownMenuItem>
115
+ );
116
+ })}
117
+ </DropdownMenuGroup>
118
+ </DropdownMenuPopup>
119
+ </DropdownMenuPositioner>
120
+ </DropdownMenuPortal>
121
+ </DropdownMenuSubmenuRoot>
96
122
  );
97
123
  },
98
124
  );
@@ -1,29 +1,22 @@
1
1
  import { Flexbox, TooltipGroup } from '@lobehub/ui';
2
2
  import type { FC } from 'react';
3
- import { useMemo } from 'react';
3
+ import { useCallback, useMemo, useState } from 'react';
4
4
  import { useTranslation } from 'react-i18next';
5
+ import { Virtuoso } from 'react-virtuoso';
5
6
 
6
7
  import { useEnabledChatModels } from '@/hooks/useEnabledChatModels';
7
8
 
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';
9
+ import { FOOTER_HEIGHT, ITEM_HEIGHT, MAX_PANEL_HEIGHT, TOOLBAR_HEIGHT } from '../../const';
10
+ import { useBuildListItems } from '../../hooks/useBuildListItems';
17
11
  import { useModelAndProvider } from '../../hooks/useModelAndProvider';
18
12
  import { usePanelHandlers } from '../../hooks/usePanelHandlers';
19
13
  import { styles } from '../../styles';
20
14
  import type { GroupMode } from '../../types';
21
- import { getVirtualItemKey, menuKey } from '../../utils';
22
- import { VirtualItemRenderer } from './VirtualItemRenderer';
15
+ import { menuKey } from '../../utils';
16
+ import { ListItemRenderer } from './ListItemRenderer';
23
17
 
24
18
  interface ListProps {
25
19
  groupMode: GroupMode;
26
- isOpen: boolean;
27
20
  model?: string;
28
21
  onModelChange?: (params: { model: string; provider: string }) => Promise<void>;
29
22
  onOpenChange?: (open: boolean) => void;
@@ -33,7 +26,6 @@ interface ListProps {
33
26
 
34
27
  export const List: FC<ListProps> = ({
35
28
  groupMode,
36
- isOpen,
37
29
  model: modelProp,
38
30
  onModelChange: onModelChangeProp,
39
31
  onOpenChange,
@@ -43,25 +35,15 @@ export const List: FC<ListProps> = ({
43
35
  const { t: tCommon } = useTranslation('common');
44
36
  const newLabel = tCommon('new');
45
37
 
46
- // Get enabled models list
38
+ const [isScrolling, setIsScrolling] = useState(false);
47
39
  const enabledList = useEnabledChatModels();
48
-
49
- // Get delayed render state
50
- const renderAll = useDelayedRender(isOpen);
51
-
52
- // Get model and provider
53
40
  const { model, provider } = useModelAndProvider(modelProp, providerProp);
54
-
55
- // Get handlers
56
41
  const { handleModelChange, handleClose } = usePanelHandlers({
57
42
  onModelChange: onModelChangeProp,
58
43
  onOpenChange,
59
44
  });
45
+ const listItems = useBuildListItems(enabledList, groupMode, searchKeyword);
60
46
 
61
- // Build virtual items
62
- const virtualItems = useBuildVirtualItems(enabledList, groupMode, searchKeyword);
63
-
64
- // Calculate panel height
65
47
  const panelHeight = useMemo(
66
48
  () =>
67
49
  enabledList.length === 0
@@ -70,31 +52,48 @@ export const List: FC<ListProps> = ({
70
52
  [enabledList.length],
71
53
  );
72
54
 
73
- // Calculate active key
74
55
  const activeKey = menuKey(provider, model);
75
56
 
57
+ const handleScrollingStateChange = useCallback((scrolling: boolean) => {
58
+ setIsScrolling(scrolling);
59
+ }, []);
60
+
61
+ const itemContent = useCallback(
62
+ (index: number) => {
63
+ const item = listItems[index];
64
+ return (
65
+ <ListItemRenderer
66
+ activeKey={activeKey}
67
+ isScrolling={isScrolling}
68
+ item={item}
69
+ newLabel={newLabel}
70
+ onClose={handleClose}
71
+ onModelChange={handleModelChange}
72
+ />
73
+ );
74
+ },
75
+ [activeKey, handleClose, handleModelChange, isScrolling, listItems, newLabel],
76
+ );
77
+
78
+ const listHeight = panelHeight - TOOLBAR_HEIGHT - FOOTER_HEIGHT;
79
+
76
80
  return (
77
81
  <Flexbox
78
82
  className={styles.list}
79
83
  flex={1}
80
84
  style={{
81
- height: panelHeight - TOOLBAR_HEIGHT - FOOTER_HEIGHT,
85
+ height: listHeight,
82
86
  paddingBlock: groupMode === 'byModel' ? 8 : 0,
83
87
  }}
84
88
  >
85
89
  <TooltipGroup>
86
- {virtualItems
87
- .slice(0, renderAll ? virtualItems.length : INITIAL_RENDER_COUNT)
88
- .map((item) => (
89
- <VirtualItemRenderer
90
- activeKey={activeKey}
91
- item={item}
92
- key={getVirtualItemKey(item)}
93
- newLabel={newLabel}
94
- onClose={handleClose}
95
- onModelChange={handleModelChange}
96
- />
97
- ))}
90
+ <Virtuoso
91
+ isScrolling={handleScrollingStateChange}
92
+ itemContent={itemContent}
93
+ overscan={200}
94
+ style={{ height: listHeight }}
95
+ totalCount={listItems.length}
96
+ />
98
97
  </TooltipGroup>
99
98
  </Flexbox>
100
99
  );
@@ -13,7 +13,6 @@ import { List } from './List';
13
13
  import { Toolbar } from './Toolbar';
14
14
 
15
15
  interface PanelContentProps {
16
- isOpen: boolean;
17
16
  model?: string;
18
17
  onModelChange?: (params: { model: string; provider: string }) => Promise<void>;
19
18
  onOpenChange?: (open: boolean) => void;
@@ -21,19 +20,13 @@ interface PanelContentProps {
21
20
  }
22
21
 
23
22
  export const PanelContent: FC<PanelContentProps> = ({
24
- isOpen,
25
23
  model: modelProp,
26
24
  onModelChange: onModelChangeProp,
27
25
  onOpenChange,
28
26
  provider: providerProp,
29
27
  }) => {
30
- // Get enabled models list
31
28
  const enabledList = useEnabledChatModels();
32
-
33
- // Search keyword state
34
29
  const [searchKeyword, setSearchKeyword] = useState('');
35
-
36
- // Hooks for state management
37
30
  const { groupMode, handleGroupModeChange } = usePanelState();
38
31
  const { panelHeight, panelWidth, handlePanelWidthChange } = usePanelSize(enabledList.length);
39
32
  const { handleClose } = usePanelHandlers({
@@ -62,7 +55,6 @@ export const PanelContent: FC<PanelContentProps> = ({
62
55
  />
63
56
  <List
64
57
  groupMode={groupMode}
65
- isOpen={isOpen}
66
58
  model={modelProp}
67
59
  onModelChange={onModelChangeProp}
68
60
  onOpenChange={onOpenChange}
@@ -2,26 +2,25 @@ import { useMemo } from 'react';
2
2
 
3
3
  import type { EnabledProviderWithModels } from '@/types/aiProvider';
4
4
 
5
- import type { GroupMode, ModelWithProviders, VirtualItem } from '../types';
5
+ import type { GroupMode, ListItem, ModelWithProviders } from '../types';
6
6
 
7
- export const useBuildVirtualItems = (
7
+ export const useBuildListItems = (
8
8
  enabledList: EnabledProviderWithModels[],
9
9
  groupMode: GroupMode,
10
10
  searchKeyword: string = '',
11
- ): VirtualItem[] => {
11
+ ): ListItem[] => {
12
12
  return useMemo(() => {
13
13
  if (enabledList.length === 0) {
14
- return [{ type: 'no-provider' }] as VirtualItem[];
14
+ return [{ type: 'no-provider' }] as ListItem[];
15
15
  }
16
16
 
17
- // Filter function for search
18
17
  const matchesSearch = (text: string): boolean => {
19
18
  if (!searchKeyword.trim()) return true;
20
19
  const keyword = searchKeyword.toLowerCase().trim();
21
20
  return text.toLowerCase().includes(keyword);
22
21
  };
23
22
 
24
- // Sort providers: lobehub first, then others
23
+ // lobehub first, then others
25
24
  const sortedProviders = [...enabledList].sort((a, b) => {
26
25
  const aIsLobehub = a.id === 'lobehub';
27
26
  const bIsLobehub = b.id === 'lobehub';
@@ -31,14 +30,12 @@ export const useBuildVirtualItems = (
31
30
  });
32
31
 
33
32
  if (groupMode === 'byModel') {
34
- // Group models by display name
35
33
  const modelMap = new Map<string, ModelWithProviders>();
36
34
 
37
35
  for (const providerItem of sortedProviders) {
38
36
  for (const modelItem of providerItem.children) {
39
37
  const displayName = modelItem.displayName || modelItem.id;
40
38
 
41
- // Filter by search keyword
42
39
  if (!matchesSearch(displayName) && !matchesSearch(providerItem.name)) {
43
40
  continue;
44
41
  }
@@ -61,7 +58,7 @@ export const useBuildVirtualItems = (
61
58
  }
62
59
  }
63
60
 
64
- // Sort providers within each model: lobehub first
61
+ // lobehub first
65
62
  const modelArray = Array.from(modelMap.values());
66
63
  for (const model of modelArray) {
67
64
  model.providers.sort((a, b) => {
@@ -73,7 +70,6 @@ export const useBuildVirtualItems = (
73
70
  });
74
71
  }
75
72
 
76
- // Convert to array and sort by display name
77
73
  return modelArray
78
74
  .sort((a, b) => a.displayName.localeCompare(b.displayName))
79
75
  .map((data) => ({
@@ -84,27 +80,21 @@ export const useBuildVirtualItems = (
84
80
  : ('model-item-multiple' as const),
85
81
  }));
86
82
  } else {
87
- // Group by provider (original structure)
88
- const items: VirtualItem[] = [];
83
+ const items: ListItem[] = [];
89
84
 
90
85
  for (const providerItem of sortedProviders) {
91
- // Filter models by search keyword
92
86
  const filteredModels = providerItem.children.filter(
93
87
  (modelItem) =>
94
88
  matchesSearch(modelItem.displayName || modelItem.id) ||
95
89
  matchesSearch(providerItem.name),
96
90
  );
97
91
 
98
- // Only add provider group header if there are matching models or if search is empty
99
92
  if (filteredModels.length > 0 || !searchKeyword.trim()) {
100
- // Add provider group header
101
93
  items.push({ provider: providerItem, type: 'group-header' });
102
94
 
103
95
  if (filteredModels.length === 0) {
104
- // Add empty model placeholder
105
96
  items.push({ provider: providerItem, type: 'empty-model' });
106
97
  } else {
107
- // Add each filtered model item
108
98
  for (const modelItem of filteredModels) {
109
99
  items.push({
110
100
  model: modelItem,