🐛 fix: fix editor modal and refactor ModelSwitchPanel (#11273)

* fix: fix editor modal

* style: update modelSwitchPanel
This commit is contained in:
CanisMinor
2026-01-06 15:30:21 +08:00
committed by GitHub
parent 5277650dc6
commit 0c57ec427f
29 changed files with 1237 additions and 871 deletions

View File

@@ -99,6 +99,7 @@
"ModelSwitchPanel.goToSettings": "前往设置",
"ModelSwitchPanel.manageProvider": "管理提供商",
"ModelSwitchPanel.provider": "提供方",
"ModelSwitchPanel.searchPlaceholder": "搜索模型...",
"ModelSwitchPanel.title": "模型",
"ModelSwitchPanel.useModelFrom": "使用此模型来自:",
"MultiImagesUpload.actions.uploadMore": "点击或拖拽上传更多",

View File

@@ -1,6 +1,6 @@
import { type ChatModelCard } from '@lobechat/types';
import { type IconAvatarProps, ModelIcon, ProviderIcon } from '@lobehub/icons';
import { Avatar, Flexbox, Icon, Tag, Text, Tooltip } from '@lobehub/ui';
import { type IconAvatarProps, LobeHub, ModelIcon, ProviderIcon } from '@lobehub/icons';
import { Avatar, Flexbox, FlexboxProps, Icon, Tag, Text, Tooltip } from '@lobehub/ui';
import { createStaticStyles, useResponsive } from 'antd-style';
import {
Infinity,
@@ -14,7 +14,7 @@ import {
} from 'lucide-react';
import { type ModelAbilities } from 'model-bank';
import numeral from 'numeral';
import { type ComponentProps, type FC, memo, useState } from 'react';
import { CSSProperties, type ComponentProps, type FC, memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { type AiProviderSourceType } from '@/types/aiProvider';
@@ -64,6 +64,7 @@ interface ModelInfoTagsProps extends ModelAbilities {
directionReverse?: boolean;
isCustom?: boolean;
placement?: 'top' | 'right';
style?: CSSProperties;
/**
* Whether to render tooltip overlays for each tag.
* Disable this when rendering a large list (e.g. dropdown menus) to avoid mounting hundreds of Tooltip instances.
@@ -237,13 +238,13 @@ const Context = memo(
);
export const ModelInfoTags = memo<ModelInfoTagsProps>(
({ directionReverse, placement = 'top', withTooltip = true, ...model }) => {
({ directionReverse, placement = 'top', withTooltip = true, style, ...model }) => {
return (
<Flexbox
className={TAG_CLASSNAME}
direction={directionReverse ? 'horizontal-reverse' : 'horizontal'}
gap={4}
style={{ marginLeft: 'auto' }}
gap={2}
style={{ marginLeft: 'auto', ...style }}
width={'fit-content'}
>
<FeatureTags
@@ -271,7 +272,7 @@ export const ModelInfoTags = memo<ModelInfoTagsProps>(
},
);
interface ModelItemRenderProps extends ChatModelCard {
interface ModelItemRenderProps extends ChatModelCard, Partial<Omit<FlexboxProps, 'id' | 'title'>> {
abilities?: ModelAbilities;
infoTagTooltip?: boolean;
/**
@@ -286,10 +287,9 @@ interface ModelItemRenderProps extends ChatModelCard {
showInfoTag?: boolean;
}
export const ModelItemRender = memo<ModelItemRenderProps>(({ showInfoTag = true, ...model }) => {
const { mobile } = useResponsive();
const [hovered, setHovered] = useState(false);
const {
export const ModelItemRender = memo<ModelItemRenderProps>(
({
showInfoTag = true,
abilities,
infoTagTooltip = true,
infoTagTooltipOnHover = false,
@@ -302,88 +302,119 @@ export const ModelItemRender = memo<ModelItemRenderProps>(({ showInfoTag = true,
search,
video,
vision,
} = model;
id,
displayName,
releasedAt,
...rest
}) => {
const { mobile } = useResponsive();
const [hovered, setHovered] = useState(false);
const shouldLazyMountTooltip = infoTagTooltipOnHover && !mobile;
/**
* When `infoTagTooltipOnHover` is enabled, we don't mount Tooltip components until the row is hovered.
* This avoids creating many overlays on dropdown open, while keeping the tooltip UX on demand.
*/
const withTooltip = infoTagTooltip && (!shouldLazyMountTooltip || hovered);
const displayName = model.displayName || model.id;
const shouldLazyMountTooltip = infoTagTooltipOnHover && !mobile;
/**
* When `infoTagTooltipOnHover` is enabled, we don't mount Tooltip components until the row is hovered.
* This avoids creating many overlays on dropdown open, while keeping the tooltip UX on demand.
*/
const withTooltip = infoTagTooltip && (!shouldLazyMountTooltip || hovered);
const displayNameOrId = displayName || id;
return (
<Flexbox
align={'center'}
gap={32}
horizontal
justify={'space-between'}
onMouseEnter={shouldLazyMountTooltip && !hovered ? () => setHovered(true) : undefined}
style={{
overflow: 'hidden',
position: 'relative',
width: '100%',
}}
>
return (
<Flexbox
align={'center'}
gap={8}
gap={32}
horizontal
style={{ flexShrink: 1, minWidth: 0, overflow: 'hidden' }}
justify={'space-between'}
onMouseEnter={shouldLazyMountTooltip && !hovered ? () => setHovered(true) : undefined}
{...rest}
style={{
overflow: 'hidden',
position: 'relative',
width: '100%',
...rest.style,
}}
>
<ModelIcon model={model.id} size={20} />
<Text
ellipsis={
withTooltip
? {
tooltip: displayName,
}
: true
}
style={mobile ? { maxWidth: '60vw' } : { minWidth: 0, overflow: 'hidden' }}
<Flexbox
align={'center'}
gap={8}
horizontal
style={{ flexShrink: 1, minWidth: 0, overflow: 'hidden' }}
>
{displayName}
</Text>
{newBadgeLabel ? (
<NewModelBadgeCore label={newBadgeLabel} releasedAt={model.releasedAt} />
) : (
<NewModelBadgeI18n releasedAt={model.releasedAt} />
<ModelIcon model={id} size={20} />
<Text
ellipsis={
withTooltip
? {
tooltip: displayNameOrId,
}
: true
}
style={mobile ? { maxWidth: '60vw' } : { minWidth: 0, overflow: 'hidden' }}
>
{displayNameOrId}
</Text>
{newBadgeLabel ? (
<NewModelBadgeCore label={newBadgeLabel} releasedAt={releasedAt} />
) : (
<NewModelBadgeI18n releasedAt={releasedAt} />
)}
</Flexbox>
{showInfoTag && (
<ModelInfoTags
contextWindowTokens={contextWindowTokens}
files={files ?? abilities?.files}
functionCall={functionCall ?? abilities?.functionCall}
imageOutput={imageOutput ?? abilities?.imageOutput}
reasoning={reasoning ?? abilities?.reasoning}
search={search ?? abilities?.search}
style={{ zoom: 0.9 }}
video={video ?? abilities?.video}
vision={vision ?? abilities?.vision}
withTooltip={withTooltip}
/>
)}
</Flexbox>
{showInfoTag && (
<ModelInfoTags
contextWindowTokens={contextWindowTokens}
files={files ?? abilities?.files}
functionCall={functionCall ?? abilities?.functionCall}
imageOutput={imageOutput ?? abilities?.imageOutput}
reasoning={reasoning ?? abilities?.reasoning}
search={search ?? abilities?.search}
video={video ?? abilities?.video}
vision={vision ?? abilities?.vision}
withTooltip={withTooltip}
/>
)}
</Flexbox>
);
});
);
},
);
interface ProviderItemRenderProps {
logo?: string;
name: string;
provider: string;
size?: number;
source?: AiProviderSourceType;
type?: 'mono' | 'color' | 'avatar';
}
export const ProviderItemRender = memo<ProviderItemRenderProps>(
({ provider, name, source, logo }) => {
({ provider, name, source, logo, type = 'mono', size = 16 }) => {
const isMono = type === 'mono';
return (
<Flexbox align={'center'} gap={4} horizontal>
<Flexbox
align={'center'}
gap={6}
horizontal
style={{
overflow: 'hidden',
}}
width={'100%'}
>
{source === 'custom' && !!logo ? (
<Avatar avatar={logo} size={20} style={{ filter: 'grayscale(1)' }} title={name} />
<Avatar
avatar={logo}
shape={'circle'}
size={size}
style={isMono ? { filter: 'grayscale(1)' } : {}}
title={name}
/>
) : provider === 'lobehub' ? (
<LobeHub.Morden size={size} />
) : (
<ProviderIcon provider={provider} size={20} type={'mono'} />
<ProviderIcon provider={provider} size={size} type={type} />
)}
{name}
<Text color={'inherit'} ellipsis>
{name}
</Text>
</Flexbox>
);
},

View File

@@ -1,7 +1,6 @@
import { Flexbox } from '@lobehub/ui';
import { MessageInput } from '@lobehub/ui/chat';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { memo } from 'react';
import { EditorModal } from '@/features/EditorModal';
import { useConversationStore } from '../../../store';
@@ -11,40 +10,24 @@ export interface EditStateProps {
}
const EditState = memo<EditStateProps>(({ id, content }) => {
const { t } = useTranslation('common');
const text = useMemo(
() => ({
cancel: t('cancel'),
confirm: t('ok'),
edit: t('edit'),
}),
[],
);
const [toggleMessageEditing, updateMessageContent] = useConversationStore((s) => [
s.toggleMessageEditing,
s.modifyMessageContent,
]);
const onEditingChange = (value: string) => {
updateMessageContent(id, value);
toggleMessageEditing(id, false);
};
return (
<Flexbox paddingBlock={'0 8px'}>
<MessageInput
defaultValue={content ? String(content) : ''}
editButtonSize={'small'}
onCancel={() => {
toggleMessageEditing(id, false);
}}
onConfirm={onEditingChange}
text={text}
variant={'outlined'}
/>
</Flexbox>
<EditorModal
onCancel={() => {
toggleMessageEditing(id, false);
}}
onConfirm={async (value) => {
if (!id) return;
await updateMessageContent(id, value);
toggleMessageEditing(id, false);
}}
open={!!id}
value={content ? String(content) : ''}
/>
);
});

View File

@@ -2,7 +2,8 @@
import type { AssistantContentBlock } from '@lobechat/types';
import isEqual from 'fast-deep-equal';
import { type MouseEventHandler, memo, useCallback, useMemo } from 'react';
import dynamic from 'next/dynamic';
import { type MouseEventHandler, Suspense, memo, useCallback, useMemo } from 'react';
import { MESSAGE_ACTION_BAR_PORTAL_ATTRIBUTES } from '@/const/messageActionPortal';
import { ChatItem } from '@/features/Conversation/ChatItem';
@@ -21,9 +22,12 @@ import {
import FileListViewer from '../User/components/FileListViewer';
import Usage from '../components/Extras/Usage';
import MessageBranch from '../components/MessageBranch';
import EditState from './components/EditState';
import Group from './components/Group';
const EditState = dynamic(() => import('./components/EditState'), {
ssr: false,
});
const actionBarHolder = (
<div
{...{ [MESSAGE_ACTION_BAR_PORTAL_ATTRIBUTES.assistantGroup]: '' }}
@@ -92,11 +96,6 @@ const GroupMessage = memo<GroupMessageProps>(({ id, index, disableEditing, isLat
}
}, [isInbox]);
// If editing, show edit state
if (editing && contentId) {
return <EditState content={lastAssistantMsg?.content} id={contentId} />;
}
return (
<ChatItem
actions={
@@ -139,6 +138,9 @@ const GroupMessage = memo<GroupMessageProps>(({ id, index, disableEditing, isLat
{model && (
<Usage model={model} performance={performance} provider={provider!} usage={usage} />
)}
<Suspense fallback={null}>
{editing && contentId && <EditState content={lastAssistantMsg?.content} id={contentId} />}
</Suspense>
</ChatItem>
);
}, isEqual);

View File

@@ -1,4 +1,5 @@
import {
IEditor,
ReactCodePlugin,
ReactCodemirrorPlugin,
ReactHRPlugin,
@@ -7,72 +8,52 @@ import {
ReactMathPlugin,
ReactTablePlugin,
} from '@lobehub/editor';
import { Editor, useEditor } from '@lobehub/editor/react';
import { Editor } from '@lobehub/editor/react';
import { Flexbox } from '@lobehub/ui';
import { FC } from 'react';
import TypoBar from './Typobar';
interface EditorCanvasProps {
onChange?: (value: string) => void;
value?: string;
defaultValue?: string;
editor?: IEditor;
}
const EditorCanvas: FC<EditorCanvasProps> = ({ value, onChange }) => {
const editor = useEditor();
const EditorCanvas: FC<EditorCanvasProps> = ({ defaultValue, editor }) => {
return (
<>
<TypoBar editor={editor} />
<Flexbox
onClick={() => {
editor?.focus();
}}
padding={16}
style={{ cursor: 'text', maxHeight: '80vh', minHeight: '50vh', overflowY: 'auto' }}
>
<div
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
<Editor
autoFocus
content={''}
editor={editor}
onInit={(editor) => {
if (!editor || !defaultValue) return;
try {
editor?.setDocument('markdown', defaultValue);
} catch (e) {
console.error('setDocument error:', e);
}
}}
>
<Editor
autoFocus
content={''}
editor={editor}
onInit={(editor) => {
if (!editor || !value) return;
try {
editor?.setDocument('markdown', value);
} catch (e) {
console.error('setDocument error:', e);
}
}}
onTextChange={(editor) => {
try {
const newValue = editor.getDocument('markdown') as unknown as string;
onChange?.(newValue);
} catch (e) {
console.error('getDocument error:', e);
onChange?.('');
}
}}
plugins={[
ReactListPlugin,
ReactCodePlugin,
ReactCodemirrorPlugin,
ReactHRPlugin,
ReactLinkPlugin,
ReactTablePlugin,
ReactMathPlugin,
]}
style={{
paddingBottom: 120,
}}
type={'text'}
variant={'chat'}
/>
</div>
plugins={[
ReactListPlugin,
ReactCodePlugin,
ReactCodemirrorPlugin,
ReactHRPlugin,
ReactLinkPlugin,
ReactTablePlugin,
ReactMathPlugin,
]}
style={{
paddingBottom: 120,
}}
type={'text'}
variant={'chat'}
/>
</Flexbox>
</>
);

View File

@@ -2,13 +2,15 @@ import { TextArea } from '@lobehub/ui';
import { FC } from 'react';
interface EditorCanvasProps {
defaultValue?: string;
onChange?: (value: string) => void;
value?: string;
}
const EditorCanvas: FC<EditorCanvasProps> = ({ value, onChange }) => {
const EditorCanvas: FC<EditorCanvasProps> = ({ defaultValue, value, onChange }) => {
return (
<TextArea
defaultValue={defaultValue}
onChange={(e) => {
onChange?.(e.target.value);
}}

View File

@@ -1,3 +1,4 @@
import { useEditor } from '@lobehub/editor/react';
import { Modal, ModalProps, createRawModal } from '@lobehub/ui';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -18,8 +19,7 @@ export const EditorModal = memo<EditorModalProps>(({ value, onConfirm, ...rest }
const { t } = useTranslation('common');
const [v, setV] = useState(value);
const enableRichRender = useUserStore(labPreferSelectors.enableInputMarkdown);
const Canvas = enableRichRender ? EditorCanvas : TextareCanvas;
const editor = useEditor();
return (
<Modal
@@ -30,7 +30,13 @@ export const EditorModal = memo<EditorModalProps>(({ value, onConfirm, ...rest }
okText={t('ok')}
onOk={async () => {
setConfirmLoading(true);
await onConfirm?.(v || '');
let finalValue;
if (enableRichRender) {
finalValue = editor?.getDocument('markdown') as unknown as string;
} else {
finalValue = v;
}
await onConfirm?.(finalValue || '');
setConfirmLoading(false);
}}
styles={{
@@ -43,7 +49,11 @@ export const EditorModal = memo<EditorModalProps>(({ value, onConfirm, ...rest }
width={'min(90vw, 920px)'}
{...rest}
>
<Canvas onChange={(v) => setV(v)} value={v} />
{enableRichRender ? (
<EditorCanvas defaultValue={value} editor={editor} />
) : (
<TextareCanvas defaultValue={value} onChange={(v) => setV(v)} value={v} />
)}
</Modal>
);
});

View File

@@ -0,0 +1,42 @@
import { Block, Flexbox, Icon } from '@lobehub/ui';
import { cssVar } from 'antd-style';
import { LucideArrowRight, LucideBolt } from 'lucide-react';
import type { FC } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { styles } from '../styles';
interface FooterProps {
onClose: () => void;
}
export const Footer: FC<FooterProps> = ({ onClose }) => {
const { t } = useTranslation('components');
const navigate = useNavigate();
return (
<Flexbox className={styles.footer} padding={4}>
<Block
clickable
gap={8}
horizontal
onClick={() => {
navigate('/settings/provider/all');
onClose();
}}
paddingBlock={8}
paddingInline={12}
variant={'borderless'}
>
<Flexbox align={'center'} gap={8} horizontal style={{ flex: 1 }}>
<Icon icon={LucideBolt} size={'small'} />
{t('ModelSwitchPanel.manageProvider')}
</Flexbox>
<Icon color={cssVar.colorTextDescription} icon={LucideArrowRight} size={'small'} />
</Block>
</Flexbox>
);
};
Footer.displayName = 'Footer';

View File

@@ -0,0 +1,103 @@
import { ActionIcon, Dropdown } from '@lobehub/ui';
import { MenuItemType } from 'antd/es/menu/interface';
import { LucideBolt } from 'lucide-react';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import urlJoin from 'url-join';
import { ModelItemRender, ProviderItemRender } from '@/components/ModelSelect';
import { styles } from '../../styles';
import type { ModelWithProviders } from '../../types';
import { menuKey } from '../../utils';
interface MultipleProvidersModelItemProps {
activeKey: string;
data: ModelWithProviders;
newLabel: string;
onClose: () => void;
onModelChange: (modelId: string, providerId: string) => Promise<void>;
}
export const MultipleProvidersModelItem = memo<MultipleProvidersModelItemProps>(
({ activeKey, data, newLabel, onModelChange, onClose }) => {
const { t } = useTranslation('components');
const navigate = useNavigate();
const items = useMemo(
() =>
[
{
key: 'header',
label: t('ModelSwitchPanel.useModelFrom'),
type: 'group',
},
...data.providers.map((p) => {
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')}
/>
),
key: menuKey(p.id, data.model.id),
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 MenuItemType[],
[data.model.id, data.providers, navigate, onModelChange, onClose, t],
);
return (
<Dropdown
align={{ offset: [12, -48] }}
arrow={false}
classNames={{
item: styles.menuItem,
}}
menu={{
items,
selectedKeys: [activeKey],
}}
// @ts-ignore
placement="rightTop"
>
<ModelItemRender
{...data.model}
{...data.model.abilities}
infoTagTooltip={false}
newBadgeLabel={newLabel}
showInfoTag={true}
/>
</Dropdown>
);
},
);
MultipleProvidersModelItem.displayName = 'MultipleProvidersModelItem';

View File

@@ -0,0 +1,24 @@
import { memo } from 'react';
import { ModelItemRender } from '@/components/ModelSelect';
import type { ModelWithProviders } from '../../types';
interface SingleProviderModelItemProps {
data: ModelWithProviders;
newLabel: string;
}
export const SingleProviderModelItem = memo<SingleProviderModelItemProps>(({ data, newLabel }) => {
return (
<ModelItemRender
{...data.model}
{...data.model.abilities}
infoTagTooltip={false}
newBadgeLabel={newLabel}
showInfoTag={true}
/>
);
});
SingleProviderModelItem.displayName = 'SingleProviderModelItem';

View File

@@ -0,0 +1,180 @@
import { ActionIcon, Block, Flexbox, Icon } from '@lobehub/ui';
import { cssVar } from 'antd-style';
import { LucideArrowRight, LucideBolt } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import urlJoin from 'url-join';
import { ModelItemRender, ProviderItemRender } from '@/components/ModelSelect';
import { styles } from '../../styles';
import type { VirtualItem } from '../../types';
import { menuKey } from '../../utils';
import { MultipleProvidersModelItem } from './MultipleProvidersModelItem';
import { SingleProviderModelItem } from './SingleProviderModelItem';
interface VirtualItemRendererProps {
activeKey: string;
item: VirtualItem;
newLabel: string;
onClose: () => void;
onModelChange: (modelId: string, providerId: string) => Promise<void>;
}
export const VirtualItemRenderer = memo<VirtualItemRendererProps>(
({ activeKey, item, newLabel, onModelChange, onClose }) => {
const { t } = useTranslation('components');
const navigate = useNavigate();
switch (item.type) {
case 'no-provider': {
return (
<Block
className={styles.menuItem}
clickable
gap={8}
horizontal
key="no-provider"
onClick={() => navigate('/settings/provider/all')}
style={{ color: cssVar.colorTextTertiary }}
variant={'borderless'}
>
{t('ModelSwitchPanel.emptyProvider')}
<Icon icon={LucideArrowRight} />
</Block>
);
}
case 'group-header': {
return (
<Flexbox
className={styles.groupHeader}
horizontal
justify="space-between"
key={`header-${item.provider.id}`}
paddingBlock={'12px 4px'}
paddingInline={'12px 8px'}
>
<ProviderItemRender
logo={item.provider.logo}
name={item.provider.name}
provider={item.provider.id}
source={item.provider.source}
/>
<ActionIcon
className="settings-icon"
icon={LucideBolt}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
const url = urlJoin('/settings/provider', item.provider.id || 'all');
if (e.ctrlKey || e.metaKey) {
window.open(url, '_blank');
} else {
navigate(url);
}
}}
size={'small'}
title={t('ModelSwitchPanel.goToSettings')}
/>
</Flexbox>
);
}
case 'empty-model': {
return (
<Flexbox
className={styles.menuItem}
gap={8}
horizontal
key={`empty-${item.provider.id}`}
onClick={() => navigate(`/settings/provider/${item.provider.id}`)}
style={{ color: cssVar.colorTextTertiary }}
>
{t('ModelSwitchPanel.emptyModel')}
<Icon icon={LucideArrowRight} />
</Flexbox>
);
}
case 'provider-model-item': {
const key = menuKey(item.provider.id, item.model.id);
const isActive = key === activeKey;
return (
<Block
className={styles.menuItem}
clickable
key={key}
onClick={async () => {
onModelChange(item.model.id, item.provider.id);
onClose();
}}
variant={isActive ? 'filled' : 'borderless'}
>
<ModelItemRender
{...item.model}
{...item.model.abilities}
infoTagTooltip={false}
newBadgeLabel={newLabel}
showInfoTag
/>
</Block>
);
}
case 'model-item-single': {
const singleProvider = item.data.providers[0];
const key = menuKey(singleProvider.id, item.data.model.id);
const isActive = key === activeKey;
return (
<Block
className={styles.menuItem}
clickable
key={key}
onClick={async () => {
onModelChange(item.data.model.id, singleProvider.id);
onClose();
}}
variant={isActive ? 'filled' : 'borderless'}
>
<SingleProviderModelItem data={item.data} newLabel={newLabel} />
</Block>
);
}
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
key={item.data.displayName}
variant={isActive ? 'filled' : 'borderless'}
>
<MultipleProvidersModelItem
activeKey={activeKey}
data={item.data}
newLabel={newLabel}
onClose={onClose}
onModelChange={onModelChange}
/>
</Block>
);
}
default: {
return null;
}
}
},
);
VirtualItemRenderer.displayName = 'VirtualItemRenderer';

View File

@@ -0,0 +1,99 @@
import { Flexbox } from '@lobehub/ui';
import type { FC } from 'react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
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 { 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';
interface ListProps {
groupMode: GroupMode;
isOpen: boolean;
model?: string;
onModelChange?: (params: { model: string; provider: string }) => Promise<void>;
onOpenChange?: (open: boolean) => void;
provider?: string;
searchKeyword?: string;
}
export const List: FC<ListProps> = ({
groupMode,
isOpen,
model: modelProp,
onModelChange: onModelChangeProp,
onOpenChange,
provider: providerProp,
searchKeyword = '',
}) => {
const { t: tCommon } = useTranslation('common');
const newLabel = tCommon('new');
// Get enabled models list
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,
});
// Build virtual items
const virtualItems = useBuildVirtualItems(enabledList, groupMode, searchKeyword);
// Calculate panel height
const panelHeight = useMemo(
() =>
enabledList.length === 0
? TOOLBAR_HEIGHT + ITEM_HEIGHT['no-provider'] + FOOTER_HEIGHT
: MAX_PANEL_HEIGHT,
[enabledList.length],
);
// Calculate active key
const activeKey = menuKey(provider, model);
return (
<Flexbox
className={styles.list}
flex={1}
style={{
height: panelHeight - TOOLBAR_HEIGHT - FOOTER_HEIGHT,
paddingBlock: groupMode === 'byModel' ? 8 : 0,
}}
>
{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}
/>
))}
</Flexbox>
);
};
List.displayName = 'List';

View File

@@ -0,0 +1,77 @@
import type { FC } from 'react';
import { useState } from 'react';
import { Rnd } from 'react-rnd';
import { useEnabledChatModels } from '@/hooks/useEnabledChatModels';
import { ENABLE_RESIZING, MAX_WIDTH, MIN_WIDTH } from '../const';
import { usePanelHandlers } from '../hooks/usePanelHandlers';
import { usePanelSize } from '../hooks/usePanelSize';
import { usePanelState } from '../hooks/usePanelState';
import { Footer } from './Footer';
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;
provider?: string;
}
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({
onModelChange: onModelChangeProp,
onOpenChange,
});
return (
<Rnd
disableDragging
enableResizing={ENABLE_RESIZING}
maxWidth={MAX_WIDTH}
minWidth={MIN_WIDTH}
onResizeStop={(_e, _direction, ref) => {
handlePanelWidthChange(ref.offsetWidth);
}}
position={{ x: 0, y: 0 }}
size={{ height: panelHeight, width: panelWidth }}
style={{ display: 'flex', flexDirection: 'column', position: 'relative' }}
>
<Toolbar
groupMode={groupMode}
onGroupModeChange={handleGroupModeChange}
onSearchKeywordChange={setSearchKeyword}
searchKeyword={searchKeyword}
/>
<List
groupMode={groupMode}
isOpen={isOpen}
model={modelProp}
onModelChange={onModelChangeProp}
onOpenChange={onOpenChange}
provider={providerProp}
searchKeyword={searchKeyword}
/>
<Footer onClose={handleClose} />
</Rnd>
);
};
PanelContent.displayName = 'PanelContent';

View File

@@ -0,0 +1,54 @@
import { Flexbox, Icon, SearchBar, Segmented } from '@lobehub/ui';
import { ProviderIcon } from '@lobehub/ui/icons';
import { Brain } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { styles } from '../styles';
import type { GroupMode } from '../types';
interface ToolbarProps {
groupMode: GroupMode;
onGroupModeChange: (mode: GroupMode) => void;
onSearchKeywordChange: (keyword: string) => void;
searchKeyword: string;
}
export const Toolbar = memo<ToolbarProps>(
({ groupMode, onGroupModeChange, searchKeyword, onSearchKeywordChange }) => {
const { t } = useTranslation('components');
return (
<Flexbox className={styles.toolbar} gap={4} horizontal paddingBlock={8} paddingInline={8}>
<SearchBar
allowClear
onChange={(e) => onSearchKeywordChange(e.target.value)}
placeholder={t('ModelSwitchPanel.searchPlaceholder')}
size="small"
style={{ flex: 1 }}
value={searchKeyword}
variant="borderless"
/>
<Segmented
onChange={(value) => onGroupModeChange(value as GroupMode)}
options={[
{
icon: <Icon icon={Brain} />,
title: t('ModelSwitchPanel.byModel'),
value: 'byModel',
},
{
icon: <Icon icon={ProviderIcon} />,
title: t('ModelSwitchPanel.byProvider'),
value: 'byProvider',
},
]}
size="small"
value={groupMode}
/>
</Flexbox>
);
},
);
Toolbar.displayName = 'Toolbar';

View File

@@ -0,0 +1,29 @@
export const STORAGE_KEY = 'MODEL_SWITCH_PANEL_WIDTH';
export const STORAGE_KEY_MODE = 'MODEL_SWITCH_PANEL_MODE';
export const DEFAULT_WIDTH = 430;
export const MIN_WIDTH = 280;
export const MAX_WIDTH = 600;
export const MAX_PANEL_HEIGHT = 460;
export const TOOLBAR_HEIGHT = 40;
export const FOOTER_HEIGHT = 48;
export const INITIAL_RENDER_COUNT = 15;
export const RENDER_ALL_DELAY_MS = 500;
export const ITEM_HEIGHT = {
'empty-model': 32,
'group-header': 32,
'model-item': 32,
'no-provider': 32,
} as const;
export const ENABLE_RESIZING = {
bottom: false,
bottomLeft: false,
bottomRight: false,
left: false,
right: true,
top: false,
topLeft: false,
topRight: false,
} as const;

View File

@@ -0,0 +1,122 @@
import { useMemo } from 'react';
import type { EnabledProviderWithModels } from '@/types/aiProvider';
import type { GroupMode, ModelWithProviders, VirtualItem } from '../types';
export const useBuildVirtualItems = (
enabledList: EnabledProviderWithModels[],
groupMode: GroupMode,
searchKeyword: string = '',
): VirtualItem[] => {
return useMemo(() => {
if (enabledList.length === 0) {
return [{ type: 'no-provider' }] as VirtualItem[];
}
// 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
const sortedProviders = [...enabledList].sort((a, b) => {
const aIsLobehub = a.id === 'lobehub';
const bIsLobehub = b.id === 'lobehub';
if (aIsLobehub && !bIsLobehub) return -1;
if (!aIsLobehub && bIsLobehub) return 1;
return 0;
});
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;
}
if (!modelMap.has(displayName)) {
modelMap.set(displayName, {
displayName,
model: modelItem,
providers: [],
});
}
const entry = modelMap.get(displayName)!;
entry.providers.push({
id: providerItem.id,
logo: providerItem.logo,
name: providerItem.name,
source: providerItem.source,
});
}
}
// Sort providers within each model: lobehub first
const modelArray = Array.from(modelMap.values());
for (const model of modelArray) {
model.providers.sort((a, b) => {
const aIsLobehub = a.id === 'lobehub';
const bIsLobehub = b.id === 'lobehub';
if (aIsLobehub && !bIsLobehub) return -1;
if (!aIsLobehub && bIsLobehub) return 1;
return 0;
});
}
// Convert to array and sort by display name
return modelArray
.sort((a, b) => a.displayName.localeCompare(b.displayName))
.map((data) => ({
data,
type:
data.providers.length === 1
? ('model-item-single' as const)
: ('model-item-multiple' as const),
}));
} else {
// Group by provider (original structure)
const items: VirtualItem[] = [];
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,
provider: providerItem,
type: 'provider-model-item',
});
}
}
}
}
return items;
}
}, [enabledList, groupMode, searchKeyword]);
};

View File

@@ -0,0 +1,18 @@
import { useMemo } from 'react';
import type { EnabledProviderWithModels } from '@/types/aiProvider';
export const useCurrentModelName = (
enabledList: EnabledProviderWithModels[],
model: string,
): string => {
return useMemo(() => {
for (const providerItem of enabledList) {
const modelItem = providerItem.children.find((m) => m.id === model);
if (modelItem) {
return modelItem.displayName || modelItem.id;
}
}
return model;
}, [enabledList, model]);
};

View File

@@ -0,0 +1,18 @@
import { useEffect, useState } from 'react';
import { RENDER_ALL_DELAY_MS } from '../const';
export const useDelayedRender = (isOpen: boolean) => {
const [renderAll, setRenderAll] = useState(false);
useEffect(() => {
if (isOpen && !renderAll) {
const timer = setTimeout(() => {
setRenderAll(true);
}, RENDER_ALL_DELAY_MS);
return () => clearTimeout(timer);
}
}, [isOpen, renderAll]);
return renderAll;
};

View File

@@ -0,0 +1,14 @@
import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
export const useModelAndProvider = (modelProp?: string, providerProp?: string) => {
const [storeModel, storeProvider] = useAgentStore((s) => [
agentSelectors.currentAgentModel(s),
agentSelectors.currentAgentModelProvider(s),
]);
const model = modelProp ?? storeModel;
const provider = providerProp ?? storeProvider;
return { model, provider };
};

View File

@@ -0,0 +1,33 @@
import { useCallback } from 'react';
import { useAgentStore } from '@/store/agent';
interface UsePanelHandlersProps {
onModelChange?: (params: { model: string; provider: string }) => Promise<void>;
onOpenChange?: (open: boolean) => void;
}
export const usePanelHandlers = ({
onModelChange: onModelChangeProp,
onOpenChange,
}: UsePanelHandlersProps) => {
const updateAgentConfig = useAgentStore((s) => s.updateAgentConfig);
const handleModelChange = useCallback(
async (modelId: string, providerId: string) => {
const params = { model: modelId, provider: providerId };
if (onModelChangeProp) {
onModelChangeProp(params);
} else {
updateAgentConfig(params);
}
},
[onModelChangeProp, updateAgentConfig],
);
const handleClose = useCallback(() => {
onOpenChange?.(false);
}, [onOpenChange]);
return { handleClose, handleModelChange };
};

View File

@@ -0,0 +1,33 @@
import { useCallback, useMemo } from 'react';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors/systemStatus';
import {
FOOTER_HEIGHT,
ITEM_HEIGHT,
MAX_PANEL_HEIGHT,
TOOLBAR_HEIGHT,
} from '../const';
export const usePanelSize = (enabledListLength: number) => {
const panelWidth = useGlobalStore(systemStatusSelectors.modelSwitchPanelWidth);
const updateSystemStatus = useGlobalStore((s) => s.updateSystemStatus);
const panelHeight = useMemo(
() =>
enabledListLength === 0
? TOOLBAR_HEIGHT + ITEM_HEIGHT['no-provider'] + FOOTER_HEIGHT
: MAX_PANEL_HEIGHT,
[enabledListLength],
);
const handlePanelWidthChange = useCallback(
(width: number) => {
updateSystemStatus({ modelSwitchPanelWidth: width });
},
[updateSystemStatus],
);
return { handlePanelWidthChange, panelHeight, panelWidth };
};

View File

@@ -0,0 +1,20 @@
import { useCallback } from 'react';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors/systemStatus';
import type { GroupMode } from '../types';
export const usePanelState = () => {
const groupMode = useGlobalStore(systemStatusSelectors.modelSwitchPanelGroupMode) as GroupMode;
const updateSystemStatus = useGlobalStore((s) => s.updateSystemStatus);
const handleGroupModeChange = useCallback(
(mode: GroupMode) => {
updateSystemStatus({ modelSwitchPanelGroupMode: mode });
},
[updateSystemStatus],
);
return { groupMode, handleGroupModeChange };
};

View File

@@ -1,246 +1,10 @@
import { ActionIcon, Flexbox, Icon, Segmented, TooltipGroup } from '@lobehub/ui';
import { ProviderIcon } from '@lobehub/ui/icons';
import { Dropdown } from 'antd';
import { createStaticStyles, cssVar, cx } from 'antd-style';
import {
Brain,
LucideArrowRight,
LucideBolt,
LucideCheck,
LucideChevronRight,
LucideSettings,
} from 'lucide-react';
import { type AiModelForSelect } from 'model-bank';
import { type ReactNode, memo, useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Rnd } from 'react-rnd';
import { useNavigate } from 'react-router-dom';
import urlJoin from 'url-join';
import { TooltipGroup } from '@lobehub/ui';
import { Popover } from 'antd';
import { memo, useCallback, useState } from 'react';
import { ModelItemRender, ProviderItemRender } from '@/components/ModelSelect';
import { useEnabledChatModels } from '@/hooks/useEnabledChatModels';
import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
import { type EnabledProviderWithModels } from '@/types/aiProvider';
const STORAGE_KEY = 'MODEL_SWITCH_PANEL_WIDTH';
const STORAGE_KEY_MODE = 'MODEL_SWITCH_PANEL_MODE';
const DEFAULT_WIDTH = 430;
const MIN_WIDTH = 280;
const MAX_WIDTH = 600;
const MAX_PANEL_HEIGHT = 460;
const TOOLBAR_HEIGHT = 40;
const FOOTER_HEIGHT = 48;
const INITIAL_RENDER_COUNT = 15;
const RENDER_ALL_DELAY_MS = 500;
const ITEM_HEIGHT = {
'empty-model': 32,
'group-header': 32,
'model-item': 32,
'no-provider': 32,
} as const;
type GroupMode = 'byModel' | 'byProvider';
const ENABLE_RESIZING = {
bottom: false,
bottomLeft: false,
bottomRight: false,
left: false,
right: true,
top: false,
topLeft: false,
topRight: false,
} as const;
const styles = createStaticStyles(({ css, cssVar }) => ({
dropdown: css`
overflow: hidden;
width: 100%;
height: 100%;
border: 1px solid ${cssVar.colorBorderSecondary};
border-radius: ${cssVar.borderRadiusLG};
background: ${cssVar.colorBgElevated};
box-shadow: ${cssVar.boxShadowSecondary};
`,
footer: css`
position: sticky;
z-index: 10;
inset-block-end: 0;
padding-block: 6px;
padding-inline: 0;
border-block-start: 1px solid ${cssVar.colorBorderSecondary};
background: ${cssVar.colorBgElevated};
`,
footerButton: css`
cursor: pointer;
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
box-sizing: border-box;
margin-inline: 8px;
padding-block: 6px;
padding-inline: 8px;
border-radius: ${cssVar.borderRadiusSM};
color: ${cssVar.colorTextSecondary};
transition: all 0.2s;
&:hover {
color: ${cssVar.colorText};
background: ${cssVar.colorFillTertiary};
}
`,
groupHeader: css`
margin-inline: 8px;
padding-block: 6px;
padding-inline: 8px;
color: ${cssVar.colorTextSecondary};
`,
menuItem: css`
cursor: pointer;
display: flex;
gap: 8px;
align-items: center;
box-sizing: border-box;
margin-inline: 8px;
padding-block: 6px;
padding-inline: 8px;
border-radius: ${cssVar.borderRadiusSM};
white-space: nowrap;
&:hover {
background: ${cssVar.colorFillTertiary};
.settings-icon {
opacity: 1;
}
}
`,
menuItemActive: css`
background: ${cssVar.colorFillTertiary};
`,
settingsIcon: css`
opacity: 0;
`,
submenu: css`
.ant-dropdown-menu {
padding: 4px;
}
.ant-dropdown-menu-item {
margin-inline: 0;
padding-block: 6px;
padding-inline: 8px;
border-radius: ${cssVar.borderRadiusSM};
}
.ant-dropdown-menu-item-group-title {
padding-block: 6px;
padding-inline: 8px;
font-size: 12px;
color: ${cssVar.colorTextSecondary};
}
`,
tag: css`
cursor: pointer;
`,
toolbar: css`
position: sticky;
z-index: 10;
inset-block-start: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding-block: 6px;
padding-inline: 8px;
border-block-end: 1px solid ${cssVar.colorBorderSecondary};
background: ${cssVar.colorBgElevated};
`,
toolbarModelName: css`
overflow: hidden;
font-size: 12px;
color: ${cssVar.colorTextSecondary};
text-overflow: ellipsis;
white-space: nowrap;
`,
}));
const menuKey = (provider: string, model: string) => `${provider}-${model}`;
interface ModelWithProviders {
displayName: string;
model: AiModelForSelect;
providers: Array<{
id: string;
logo?: string;
name: string;
source?: EnabledProviderWithModels['source'];
}>;
}
type VirtualItem =
| {
data: ModelWithProviders;
type: 'model-item';
}
| {
provider: EnabledProviderWithModels;
type: 'group-header';
}
| {
model: AiModelForSelect;
provider: EnabledProviderWithModels;
type: 'provider-model-item';
}
| {
provider: EnabledProviderWithModels;
type: 'empty-model';
}
| {
type: 'no-provider';
};
type DropdownPlacement = 'bottom' | 'bottomLeft' | 'bottomRight' | 'top' | 'topLeft' | 'topRight';
interface ModelSwitchPanelProps {
children?: ReactNode;
/**
* Current model ID. If not provided, uses currentAgentModel from store.
*/
model?: string;
/**
* Callback when model changes. If not provided, uses updateAgentConfig from store.
*/
onModelChange?: (params: { model: string; provider: string }) => Promise<void>;
onOpenChange?: (open: boolean) => void;
open?: boolean;
/**
* Dropdown placement. Defaults to 'topLeft'.
*/
placement?: DropdownPlacement;
/**
* Current provider ID. If not provided, uses currentAgentModelProvider from store.
*/
provider?: string;
}
import { PanelContent } from './components/PanelContent';
import { styles } from './styles';
import type { ModelSwitchPanelProps } from './types';
const ModelSwitchPanel = memo<ModelSwitchPanelProps>(
({
@@ -252,38 +16,11 @@ const ModelSwitchPanel = memo<ModelSwitchPanelProps>(
placement = 'topLeft',
provider: providerProp,
}) => {
const { t } = useTranslation('components');
const { t: tCommon } = useTranslation('common');
const newLabel = tCommon('new');
const [panelWidth, setPanelWidth] = useState(() => {
if (typeof window === 'undefined') return DEFAULT_WIDTH;
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? Number(stored) : DEFAULT_WIDTH;
});
const [groupMode, setGroupMode] = useState<GroupMode>(() => {
if (typeof window === 'undefined') return 'byModel';
const stored = localStorage.getItem(STORAGE_KEY_MODE);
return (stored as GroupMode) || 'byModel';
});
const [renderAll, setRenderAll] = useState(false);
const [internalOpen, setInternalOpen] = useState(false);
// Use controlled open if provided, otherwise use internal state
const isOpen = open ?? internalOpen;
// Only delay render all items on first open, then keep cached
useEffect(() => {
if (isOpen && !renderAll) {
const timer = setTimeout(() => {
setRenderAll(true);
}, RENDER_ALL_DELAY_MS);
return () => clearTimeout(timer);
}
}, [isOpen, renderAll]);
const handleOpenChange = useCallback(
(nextOpen: boolean) => {
setInternalOpen(nextOpen);
@@ -292,453 +29,35 @@ const ModelSwitchPanel = memo<ModelSwitchPanelProps>(
[onOpenChange],
);
// Get values from store for fallback when props are not provided
const [storeModel, storeProvider, updateAgentConfig] = useAgentStore((s) => [
agentSelectors.currentAgentModel(s),
agentSelectors.currentAgentModelProvider(s),
s.updateAgentConfig,
]);
// Use props if provided, otherwise fallback to store values
const model = modelProp ?? storeModel;
const provider = providerProp ?? storeProvider;
const navigate = useNavigate();
const enabledList = useEnabledChatModels();
const handleModelChange = useCallback(
async (modelId: string, providerId: string) => {
const params = { model: modelId, provider: providerId };
if (onModelChange) {
await onModelChange(params);
} else {
await updateAgentConfig(params);
}
},
[onModelChange, updateAgentConfig],
);
// Build virtual items based on group mode
const virtualItems = useMemo(() => {
if (enabledList.length === 0) {
return [{ type: 'no-provider' }] as VirtualItem[];
}
if (groupMode === 'byModel') {
// Group models by display name
const modelMap = new Map<string, ModelWithProviders>();
for (const providerItem of enabledList) {
for (const modelItem of providerItem.children) {
const displayName = modelItem.displayName || modelItem.id;
if (!modelMap.has(displayName)) {
modelMap.set(displayName, {
displayName,
model: modelItem,
providers: [],
});
}
const entry = modelMap.get(displayName)!;
entry.providers.push({
id: providerItem.id,
logo: providerItem.logo,
name: providerItem.name,
source: providerItem.source,
});
}
}
// Convert to array and sort by display name
return Array.from(modelMap.values())
.sort((a, b) => a.displayName.localeCompare(b.displayName))
.map((data) => ({ data, type: 'model-item' as const }));
} else {
// Group by provider (original structure)
const items: VirtualItem[] = [];
for (const providerItem of enabledList) {
// Add provider group header
items.push({ provider: providerItem, type: 'group-header' });
if (providerItem.children.length === 0) {
// Add empty model placeholder
items.push({ provider: providerItem, type: 'empty-model' });
} else {
// Add each model item
for (const modelItem of providerItem.children) {
items.push({
model: modelItem,
provider: providerItem,
type: 'provider-model-item',
});
}
}
}
return items;
}
}, [enabledList, groupMode]);
// Use a fixed panel height to prevent shifting when switching modes
const panelHeight =
enabledList.length === 0
? TOOLBAR_HEIGHT + ITEM_HEIGHT['no-provider'] + FOOTER_HEIGHT
: MAX_PANEL_HEIGHT;
const activeKey = menuKey(provider, model);
// Find current model's display name
const currentModelName = useMemo(() => {
for (const providerItem of enabledList) {
const modelItem = providerItem.children.find((m) => m.id === model);
if (modelItem) {
return modelItem.displayName || modelItem.id;
}
}
return model;
}, [enabledList, model]);
const renderVirtualItem = useCallback(
(item: VirtualItem) => {
switch (item.type) {
case 'no-provider': {
return (
<div
className={styles.menuItem}
key="no-provider"
onClick={() => navigate('/settings/provider/all')}
>
<Flexbox gap={8} horizontal style={{ color: cssVar.colorTextTertiary }}>
{t('ModelSwitchPanel.emptyProvider')}
<Icon icon={LucideArrowRight} />
</Flexbox>
</div>
);
}
case 'group-header': {
return (
<div className={styles.groupHeader} key={`header-${item.provider.id}`}>
<Flexbox horizontal justify="space-between">
<ProviderItemRender
logo={item.provider.logo}
name={item.provider.name}
provider={item.provider.id}
source={item.provider.source}
/>
<ActionIcon
icon={LucideBolt}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
const url = urlJoin('/settings/provider', item.provider.id || 'all');
if (e.ctrlKey || e.metaKey) {
window.open(url, '_blank');
} else {
navigate(url);
}
}}
size={'small'}
title={t('ModelSwitchPanel.goToSettings')}
/>
</Flexbox>
</div>
);
}
case 'empty-model': {
return (
<div
className={styles.menuItem}
key={`empty-${item.provider.id}`}
onClick={() => navigate(`/settings/provider/${item.provider.id}`)}
>
<Flexbox gap={8} horizontal style={{ color: cssVar.colorTextTertiary }}>
{t('ModelSwitchPanel.emptyModel')}
<Icon icon={LucideArrowRight} />
</Flexbox>
</div>
);
}
case 'provider-model-item': {
const key = menuKey(item.provider.id, item.model.id);
const isActive = key === activeKey;
return (
<div
className={cx(styles.menuItem, isActive && styles.menuItemActive)}
key={key}
onClick={async () => {
await handleModelChange(item.model.id, item.provider.id);
handleOpenChange(false);
}}
>
<ModelItemRender
{...item.model}
{...item.model.abilities}
infoTagTooltip={false}
newBadgeLabel={newLabel}
showInfoTag
/>
</div>
);
}
case 'model-item': {
const { data } = item;
const hasSingleProvider = data.providers.length === 1;
// Check if this model is currently active and find active provider
const activeProvider = data.providers.find(
(p) => menuKey(p.id, data.model.id) === activeKey,
);
const isActive = !!activeProvider;
// Use active provider if found, otherwise use first provider for settings link
const settingsProvider = activeProvider || data.providers[0];
// Single provider - direct click without submenu
if (hasSingleProvider) {
const singleProvider = data.providers[0];
const key = menuKey(singleProvider.id, data.model.id);
return (
<div className={cx(styles.menuItem, isActive && styles.menuItemActive)} key={key}>
<Flexbox
align={'center'}
gap={8}
horizontal
justify={'space-between'}
onClick={async () => {
await handleModelChange(data.model.id, singleProvider.id);
handleOpenChange(false);
}}
style={{ flex: 1, minWidth: 0 }}
>
<ModelItemRender
{...data.model}
{...data.model.abilities}
infoTagTooltip={false}
newBadgeLabel={newLabel}
showInfoTag={false}
/>
</Flexbox>
<div className={cx(styles.settingsIcon, 'settings-icon')}>
<ActionIcon
icon={LucideBolt}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
const url = urlJoin('/settings/provider', settingsProvider.id || 'all');
if (e.ctrlKey || e.metaKey) {
window.open(url, '_blank');
} else {
navigate(url);
}
}}
size={'small'}
title={t('ModelSwitchPanel.goToSettings')}
/>
</div>
</div>
);
}
// Multiple providers - show submenu on hover
return (
<Dropdown
align={{ offset: [4, 0] }}
arrow={false}
dropdownRender={(menu) => (
<div className={styles.submenu} style={{ minWidth: 240 }}>
{menu}
</div>
)}
key={data.displayName}
menu={{
items: [
{
key: 'header',
label: t('ModelSwitchPanel.useModelFrom'),
type: 'group',
},
...data.providers.map((p) => {
const isCurrentProvider = menuKey(p.id, data.model.id) === activeKey;
return {
key: menuKey(p.id, data.model.id),
label: (
<Flexbox
align={'center'}
gap={8}
horizontal
justify={'space-between'}
style={{ minWidth: 0 }}
>
<Flexbox align={'center'} gap={8} horizontal style={{ minWidth: 0 }}>
<div style={{ flexShrink: 0, width: 16 }}>
{isCurrentProvider && (
<Icon
icon={LucideCheck}
size={16}
style={{ color: cssVar.colorPrimary }}
/>
)}
</div>
<ProviderItemRender
logo={p.logo}
name={p.name}
provider={p.id}
source={p.source}
/>
</Flexbox>
<ActionIcon
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')}
/>
</Flexbox>
),
onClick: async () => {
await handleModelChange(data.model.id, p.id);
handleOpenChange(false);
},
};
}),
],
}}
// @ts-ignore
placement="rightTop"
trigger={['hover']}
>
<div className={cx(styles.menuItem, isActive && styles.menuItemActive)}>
<Flexbox
align={'center'}
gap={8}
horizontal
justify={'space-between'}
style={{ width: '100%' }}
>
<ModelItemRender
{...data.model}
{...data.model.abilities}
infoTagTooltip={false}
newBadgeLabel={newLabel}
showInfoTag={false}
/>
<Icon
icon={LucideChevronRight}
size={16}
style={{ color: cssVar.colorTextSecondary, flexShrink: 0 }}
/>
</Flexbox>
</div>
</Dropdown>
);
}
default: {
return null;
}
}
},
[activeKey, cx, handleModelChange, handleOpenChange, navigate, newLabel, styles, t],
);
return (
<TooltipGroup>
<Dropdown
<Popover
arrow={false}
classNames={{
container: styles.container,
}}
content={
<PanelContent
isOpen={isOpen}
model={modelProp}
onModelChange={onModelChange}
onOpenChange={handleOpenChange}
provider={providerProp}
/>
}
onOpenChange={handleOpenChange}
open={isOpen}
placement={placement}
popupRender={() => (
<Rnd
className={styles.dropdown}
disableDragging
enableResizing={ENABLE_RESIZING}
maxWidth={MAX_WIDTH}
minWidth={MIN_WIDTH}
onResizeStop={(_e, _direction, ref) => {
const newWidth = ref.offsetWidth;
setPanelWidth(newWidth);
localStorage.setItem(STORAGE_KEY, String(newWidth));
}}
position={{ x: 0, y: 0 }}
size={{ height: panelHeight, width: panelWidth }}
style={{ position: 'relative' }}
>
<div className={styles.toolbar}>
<div className={styles.toolbarModelName}>{currentModelName}</div>
<Segmented
onChange={(value) => {
const mode = value as GroupMode;
setGroupMode(mode);
localStorage.setItem(STORAGE_KEY_MODE, mode);
}}
options={[
{
icon: <Icon icon={Brain} />,
title: t('ModelSwitchPanel.byModel'),
value: 'byModel',
},
{
icon: <Icon icon={ProviderIcon} />,
title: t('ModelSwitchPanel.byProvider'),
value: 'byProvider',
},
]}
size="small"
value={groupMode}
/>
</div>
<div
style={{
height: panelHeight - TOOLBAR_HEIGHT - FOOTER_HEIGHT,
overflow: 'auto',
paddingBlock: groupMode === 'byModel' ? 8 : 0,
width: '100%',
}}
>
{(renderAll ? virtualItems : virtualItems.slice(0, INITIAL_RENDER_COUNT)).map(
renderVirtualItem,
)}
</div>
<div className={styles.footer}>
<div
className={styles.footerButton}
onClick={() => {
navigate('/settings/provider/all');
handleOpenChange(false);
}}
>
<Flexbox align={'center'} gap={8} horizontal style={{ flex: 1 }}>
<Icon icon={LucideSettings} size={16} />
{t('ModelSwitchPanel.manageProvider')}
</Flexbox>
<Icon icon={LucideArrowRight} size={16} />
</div>
</div>
</Rnd>
)}
>
<div className={styles.tag}>{children}</div>
</Dropdown>
{children}
</Popover>
</TooltipGroup>
);
},
);
ModelSwitchPanel.displayName = 'ModelSwitchPanel';
export default ModelSwitchPanel;
export type { ModelSwitchPanelProps };
export { type ModelSwitchPanelProps } from './types';

View File

@@ -0,0 +1,58 @@
import { createStaticStyles } from 'antd-style';
export const styles = createStaticStyles(({ css, cssVar }) => ({
container: css`
overflow: hidden;
padding: 0 !important;
`,
footer: css`
border-block-start: 1px solid ${cssVar.colorBorderSecondary};
`,
groupHeader: css`
width: 100%;
color: ${cssVar.colorTextSecondary};
.settings-icon {
opacity: 0;
}
&:hover {
.settings-icon {
opacity: 1;
}
}
`,
list: css`
position: relative;
overflow: hidden auto;
width: 100%;
`,
menuItem: css`
cursor: pointer;
position: relative;
gap: 8px;
align-items: center;
margin-block: 1px;
margin-inline: 4px;
padding-block: 8px;
padding-inline: 8px;
border-radius: ${cssVar.borderRadiusSM};
.settings-icon {
opacity: 0;
}
&:hover {
.settings-icon {
opacity: 1;
}
}
`,
toolbar: css`
border-block-end: 1px solid ${cssVar.colorBorderSecondary};
`,
}));

View File

@@ -0,0 +1,73 @@
import type { AiModelForSelect } from 'model-bank';
import type { ReactNode } from 'react';
import type { EnabledProviderWithModels } from '@/types/aiProvider';
export type GroupMode = 'byModel' | 'byProvider';
export interface ModelWithProviders {
displayName: string;
model: AiModelForSelect;
providers: Array<{
id: string;
logo?: string;
name: string;
source?: EnabledProviderWithModels['source'];
}>;
}
export type VirtualItem =
| {
data: ModelWithProviders;
type: 'model-item-single';
}
| {
data: ModelWithProviders;
type: 'model-item-multiple';
}
| {
provider: EnabledProviderWithModels;
type: 'group-header';
}
| {
model: AiModelForSelect;
provider: EnabledProviderWithModels;
type: 'provider-model-item';
}
| {
provider: EnabledProviderWithModels;
type: 'empty-model';
}
| {
type: 'no-provider';
};
export type DropdownPlacement =
| 'bottom'
| 'bottomLeft'
| 'bottomRight'
| 'top'
| 'topLeft'
| 'topRight';
export interface ModelSwitchPanelProps {
children?: ReactNode;
/**
* Current model ID. If not provided, uses currentAgentModel from store.
*/
model?: string;
/**
* Callback when model changes. If not provided, uses updateAgentConfig from store.
*/
onModelChange?: (params: { model: string; provider: string }) => Promise<void>;
onOpenChange?: (open: boolean) => void;
open?: boolean;
/**
* Dropdown placement. Defaults to 'topLeft'.
*/
placement?: DropdownPlacement;
/**
* Current provider ID. If not provided, uses currentAgentModelProvider from store.
*/
provider?: string;
}

View File

@@ -0,0 +1,24 @@
import type { VirtualItem } from './types';
export const menuKey = (provider: string, model: string) => `${provider}-${model}`;
export const getVirtualItemKey = (item: VirtualItem): string => {
switch (item.type) {
case 'model-item-single':
case 'model-item-multiple': {
return item.data.displayName;
}
case 'provider-model-item': {
return menuKey(item.provider.id, item.model.id);
}
case 'group-header': {
return `header-${item.provider.id}`;
}
case 'empty-model': {
return `empty-${item.provider.id}`;
}
case 'no-provider': {
return 'no-provider';
}
}
};

View File

@@ -114,6 +114,7 @@ export default {
'ModelSwitchPanel.goToSettings': 'Go to settings',
'ModelSwitchPanel.manageProvider': 'Manage Provider',
'ModelSwitchPanel.provider': 'Provider',
'ModelSwitchPanel.searchPlaceholder': 'Search models...',
'ModelSwitchPanel.title': 'Model',
'ModelSwitchPanel.useModelFrom': 'Use this model from:',
'MultiImagesUpload.actions.uploadMore': 'Click or drag to upload more',

View File

@@ -113,6 +113,14 @@ export interface SystemStatus {
leftPanelWidth: number;
mobileShowPortal?: boolean;
mobileShowTopic?: boolean;
/**
* ModelSwitchPanel 的分组模式
*/
modelSwitchPanelGroupMode?: 'byModel' | 'byProvider';
/**
* ModelSwitchPanel 的宽度
*/
modelSwitchPanelWidth?: number;
noWideScreen?: boolean;
/**
* number of pages (documents) to display per page
@@ -179,6 +187,8 @@ export const INITIAL_STATUS = {
knowledgeBaseModalViewMode: 'list' as const,
leftPanelWidth: 320,
mobileShowTopic: false,
modelSwitchPanelGroupMode: 'byProvider',
modelSwitchPanelWidth: 430,
noWideScreen: true,
pagePageSize: 20,
portalWidth: 400,

View File

@@ -24,6 +24,9 @@ const showImageTopicPanel = (s: GlobalState) => s.status.showImageTopicPanel;
const hidePWAInstaller = (s: GlobalState) => s.status.hidePWAInstaller;
const isShowCredit = (s: GlobalState) => s.status.isShowCredit;
const language = (s: GlobalState) => s.status.language || 'auto';
const modelSwitchPanelGroupMode = (s: GlobalState) =>
s.status.modelSwitchPanelGroupMode || 'byProvider';
const modelSwitchPanelWidth = (s: GlobalState) => s.status.modelSwitchPanelWidth || 430;
const showChatHeader = (s: GlobalState) => !s.status.zenMode;
const inZenMode = (s: GlobalState) => s.status.zenMode;
@@ -68,6 +71,8 @@ export const systemStatusSelectors = {
leftPanelWidth,
mobileShowPortal,
mobileShowTopic,
modelSwitchPanelGroupMode,
modelSwitchPanelWidth,
pagePageSize,
portalWidth,
sessionGroupKeys,