Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eeb6bff68d | |||
| dcdd8b4031 |
@@ -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<ModelEditPopupProps> = ({ 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 (
|
||||||
|
<Modal
|
||||||
|
title={t('models.edit')}
|
||||||
|
open={open}
|
||||||
|
onCancel={handleClose}
|
||||||
|
footer={null}
|
||||||
|
maskClosable={false}
|
||||||
|
centered
|
||||||
|
width={600} // 增加宽度
|
||||||
|
styles={{
|
||||||
|
content: {
|
||||||
|
padding: '20px', // 增加内边距
|
||||||
|
borderRadius: 15 // 增加圆角
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
afterOpenChange={(visible) => {
|
||||||
|
if (visible) {
|
||||||
|
form.getFieldInstance('id')?.focus()
|
||||||
|
} else {
|
||||||
|
setShowModelTypes(false)
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
labelCol={{ flex: '120px' }} // 增加标签宽度
|
||||||
|
labelAlign="left"
|
||||||
|
colon={false}
|
||||||
|
style={{ marginTop: 15 }}
|
||||||
|
size="large" // 使表单控件更大
|
||||||
|
initialValues={{
|
||||||
|
id: model.id,
|
||||||
|
name: model.name,
|
||||||
|
group: model.group
|
||||||
|
}}
|
||||||
|
onFinish={onFinish}>
|
||||||
|
<Form.Item
|
||||||
|
name="id"
|
||||||
|
label={t('settings.models.add.model_id')}
|
||||||
|
tooltip={t('settings.models.add.model_id.tooltip')}
|
||||||
|
rules={[{ required: true }]}>
|
||||||
|
<Flex justify="space-between" gap={5}>
|
||||||
|
<Input
|
||||||
|
placeholder={t('settings.models.add.model_id.placeholder')}
|
||||||
|
spellCheck={false}
|
||||||
|
maxLength={200}
|
||||||
|
disabled={true}
|
||||||
|
value={model.id}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value
|
||||||
|
form.setFieldValue('name', value)
|
||||||
|
form.setFieldValue('group', getDefaultGroupName(value))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<CopyIcon />}
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(model.id)
|
||||||
|
message.success(t('message.copy.success'))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="name"
|
||||||
|
label={t('settings.models.add.model_name')}
|
||||||
|
tooltip={t('settings.models.add.model_name.tooltip')}
|
||||||
|
rules={[{ required: true }]}>
|
||||||
|
<Input placeholder={t('settings.models.add.model_name.placeholder')} spellCheck={false} maxLength={200} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="group"
|
||||||
|
label={t('settings.models.add.model_group')}
|
||||||
|
tooltip={t('settings.models.add.model_group.tooltip')}>
|
||||||
|
<Input placeholder={t('settings.models.add.model_group.placeholder')} spellCheck={false} maxLength={200} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item style={{ marginBottom: 20, textAlign: 'center', marginTop: 10 }}>
|
||||||
|
<Flex justify="space-between" align="center" style={{ position: 'relative' }}>
|
||||||
|
<MoreSettingsRow onClick={() => setShowModelTypes(!showModelTypes)}>
|
||||||
|
{t('settings.moresetting')}
|
||||||
|
<ExpandIcon>{showModelTypes ? <UpOutlined /> : <DownOutlined />}</ExpandIcon>
|
||||||
|
</MoreSettingsRow>
|
||||||
|
<Button type="primary" htmlType="submit" size="large">
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</Form.Item>
|
||||||
|
{showModelTypes && (
|
||||||
|
<div>
|
||||||
|
<Divider style={{ margin: '0 0 15px 0' }} />
|
||||||
|
<TypeTitle>{t('models.type.select')}:</TypeTitle>
|
||||||
|
{(() => {
|
||||||
|
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 (
|
||||||
|
<Checkbox.Group
|
||||||
|
value={selectedTypes}
|
||||||
|
onChange={handleTypeChange}
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', gap: 15 }}>
|
||||||
|
<StyledCheckbox value="vision">{t('models.type.vision')}</StyledCheckbox>
|
||||||
|
<StyledCheckbox value="web_search">{t('models.type.websearch')}</StyledCheckbox>
|
||||||
|
<StyledCheckbox value="reasoning">{t('models.type.reasoning')}</StyledCheckbox>
|
||||||
|
<StyledCheckbox value="function_calling">{t('models.type.function_calling')}</StyledCheckbox>
|
||||||
|
<StyledCheckbox value="embedding">{t('models.type.embedding')}</StyledCheckbox>
|
||||||
|
<StyledCheckbox value="rerank">{t('models.type.rerank')}</StyledCheckbox>
|
||||||
|
</Checkbox.Group>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)<CheckboxProps>`
|
||||||
|
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<Model | undefined>((resolve) => {
|
||||||
|
TopView.show(<PopupContainer model={model} resolve={resolve} />, 'ModelEditPopup')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ModelSettingsButtonProps> = ({ 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 (
|
||||||
|
<Tooltip title={t('models.edit')} placement="top">
|
||||||
|
<StyledButton
|
||||||
|
type="text"
|
||||||
|
icon={<SettingOutlined style={{ fontSize: size }} />}
|
||||||
|
onClick={handleClick}
|
||||||
|
className={className}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
@@ -4,546 +4,482 @@ import { getModelLogo, isEmbeddingModel, isRerankModel } from '@renderer/config/
|
|||||||
import db from '@renderer/databases'
|
import db from '@renderer/databases'
|
||||||
import { useProviders } from '@renderer/hooks/useProvider'
|
import { useProviders } from '@renderer/hooks/useProvider'
|
||||||
import { getModelUniqId } from '@renderer/services/ModelService'
|
import { getModelUniqId } from '@renderer/services/ModelService'
|
||||||
import { Model } from '@renderer/types'
|
import { Model } from '@renderer/types' // Removed unused 'Provider' import
|
||||||
import { Avatar, Divider, Empty, Input, InputRef, Menu, MenuProps, Modal } from 'antd'
|
import { Avatar, Divider, Empty, Input, InputRef, Modal, Tooltip } from 'antd'
|
||||||
import { first, sortBy } from 'lodash'
|
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 { useTranslation } from 'react-i18next'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
import { HStack } from '../Layout'
|
import { HStack } from '../Layout'
|
||||||
import ModelTagsWithLabel from '../ModelTagsWithLabel'
|
import ModelTags from '../ModelTags'
|
||||||
import Scrollbar from '../Scrollbar'
|
import Scrollbar from '../Scrollbar'
|
||||||
|
import ModelSettingsButton from './ModelSettingsButton'
|
||||||
type MenuItem = Required<MenuProps>['items'][number]
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
model?: Model
|
model?: Model // The currently active model, for highlighting
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PopupContainerProps extends Props {
|
interface PopupContainerProps extends Props {
|
||||||
resolve: (value: Model | undefined) => void
|
resolve: (value: Model | undefined) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
|
const PINNED_PROVIDER_ID = '__pinned__' // Special ID for pinned section
|
||||||
|
|
||||||
|
const PopupContainer: React.FC<PopupContainerProps> = ({ model: activeModel, resolve }) => {
|
||||||
const [open, setOpen] = useState(true)
|
const [open, setOpen] = useState(true)
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [searchText, setSearchText] = useState('')
|
const [searchText, setSearchText] = useState('')
|
||||||
const inputRef = useRef<InputRef>(null)
|
const inputRef = useRef<InputRef>(null)
|
||||||
const { providers } = useProviders()
|
const { providers } = useProviders()
|
||||||
const [pinnedModels, setPinnedModels] = useState<string[]>([])
|
const [pinnedModels, setPinnedModels] = useState<string[]>([])
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
const [selectedProviderId, setSelectedProviderId] = useState<string>('all')
|
||||||
const [keyboardSelectedId, setKeyboardSelectedId] = useState<string>('')
|
// 移除未使用的状态
|
||||||
const menuItemRefs = useRef<Record<string, HTMLElement | null>>({})
|
|
||||||
|
|
||||||
const setMenuItemRef = useCallback(
|
|
||||||
(key: string) => (el: HTMLElement | null) => {
|
|
||||||
if (el) {
|
|
||||||
menuItemRefs.current[key] = el
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
// --- Load Pinned Models ---
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadPinnedModels = async () => {
|
const loadPinnedModels = async () => {
|
||||||
const setting = await db.settings.get('pinned:models')
|
const setting = await db.settings.get('pinned:models')
|
||||||
const savedPinnedModels = setting?.value || []
|
const savedPinnedModels = setting?.value || []
|
||||||
|
|
||||||
// Filter out invalid pinned models
|
|
||||||
const allModelIds = providers.flatMap((p) => p.models || []).map((m) => getModelUniqId(m))
|
const allModelIds = providers.flatMap((p) => p.models || []).map((m) => getModelUniqId(m))
|
||||||
const validPinnedModels = savedPinnedModels.filter((id) => allModelIds.includes(id))
|
const validPinnedModels = savedPinnedModels.filter((id: string) => allModelIds.includes(id))
|
||||||
|
|
||||||
// Update storage if there were invalid models
|
|
||||||
if (validPinnedModels.length !== savedPinnedModels.length) {
|
if (validPinnedModels.length !== savedPinnedModels.length) {
|
||||||
await db.settings.put({ id: 'pinned:models', value: validPinnedModels })
|
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()
|
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])
|
}, [providers])
|
||||||
|
|
||||||
const togglePin = async (modelId: string) => {
|
// --- Filter Models for Right Column ---
|
||||||
const newPinnedModels = pinnedModels.includes(modelId)
|
const displayedModels = useMemo(() => {
|
||||||
? pinnedModels.filter((id) => id !== modelId)
|
let modelsToShow: Model[] = []
|
||||||
: [...pinnedModels, modelId]
|
|
||||||
|
|
||||||
await db.settings.put({ id: 'pinned:models', value: newPinnedModels })
|
// 如果有搜索文本,在所有模型中搜索
|
||||||
setPinnedModels(sortBy(newPinnedModels, ['group', 'name']))
|
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 getFilteredModels = useCallback(
|
const providerName = provider ? (provider.isSystem ? t(`provider.${provider.id}`) : provider.name) : ''
|
||||||
(provider) => {
|
const fullName = `${m.name} ${providerName}`.toLowerCase()
|
||||||
let models = provider.models.filter((m) => !isEmbeddingModel(m) && !isRerankModel(m))
|
return keywords.every((keyword) => fullName.includes(keyword))
|
||||||
|
|
||||||
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: <div ref={setMenuItemRef(item.key)}>{item.label}</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return items.map((item) => {
|
|
||||||
if (item && 'children' in item && item.children) {
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
children: (item.children as MenuItem[]).map(renderMenuItem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return item
|
|
||||||
})
|
})
|
||||||
},
|
} else {
|
||||||
[setMenuItemRef]
|
// 没有搜索文本时,根据选择的供应商筛选
|
||||||
)
|
if (selectedProviderId === 'all') {
|
||||||
|
// 显示所有模型
|
||||||
const filteredItems: MenuItem[] = providers
|
modelsToShow = allModels
|
||||||
.filter((p) => p.models && p.models.length > 0)
|
} else if (selectedProviderId === PINNED_PROVIDER_ID) {
|
||||||
.map((p) => {
|
// 显示固定的模型
|
||||||
const filteredModels = getFilteredModels(p).map((m) => ({
|
modelsToShow = allModels.filter((m) => pinnedModels.includes(getModelUniqId(m)))
|
||||||
key: getModelUniqId(m),
|
} else if (selectedProviderId) {
|
||||||
label: (
|
// 显示选中供应商的模型
|
||||||
<ModelItem>
|
const provider = providers.find((p) => p.id === selectedProviderId)
|
||||||
<ModelNameRow>
|
if (provider && provider.models) {
|
||||||
<span>{m?.name}</span> <ModelTagsWithLabel model={m} size={11} showLabel={false} />
|
modelsToShow = provider.models.filter((m) => !isEmbeddingModel(m) && !isRerankModel(m))
|
||||||
</ModelNameRow>
|
|
||||||
<PinIcon
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
togglePin(getModelUniqId(m))
|
|
||||||
}}
|
|
||||||
isPinned={pinnedModels.includes(getModelUniqId(m))}>
|
|
||||||
<PushpinOutlined />
|
|
||||||
</PinIcon>
|
|
||||||
</ModelItem>
|
|
||||||
),
|
|
||||||
icon: (
|
|
||||||
<Avatar src={getModelLogo(m?.id || '')} size={24}>
|
|
||||||
{first(m?.name)}
|
|
||||||
</Avatar>
|
|
||||||
),
|
|
||||||
onClick: () => {
|
|
||||||
resolve(m)
|
|
||||||
setOpen(false)
|
|
||||||
}
|
}
|
||||||
}))
|
}
|
||||||
|
|
||||||
// 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: (
|
|
||||||
<ModelItem>
|
|
||||||
<ModelNameRow>
|
|
||||||
<span>
|
|
||||||
{m.model?.name} | {m.provider.isSystem ? t(`provider.${m.provider.id}`) : m.provider.name}
|
|
||||||
</span>{' '}
|
|
||||||
<ModelTagsWithLabel model={m.model} size={11} showLabel={false} />
|
|
||||||
</ModelNameRow>
|
|
||||||
<PinIcon
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
togglePin(getModelUniqId(m.model))
|
|
||||||
}}
|
|
||||||
isPinned={true}>
|
|
||||||
<PushpinOutlined />
|
|
||||||
</PinIcon>
|
|
||||||
</ModelItem>
|
|
||||||
),
|
|
||||||
icon: (
|
|
||||||
<Avatar src={getModelLogo(m.model?.id || '')} size={24}>
|
|
||||||
{first(m.model?.name)}
|
|
||||||
</Avatar>
|
|
||||||
),
|
|
||||||
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
|
return sortBy(modelsToShow, ['group', 'name'])
|
||||||
const processedItems = processMenuItems(filteredItems)
|
}, [selectedProviderId, pinnedModels, searchText, allModels, providers, t])
|
||||||
|
|
||||||
const onCancel = () => {
|
// --- Event Handlers ---
|
||||||
setKeyboardSelectedId('')
|
const handleProviderSelect = useCallback((providerId: string) => {
|
||||||
|
setSelectedProviderId(providerId)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleModelSelect = useCallback((model: Model) => {
|
||||||
|
resolve(model)
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
}
|
}, [resolve, setOpen])
|
||||||
|
|
||||||
const onClose = async () => {
|
const onCancel = useCallback(() => {
|
||||||
setKeyboardSelectedId('')
|
setOpen(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onClose = useCallback(async () => {
|
||||||
resolve(undefined)
|
resolve(undefined)
|
||||||
SelectModelPopup.hide()
|
SelectModelPopup.hide()
|
||||||
}
|
}, [resolve])
|
||||||
|
|
||||||
|
// --- Focus Input on Open ---
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
open && setTimeout(() => inputRef.current?.focus(), 0)
|
open && setTimeout(() => inputRef.current?.focus(), 0)
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
useEffect(() => {
|
// --- Provider List for Left Column ---
|
||||||
if (open && model) {
|
const providerListItems = useMemo(() => {
|
||||||
setTimeout(() => {
|
const items: { id: string; name: string }[] = [
|
||||||
const modelId = getModelUniqId(model)
|
{ id: 'all', name: t('models.all') || '全部' } // 添加“全部”选项
|
||||||
if (menuItemRefs.current[modelId]) {
|
]
|
||||||
menuItemRefs.current[modelId]?.scrollIntoView({ block: 'center', behavior: 'auto' })
|
if (pinnedModels.length > 0) {
|
||||||
}
|
items.push({ id: PINNED_PROVIDER_ID, name: t('models.pinned') })
|
||||||
}, 100) // Small delay to ensure menu is rendered
|
|
||||||
}
|
}
|
||||||
}, [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) => {
|
providers.forEach((p) => {
|
||||||
if (p.models) {
|
// Only add provider if it has non-embedding/rerank models
|
||||||
getFilteredModels(p).forEach((m) => {
|
if (p.models?.some((m) => !isEmbeddingModel(m) && !isRerankModel(m))) {
|
||||||
const modelId = getModelUniqId(m)
|
items.push({ id: p.id, name: p.isSystem ? t(`provider.${p.id}`) : p.name })
|
||||||
const isPinned = pinnedModels.includes(modelId)
|
|
||||||
|
|
||||||
// 搜索状态下,所有匹配的模型都应该可以被选中,包括固定的模型
|
|
||||||
// 非搜索状态下,只添加非固定模型(固定模型已在上面添加)
|
|
||||||
if (searchText.length > 0 || !isPinned) {
|
|
||||||
items.push({
|
|
||||||
key: modelId,
|
|
||||||
model: m
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return items
|
return items
|
||||||
}, [pinnedModels, searchText, providers, getFilteredModels])
|
}, [providers, pinnedModels, t])
|
||||||
|
|
||||||
// 添加一个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)] : []
|
|
||||||
|
|
||||||
|
// --- Render ---
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
centered
|
centered
|
||||||
open={open}
|
open={open}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
afterClose={onClose}
|
afterClose={onClose}
|
||||||
width={600}
|
transitionName=""
|
||||||
transitionName="ant-move-down"
|
|
||||||
styles={{
|
styles={{
|
||||||
content: {
|
content: {
|
||||||
borderRadius: 20,
|
borderRadius: 15, // Adjusted border radius
|
||||||
padding: 0,
|
padding: 0,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
paddingBottom: 20,
|
|
||||||
border: '1px solid var(--color-border)'
|
border: '1px solid var(--color-border)'
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
padding: 0 // Remove default body padding
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
closeIcon={null}
|
closeIcon={null}
|
||||||
footer={null}>
|
footer={null}
|
||||||
<HStack style={{ padding: '0 12px', marginTop: 5 }}>
|
width={900} // 进一步增加宽度,使界面更宽敞
|
||||||
<Input
|
>
|
||||||
prefix={
|
{/* Search Input */}
|
||||||
<SearchIcon>
|
<SearchContainer onClick={() => inputRef.current?.focus()}>
|
||||||
<SearchOutlined />
|
<SearchInputContainer>
|
||||||
</SearchIcon>
|
<Input
|
||||||
}
|
prefix={
|
||||||
ref={inputRef}
|
<SearchIcon>
|
||||||
placeholder={t('models.search')}
|
<SearchOutlined />
|
||||||
value={searchText}
|
</SearchIcon>
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
}}
|
ref={inputRef}
|
||||||
/>
|
placeholder={t('models.search')}
|
||||||
</HStack>
|
value={searchText}
|
||||||
<Divider style={{ margin: 0, borderBlockStartWidth: 0.5 }} />
|
onChange={useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
<Scrollbar style={{ height: '50vh' }} ref={scrollContainerRef}>
|
const value = e.target.value
|
||||||
<Container>
|
setSearchText(value)
|
||||||
{processedItems.length > 0 ? (
|
// 当搜索时,自动选择"all"供应商,以显示所有匹配的模型
|
||||||
<StyledMenu
|
if (value.trim() && selectedProviderId !== 'all') {
|
||||||
items={processedItems}
|
setSelectedProviderId('all')
|
||||||
selectedKeys={selectedKeys}
|
}
|
||||||
mode="inline"
|
}, [selectedProviderId, t])}
|
||||||
inlineIndent={6}
|
// 移除焦点事件处理
|
||||||
onSelect={({ key }) => {
|
allowClear
|
||||||
setKeyboardSelectedId(key as string)
|
autoFocus
|
||||||
}}
|
style={{
|
||||||
/>
|
paddingLeft: 0,
|
||||||
) : (
|
height: '32px',
|
||||||
<EmptyState>
|
fontSize: '14px'
|
||||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
}}
|
||||||
</EmptyState>
|
variant="borderless"
|
||||||
)}
|
size="middle"
|
||||||
</Container>
|
/>
|
||||||
</Scrollbar>
|
</SearchInputContainer>
|
||||||
|
</SearchContainer>
|
||||||
|
<Divider style={{ margin: 0, borderBlockStartWidth: 0.5, marginTop: -5 }} />
|
||||||
|
|
||||||
|
{/* Two Column Layout */}
|
||||||
|
<TwoColumnContainer>
|
||||||
|
{/* Left Column: Providers */}
|
||||||
|
<ProviderListColumn>
|
||||||
|
<Scrollbar style={{ height: '60vh', paddingRight: '5px' }}>
|
||||||
|
{providerListItems.map((provider, index) => (
|
||||||
|
<React.Fragment key={provider.id}>
|
||||||
|
<Tooltip title={provider.name} placement="right" mouseEnterDelay={0.5}>
|
||||||
|
<ProviderListItem
|
||||||
|
$selected={selectedProviderId === provider.id}
|
||||||
|
onClick={() => handleProviderSelect(provider.id)}>
|
||||||
|
<ProviderName>{provider.name}</ProviderName>
|
||||||
|
{provider.id === PINNED_PROVIDER_ID && <PinnedIcon />}
|
||||||
|
</ProviderListItem>
|
||||||
|
</Tooltip>
|
||||||
|
{/* 在每个供应商之后添加分割线,除了最后一个 */}
|
||||||
|
{index < providerListItems.length - 1 && <ProviderDivider />}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</Scrollbar>
|
||||||
|
</ProviderListColumn>
|
||||||
|
|
||||||
|
{/* Right Column: Models */}
|
||||||
|
<ModelListColumn>
|
||||||
|
<Scrollbar style={{ height: '60vh', paddingRight: '5px' }}>
|
||||||
|
{displayedModels.length > 0 ? (
|
||||||
|
displayedModels.map((m) => (
|
||||||
|
<ModelListItem
|
||||||
|
key={getModelUniqId(m)}
|
||||||
|
$selected={activeModel ? getModelUniqId(activeModel) === getModelUniqId(m) : false}
|
||||||
|
onClick={() => handleModelSelect(m)}>
|
||||||
|
<Avatar src={getModelLogo(m?.id || '')} size={24}>
|
||||||
|
{first(m?.name)}
|
||||||
|
</Avatar>
|
||||||
|
<ModelDetails>
|
||||||
|
<ModelNameRow>
|
||||||
|
<Tooltip title={m?.name} mouseEnterDelay={0.5}>
|
||||||
|
<span className="model-name">{m?.name}</span>
|
||||||
|
</Tooltip>
|
||||||
|
{/* Show provider only if not in pinned view or if search is active */}
|
||||||
|
{(selectedProviderId !== PINNED_PROVIDER_ID || searchText) && (
|
||||||
|
<Tooltip title={providers.find((p) => p.id === m.provider)?.name ?? m.provider} mouseEnterDelay={0.5}>
|
||||||
|
<span className="provider-name">
|
||||||
|
| {providers.find((p) => p.id === m.provider)?.name ?? m.provider}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<ModelTags model={m} />
|
||||||
|
</ModelNameRow>
|
||||||
|
</ModelDetails>
|
||||||
|
<ActionButtons>
|
||||||
|
<ModelSettingsButton model={m} size={14} className="settings-button" />
|
||||||
|
<PinButton
|
||||||
|
$isPinned={pinnedModels.includes(getModelUniqId(m))}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation() // Prevent model selection when clicking pin
|
||||||
|
togglePin(getModelUniqId(m))
|
||||||
|
}}>
|
||||||
|
<PushpinOutlined />
|
||||||
|
</PinButton>
|
||||||
|
</ActionButtons>
|
||||||
|
</ModelListItem>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<EmptyState>
|
||||||
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t('models.no_matches')} />
|
||||||
|
</EmptyState>
|
||||||
|
)}
|
||||||
|
</Scrollbar>
|
||||||
|
</ModelListColumn>
|
||||||
|
</TwoColumnContainer>
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const Container = styled.div`
|
// --- Styled Components ---
|
||||||
margin-top: 10px;
|
|
||||||
|
const SearchContainer = styled(HStack)`
|
||||||
|
padding: 8px 15px;
|
||||||
|
cursor: pointer;
|
||||||
`
|
`
|
||||||
|
|
||||||
const StyledMenu = styled(Menu)`
|
const SearchInputContainer = styled.div`
|
||||||
background-color: transparent;
|
width: 100%;
|
||||||
padding: 5px;
|
|
||||||
margin-top: -10px;
|
|
||||||
max-height: calc(60vh - 50px);
|
|
||||||
|
|
||||||
.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`
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
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;
|
font-size: 14px;
|
||||||
position: relative;
|
flex-shrink: 0;
|
||||||
width: 100%;
|
|
||||||
|
&: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`
|
const ModelNameRow = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
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`
|
const EmptyState = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 200px;
|
height: 100%;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
`
|
`
|
||||||
|
|
||||||
const SearchIcon = styled.div`
|
const ProviderName = styled.span`
|
||||||
width: 36px;
|
overflow: hidden;
|
||||||
height: 36px;
|
text-overflow: ellipsis;
|
||||||
border-radius: 50%;
|
flex: 1;
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
background-color: var(--color-background-soft);
|
|
||||||
margin-right: 2px;
|
|
||||||
`
|
`
|
||||||
|
|
||||||
const PinIcon = styled.span.attrs({ className: 'pin-icon' })<{ isPinned: boolean }>`
|
const PinnedIcon = styled(PushpinOutlined)`
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
padding: 0 8px;
|
flex-shrink: 0;
|
||||||
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')};
|
|
||||||
}
|
|
||||||
`
|
`
|
||||||
|
|
||||||
|
const ProviderDivider = styled.div`
|
||||||
|
height: 1px;
|
||||||
|
background-color: var(--color-border);
|
||||||
|
margin: 8px 0;
|
||||||
|
opacity: 0.5;
|
||||||
|
`
|
||||||
|
|
||||||
|
// --- Export Class ---
|
||||||
export default class SelectModelPopup {
|
export default class SelectModelPopup {
|
||||||
static hide() {
|
static hide() {
|
||||||
TopView.hide('SelectModelPopup')
|
TopView.hide('SelectModelPopup')
|
||||||
}
|
}
|
||||||
static show(params: Props) {
|
static show(params: Props) {
|
||||||
return new Promise<Model | undefined>((resolve) => {
|
return new Promise<Model | undefined>((resolve) => {
|
||||||
|
// 直接显示新的弹窗,不使用setTimeout
|
||||||
TopView.show(<PopupContainer {...params} resolve={resolve} />, 'SelectModelPopup')
|
TopView.show(<PopupContainer {...params} resolve={resolve} />, 'SelectModelPopup')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user