mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-27 13:29:15 +07:00
🐛 fix: fix editor modal and refactor ModelSwitchPanel (#11273)
* fix: fix editor modal * style: update modelSwitchPanel
This commit is contained in:
@@ -99,6 +99,7 @@
|
||||
"ModelSwitchPanel.goToSettings": "前往设置",
|
||||
"ModelSwitchPanel.manageProvider": "管理提供商",
|
||||
"ModelSwitchPanel.provider": "提供方",
|
||||
"ModelSwitchPanel.searchPlaceholder": "搜索模型...",
|
||||
"ModelSwitchPanel.title": "模型",
|
||||
"ModelSwitchPanel.useModelFrom": "使用此模型来自:",
|
||||
"MultiImagesUpload.actions.uploadMore": "点击或拖拽上传更多",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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) : ''}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
42
src/features/ModelSwitchPanel/components/Footer.tsx
Normal file
42
src/features/ModelSwitchPanel/components/Footer.tsx
Normal 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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
99
src/features/ModelSwitchPanel/components/List/index.tsx
Normal file
99
src/features/ModelSwitchPanel/components/List/index.tsx
Normal 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';
|
||||
77
src/features/ModelSwitchPanel/components/PanelContent.tsx
Normal file
77
src/features/ModelSwitchPanel/components/PanelContent.tsx
Normal 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';
|
||||
54
src/features/ModelSwitchPanel/components/Toolbar.tsx
Normal file
54
src/features/ModelSwitchPanel/components/Toolbar.tsx
Normal 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';
|
||||
29
src/features/ModelSwitchPanel/const.ts
Normal file
29
src/features/ModelSwitchPanel/const.ts
Normal 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;
|
||||
122
src/features/ModelSwitchPanel/hooks/useBuildVirtualItems.ts
Normal file
122
src/features/ModelSwitchPanel/hooks/useBuildVirtualItems.ts
Normal 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]);
|
||||
};
|
||||
18
src/features/ModelSwitchPanel/hooks/useCurrentModelName.ts
Normal file
18
src/features/ModelSwitchPanel/hooks/useCurrentModelName.ts
Normal 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]);
|
||||
};
|
||||
18
src/features/ModelSwitchPanel/hooks/useDelayedRender.ts
Normal file
18
src/features/ModelSwitchPanel/hooks/useDelayedRender.ts
Normal 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;
|
||||
};
|
||||
14
src/features/ModelSwitchPanel/hooks/useModelAndProvider.ts
Normal file
14
src/features/ModelSwitchPanel/hooks/useModelAndProvider.ts
Normal 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 };
|
||||
};
|
||||
33
src/features/ModelSwitchPanel/hooks/usePanelHandlers.ts
Normal file
33
src/features/ModelSwitchPanel/hooks/usePanelHandlers.ts
Normal 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 };
|
||||
};
|
||||
33
src/features/ModelSwitchPanel/hooks/usePanelSize.ts
Normal file
33
src/features/ModelSwitchPanel/hooks/usePanelSize.ts
Normal 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 };
|
||||
};
|
||||
20
src/features/ModelSwitchPanel/hooks/usePanelState.ts
Normal file
20
src/features/ModelSwitchPanel/hooks/usePanelState.ts
Normal 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 };
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
58
src/features/ModelSwitchPanel/styles.ts
Normal file
58
src/features/ModelSwitchPanel/styles.ts
Normal 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};
|
||||
`,
|
||||
}));
|
||||
73
src/features/ModelSwitchPanel/types.ts
Normal file
73
src/features/ModelSwitchPanel/types.ts
Normal 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;
|
||||
}
|
||||
24
src/features/ModelSwitchPanel/utils.ts
Normal file
24
src/features/ModelSwitchPanel/utils.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user