mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-06 21:35:52 +08:00
feat: quickly select model
This commit is contained in:
parent
e44f666c5c
commit
7e651f9abc
@ -2,10 +2,8 @@
|
|||||||
resize: none;
|
resize: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-nav-dropdown {
|
.ant-btn:not(:disabled):focus-visible {
|
||||||
.ant-dropdown-menu {
|
outline: none;
|
||||||
padding-bottom: 12px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-segmented-group {
|
.ant-segmented-group {
|
||||||
|
|||||||
184
src/renderer/src/components/Popups/SelectModelPopup.tsx
Normal file
184
src/renderer/src/components/Popups/SelectModelPopup.tsx
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
import { SearchOutlined } from '@ant-design/icons'
|
||||||
|
import VisionIcon from '@renderer/components/Icons/VisionIcon'
|
||||||
|
import { TopView } from '@renderer/components/TopView'
|
||||||
|
import { getModelLogo, isVisionModel } from '@renderer/config/models'
|
||||||
|
import { useProviders } from '@renderer/hooks/useProvider'
|
||||||
|
import { getModelUniqId } from '@renderer/services/model'
|
||||||
|
import { Model } from '@renderer/types'
|
||||||
|
import { Avatar, Divider, Empty, Input, InputRef, Menu, MenuProps, Modal } from 'antd'
|
||||||
|
import { first, reverse, sortBy, upperFirst } from 'lodash'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import styled from 'styled-components'
|
||||||
|
|
||||||
|
import { HStack } from '../Layout'
|
||||||
|
|
||||||
|
type MenuItem = Required<MenuProps>['items'][number]
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
model?: Model
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PopupContainerProps extends Props {
|
||||||
|
resolve: (value: Model | undefined) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
|
||||||
|
const [open, setOpen] = useState(true)
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [searchText, setSearchText] = useState('')
|
||||||
|
const inputRef = useRef<InputRef>(null)
|
||||||
|
const { providers } = useProviders()
|
||||||
|
|
||||||
|
const filteredItems: MenuItem[] = providers
|
||||||
|
.filter((p) => p.models && p.models.length > 0)
|
||||||
|
.map((p) => ({
|
||||||
|
key: p.id,
|
||||||
|
label: p.isSystem ? t(`provider.${p.id}`) : p.name,
|
||||||
|
type: 'group',
|
||||||
|
children: reverse(sortBy(p.models, 'name'))
|
||||||
|
.filter((m) => m.name.toLowerCase().includes(searchText.toLowerCase()))
|
||||||
|
.map((m) => ({
|
||||||
|
key: getModelUniqId(m),
|
||||||
|
label: (
|
||||||
|
<ModelItem>
|
||||||
|
{upperFirst(m?.name)} {isVisionModel(m) && <VisionIcon />}
|
||||||
|
</ModelItem>
|
||||||
|
),
|
||||||
|
icon: (
|
||||||
|
<Avatar src={getModelLogo(m?.id || '')} size={24}>
|
||||||
|
{first(m?.name)}
|
||||||
|
</Avatar>
|
||||||
|
),
|
||||||
|
onClick: () => {
|
||||||
|
resolve(m)
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
.filter((item) => item.children && item.children.length > 0) as MenuItem[]
|
||||||
|
|
||||||
|
const onCancel = () => {
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onClose = async () => {
|
||||||
|
resolve(undefined)
|
||||||
|
SelectModelPopup.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
open && setTimeout(() => inputRef.current?.focus(), 0)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
centered
|
||||||
|
open={open}
|
||||||
|
onCancel={onCancel}
|
||||||
|
afterClose={onClose}
|
||||||
|
transitionName="ant-move-down"
|
||||||
|
maskTransitionName="ant-fade"
|
||||||
|
styles={{ content: { borderRadius: 20, padding: 0, overflow: 'hidden', paddingBottom: 20 } }}
|
||||||
|
closeIcon={null}
|
||||||
|
footer={null}>
|
||||||
|
<HStack style={{ padding: '0 12px', marginTop: 5 }}>
|
||||||
|
<Input
|
||||||
|
prefix={
|
||||||
|
<SearchIcon>
|
||||||
|
<SearchOutlined />
|
||||||
|
</SearchIcon>
|
||||||
|
}
|
||||||
|
ref={inputRef}
|
||||||
|
placeholder={t('model.search')}
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
allowClear
|
||||||
|
autoFocus
|
||||||
|
style={{ paddingLeft: 0 }}
|
||||||
|
bordered={false}
|
||||||
|
size="middle"
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
<Divider style={{ margin: 0 }} />
|
||||||
|
<Container>
|
||||||
|
{filteredItems.length > 0 ? (
|
||||||
|
<StyledMenu
|
||||||
|
items={filteredItems}
|
||||||
|
selectedKeys={model ? [getModelUniqId(model)] : []}
|
||||||
|
mode="inline"
|
||||||
|
inlineIndent={6}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EmptyState>
|
||||||
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||||
|
</EmptyState>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Container = styled.div`
|
||||||
|
height: 50vh;
|
||||||
|
margin-top: 10px;
|
||||||
|
overflow-y: auto;
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const StyledMenu = styled(Menu)`
|
||||||
|
background-color: transparent;
|
||||||
|
padding: 5px;
|
||||||
|
margin-top: -10px;
|
||||||
|
max-height: calc(60vh - 50px);
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
.ant-menu-item-group-title {
|
||||||
|
padding: 5px 10px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-menu-item {
|
||||||
|
height: 36px;
|
||||||
|
line-height: 36px;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const ModelItem = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 14px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const EmptyState = styled.div`
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 200px;
|
||||||
|
`
|
||||||
|
|
||||||
|
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;
|
||||||
|
`
|
||||||
|
|
||||||
|
export default class SelectModelPopup {
|
||||||
|
static topviewId = 0
|
||||||
|
static hide() {
|
||||||
|
TopView.hide('SelectModelPopup')
|
||||||
|
}
|
||||||
|
static show(params: Props) {
|
||||||
|
return new Promise<Model | undefined>((resolve) => {
|
||||||
|
TopView.show(<PopupContainer {...params} resolve={resolve} />, 'SelectModelPopup')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -22,7 +22,7 @@ export function useProviders() {
|
|||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
providers,
|
providers: providers || {},
|
||||||
addProvider: (provider: Provider) => dispatch(addProvider(provider)),
|
addProvider: (provider: Provider) => dispatch(addProvider(provider)),
|
||||||
removeProvider: (provider: Provider) => dispatch(removeProvider(provider)),
|
removeProvider: (provider: Provider) => dispatch(removeProvider(provider)),
|
||||||
updateProvider: (provider: Provider) => dispatch(updateProvider(provider)),
|
updateProvider: (provider: Provider) => dispatch(updateProvider(provider)),
|
||||||
|
|||||||
@ -115,7 +115,8 @@
|
|||||||
"model_settings": "Model Settings"
|
"model_settings": "Model Settings"
|
||||||
},
|
},
|
||||||
"model": {
|
"model": {
|
||||||
"stream_output": "Stream Output"
|
"stream_output": "Stream Output",
|
||||||
|
"search": "Search models..."
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"title": "Files",
|
"title": "Files",
|
||||||
|
|||||||
@ -115,7 +115,8 @@
|
|||||||
"model_settings": "模型设置"
|
"model_settings": "模型设置"
|
||||||
},
|
},
|
||||||
"model": {
|
"model": {
|
||||||
"stream_output": "流式输出"
|
"stream_output": "流式输出",
|
||||||
|
"search": "搜索模型..."
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"title": "文件",
|
"title": "文件",
|
||||||
|
|||||||
@ -115,7 +115,8 @@
|
|||||||
"model_settings": "模型設定"
|
"model_settings": "模型設定"
|
||||||
},
|
},
|
||||||
"model": {
|
"model": {
|
||||||
"stream_output": "串流輸出"
|
"stream_output": "串流輸出",
|
||||||
|
"search": "搜尋模型..."
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"title": "檔案",
|
"title": "檔案",
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import styled from 'styled-components'
|
|||||||
|
|
||||||
import Inputbar from './Inputbar/Inputbar'
|
import Inputbar from './Inputbar/Inputbar'
|
||||||
import Messages from './Messages/Messages'
|
import Messages from './Messages/Messages'
|
||||||
import RightSidebar from './RightSidebar'
|
import RightSidebar from './Tabs'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
assistant: Assistant
|
assistant: Assistant
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import styled from 'styled-components'
|
|||||||
|
|
||||||
import Chat from './Chat'
|
import Chat from './Chat'
|
||||||
import Navbar from './Navbar'
|
import Navbar from './Navbar'
|
||||||
import RightSidebar from './RightSidebar'
|
import HomeTabs from './Tabs'
|
||||||
|
|
||||||
let _activeAssistant: Assistant
|
let _activeAssistant: Assistant
|
||||||
|
|
||||||
@ -29,7 +29,7 @@ const HomePage: FC = () => {
|
|||||||
<Navbar activeAssistant={activeAssistant} activeTopic={activeTopic} setActiveTopic={setActiveTopic} />
|
<Navbar activeAssistant={activeAssistant} activeTopic={activeTopic} setActiveTopic={setActiveTopic} />
|
||||||
<ContentContainer id="content-container">
|
<ContentContainer id="content-container">
|
||||||
{showAssistants && (
|
{showAssistants && (
|
||||||
<RightSidebar
|
<HomeTabs
|
||||||
activeAssistant={activeAssistant}
|
activeAssistant={activeAssistant}
|
||||||
activeTopic={activeTopic}
|
activeTopic={activeTopic}
|
||||||
setActiveAssistant={setActiveAssistant}
|
setActiveAssistant={setActiveAssistant}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
SaveOutlined,
|
SaveOutlined,
|
||||||
SyncOutlined
|
SyncOutlined
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
|
import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup'
|
||||||
import TextEditPopup from '@renderer/components/Popups/TextEditPopup'
|
import TextEditPopup from '@renderer/components/Popups/TextEditPopup'
|
||||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
|
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
|
||||||
import { Message, Model } from '@renderer/types'
|
import { Message, Model } from '@renderer/types'
|
||||||
@ -17,8 +18,6 @@ import { FC, useCallback, useMemo, useState } from 'react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
import SelectModelDropdown from '../components/SelectModelDropdown'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
message: Message
|
message: Message
|
||||||
model?: Model
|
model?: Model
|
||||||
@ -83,6 +82,13 @@ const MessageMenubar: FC<Props> = (props) => {
|
|||||||
[message.content, message.createdAt, onEdit, t]
|
[message.content, message.createdAt, onEdit, t]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const onSelectModel = async () => {
|
||||||
|
const selectedModel = await SelectModelPopup.show({ model })
|
||||||
|
if (selectedModel) {
|
||||||
|
setModel(selectedModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MenusBar className={`menubar ${isLastMessage && 'show'}`}>
|
<MenusBar className={`menubar ${isLastMessage && 'show'}`}>
|
||||||
{message.role === 'user' && (
|
{message.role === 'user' && (
|
||||||
@ -99,13 +105,11 @@ const MessageMenubar: FC<Props> = (props) => {
|
|||||||
</ActionButton>
|
</ActionButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{canRegenerate && (
|
{canRegenerate && (
|
||||||
<SelectModelDropdown model={model} onSelect={onRegenerate} placement="topLeft">
|
<Tooltip title={t('common.regenerate')} mouseEnterDelay={0.8}>
|
||||||
<Tooltip title={t('common.regenerate')} mouseEnterDelay={0.8}>
|
<ActionButton onClick={onSelectModel}>
|
||||||
<ActionButton>
|
<SyncOutlined />
|
||||||
<SyncOutlined />
|
</ActionButton>
|
||||||
</ActionButton>
|
</Tooltip>
|
||||||
</Tooltip>
|
|
||||||
</SelectModelDropdown>
|
|
||||||
)}
|
)}
|
||||||
{isAssistantMessage && (
|
{isAssistantMessage && (
|
||||||
<Tooltip title={t('chat.message.new.branch')} mouseEnterDelay={0.8}>
|
<Tooltip title={t('chat.message.new.branch')} mouseEnterDelay={0.8}>
|
||||||
|
|||||||
@ -27,7 +27,7 @@ type Tab = 'assistants' | 'topic' | 'settings'
|
|||||||
|
|
||||||
let _tab: any = ''
|
let _tab: any = ''
|
||||||
|
|
||||||
const RightSidebar: FC<Props> = ({ activeAssistant, activeTopic, setActiveAssistant, setActiveTopic, position }) => {
|
const HomeTabs: FC<Props> = ({ activeAssistant, activeTopic, setActiveAssistant, setActiveTopic, position }) => {
|
||||||
const { addAssistant } = useAssistants()
|
const { addAssistant } = useAssistants()
|
||||||
const [tab, setTab] = useState<Tab>(position === 'left' ? _tab || 'assistants' : 'topic')
|
const [tab, setTab] = useState<Tab>(position === 'left' ? _tab || 'assistants' : 'topic')
|
||||||
const { topicPosition } = useSettings()
|
const { topicPosition } = useSettings()
|
||||||
@ -164,4 +164,4 @@ const TabContent = styled.div`
|
|||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
`
|
`
|
||||||
|
|
||||||
export default RightSidebar
|
export default HomeTabs
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
|
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
|
||||||
import VisionIcon from '@renderer/components/Icons/VisionIcon'
|
import VisionIcon from '@renderer/components/Icons/VisionIcon'
|
||||||
|
import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup'
|
||||||
import { isLocalAi } from '@renderer/config/env'
|
import { isLocalAi } from '@renderer/config/env'
|
||||||
import { isVisionModel } from '@renderer/config/models'
|
import { isVisionModel } from '@renderer/config/models'
|
||||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||||
@ -10,8 +11,6 @@ import { FC } from 'react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
import SelectModelDropdown from './SelectModelDropdown'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
assistant: Assistant
|
assistant: Assistant
|
||||||
}
|
}
|
||||||
@ -24,14 +23,19 @@ const SelectModelButton: FC<Props> = ({ assistant }) => {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onSelectModel = async () => {
|
||||||
|
const selectedModel = await SelectModelPopup.show({ model })
|
||||||
|
if (selectedModel) {
|
||||||
|
setModel(selectedModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SelectModelDropdown model={model} onSelect={setModel} placement="top">
|
<DropdownButton size="small" type="default" onClick={onSelectModel}>
|
||||||
<DropdownButton size="small" type="default">
|
<ModelAvatar model={model} size={20} />
|
||||||
<ModelAvatar model={model} size={20} />
|
<ModelName>{model ? upperFirst(model.name) : t('button.select_model')}</ModelName>
|
||||||
<ModelName>{model ? upperFirst(model.name) : t('button.select_model')}</ModelName>
|
{isVisionModel(model) && <VisionIcon style={{ marginLeft: 0 }} />}
|
||||||
{isVisionModel(model) && <VisionIcon style={{ marginLeft: 0 }} />}
|
</DropdownButton>
|
||||||
</DropdownButton>
|
|
||||||
</SelectModelDropdown>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -39,6 +43,7 @@ const DropdownButton = styled(Button)`
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
border-radius: 15px;
|
border-radius: 15px;
|
||||||
padding: 12px 8px 12px 3px;
|
padding: 12px 8px 12px 3px;
|
||||||
|
-webkit-app-region: none;
|
||||||
`
|
`
|
||||||
|
|
||||||
const ModelName = styled.span`
|
const ModelName = styled.span`
|
||||||
|
|||||||
@ -1,64 +0,0 @@
|
|||||||
import VisionIcon from '@renderer/components/Icons/VisionIcon'
|
|
||||||
import { getModelLogo, isVisionModel } from '@renderer/config/models'
|
|
||||||
import { useProviders } from '@renderer/hooks/useProvider'
|
|
||||||
import { getModelUniqId } from '@renderer/services/model'
|
|
||||||
import { Model } from '@renderer/types'
|
|
||||||
import { Avatar, Dropdown, DropdownProps, MenuProps } from 'antd'
|
|
||||||
import { first, reverse, sortBy, upperFirst } from 'lodash'
|
|
||||||
import { FC, PropsWithChildren } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import styled from 'styled-components'
|
|
||||||
|
|
||||||
interface Props extends DropdownProps {
|
|
||||||
model?: Model
|
|
||||||
onSelect: (model: Model) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const SelectModelDropdown: FC<Props & PropsWithChildren> = ({ children, model, onSelect, ...props }) => {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const { providers } = useProviders()
|
|
||||||
|
|
||||||
const items: MenuProps['items'] = (providers || [])
|
|
||||||
.filter((p) => p.models && p.models.length > 0)
|
|
||||||
.map((p) => ({
|
|
||||||
key: p.id,
|
|
||||||
label: p.isSystem ? t(`provider.${p.id}`) : p.name,
|
|
||||||
type: 'group',
|
|
||||||
children: reverse(sortBy(p.models, 'name')).map((m) => ({
|
|
||||||
key: getModelUniqId(m),
|
|
||||||
label: (
|
|
||||||
<div>
|
|
||||||
{upperFirst(m?.name)} {isVisionModel(m) && <VisionIcon />}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
icon: (
|
|
||||||
<Avatar src={getModelLogo(m?.id || '')} size={24}>
|
|
||||||
{first(m?.name)}
|
|
||||||
</Avatar>
|
|
||||||
),
|
|
||||||
onClick: () => m && onSelect(m)
|
|
||||||
}))
|
|
||||||
}))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu
|
|
||||||
menu={{
|
|
||||||
items,
|
|
||||||
style: { maxHeight: '55vh', overflow: 'auto' },
|
|
||||||
selectedKeys: model ? [getModelUniqId(model)] : []
|
|
||||||
}}
|
|
||||||
trigger={['click']}
|
|
||||||
arrow
|
|
||||||
placement="bottom"
|
|
||||||
overlayClassName="chat-nav-dropdown"
|
|
||||||
{...props}>
|
|
||||||
{children}
|
|
||||||
</DropdownMenu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const DropdownMenu = styled(Dropdown)`
|
|
||||||
-webkit-app-region: none;
|
|
||||||
`
|
|
||||||
|
|
||||||
export default SelectModelDropdown
|
|
||||||
Loading…
Reference in New Issue
Block a user