diff --git a/src/renderer/src/components/Popups/ModelEditPopup.tsx b/src/renderer/src/components/Popups/ModelEditPopup.tsx new file mode 100644 index 0000000000..624774935e --- /dev/null +++ b/src/renderer/src/components/Popups/ModelEditPopup.tsx @@ -0,0 +1,257 @@ +import { DownOutlined, UpOutlined } from '@ant-design/icons' +import CopyIcon from '@renderer/components/Icons/CopyIcon' +import { TopView } from '@renderer/components/TopView' +import { + isEmbeddingModel, + isFunctionCallingModel, + isReasoningModel, + isVisionModel, + isWebSearchModel +} from '@renderer/config/models' +import { useProvider } from '@renderer/hooks/useProvider' +import { Model, ModelType } from '@renderer/types' +import { getDefaultGroupName } from '@renderer/utils' +import { Button, Checkbox, Divider, Flex, Form, Input, message, Modal } from 'antd' +import { CheckboxProps } from 'antd/lib/checkbox' +import { FC, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +interface ModelEditPopupProps { + model: Model + resolve: (updatedModel?: Model) => void +} + +const PopupContainer: FC = ({ model, resolve }) => { + const [open, setOpen] = useState(true) + const [form] = Form.useForm() + const { t } = useTranslation() + const [showModelTypes, setShowModelTypes] = useState(false) + const { updateModel } = useProvider(model.provider) + + const onFinish = (values: any) => { + const updatedModel = { + ...model, + id: values.id || model.id, + name: values.name || model.name, + group: values.group || model.group + } + updateModel(updatedModel) + setShowModelTypes(false) + setOpen(false) + resolve(updatedModel) + } + + const handleClose = () => { + setShowModelTypes(false) + setOpen(false) + resolve() + } + + const onUpdateModel = (updatedModel: Model) => { + updateModel(updatedModel) + // 只更新模型数据,不关闭弹窗,不返回结果 + } + + return ( + { + if (visible) { + form.getFieldInstance('id')?.focus() + } else { + setShowModelTypes(false) + } + }}> +
+ + + { + const value = e.target.value + form.setFieldValue('name', value) + form.setFieldValue('group', getDefaultGroupName(value)) + }} + /> + + + + {showModelTypes && ( +
+ + {t('models.type.select')}: + {(() => { + const defaultTypes = [ + ...(isVisionModel(model) ? ['vision'] : []), + ...(isEmbeddingModel(model) ? ['embedding'] : []), + ...(isReasoningModel(model) ? ['reasoning'] : []), + ...(isFunctionCallingModel(model) ? ['function_calling'] : []), + ...(isWebSearchModel(model) ? ['web_search'] : []) + ] as ModelType[] + + // 合并现有选择和默认类型 + const selectedTypes = [...new Set([...(model.type || []), ...defaultTypes])] + + const showTypeConfirmModal = (type: string) => { + window.modal.confirm({ + title: t('settings.moresetting.warn'), + content: t('settings.moresetting.check.warn'), + okText: t('settings.moresetting.check.confirm'), + cancelText: t('common.cancel'), + okButtonProps: { danger: true }, + cancelButtonProps: { type: 'primary' }, + onOk: () => { + const updatedModel = { ...model, type: [...selectedTypes, type] as ModelType[] } + onUpdateModel(updatedModel) + }, + onCancel: () => {}, + centered: true + }) + } + + const handleTypeChange = (types: string[]) => { + const newType = types.find((type) => !selectedTypes.includes(type as ModelType)) + + if (newType) { + // 如果有新类型被添加,显示确认对话框 + showTypeConfirmModal(newType) + } else { + // 如果没有新类型,只是取消选择了某些类型,直接更新 + const updatedModel = { ...model, type: types as ModelType[] } + onUpdateModel(updatedModel) + } + } + + return ( + + {t('models.type.vision')} + {t('models.type.websearch')} + {t('models.type.reasoning')} + {t('models.type.function_calling')} + {t('models.type.embedding')} + {t('models.type.rerank')} + + ) + })()} +
+ )} +
+
+ ) +} + +const MoreSettingsRow = styled.div` + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; // 增加间距 + color: var(--color-text-secondary); + font-size: 14px; // 增加字体大小 + &:hover { + color: var(--color-text-primary); + } +` + +const ExpandIcon = styled.span` + font-size: 12px; // 增加图标大小 + display: flex; + align-items: center; +` + +const TypeTitle = styled.div` + font-size: 16px; // 增加字体大小 + margin-bottom: 15px; // 增加下边距 + font-weight: 500; +` + +const StyledCheckbox = styled(Checkbox)` + font-size: 14px; // 增加字体大小 + padding: 5px 0; // 增加内边距 + + .ant-checkbox-inner { + width: 18px; // 增加复选框大小 + height: 18px; // 增加复选框大小 + } + + .ant-checkbox + span { + padding-left: 12px; // 增加文字与复选框的间距 + } +` + +export default class ModelEditPopup { + static hide() { + TopView.hide('ModelEditPopup') + } + static show(model: Model) { + return new Promise((resolve) => { + TopView.show(, 'ModelEditPopup') + }) + } +} diff --git a/src/renderer/src/components/Popups/ModelSettingsButton.tsx b/src/renderer/src/components/Popups/ModelSettingsButton.tsx new file mode 100644 index 0000000000..e3b905a5fb --- /dev/null +++ b/src/renderer/src/components/Popups/ModelSettingsButton.tsx @@ -0,0 +1,64 @@ +import { SettingOutlined } from '@ant-design/icons' +import { useProvider } from '@renderer/hooks/useProvider' +import { Model } from '@renderer/types' +import { Button, Tooltip } from 'antd' +import { FC, useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +import ModelEditPopup from './ModelEditPopup' + +interface ModelSettingsButtonProps { + model: Model + size?: number + className?: string +} + +const ModelSettingsButton: FC = ({ model, size = 16, className }) => { + const { t } = useTranslation() + const { updateModel } = useProvider(model.provider) + + const handleClick = useCallback( + async (e: React.MouseEvent) => { + e.stopPropagation() // 防止触发父元素的点击事件 + const updatedModel = await ModelEditPopup.show(model) + if (updatedModel) { + updateModel(updatedModel) + } + }, + [model, updateModel] + ) + + return ( + + } + onClick={handleClick} + className={className} + /> + + ) +} + +const StyledButton = styled(Button)` + display: flex; + align-items: center; + justify-content: center; + padding: 6px; // 增加内边距 + margin: 0; + height: auto; + width: auto; + min-width: auto; + background: transparent; + border: none; + opacity: 0.5; + transition: opacity 0.2s; + + &:hover { + opacity: 1; + background: transparent; + } +` + +export default ModelSettingsButton diff --git a/src/renderer/src/components/Popups/SelectModelPopup.tsx b/src/renderer/src/components/Popups/SelectModelPopup.tsx index 5b2e0a7886..f74f79d94e 100644 --- a/src/renderer/src/components/Popups/SelectModelPopup.tsx +++ b/src/renderer/src/components/Popups/SelectModelPopup.tsx @@ -4,628 +4,482 @@ import { getModelLogo, isEmbeddingModel, isRerankModel } from '@renderer/config/ import db from '@renderer/databases' import { useProviders } from '@renderer/hooks/useProvider' import { getModelUniqId } from '@renderer/services/ModelService' -import { Model, Provider } from '@renderer/types' -import { Avatar, Divider, Empty, Input, InputRef, Menu, MenuProps, Modal } from 'antd' +import { Model } from '@renderer/types' // Removed unused 'Provider' import +import { Avatar, Divider, Empty, Input, InputRef, Modal, Tooltip } from 'antd' import { first, sortBy } from 'lodash' -import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' // Added useMemo here import { useTranslation } from 'react-i18next' import styled from 'styled-components' import { HStack } from '../Layout' -import ModelTagsWithLabel from '../ModelTagsWithLabel' +import ModelTags from '../ModelTags' import Scrollbar from '../Scrollbar' - -type MenuItem = Required['items'][number] +import ModelSettingsButton from './ModelSettingsButton' interface Props { - model?: Model + model?: Model // The currently active model, for highlighting } interface PopupContainerProps extends Props { resolve: (value: Model | undefined) => void } -const PopupContainer: React.FC = ({ model, resolve }) => { +const PINNED_PROVIDER_ID = '__pinned__' // Special ID for pinned section + +const PopupContainer: React.FC = ({ model: activeModel, resolve }) => { const [open, setOpen] = useState(true) const { t } = useTranslation() const [searchText, setSearchText] = useState('') const inputRef = useRef(null) const { providers } = useProviders() const [pinnedModels, setPinnedModels] = useState([]) - const scrollContainerRef = useRef(null) - const [keyboardSelectedId, setKeyboardSelectedId] = useState('') - const menuItemRefs = useRef>({}) - const [selectedProvider, setSelectedProvider] = useState('all') - - const setMenuItemRef = useCallback( - (key: string) => (el: HTMLElement | null) => { - if (el) { - menuItemRefs.current[key] = el - } - }, - [] - ) + const [selectedProviderId, setSelectedProviderId] = useState('all') + // 移除未使用的状态 + // --- Load Pinned Models --- useEffect(() => { const loadPinnedModels = async () => { const setting = await db.settings.get('pinned:models') const savedPinnedModels = setting?.value || [] - - // Filter out invalid pinned models const allModelIds = providers.flatMap((p) => p.models || []).map((m) => getModelUniqId(m)) - const validPinnedModels = savedPinnedModels.filter((id) => allModelIds.includes(id)) - - // Update storage if there were invalid models + const validPinnedModels = savedPinnedModels.filter((id: string) => allModelIds.includes(id)) if (validPinnedModels.length !== savedPinnedModels.length) { await db.settings.put({ id: 'pinned:models', value: validPinnedModels }) } + setPinnedModels(sortBy(validPinnedModels)) // Keep pinned models sorted if needed - setPinnedModels(sortBy(validPinnedModels, ['group', 'name'])) + // Set initial selected provider + if (activeModel) { + const activeModelId = getModelUniqId(activeModel) + if (validPinnedModels.includes(activeModelId)) { + setSelectedProviderId(PINNED_PROVIDER_ID) + } else { + setSelectedProviderId(activeModel.provider) + } + } else if (validPinnedModels.length > 0) { + setSelectedProviderId(PINNED_PROVIDER_ID) + } else if (providers.length > 0) { + setSelectedProviderId(providers[0].id) + } } loadPinnedModels() + }, [providers, activeModel]) // Depend on providers and activeModel + + // --- Pin/Unpin Logic --- + const togglePin = useCallback( + async (modelId: string) => { + const newPinnedModels = pinnedModels.includes(modelId) + ? pinnedModels.filter((id) => id !== modelId) + : [...pinnedModels, modelId] + + await db.settings.put({ id: 'pinned:models', value: newPinnedModels }) + setPinnedModels(sortBy(newPinnedModels)) // Keep sorted + + // If unpinning the last pinned model and currently viewing pinned, switch provider + if (newPinnedModels.length === 0 && selectedProviderId === PINNED_PROVIDER_ID) { + setSelectedProviderId(providers[0]?.id || 'all') + } + // If pinning a model while viewing its provider, maybe switch to pinned? (Optional UX decision) + // else if (!pinnedModels.includes(modelId) && selectedProviderId !== PINNED_PROVIDER_ID) { + // setSelectedProviderId(PINNED_PROVIDER_ID); + // } + }, + [pinnedModels, selectedProviderId, providers] + ) + + // 缓存所有模型列表,只在providers变化时重新计算 + const allModels = useMemo(() => { + return providers.flatMap((p) => p.models || []) + .filter((m) => !isEmbeddingModel(m) && !isRerankModel(m)) }, [providers]) - const togglePin = async (modelId: string) => { - const newPinnedModels = pinnedModels.includes(modelId) - ? pinnedModels.filter((id) => id !== modelId) - : [...pinnedModels, modelId] + // --- Filter Models for Right Column --- + const displayedModels = useMemo(() => { + let modelsToShow: Model[] = [] - await db.settings.put({ id: 'pinned:models', value: newPinnedModels }) - setPinnedModels(sortBy(newPinnedModels, ['group', 'name'])) - } - - // 根据输入的文本筛选模型 - const getFilteredModels = useCallback( - (provider) => { - let models = provider.models.filter((m) => !isEmbeddingModel(m) && !isRerankModel(m)) - - if (searchText.trim()) { - const keywords = searchText.toLowerCase().split(/\s+/).filter(Boolean) - models = models.filter((m) => { - const fullName = provider.isSystem - ? `${m.name} ${provider.name} ${t('provider.' + provider.id)}` - : `${m.name} ${provider.name}` - - const lowerFullName = fullName.toLowerCase() - return keywords.every((keyword) => lowerFullName.includes(keyword)) - }) - } else { - // 如果不是搜索状态,过滤掉已固定的模型 - models = models.filter((m) => !pinnedModels.includes(getModelUniqId(m))) - } - - return sortBy(models, ['group', 'name']) - }, - [searchText, t, pinnedModels] - ) - - // 递归处理菜单项,为每个项添加ref - const processMenuItems = useCallback( - (items: MenuItem[]) => { - // 内部定义 renderMenuItem 函数 - const renderMenuItem = (item: any) => { - return { - ...item, - label:
{item.label}
- } - } - - return items.map((item) => { - if (item && 'children' in item && item.children) { - return { - ...item, - children: (item.children as MenuItem[]).map(renderMenuItem) - } - } - return item + // 如果有搜索文本,在所有模型中搜索 + if (searchText.trim()) { + const keywords = searchText.toLowerCase().split(/\s+/).filter(Boolean) + modelsToShow = allModels.filter((m) => { + const provider = providers.find((p) => p.id === m.provider) + const providerName = provider ? (provider.isSystem ? t(`provider.${provider.id}`) : provider.name) : '' + const fullName = `${m.name} ${providerName}`.toLowerCase() + return keywords.every((keyword) => fullName.includes(keyword)) }) - }, - [setMenuItemRef] - ) - - const filteredItems: MenuItem[] = providers - .filter((p) => p.models && p.models.length > 0) - .map((p) => { - const filteredModels = getFilteredModels(p).map((m) => ({ - key: getModelUniqId(m), - label: ( - - - {m?.name} - - { - e.stopPropagation() - togglePin(getModelUniqId(m)) - }} - isPinned={pinnedModels.includes(getModelUniqId(m))}> - - - - ), - icon: ( - - {first(m?.name)} - - ), - onClick: () => { - resolve(m) - setOpen(false) + } else { + // 没有搜索文本时,根据选择的供应商筛选 + if (selectedProviderId === 'all') { + // 显示所有模型 + modelsToShow = allModels + } else if (selectedProviderId === PINNED_PROVIDER_ID) { + // 显示固定的模型 + modelsToShow = allModels.filter((m) => pinnedModels.includes(getModelUniqId(m))) + } else if (selectedProviderId) { + // 显示选中供应商的模型 + const provider = providers.find((p) => p.id === selectedProviderId) + if (provider && provider.models) { + modelsToShow = provider.models.filter((m) => !isEmbeddingModel(m) && !isRerankModel(m)) } - })) - - // Only return the group if it has filtered models - return filteredModels.length > 0 - ? { - key: p.id, - label: p.isSystem ? t(`provider.${p.id}`) : p.name, - type: 'group', - children: filteredModels - } - : null - }) - .filter(Boolean) as MenuItem[] // Filter out null items - - if (pinnedModels.length > 0 && searchText.length === 0) { - const pinnedItems = providers - .flatMap((p) => - p.models - .filter((m) => pinnedModels.includes(getModelUniqId(m))) - .map((m) => ({ - key: getModelUniqId(m), - model: m, - provider: p - })) - ) - .map((m) => ({ - key: getModelUniqId(m.model) + '_pinned', - label: ( - - - - {m.model?.name} | {m.provider.isSystem ? t(`provider.${m.provider.id}`) : m.provider.name} - {' '} - - - { - e.stopPropagation() - togglePin(getModelUniqId(m.model)) - }} - isPinned={true}> - - - - ), - icon: ( - - {first(m.model?.name)} - - ), - onClick: () => { - resolve(m.model) - setOpen(false) - } - })) - - if (pinnedItems.length > 0) { - filteredItems.unshift({ - key: 'pinned', - label: t('models.pinned'), - type: 'group', - children: pinnedItems - } as MenuItem) + } } - } - // 处理菜单项,添加ref - const processedItems = processMenuItems(filteredItems) + return sortBy(modelsToShow, ['group', 'name']) + }, [selectedProviderId, pinnedModels, searchText, allModels, providers, t]) - const onCancel = () => { - setKeyboardSelectedId('') + // --- Event Handlers --- + const handleProviderSelect = useCallback((providerId: string) => { + setSelectedProviderId(providerId) + }, []) + + const handleModelSelect = useCallback((model: Model) => { + resolve(model) setOpen(false) - } + }, [resolve, setOpen]) - const onClose = async () => { - setKeyboardSelectedId('') + const onCancel = useCallback(() => { + setOpen(false) + }, []) + + const onClose = useCallback(async () => { resolve(undefined) SelectModelPopup.hide() - } + }, [resolve]) + // --- Focus Input on Open --- useEffect(() => { open && setTimeout(() => inputRef.current?.focus(), 0) }, [open]) - useEffect(() => { - if (open && model) { - setTimeout(() => { - const modelId = getModelUniqId(model) - if (menuItemRefs.current[modelId]) { - menuItemRefs.current[modelId]?.scrollIntoView({ block: 'center', behavior: 'auto' }) - } - }, 100) // Small delay to ensure menu is rendered + // --- Provider List for Left Column --- + const providerListItems = useMemo(() => { + const items: { id: string; name: string }[] = [ + { id: 'all', name: t('models.all') || '全部' } // 添加“全部”选项 + ] + if (pinnedModels.length > 0) { + items.push({ id: PINNED_PROVIDER_ID, name: t('models.pinned') }) } - }, [open, model]) - - // 获取所有可见的模型项 - const getVisibleModelItems = useCallback(() => { - const items: { key: string; model: Model }[] = [] - - // 如果有置顶模型且没有搜索文本,添加置顶模型 - if (pinnedModels.length > 0 && searchText.length === 0) { - providers - .flatMap((p) => p.models || []) - .filter((m) => pinnedModels.includes(getModelUniqId(m))) - .forEach((m) => items.push({ key: getModelUniqId(m) + '_pinned', model: m })) - } - - // 添加其他过滤后的模型 providers.forEach((p) => { - if (p.models) { - getFilteredModels(p).forEach((m) => { - const modelId = getModelUniqId(m) - const isPinned = pinnedModels.includes(modelId) - - // 搜索状态下,所有匹配的模型都应该可以被选中,包括固定的模型 - // 非搜索状态下,只添加非固定模型(固定模型已在上面添加) - if (searchText.length > 0 || !isPinned) { - items.push({ - key: modelId, - model: m - }) - } - }) + // Only add provider if it has non-embedding/rerank models + if (p.models?.some((m) => !isEmbeddingModel(m) && !isRerankModel(m))) { + items.push({ id: p.id, name: p.isSystem ? t(`provider.${p.id}`) : p.name }) } }) - return items - }, [pinnedModels, searchText, providers, getFilteredModels]) - - // 添加一个useLayoutEffect来处理滚动 - useLayoutEffect(() => { - if (open && keyboardSelectedId && menuItemRefs.current[keyboardSelectedId]) { - // 获取当前选中元素和容器 - const selectedElement = menuItemRefs.current[keyboardSelectedId] - const scrollContainer = scrollContainerRef.current - - if (!scrollContainer) return - - const selectedRect = selectedElement.getBoundingClientRect() - const containerRect = scrollContainer.getBoundingClientRect() - - // 计算元素相对于容器的位置 - const currentScrollTop = scrollContainer.scrollTop - const elementTop = selectedRect.top - containerRect.top + currentScrollTop - const groupTitleHeight = 30 - - // 确定滚动位置 - if (selectedRect.top < containerRect.top + groupTitleHeight) { - // 元素被组标题遮挡,向上滚动 - scrollContainer.scrollTo({ - top: elementTop - groupTitleHeight, - behavior: 'smooth' - }) - } else if (selectedRect.bottom > containerRect.bottom) { - // 元素在视口下方,向下滚动 - scrollContainer.scrollTo({ - top: elementTop - containerRect.height + selectedRect.height, - behavior: 'smooth' - }) - } - } - }, [open, keyboardSelectedId]) - - // 处理键盘导航 - const handleKeyDown = useCallback( - (e: KeyboardEvent) => { - const items = getVisibleModelItems() - if (items.length === 0) return - - if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { - e.preventDefault() - const currentIndex = items.findIndex((item) => item.key === keyboardSelectedId) - let nextIndex - - if (currentIndex === -1) { - nextIndex = e.key === 'ArrowDown' ? 0 : items.length - 1 - } else { - nextIndex = - e.key === 'ArrowDown' ? (currentIndex + 1) % items.length : (currentIndex - 1 + items.length) % items.length - } - - const nextItem = items[nextIndex] - setKeyboardSelectedId(nextItem.key) - } else if (e.key === 'Enter') { - e.preventDefault() // 阻止回车的默认行为 - if (keyboardSelectedId) { - const selectedItem = items.find((item) => item.key === keyboardSelectedId) - if (selectedItem) { - resolve(selectedItem.model) - setOpen(false) - } - } - } - }, - [keyboardSelectedId, getVisibleModelItems, resolve, setOpen] - ) - - useEffect(() => { - window.addEventListener('keydown', handleKeyDown) - return () => window.removeEventListener('keydown', handleKeyDown) - }, [handleKeyDown]) - - // 搜索文本改变时重置键盘选中状态 - useEffect(() => { - setKeyboardSelectedId('') - }, [searchText]) - - const selectedKeys = keyboardSelectedId ? [keyboardSelectedId] : model ? [getModelUniqId(model)] : [] - - // 创建左侧提供商列表 - const providerItems = [ - { key: 'all', label: t('models.all') || '全部' }, - ...(pinnedModels.length > 0 ? [{ key: 'pinned', label: t('models.pinned') || '已固定' }] : []), - ...providers.map(p => ({ - key: p.id, - label: p.isSystem ? t(`provider.${p.id}`) || p.name : p.name, - icon: {first(p.name)} - })) - ]; - - // 根据选中的提供商筛选模型 - const getFilteredModelsByProvider = useCallback(() => { - if (searchText.trim()) { - // 搜索模式下显示所有匹配的模型 - return processedItems; - } - - if (selectedProvider === 'all') { - // 显示所有模型 - return processedItems; - } else if (selectedProvider === 'pinned') { - // 只显示固定的模型 - const pinnedGroup = processedItems.find(item => item && 'key' in item && item.key === 'pinned'); - return pinnedGroup ? [pinnedGroup] : []; - } else { - // 显示选中提供商的模型 - const providerGroup = processedItems.find(item => item && 'key' in item && item.key === selectedProvider); - return providerGroup ? [providerGroup] : []; - } - }, [selectedProvider, processedItems, searchText]); - - // 获取要显示的模型列表 - const displayedItems = getFilteredModelsByProvider(); + }, [providers, pinnedModels, t]) + // --- Render --- return ( - - - - - } - ref={inputRef} - placeholder={t('models.search')} - value={searchText} - onChange={(e) => setSearchText(e.target.value)} - allowClear - autoFocus - style={{ paddingLeft: 0 }} - variant="borderless" - size="middle" - onKeyDown={(e) => { - // 防止上下键移动光标 - if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { - e.preventDefault() + footer={null} + width={900} // 进一步增加宽度,使界面更宽敞 + > + {/* Search Input */} + inputRef.current?.focus()}> + + + + } - }} - /> - - - - - - setSelectedProvider(key as string)} - /> + ref={inputRef} + placeholder={t('models.search')} + value={searchText} + onChange={useCallback((e: React.ChangeEvent) => { + const value = e.target.value + setSearchText(value) + // 当搜索时,自动选择"all"供应商,以显示所有匹配的模型 + if (value.trim() && selectedProviderId !== 'all') { + setSelectedProviderId('all') + } + }, [selectedProviderId, t])} + // 移除焦点事件处理 + allowClear + autoFocus + style={{ + paddingLeft: 0, + height: '32px', + fontSize: '14px' + }} + variant="borderless" + size="middle" + /> + + + + + {/* Two Column Layout */} + + {/* Left Column: Providers */} + + + {providerListItems.map((provider, index) => ( + + + handleProviderSelect(provider.id)}> + {provider.name} + {provider.id === PINNED_PROVIDER_ID && } + + + {/* 在每个供应商之后添加分割线,除了最后一个 */} + {index < providerListItems.length - 1 && } + + ))} - - - - - {displayedItems.length > 0 ? ( - { - setKeyboardSelectedId(key as string) - }} - /> - ) : ( - - - - )} - + + + {/* Right Column: Models */} + + + {displayedModels.length > 0 ? ( + displayedModels.map((m) => ( + handleModelSelect(m)}> + + {first(m?.name)} + + + + + {m?.name} + + {/* Show provider only if not in pinned view or if search is active */} + {(selectedProviderId !== PINNED_PROVIDER_ID || searchText) && ( + p.id === m.provider)?.name ?? m.provider} mouseEnterDelay={0.5}> + + | {providers.find((p) => p.id === m.provider)?.name ?? m.provider} + + + )} + + + + + + { + e.stopPropagation() // Prevent model selection when clicking pin + togglePin(getModelUniqId(m)) + }}> + + + + + )) + ) : ( + + + + )} - - + + ) } -const Container = styled.div` - margin-top: 10px; +// --- Styled Components --- + +const SearchContainer = styled(HStack)` + padding: 8px 15px; + cursor: pointer; ` -const SplitContainer = styled.div` - display: flex; - flex-direction: row; - height: 50vh; -` - -const LeftPanel = styled.div` - width: 200px; - border-right: 1px solid var(--color-border); - overflow-y: auto; -` - -const RightPanel = styled.div` - flex: 1; - overflow-y: auto; -` - -const ProviderMenu = styled(Menu)` - background-color: transparent; - border-inline-end: none !important; - - .ant-menu-item { - height: 40px; - line-height: 40px; - margin: 0; - border-radius: 0; - - &.ant-menu-item-selected { - background-color: var(--color-background-mute) !important; - color: var(--color-text-primary) !important; - } - } -` - -const StyledMenu = styled(Menu)` - background-color: transparent; - padding: 5px; - margin-top: -10px; - - .ant-menu-item-group-title { - position: sticky; - top: 0; - z-index: 1; - margin: 0 -5px; - padding: 5px 10px; - padding-left: 18px; - font-size: 12px; - font-weight: 500; - - /* Scroll-driven animation for sticky header */ - animation: background-change linear both; - animation-timeline: scroll(); - animation-range: entry 0% entry 1%; - } - - /* Simple animation that changes background color when sticky */ - @keyframes background-change { - to { - background-color: var(--color-background-soft); - opacity: 0.95; - } - } - - .ant-menu-item { - height: 36px; - line-height: 36px; - - &.ant-menu-item-selected { - background-color: var(--color-background-mute) !important; - color: var(--color-text-primary) !important; - } - - &:not([data-menu-id^='pinned-']) { - .pin-icon { - opacity: 0; - } - - &:hover { - .pin-icon { - opacity: 0.3; - } - } - } - - .anticon { - min-width: auto; - } - } -` - -const ModelItem = styled.div` +const SearchInputContainer = styled.div` + width: 100%; display: flex; align-items: center; +` + +const SearchIcon = styled.div` + width: 30px; + height: 30px; + border-radius: 50%; + display: flex; + justify-content: center; + align-items: center; + background-color: var(--color-background-soft); + margin-right: 5px; + color: var(--color-icon); font-size: 14px; - position: relative; - width: 100%; + flex-shrink: 0; + + &:hover { + background-color: var(--color-background-mute); + } +` + +const TwoColumnContainer = styled.div` + display: flex; + height: 60vh; // 增加高度 +` + +const ProviderListColumn = styled.div` + width: 200px; // 减小宽度到200px + border-right: 0.5px solid var(--color-border); + padding: 15px 10px; // 减小内边距 + box-sizing: border-box; + background-color: var(--color-background-soft); // Slight background difference +` + +const ProviderListItem = styled.div<{ $selected: boolean }>` + padding: 10px 12px; // 增加上下内边距 + cursor: pointer; + border-radius: 8px; // 减小圆角 + margin-bottom: 8px; // 增加下边距 + font-size: 14px; // 减小字体大小 + font-weight: ${(props) => (props.$selected ? '600' : '400')}; + background-color: ${(props) => (props.$selected ? 'var(--color-background-mute)' : 'transparent')}; + color: ${(props) => (props.$selected ? 'var(--color-text-primary)' : 'var(--color-text)')}; + display: flex; + align-items: center; + justify-content: space-between; // To push pin icon to the right for "Pinned" + overflow: hidden; // 防止文本溢出 + text-overflow: ellipsis; // 溢出显示省略号 + white-space: nowrap; // 不换行 + + &:hover { + background-color: var(--color-background-mute); + } +` + +const ModelListColumn = styled.div` + flex: 1; + padding: 12px; // 减小内边距 + box-sizing: border-box; +` + +const ModelListItem = styled.div<{ $selected: boolean }>` + display: flex; + align-items: center; + padding: 8px 12px; // 进一步减小内边距 + margin-bottom: 6px; // 进一步减小下边距 + border-radius: 6px; // 进一步减小圆角 + cursor: pointer; + background-color: ${(props) => (props.$selected ? 'var(--color-background-mute)' : 'transparent')}; + + &:hover { + background-color: var(--color-background-mute); + .pin-button, .settings-button { + opacity: 0.5; // Show buttons on hover + } + } + + .pin-button, .settings-button { + opacity: ${(props) => (props.$selected ? 0.5 : 0)}; // Show if selected or hovered + transition: opacity 0.2s; + &:hover { + opacity: 1 !important; // Full opacity on direct hover + } + } +` + +const ModelDetails = styled.div` + margin-left: 10px; // 进一步减小左边距 + flex: 1; + overflow: hidden; // Prevent long names from breaking layout ` const ModelNameRow = styled.div` display: flex; - flex-direction: row; align-items: center; - gap: 8px; + gap: 6px; // 进一步减小间距 + font-size: 13px; // 进一步减小字体大小 + + .model-name { + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 160px; // 进一步减小最大宽度 + } + .provider-name { + color: var(--color-text-secondary); + font-size: 11px; // 进一步减小字体大小 + white-space: nowrap; + overflow: hidden; // 防止文本溢出 + text-overflow: ellipsis; // 溢出显示省略号 + max-width: 120px; // 增加最大宽度 + } +` +const ActionButtons = styled.div` + display: flex; + align-items: center; + gap: 4px; // 进一步减小间距 + margin-left: auto; // Push to the right +` + +const PinButton = styled.button<{ $isPinned: boolean }>` + background: none; + border: none; + cursor: pointer; + padding: 4px; // 进一步减小内边距 + color: ${(props) => (props.$isPinned ? 'var(--color-primary)' : 'var(--color-icon)')}; + transform: ${(props) => (props.$isPinned ? 'rotate(-45deg)' : 'none')}; + font-size: 14px; // 进一步减小图标大小 + line-height: 1; // Ensure icon aligns well + + &:hover { + color: ${(props) => (props.$isPinned ? 'var(--color-primary)' : 'var(--color-text-primary)')}; + } ` const EmptyState = styled.div` display: flex; justify-content: center; align-items: center; - height: 200px; + height: 100%; + color: var(--color-text-secondary); ` -const SearchIcon = styled.div` - width: 36px; - height: 36px; - border-radius: 50%; - display: flex; - flex-direction: row; - justify-content: center; - align-items: center; - background-color: var(--color-background-soft); - margin-right: 2px; +const ProviderName = styled.span` + overflow: hidden; + text-overflow: ellipsis; + flex: 1; ` -const PinIcon = styled.span.attrs({ className: 'pin-icon' })<{ isPinned: boolean }>` +const PinnedIcon = styled(PushpinOutlined)` margin-left: auto; - padding: 0 8px; - opacity: ${(props) => (props.isPinned ? 1 : 'inherit')}; - transition: opacity 0.2s; - position: absolute; - right: 0; - color: ${(props) => (props.isPinned ? 'var(--color-primary)' : 'inherit')}; - transform: ${(props) => (props.isPinned ? 'rotate(-45deg)' : 'none')}; - - &:hover { - opacity: 1 !important; - color: ${(props) => (props.isPinned ? 'var(--color-primary)' : 'inherit')}; - } + flex-shrink: 0; ` +const ProviderDivider = styled.div` + height: 1px; + background-color: var(--color-border); + margin: 8px 0; + opacity: 0.5; +` + +// --- Export Class --- export default class SelectModelPopup { static hide() { TopView.hide('SelectModelPopup') } static show(params: Props) { return new Promise((resolve) => { + // 直接显示新的弹窗,不使用setTimeout TopView.show(, 'SelectModelPopup') }) }