feat: quickly select model

This commit is contained in:
kangfenmao 2024-10-11 13:05:03 +08:00
parent e44f666c5c
commit 7e651f9abc
15 changed files with 225 additions and 95 deletions

View File

@ -2,10 +2,8 @@
resize: none;
}
.chat-nav-dropdown {
.ant-dropdown-menu {
padding-bottom: 12px;
}
.ant-btn:not(:disabled):focus-visible {
outline: none;
}
.ant-segmented-group {

View 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')
})
}
}

View File

@ -22,7 +22,7 @@ export function useProviders() {
const dispatch = useAppDispatch()
return {
providers,
providers: providers || {},
addProvider: (provider: Provider) => dispatch(addProvider(provider)),
removeProvider: (provider: Provider) => dispatch(removeProvider(provider)),
updateProvider: (provider: Provider) => dispatch(updateProvider(provider)),

View File

@ -115,7 +115,8 @@
"model_settings": "Model Settings"
},
"model": {
"stream_output": "Stream Output"
"stream_output": "Stream Output",
"search": "Search models..."
},
"files": {
"title": "Files",

View File

@ -115,7 +115,8 @@
"model_settings": "模型设置"
},
"model": {
"stream_output": "流式输出"
"stream_output": "流式输出",
"search": "搜索模型..."
},
"files": {
"title": "文件",

View File

@ -115,7 +115,8 @@
"model_settings": "模型設定"
},
"model": {
"stream_output": "串流輸出"
"stream_output": "串流輸出",
"search": "搜尋模型..."
},
"files": {
"title": "檔案",

View File

@ -8,7 +8,7 @@ import styled from 'styled-components'
import Inputbar from './Inputbar/Inputbar'
import Messages from './Messages/Messages'
import RightSidebar from './RightSidebar'
import RightSidebar from './Tabs'
interface Props {
assistant: Assistant

View File

@ -8,7 +8,7 @@ import styled from 'styled-components'
import Chat from './Chat'
import Navbar from './Navbar'
import RightSidebar from './RightSidebar'
import HomeTabs from './Tabs'
let _activeAssistant: Assistant
@ -29,7 +29,7 @@ const HomePage: FC = () => {
<Navbar activeAssistant={activeAssistant} activeTopic={activeTopic} setActiveTopic={setActiveTopic} />
<ContentContainer id="content-container">
{showAssistants && (
<RightSidebar
<HomeTabs
activeAssistant={activeAssistant}
activeTopic={activeTopic}
setActiveAssistant={setActiveAssistant}

View File

@ -8,6 +8,7 @@ import {
SaveOutlined,
SyncOutlined
} from '@ant-design/icons'
import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup'
import TextEditPopup from '@renderer/components/Popups/TextEditPopup'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
import { Message, Model } from '@renderer/types'
@ -17,8 +18,6 @@ import { FC, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import SelectModelDropdown from '../components/SelectModelDropdown'
interface Props {
message: Message
model?: Model
@ -83,6 +82,13 @@ const MessageMenubar: FC<Props> = (props) => {
[message.content, message.createdAt, onEdit, t]
)
const onSelectModel = async () => {
const selectedModel = await SelectModelPopup.show({ model })
if (selectedModel) {
setModel(selectedModel)
}
}
return (
<MenusBar className={`menubar ${isLastMessage && 'show'}`}>
{message.role === 'user' && (
@ -99,13 +105,11 @@ const MessageMenubar: FC<Props> = (props) => {
</ActionButton>
</Tooltip>
{canRegenerate && (
<SelectModelDropdown model={model} onSelect={onRegenerate} placement="topLeft">
<Tooltip title={t('common.regenerate')} mouseEnterDelay={0.8}>
<ActionButton>
<SyncOutlined />
</ActionButton>
</Tooltip>
</SelectModelDropdown>
<Tooltip title={t('common.regenerate')} mouseEnterDelay={0.8}>
<ActionButton onClick={onSelectModel}>
<SyncOutlined />
</ActionButton>
</Tooltip>
)}
{isAssistantMessage && (
<Tooltip title={t('chat.message.new.branch')} mouseEnterDelay={0.8}>

View File

@ -27,7 +27,7 @@ type Tab = 'assistants' | 'topic' | 'settings'
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 [tab, setTab] = useState<Tab>(position === 'left' ? _tab || 'assistants' : 'topic')
const { topicPosition } = useSettings()
@ -164,4 +164,4 @@ const TabContent = styled.div`
overflow-x: hidden;
`
export default RightSidebar
export default HomeTabs

View File

@ -1,5 +1,6 @@
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
import VisionIcon from '@renderer/components/Icons/VisionIcon'
import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup'
import { isLocalAi } from '@renderer/config/env'
import { isVisionModel } from '@renderer/config/models'
import { useAssistant } from '@renderer/hooks/useAssistant'
@ -10,8 +11,6 @@ import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import SelectModelDropdown from './SelectModelDropdown'
interface Props {
assistant: Assistant
}
@ -24,14 +23,19 @@ const SelectModelButton: FC<Props> = ({ assistant }) => {
return null
}
const onSelectModel = async () => {
const selectedModel = await SelectModelPopup.show({ model })
if (selectedModel) {
setModel(selectedModel)
}
}
return (
<SelectModelDropdown model={model} onSelect={setModel} placement="top">
<DropdownButton size="small" type="default">
<ModelAvatar model={model} size={20} />
<ModelName>{model ? upperFirst(model.name) : t('button.select_model')}</ModelName>
{isVisionModel(model) && <VisionIcon style={{ marginLeft: 0 }} />}
</DropdownButton>
</SelectModelDropdown>
<DropdownButton size="small" type="default" onClick={onSelectModel}>
<ModelAvatar model={model} size={20} />
<ModelName>{model ? upperFirst(model.name) : t('button.select_model')}</ModelName>
{isVisionModel(model) && <VisionIcon style={{ marginLeft: 0 }} />}
</DropdownButton>
)
}
@ -39,6 +43,7 @@ const DropdownButton = styled(Button)`
font-size: 11px;
border-radius: 15px;
padding: 12px 8px 12px 3px;
-webkit-app-region: none;
`
const ModelName = styled.span`

View File

@ -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