♻️ refactor(ModelSwitchPanel): migrate from Popover to DropdownMenu with virtual scrolling (#11663)

* ♻️ refactor(ModelSwitchPanel): migrate from Popover to DropdownMenu with virtual scrolling

- Replace Popover with DropdownMenu atom components from @lobehub/ui
- Add react-virtuoso for proper virtual scrolling implementation
- Auto-close submenu when scrolling to prevent position offset issues
- Rename misleading "Virtual*" naming to "List*" for clarity

LOBE-3844

* 🔨 chore: clean up unnecessary comments in ModelSwitchPanel

* 🔨 chore(router): remove unused loader property from route configuration

Signed-off-by: Innei <tukon479@gmail.com>

---------

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2026-01-20 23:07:56 +08:00
committed by GitHub
parent cf5320e27f
commit c9d9dff635
16 changed files with 404 additions and 200 deletions

View File

@@ -32,6 +32,10 @@ This repository adopts a monorepo structure.
see @.cursor/rules/typescript.mdc
### Code Comments
- **Avoid meaningless comments**: Do not write comments that merely restate what the code does. Comments should explain _why_ something is done, not _what_ is being done. The code itself should be self-explanatory.
### Testing
- **Required Rule**: read `.cursor/rules/testing-guide/testing-guide.mdc` before writing tests

View File

@@ -3,6 +3,7 @@ import { MainBroadcastEventKey, MainBroadcastParams } from '@lobechat/electron-c
import {
BrowserWindow,
BrowserWindowConstructorOptions,
Menu,
session as electronSession,
ipcMain,
screen,
@@ -11,7 +12,7 @@ import console from 'node:console';
import { join } from 'node:path';
import { preloadDir, resourcesDir } from '@/const/dir';
import { isMac } from '@/const/env';
import { isDev, isMac } from '@/const/env';
import { ELECTRON_BE_PROTOCOL_SCHEME } from '@/const/protocol';
import RemoteServerConfigCtr from '@/controllers/RemoteServerConfigCtr';
import { backendProxyProtocolManager } from '@/core/infrastructure/BackendProxyProtocolManager';
@@ -191,6 +192,7 @@ export default class Browser {
this.setupCloseListener(browserWindow);
this.setupFocusListener(browserWindow);
this.setupWillPreventUnloadListener(browserWindow);
this.setupDevContextMenu(browserWindow);
}
private setupWillPreventUnloadListener(browserWindow: BrowserWindow): void {
@@ -236,6 +238,43 @@ export default class Browser {
});
}
/**
* Setup context menu with "Inspect Element" option in development mode
*/
private setupDevContextMenu(browserWindow: BrowserWindow): void {
if (!isDev) return;
logger.debug(`[${this.identifier}] Setting up dev context menu.`);
browserWindow.webContents.on('context-menu', (_event, params) => {
const { x, y } = params;
const menu = Menu.buildFromTemplate([
{
click: () => {
browserWindow.webContents.inspectElement(x, y);
},
label: 'Inspect Element',
},
{ type: 'separator' },
{
click: () => {
browserWindow.webContents.openDevTools();
},
label: 'Open DevTools',
},
{
click: () => {
browserWindow.webContents.reload();
},
label: 'Reload',
},
]);
menu.popup({ window: browserWindow });
});
}
// ==================== Window Actions ====================
show(): void {

View File

@@ -35,12 +35,12 @@
"prebuild": "tsx scripts/prebuild.mts && npm run lint",
"build": "cross-env NODE_OPTIONS=--max-old-space-size=8192 next build --webpack",
"postbuild": "npm run build-sitemap && npm run build-migrate-db",
"build-migrate-db": "bun run db:migrate",
"build-sitemap": "tsx ./scripts/buildSitemapIndex/index.ts",
"build:analyze": "NODE_OPTIONS=--max-old-space-size=81920 ANALYZE=true next build --webpack",
"build:docker": "npm run prebuild && NODE_OPTIONS=--max-old-space-size=8192 DOCKER=true next build --webpack && npm run build-sitemap",
"build:electron": "cross-env NODE_OPTIONS=--max-old-space-size=8192 NEXT_PUBLIC_IS_DESKTOP_APP=1 tsx scripts/electronWorkflow/buildNextApp.mts",
"build:vercel": "npm run prebuild && cross-env NODE_OPTIONS=--max-old-space-size=6144 next build --webpack && npm run postbuild",
"build-migrate-db": "bun run db:migrate",
"build-sitemap": "tsx ./scripts/buildSitemapIndex/index.ts",
"clean:node_modules": "bash -lc 'set -e; echo \"Removing all node_modules...\"; rm -rf node_modules; pnpm -r exec rm -rf node_modules; rm -rf apps/desktop/node_modules; echo \"All node_modules removed.\"'",
"db:generate": "drizzle-kit generate && npm run workflow:dbml",
"db:migrate": "MIGRATION_DB=1 tsx ./scripts/migrateServerDB/index.ts",
@@ -87,11 +87,11 @@
"start": "next start -p 3210",
"stylelint": "stylelint \"src/**/*.{js,jsx,ts,tsx}\" --fix",
"test": "npm run test-app && npm run test-server",
"test-app": "vitest run",
"test-app:coverage": "vitest --coverage --silent='passed-only'",
"test:e2e": "pnpm --filter @lobechat/e2e-tests test",
"test:e2e:smoke": "pnpm --filter @lobechat/e2e-tests test:smoke",
"test:update": "vitest -u",
"test-app": "vitest run",
"test-app:coverage": "vitest --coverage --silent='passed-only'",
"tunnel:cloudflare": "cloudflared tunnel --url http://localhost:3010",
"tunnel:ngrok": "ngrok http http://localhost:3011",
"type-check": "tsgo --noEmit",
@@ -207,7 +207,7 @@
"@lobehub/icons": "^4.0.2",
"@lobehub/market-sdk": "0.29.1",
"@lobehub/tts": "^4.0.2",
"@lobehub/ui": "^4.22.0",
"@lobehub/ui": "^4.24.0",
"@modelcontextprotocol/sdk": "^1.25.1",
"@neondatabase/serverless": "^1.0.2",
"@next/third-parties": "^16.1.1",

View File

@@ -3,7 +3,7 @@
import { CheckCircleFilled } from '@ant-design/icons';
import { type ChatMessageError, TraceNameMap } from '@lobechat/types';
import { ModelIcon } from '@lobehub/icons';
import { Alert, Button, Flexbox, Highlighter, Icon, Select } from '@lobehub/ui';
import { Alert, Button, Flexbox, Highlighter, Icon, LobeSelect as Select } from '@lobehub/ui';
import { cssVar } from 'antd-style';
import { Loader2Icon } from 'lucide-react';
import { type ReactNode, memo, useEffect, useState } from 'react';

View File

@@ -0,0 +1,159 @@
import type { AiModelForSelect } from 'model-bank';
import type { EnabledProviderWithModels } from '@/types/aiProvider';
/**
* Mock data for testing ModelSwitchPanel
*
* This data includes:
* - Multiple providers (OpenAI, Azure, Ollama)
* - Same model provided by multiple providers (gpt-4o -> model-item-multiple)
* - Single provider model (llama3 -> model-item-single)
*/
export const mockEnabledChatModels: EnabledProviderWithModels[] = [
{
children: [
{
abilities: {
functionCall: true,
reasoning: false,
vision: true,
},
contextWindowTokens: 128_000,
displayName: 'GPT-4o',
id: 'gpt-4o',
maxOutput: 16_384,
releasedAt: '2024-05-13',
type: 'chat',
} as AiModelForSelect,
{
abilities: {
functionCall: true,
reasoning: false,
vision: true,
},
contextWindowTokens: 128_000,
displayName: 'GPT-4o Mini',
id: 'gpt-4o-mini',
maxOutput: 16_384,
releasedAt: '2024-07-18',
type: 'chat',
} as AiModelForSelect,
{
abilities: {
functionCall: true,
reasoning: true,
vision: false,
},
contextWindowTokens: 200_000,
displayName: 'o1',
id: 'o1',
maxOutput: 100_000,
releasedAt: '2024-12-17',
type: 'chat',
} as AiModelForSelect,
],
id: 'openai',
logo: 'https://registry.npmmirror.com/@lobehub/icons-static-png/1.45.0/files/dark/openai.png',
name: 'OpenAI',
source: 'builtin',
},
{
children: [
{
// Same displayName as OpenAI's gpt-4o -> will create model-item-multiple
abilities: {
functionCall: true,
reasoning: false,
vision: true,
},
contextWindowTokens: 128_000,
displayName: 'GPT-4o',
id: 'gpt-4o',
maxOutput: 16_384,
type: 'chat',
} as AiModelForSelect,
{
// Same displayName as OpenAI's gpt-4o-mini -> will create model-item-multiple
abilities: {
functionCall: true,
reasoning: false,
vision: true,
},
contextWindowTokens: 128_000,
displayName: 'GPT-4o Mini',
id: 'gpt-4o-mini',
maxOutput: 16_384,
type: 'chat',
} as AiModelForSelect,
],
id: 'azure',
logo: 'https://registry.npmmirror.com/@lobehub/icons-static-png/1.45.0/files/dark/azure.png',
name: 'Azure OpenAI',
source: 'builtin',
},
{
children: [
{
// Unique model -> will create model-item-single
abilities: {
functionCall: true,
reasoning: false,
vision: false,
},
contextWindowTokens: 128_000,
displayName: 'Llama 3.3 70B',
id: 'llama3.3:70b',
maxOutput: 8192,
type: 'chat',
} as AiModelForSelect,
{
abilities: {
functionCall: false,
reasoning: false,
vision: true,
},
contextWindowTokens: 128_000,
displayName: 'Llava',
id: 'llava:latest',
maxOutput: 4096,
type: 'chat',
} as AiModelForSelect,
],
id: 'ollama',
logo: 'https://registry.npmmirror.com/@lobehub/icons-static-png/1.45.0/files/dark/ollama.png',
name: 'Ollama',
source: 'builtin',
},
{
children: [
{
// Same as OpenAI's o1 -> will create model-item-multiple
abilities: {
functionCall: true,
reasoning: true,
vision: false,
},
contextWindowTokens: 200_000,
displayName: 'o1',
id: 'o1',
maxOutput: 100_000,
type: 'chat',
} as AiModelForSelect,
],
id: 'openrouter',
logo: 'https://registry.npmmirror.com/@lobehub/icons-static-png/1.45.0/files/dark/openrouter.png',
name: 'OpenRouter',
source: 'builtin',
},
];
/**
* Expected result when groupMode = 'byModel':
*
* - GPT-4o (model-item-multiple) -> OpenAI, Azure
* - GPT-4o Mini (model-item-multiple) -> OpenAI, Azure
* - Llama 3.3 70B (model-item-single) -> Ollama
* - Llava (model-item-single) -> Ollama
* - o1 (model-item-multiple) -> OpenAI, OpenRouter
*/

View File

@@ -9,21 +9,22 @@ import urlJoin from 'url-join';
import { ModelItemRender, ProviderItemRender } from '@/components/ModelSelect';
import { styles } from '../../styles';
import type { VirtualItem } from '../../types';
import type { ListItem } from '../../types';
import { menuKey } from '../../utils';
import { MultipleProvidersModelItem } from './MultipleProvidersModelItem';
import { SingleProviderModelItem } from './SingleProviderModelItem';
interface VirtualItemRendererProps {
interface ListItemRendererProps {
activeKey: string;
item: VirtualItem;
isScrolling: boolean;
item: ListItem;
newLabel: string;
onClose: () => void;
onModelChange: (modelId: string, providerId: string) => Promise<void>;
}
export const VirtualItemRenderer = memo<VirtualItemRendererProps>(
({ activeKey, item, newLabel, onModelChange, onClose }) => {
export const ListItemRenderer = memo<ListItemRendererProps>(
({ activeKey, isScrolling, item, newLabel, onModelChange, onClose }) => {
const { t } = useTranslation('components');
const navigate = useNavigate();
@@ -145,27 +146,16 @@ export const VirtualItemRenderer = memo<VirtualItemRendererProps>(
}
case 'model-item-multiple': {
// Check if any provider of this model is active
const activeProvider = item.data.providers.find(
(p) => menuKey(p.id, item.data.model.id) === activeKey,
);
const isActive = !!activeProvider;
return (
<Block
className={styles.menuItem}
clickable
<MultipleProvidersModelItem
activeKey={activeKey}
data={item.data}
isScrolling={isScrolling}
key={item.data.displayName}
variant={isActive ? 'filled' : 'borderless'}
>
<MultipleProvidersModelItem
activeKey={activeKey}
data={item.data}
newLabel={newLabel}
onClose={onClose}
onModelChange={onModelChange}
/>
</Block>
newLabel={newLabel}
onClose={onClose}
onModelChange={onModelChange}
/>
);
}
@@ -176,4 +166,4 @@ export const VirtualItemRenderer = memo<VirtualItemRendererProps>(
},
);
VirtualItemRenderer.displayName = 'VirtualItemRenderer';
ListItemRenderer.displayName = 'ListItemRenderer';

View File

@@ -1,6 +1,21 @@
import { ActionIcon, type DropdownItem, DropdownMenu } from '@lobehub/ui';
import {
ActionIcon,
DropdownMenuGroup,
DropdownMenuGroupLabel,
DropdownMenuItem,
DropdownMenuItemExtra,
DropdownMenuItemIcon,
DropdownMenuItemLabel,
DropdownMenuPopup,
DropdownMenuPortal,
DropdownMenuPositioner,
DropdownMenuSubmenuRoot,
DropdownMenuSubmenuTrigger,
menuSharedStyles,
} from '@lobehub/ui';
import { cx } from 'antd-style';
import { Check, LucideBolt } from 'lucide-react';
import { memo, useMemo } from 'react';
import { memo, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import urlJoin from 'url-join';
@@ -14,85 +29,96 @@ import { menuKey } from '../../utils';
interface MultipleProvidersModelItemProps {
activeKey: string;
data: ModelWithProviders;
isScrolling: boolean;
newLabel: string;
onClose: () => void;
onModelChange: (modelId: string, providerId: string) => Promise<void>;
}
export const MultipleProvidersModelItem = memo<MultipleProvidersModelItemProps>(
({ activeKey, data, newLabel, onModelChange, onClose }) => {
({ activeKey, data, isScrolling, newLabel, onModelChange, onClose }) => {
const { t } = useTranslation('components');
const navigate = useNavigate();
const [submenuOpen, setSubmenuOpen] = useState(false);
const items = useMemo(
() =>
[
{
key: 'header',
label: t('ModelSwitchPanel.useModelFrom'),
type: 'group',
},
...data.providers.map((p) => {
const key = menuKey(p.id, data.model.id);
useEffect(() => {
if (isScrolling) {
setSubmenuOpen(false);
}
}, [isScrolling]);
return {
extra: (
<ActionIcon
className={'settings-icon'}
icon={LucideBolt}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
const url = urlJoin('/settings/provider', p.id || 'all');
if (e.ctrlKey || e.metaKey) {
window.open(url, '_blank');
} else {
navigate(url);
}
}}
size={'small'}
title={t('ModelSwitchPanel.goToSettings')}
/>
),
icon: activeKey === key ? Check : undefined,
key,
label: (
<ProviderItemRender
logo={p.logo}
name={p.name}
provider={p.id}
size={20}
source={p.source}
type={'avatar'}
/>
),
onClick: async () => {
onModelChange(data.model.id, p.id);
onClose();
},
};
}),
] as DropdownItem[],
[activeKey, data.model.id, data.providers, navigate, onModelChange, onClose, t],
);
const isActive = data.providers.some((p) => menuKey(p.id, data.model.id) === activeKey);
return (
<DropdownMenu
items={items}
placement="rightTop"
popupProps={{ className: styles.dropdownMenu }}
positionerProps={{
alignOffset: -48,
sideOffset: 12,
}}
>
<ModelItemRender
{...data.model}
{...data.model.abilities}
newBadgeLabel={newLabel}
showInfoTag={true}
/>
</DropdownMenu>
<DropdownMenuSubmenuRoot onOpenChange={setSubmenuOpen} open={submenuOpen}>
<DropdownMenuSubmenuTrigger
className={cx(menuSharedStyles.item, isActive && styles.menuItemActive)}
>
<ModelItemRender
{...data.model}
{...data.model.abilities}
newBadgeLabel={newLabel}
showInfoTag={true}
/>
</DropdownMenuSubmenuTrigger>
<DropdownMenuPortal>
<DropdownMenuPositioner anchor={null} placement="rightTop" sideOffset={-4}>
<DropdownMenuPopup className={styles.dropdownMenu}>
<DropdownMenuGroup>
<DropdownMenuGroupLabel>
{t('ModelSwitchPanel.useModelFrom')}
</DropdownMenuGroupLabel>
{data.providers.map((p) => {
const key = menuKey(p.id, data.model.id);
const isProviderActive = activeKey === key;
return (
<DropdownMenuItem
key={key}
onClick={async () => {
await onModelChange(data.model.id, p.id);
onClose();
}}
>
<DropdownMenuItemIcon>
{isProviderActive ? <Check size={16} /> : null}
</DropdownMenuItemIcon>
<DropdownMenuItemLabel>
<ProviderItemRender
logo={p.logo}
name={p.name}
provider={p.id}
size={20}
source={p.source}
type={'avatar'}
/>
</DropdownMenuItemLabel>
<DropdownMenuItemExtra>
<ActionIcon
className={'settings-icon'}
icon={LucideBolt}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
const url = urlJoin('/settings/provider', p.id || 'all');
if (e.ctrlKey || e.metaKey) {
window.open(url, '_blank');
} else {
navigate(url);
}
}}
size={'small'}
title={t('ModelSwitchPanel.goToSettings')}
/>
</DropdownMenuItemExtra>
</DropdownMenuItem>
);
})}
</DropdownMenuGroup>
</DropdownMenuPopup>
</DropdownMenuPositioner>
</DropdownMenuPortal>
</DropdownMenuSubmenuRoot>
);
},
);

View File

@@ -1,29 +1,22 @@
import { Flexbox, TooltipGroup } from '@lobehub/ui';
import type { FC } from 'react';
import { useMemo } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Virtuoso } from 'react-virtuoso';
import { useEnabledChatModels } from '@/hooks/useEnabledChatModels';
import {
FOOTER_HEIGHT,
INITIAL_RENDER_COUNT,
ITEM_HEIGHT,
MAX_PANEL_HEIGHT,
TOOLBAR_HEIGHT,
} from '../../const';
import { useBuildVirtualItems } from '../../hooks/useBuildVirtualItems';
import { useDelayedRender } from '../../hooks/useDelayedRender';
import { FOOTER_HEIGHT, ITEM_HEIGHT, MAX_PANEL_HEIGHT, TOOLBAR_HEIGHT } from '../../const';
import { useBuildListItems } from '../../hooks/useBuildListItems';
import { useModelAndProvider } from '../../hooks/useModelAndProvider';
import { usePanelHandlers } from '../../hooks/usePanelHandlers';
import { styles } from '../../styles';
import type { GroupMode } from '../../types';
import { getVirtualItemKey, menuKey } from '../../utils';
import { VirtualItemRenderer } from './VirtualItemRenderer';
import { menuKey } from '../../utils';
import { ListItemRenderer } from './ListItemRenderer';
interface ListProps {
groupMode: GroupMode;
isOpen: boolean;
model?: string;
onModelChange?: (params: { model: string; provider: string }) => Promise<void>;
onOpenChange?: (open: boolean) => void;
@@ -33,7 +26,6 @@ interface ListProps {
export const List: FC<ListProps> = ({
groupMode,
isOpen,
model: modelProp,
onModelChange: onModelChangeProp,
onOpenChange,
@@ -43,25 +35,15 @@ export const List: FC<ListProps> = ({
const { t: tCommon } = useTranslation('common');
const newLabel = tCommon('new');
// Get enabled models list
const [isScrolling, setIsScrolling] = useState(false);
const enabledList = useEnabledChatModels();
// Get delayed render state
const renderAll = useDelayedRender(isOpen);
// Get model and provider
const { model, provider } = useModelAndProvider(modelProp, providerProp);
// Get handlers
const { handleModelChange, handleClose } = usePanelHandlers({
onModelChange: onModelChangeProp,
onOpenChange,
});
const listItems = useBuildListItems(enabledList, groupMode, searchKeyword);
// Build virtual items
const virtualItems = useBuildVirtualItems(enabledList, groupMode, searchKeyword);
// Calculate panel height
const panelHeight = useMemo(
() =>
enabledList.length === 0
@@ -70,31 +52,48 @@ export const List: FC<ListProps> = ({
[enabledList.length],
);
// Calculate active key
const activeKey = menuKey(provider, model);
const handleScrollingStateChange = useCallback((scrolling: boolean) => {
setIsScrolling(scrolling);
}, []);
const itemContent = useCallback(
(index: number) => {
const item = listItems[index];
return (
<ListItemRenderer
activeKey={activeKey}
isScrolling={isScrolling}
item={item}
newLabel={newLabel}
onClose={handleClose}
onModelChange={handleModelChange}
/>
);
},
[activeKey, handleClose, handleModelChange, isScrolling, listItems, newLabel],
);
const listHeight = panelHeight - TOOLBAR_HEIGHT - FOOTER_HEIGHT;
return (
<Flexbox
className={styles.list}
flex={1}
style={{
height: panelHeight - TOOLBAR_HEIGHT - FOOTER_HEIGHT,
height: listHeight,
paddingBlock: groupMode === 'byModel' ? 8 : 0,
}}
>
<TooltipGroup>
{virtualItems
.slice(0, renderAll ? virtualItems.length : INITIAL_RENDER_COUNT)
.map((item) => (
<VirtualItemRenderer
activeKey={activeKey}
item={item}
key={getVirtualItemKey(item)}
newLabel={newLabel}
onClose={handleClose}
onModelChange={handleModelChange}
/>
))}
<Virtuoso
isScrolling={handleScrollingStateChange}
itemContent={itemContent}
overscan={200}
style={{ height: listHeight }}
totalCount={listItems.length}
/>
</TooltipGroup>
</Flexbox>
);

View File

@@ -13,7 +13,6 @@ import { List } from './List';
import { Toolbar } from './Toolbar';
interface PanelContentProps {
isOpen: boolean;
model?: string;
onModelChange?: (params: { model: string; provider: string }) => Promise<void>;
onOpenChange?: (open: boolean) => void;
@@ -21,19 +20,13 @@ interface PanelContentProps {
}
export const PanelContent: FC<PanelContentProps> = ({
isOpen,
model: modelProp,
onModelChange: onModelChangeProp,
onOpenChange,
provider: providerProp,
}) => {
// Get enabled models list
const enabledList = useEnabledChatModels();
// Search keyword state
const [searchKeyword, setSearchKeyword] = useState('');
// Hooks for state management
const { groupMode, handleGroupModeChange } = usePanelState();
const { panelHeight, panelWidth, handlePanelWidthChange } = usePanelSize(enabledList.length);
const { handleClose } = usePanelHandlers({
@@ -62,7 +55,6 @@ export const PanelContent: FC<PanelContentProps> = ({
/>
<List
groupMode={groupMode}
isOpen={isOpen}
model={modelProp}
onModelChange={onModelChangeProp}
onOpenChange={onOpenChange}

View File

@@ -2,26 +2,25 @@ import { useMemo } from 'react';
import type { EnabledProviderWithModels } from '@/types/aiProvider';
import type { GroupMode, ModelWithProviders, VirtualItem } from '../types';
import type { GroupMode, ListItem, ModelWithProviders } from '../types';
export const useBuildVirtualItems = (
export const useBuildListItems = (
enabledList: EnabledProviderWithModels[],
groupMode: GroupMode,
searchKeyword: string = '',
): VirtualItem[] => {
): ListItem[] => {
return useMemo(() => {
if (enabledList.length === 0) {
return [{ type: 'no-provider' }] as VirtualItem[];
return [{ type: 'no-provider' }] as ListItem[];
}
// Filter function for search
const matchesSearch = (text: string): boolean => {
if (!searchKeyword.trim()) return true;
const keyword = searchKeyword.toLowerCase().trim();
return text.toLowerCase().includes(keyword);
};
// Sort providers: lobehub first, then others
// lobehub first, then others
const sortedProviders = [...enabledList].sort((a, b) => {
const aIsLobehub = a.id === 'lobehub';
const bIsLobehub = b.id === 'lobehub';
@@ -31,14 +30,12 @@ export const useBuildVirtualItems = (
});
if (groupMode === 'byModel') {
// Group models by display name
const modelMap = new Map<string, ModelWithProviders>();
for (const providerItem of sortedProviders) {
for (const modelItem of providerItem.children) {
const displayName = modelItem.displayName || modelItem.id;
// Filter by search keyword
if (!matchesSearch(displayName) && !matchesSearch(providerItem.name)) {
continue;
}
@@ -61,7 +58,7 @@ export const useBuildVirtualItems = (
}
}
// Sort providers within each model: lobehub first
// lobehub first
const modelArray = Array.from(modelMap.values());
for (const model of modelArray) {
model.providers.sort((a, b) => {
@@ -73,7 +70,6 @@ export const useBuildVirtualItems = (
});
}
// Convert to array and sort by display name
return modelArray
.sort((a, b) => a.displayName.localeCompare(b.displayName))
.map((data) => ({
@@ -84,27 +80,21 @@ export const useBuildVirtualItems = (
: ('model-item-multiple' as const),
}));
} else {
// Group by provider (original structure)
const items: VirtualItem[] = [];
const items: ListItem[] = [];
for (const providerItem of sortedProviders) {
// Filter models by search keyword
const filteredModels = providerItem.children.filter(
(modelItem) =>
matchesSearch(modelItem.displayName || modelItem.id) ||
matchesSearch(providerItem.name),
);
// Only add provider group header if there are matching models or if search is empty
if (filteredModels.length > 0 || !searchKeyword.trim()) {
// Add provider group header
items.push({ provider: providerItem, type: 'group-header' });
if (filteredModels.length === 0) {
// Add empty model placeholder
items.push({ provider: providerItem, type: 'empty-model' });
} else {
// Add each filtered model item
for (const modelItem of filteredModels) {
items.push({
model: modelItem,

View File

@@ -1,4 +1,10 @@
import { Popover } from '@lobehub/ui';
import {
DropdownMenuPopup,
DropdownMenuPortal,
DropdownMenuPositioner,
DropdownMenuRoot,
DropdownMenuTrigger,
} from '@lobehub/ui';
import { memo, useCallback, useState } from 'react';
import { PanelContent } from './components/PanelContent';
@@ -16,8 +22,6 @@ const ModelSwitchPanel = memo<ModelSwitchPanelProps>(
provider: providerProp,
}) => {
const [internalOpen, setInternalOpen] = useState(false);
// Use controlled open if provided, otherwise use internal state
const isOpen = open ?? internalOpen;
const handleOpenChange = useCallback(
@@ -29,26 +33,23 @@ const ModelSwitchPanel = memo<ModelSwitchPanelProps>(
);
return (
<Popover
classNames={{
content: styles.container,
}}
content={
<PanelContent
isOpen={isOpen}
model={modelProp}
onModelChange={onModelChange}
onOpenChange={handleOpenChange}
provider={providerProp}
/>
}
nativeButton={false}
onOpenChange={handleOpenChange}
open={isOpen}
placement={placement}
>
{children}
</Popover>
<DropdownMenuRoot onOpenChange={handleOpenChange} open={isOpen}>
<DropdownMenuTrigger nativeButton={false} openOnHover>
{children}
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuPositioner hoverTrigger placement={placement}>
<DropdownMenuPopup className={styles.container}>
<PanelContent
model={modelProp}
onModelChange={onModelChange}
onOpenChange={handleOpenChange}
provider={providerProp}
/>
</DropdownMenuPopup>
</DropdownMenuPositioner>
</DropdownMenuPortal>
</DropdownMenuRoot>
);
},
);

View File

@@ -69,6 +69,9 @@ export const styles = createStaticStyles(({ css, cssVar }) => ({
}
}
`,
menuItemActive: css`
background: ${cssVar.colorFillTertiary};
`,
toolbar: css`
border-block-end: 1px solid ${cssVar.colorBorderSecondary};
`,

View File

@@ -1,3 +1,4 @@
import type { DropdownMenuPlacement } from '@lobehub/ui';
import type { AiModelForSelect } from 'model-bank';
import type { ReactNode } from 'react';
@@ -16,7 +17,7 @@ export interface ModelWithProviders {
}>;
}
export type VirtualItem =
export type ListItem =
| {
data: ModelWithProviders;
type: 'model-item-single';
@@ -42,13 +43,7 @@ export type VirtualItem =
type: 'no-provider';
};
export type DropdownPlacement =
| 'bottom'
| 'bottomLeft'
| 'bottomRight'
| 'top'
| 'topLeft'
| 'topRight';
export type DropdownPlacement = DropdownMenuPlacement;
export interface ModelSwitchPanelProps {
children?: ReactNode;

View File

@@ -1,8 +1,8 @@
import type { VirtualItem } from './types';
import type { ListItem } from './types';
export const menuKey = (provider: string, model: string) => `${provider}-${model}`;
export const getVirtualItemKey = (item: VirtualItem): string => {
export const getListItemKey = (item: ListItem): string => {
switch (item.type) {
case 'model-item-single':
case 'model-item-multiple': {

View File

@@ -59,4 +59,10 @@ export default ({ token }: { prefixCls: string; token: Theme }) => css`
.${CLASSNAMES.DropdownMenuTrigger}[data-popup-open]:not([data-no-highlight]) {
background: ${token.colorFillTertiary};
}
.ant-form-item-control:has([role='combobox'][aria-controls^='base-ui-']),
.ant-form-item-control:has([role='combobox'][aria-haspopup='listbox']) {
width: min(70%, 800px);
min-width: min(70%, 800px) !important;
}
`;

View File

@@ -145,7 +145,7 @@ export interface RouteConfig {
*/
export function renderRoutes(routes: RouteConfig[]): ReactElement[] {
return routes.map((route, index) => {
const { path, element, children, index: isIndex, loader } = route;
const { path, element, children, index: isIndex } = route;
const childRoutes = children ? renderRoutes(children) : undefined;