diff --git a/CLAUDE.md b/CLAUDE.md index 17d52a87d8..0405695113 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/apps/desktop/src/main/core/browser/Browser.ts b/apps/desktop/src/main/core/browser/Browser.ts index a8aaff616f..198f54b942 100644 --- a/apps/desktop/src/main/core/browser/Browser.ts +++ b/apps/desktop/src/main/core/browser/Browser.ts @@ -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 { diff --git a/package.json b/package.json index 50468eff92..4655a92482 100644 --- a/package.json +++ b/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", diff --git a/src/app/[variants]/(main)/settings/provider/features/ProviderConfig/Checker.tsx b/src/app/[variants]/(main)/settings/provider/features/ProviderConfig/Checker.tsx index 196e6cf2bc..7a64747ed8 100644 --- a/src/app/[variants]/(main)/settings/provider/features/ProviderConfig/Checker.tsx +++ b/src/app/[variants]/(main)/settings/provider/features/ProviderConfig/Checker.tsx @@ -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'; diff --git a/src/features/ModelSwitchPanel/__mocks__/mockEnabledChatModels.ts b/src/features/ModelSwitchPanel/__mocks__/mockEnabledChatModels.ts new file mode 100644 index 0000000000..181278115c --- /dev/null +++ b/src/features/ModelSwitchPanel/__mocks__/mockEnabledChatModels.ts @@ -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 + */ diff --git a/src/features/ModelSwitchPanel/components/List/VirtualItemRenderer.tsx b/src/features/ModelSwitchPanel/components/List/ListItemRenderer.tsx similarity index 82% rename from src/features/ModelSwitchPanel/components/List/VirtualItemRenderer.tsx rename to src/features/ModelSwitchPanel/components/List/ListItemRenderer.tsx index b8fb884871..fe82fa06f7 100644 --- a/src/features/ModelSwitchPanel/components/List/VirtualItemRenderer.tsx +++ b/src/features/ModelSwitchPanel/components/List/ListItemRenderer.tsx @@ -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; } -export const VirtualItemRenderer = memo( - ({ activeKey, item, newLabel, onModelChange, onClose }) => { +export const ListItemRenderer = memo( + ({ activeKey, isScrolling, item, newLabel, onModelChange, onClose }) => { const { t } = useTranslation('components'); const navigate = useNavigate(); @@ -145,27 +146,16 @@ export const VirtualItemRenderer = memo( } 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 ( - - - + newLabel={newLabel} + onClose={onClose} + onModelChange={onModelChange} + /> ); } @@ -176,4 +166,4 @@ export const VirtualItemRenderer = memo( }, ); -VirtualItemRenderer.displayName = 'VirtualItemRenderer'; +ListItemRenderer.displayName = 'ListItemRenderer'; diff --git a/src/features/ModelSwitchPanel/components/List/MultipleProvidersModelItem.tsx b/src/features/ModelSwitchPanel/components/List/MultipleProvidersModelItem.tsx index a9319d7def..63c6f57fef 100644 --- a/src/features/ModelSwitchPanel/components/List/MultipleProvidersModelItem.tsx +++ b/src/features/ModelSwitchPanel/components/List/MultipleProvidersModelItem.tsx @@ -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; } export const MultipleProvidersModelItem = memo( - ({ 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: ( - { - 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: ( - - ), - 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 ( - - - + + + + + + + + + + {t('ModelSwitchPanel.useModelFrom')} + + {data.providers.map((p) => { + const key = menuKey(p.id, data.model.id); + const isProviderActive = activeKey === key; + + return ( + { + await onModelChange(data.model.id, p.id); + onClose(); + }} + > + + {isProviderActive ? : null} + + + + + + { + 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')} + /> + + + ); + })} + + + + + ); }, ); diff --git a/src/features/ModelSwitchPanel/components/List/index.tsx b/src/features/ModelSwitchPanel/components/List/index.tsx index 0c87c633df..251417cffe 100644 --- a/src/features/ModelSwitchPanel/components/List/index.tsx +++ b/src/features/ModelSwitchPanel/components/List/index.tsx @@ -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; onOpenChange?: (open: boolean) => void; @@ -33,7 +26,6 @@ interface ListProps { export const List: FC = ({ groupMode, - isOpen, model: modelProp, onModelChange: onModelChangeProp, onOpenChange, @@ -43,25 +35,15 @@ export const List: FC = ({ 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 = ({ [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 ( + + ); + }, + [activeKey, handleClose, handleModelChange, isScrolling, listItems, newLabel], + ); + + const listHeight = panelHeight - TOOLBAR_HEIGHT - FOOTER_HEIGHT; + return ( - {virtualItems - .slice(0, renderAll ? virtualItems.length : INITIAL_RENDER_COUNT) - .map((item) => ( - - ))} + ); diff --git a/src/features/ModelSwitchPanel/components/PanelContent.tsx b/src/features/ModelSwitchPanel/components/PanelContent.tsx index 431bd7545d..ab212c2a22 100644 --- a/src/features/ModelSwitchPanel/components/PanelContent.tsx +++ b/src/features/ModelSwitchPanel/components/PanelContent.tsx @@ -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; onOpenChange?: (open: boolean) => void; @@ -21,19 +20,13 @@ interface PanelContentProps { } export const PanelContent: FC = ({ - 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 = ({ /> { +): 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(); 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, diff --git a/src/features/ModelSwitchPanel/index.tsx b/src/features/ModelSwitchPanel/index.tsx index bfe4f3b75b..f6bbfd1731 100644 --- a/src/features/ModelSwitchPanel/index.tsx +++ b/src/features/ModelSwitchPanel/index.tsx @@ -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( 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( ); return ( - - } - nativeButton={false} - onOpenChange={handleOpenChange} - open={isOpen} - placement={placement} - > - {children} - + + + {children} + + + + + + + + + ); }, ); diff --git a/src/features/ModelSwitchPanel/styles.ts b/src/features/ModelSwitchPanel/styles.ts index 54f7f1d1a4..e9855b1b94 100644 --- a/src/features/ModelSwitchPanel/styles.ts +++ b/src/features/ModelSwitchPanel/styles.ts @@ -69,6 +69,9 @@ export const styles = createStaticStyles(({ css, cssVar }) => ({ } } `, + menuItemActive: css` + background: ${cssVar.colorFillTertiary}; + `, toolbar: css` border-block-end: 1px solid ${cssVar.colorBorderSecondary}; `, diff --git a/src/features/ModelSwitchPanel/types.ts b/src/features/ModelSwitchPanel/types.ts index 3d3f2c7742..4c785d7710 100644 --- a/src/features/ModelSwitchPanel/types.ts +++ b/src/features/ModelSwitchPanel/types.ts @@ -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; diff --git a/src/features/ModelSwitchPanel/utils.ts b/src/features/ModelSwitchPanel/utils.ts index 74189014c4..9b4c854454 100644 --- a/src/features/ModelSwitchPanel/utils.ts +++ b/src/features/ModelSwitchPanel/utils.ts @@ -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': { diff --git a/src/styles/global.ts b/src/styles/global.ts index c1e0a5ab0f..322f12071a 100644 --- a/src/styles/global.ts +++ b/src/styles/global.ts @@ -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; + } `; diff --git a/src/utils/router.tsx b/src/utils/router.tsx index 8f6af4fb5a..83b5bd2cc0 100644 --- a/src/utils/router.tsx +++ b/src/utils/router.tsx @@ -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;