mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-27 13:29:15 +07:00
♻️ 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:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
10
package.json
10
package.json
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
|
||||
159
src/features/ModelSwitchPanel/__mocks__/mockEnabledChatModels.ts
Normal file
159
src/features/ModelSwitchPanel/__mocks__/mockEnabledChatModels.ts
Normal 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
|
||||
*/
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -69,6 +69,9 @@ export const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
}
|
||||
}
|
||||
`,
|
||||
menuItemActive: css`
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
`,
|
||||
toolbar: css`
|
||||
border-block-end: 1px solid ${cssVar.colorBorderSecondary};
|
||||
`,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user