mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-03 19:30:04 +08:00
feat: add attachment button
This commit is contained in:
parent
89bdab58f7
commit
cb95562e58
7
src/renderer/src/hooks/useModel.ts
Normal file
7
src/renderer/src/hooks/useModel.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { useProviders } from './useProvider'
|
||||||
|
|
||||||
|
export function useModel(id?: string) {
|
||||||
|
const { providers } = useProviders()
|
||||||
|
const allModels = providers.map((p) => p.models).flat()
|
||||||
|
return allModels.find((m) => m.id === id)
|
||||||
|
}
|
||||||
@ -77,6 +77,7 @@ const resources = {
|
|||||||
'input.send': 'Send',
|
'input.send': 'Send',
|
||||||
'input.pause': 'Pause',
|
'input.pause': 'Pause',
|
||||||
'input.settings': 'Settings',
|
'input.settings': 'Settings',
|
||||||
|
'input.upload': 'Upload image png、jpg、jpeg',
|
||||||
'input.context_count.tip': 'Context Count',
|
'input.context_count.tip': 'Context Count',
|
||||||
'input.estimated_tokens.tip': 'Estimated tokens',
|
'input.estimated_tokens.tip': 'Estimated tokens',
|
||||||
'settings.temperature': 'Temperature',
|
'settings.temperature': 'Temperature',
|
||||||
@ -314,6 +315,7 @@ const resources = {
|
|||||||
'input.send': '发送',
|
'input.send': '发送',
|
||||||
'input.pause': '暂停',
|
'input.pause': '暂停',
|
||||||
'input.settings': '设置',
|
'input.settings': '设置',
|
||||||
|
'input.upload': '上传图片 png、jpg、jpeg',
|
||||||
'input.context_count.tip': '上下文数',
|
'input.context_count.tip': '上下文数',
|
||||||
'input.estimated_tokens.tip': '预估 token 数',
|
'input.estimated_tokens.tip': '预估 token 数',
|
||||||
'settings.temperature': '模型温度',
|
'settings.temperature': '模型温度',
|
||||||
|
|||||||
45
src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx
Normal file
45
src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { PaperClipOutlined } from '@ant-design/icons'
|
||||||
|
import { Tooltip, Upload } from 'antd'
|
||||||
|
import { FC } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
images: string[]
|
||||||
|
setImages: (images: string[]) => void
|
||||||
|
ToolbarButton: any
|
||||||
|
}
|
||||||
|
|
||||||
|
const AttachmentButton: FC<Props> = ({ images, setImages, ToolbarButton }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip placement="top" title={t('chat.input.upload')} arrow>
|
||||||
|
<Upload
|
||||||
|
customRequest={() => {}}
|
||||||
|
accept="image/*"
|
||||||
|
itemRender={() => null}
|
||||||
|
maxCount={1}
|
||||||
|
onChange={async ({ file }) => {
|
||||||
|
try {
|
||||||
|
const _file = file.originFileObj as File
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (e: ProgressEvent<FileReader>) => {
|
||||||
|
const result = e.target?.result
|
||||||
|
if (typeof result === 'string') {
|
||||||
|
setImages([result])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(_file)
|
||||||
|
} catch (error: any) {
|
||||||
|
window.message.error(error.message)
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<ToolbarButton type="text" className={images.length ? 'active' : ''}>
|
||||||
|
<PaperClipOutlined style={{ rotate: '135deg' }} />
|
||||||
|
</ToolbarButton>
|
||||||
|
</Upload>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AttachmentButton
|
||||||
@ -26,6 +26,7 @@ import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
|
import AttachmentButton from './AttachmentButton'
|
||||||
import SendMessageButton from './SendMessageButton'
|
import SendMessageButton from './SendMessageButton'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -44,6 +45,7 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
|
|||||||
const [estimateTokenCount, setEstimateTokenCount] = useState(0)
|
const [estimateTokenCount, setEstimateTokenCount] = useState(0)
|
||||||
const generating = useAppSelector((state) => state.runtime.generating)
|
const generating = useAppSelector((state) => state.runtime.generating)
|
||||||
const inputRef = useRef<TextAreaRef>(null)
|
const inputRef = useRef<TextAreaRef>(null)
|
||||||
|
const [images, setImages] = useState<string[]>([])
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
_text = text
|
_text = text
|
||||||
@ -67,13 +69,18 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
|
|||||||
status: 'success'
|
status: 'success'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (images.length > 0) {
|
||||||
|
message.images = images
|
||||||
|
}
|
||||||
|
|
||||||
EventEmitter.emit(EVENT_NAMES.SEND_MESSAGE, message)
|
EventEmitter.emit(EVENT_NAMES.SEND_MESSAGE, message)
|
||||||
|
|
||||||
setText('')
|
setText('')
|
||||||
|
setImages([])
|
||||||
setTimeout(() => setText(''), 500)
|
setTimeout(() => setText(''), 500)
|
||||||
|
|
||||||
setExpend(false)
|
setExpend(false)
|
||||||
}, [assistant.id, assistant.topics, generating, text])
|
}, [assistant.id, assistant.topics, generating, images, text])
|
||||||
|
|
||||||
const inputTokenCount = useMemo(() => estimateInputTokenCount(text), [text])
|
const inputTokenCount = useMemo(() => estimateInputTokenCount(text), [text])
|
||||||
|
|
||||||
@ -153,6 +160,20 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
|
|||||||
id="inputbar"
|
id="inputbar"
|
||||||
style={{ minHeight: expended ? '60%' : 'var(--input-bar-height)' }}
|
style={{ minHeight: expended ? '60%' : 'var(--input-bar-height)' }}
|
||||||
className={inputFocus ? 'focus' : ''}>
|
className={inputFocus ? 'focus' : ''}>
|
||||||
|
<Textarea
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={t('chat.input.placeholder')}
|
||||||
|
autoFocus
|
||||||
|
contextMenu="true"
|
||||||
|
variant="borderless"
|
||||||
|
ref={inputRef}
|
||||||
|
styles={{ textarea: { paddingLeft: 0 } }}
|
||||||
|
onFocus={() => setInputFocus(true)}
|
||||||
|
onBlur={() => setInputFocus(false)}
|
||||||
|
style={{ fontSize }}
|
||||||
|
/>
|
||||||
<Toolbar>
|
<Toolbar>
|
||||||
<ToolbarMenu>
|
<ToolbarMenu>
|
||||||
<Tooltip placement="top" title={t('chat.input.new_topic')} arrow>
|
<Tooltip placement="top" title={t('chat.input.new_topic')} arrow>
|
||||||
@ -183,6 +204,7 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
|
|||||||
<ControlOutlined />
|
<ControlOutlined />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<AttachmentButton images={images} setImages={setImages} ToolbarButton={ToolbarButton} />
|
||||||
<Tooltip placement="top" title={expended ? t('chat.input.collapse') : t('chat.input.expand')} arrow>
|
<Tooltip placement="top" title={expended ? t('chat.input.collapse') : t('chat.input.expand')} arrow>
|
||||||
<ToolbarButton type="text" onClick={() => setExpend(!expended)}>
|
<ToolbarButton type="text" onClick={() => setExpend(!expended)}>
|
||||||
{expended ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
|
{expended ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
|
||||||
@ -221,20 +243,6 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
|
|||||||
{!generating && <SendMessageButton sendMessage={sendMessage} disabled={generating || !text} />}
|
{!generating && <SendMessageButton sendMessage={sendMessage} disabled={generating || !text} />}
|
||||||
</ToolbarMenu>
|
</ToolbarMenu>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
<Textarea
|
|
||||||
value={text}
|
|
||||||
onChange={(e) => setText(e.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder={t('chat.input.placeholder')}
|
|
||||||
autoFocus
|
|
||||||
contextMenu="true"
|
|
||||||
variant="borderless"
|
|
||||||
ref={inputRef}
|
|
||||||
styles={{ textarea: { paddingLeft: 0 } }}
|
|
||||||
onFocus={() => setInputFocus(true)}
|
|
||||||
onBlur={() => setInputFocus(false)}
|
|
||||||
style={{ fontSize }}
|
|
||||||
/>
|
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -257,7 +265,7 @@ const Textarea = styled(TextArea)`
|
|||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
margin: 0 15px 5px 15px;
|
margin: 10px 15px 0 15px;
|
||||||
font-family: Ubuntu;
|
font-family: Ubuntu;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
@ -269,8 +277,8 @@ const Toolbar = styled.div`
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
padding-top: 3px;
|
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
|
margin: 3px 0;
|
||||||
`
|
`
|
||||||
|
|
||||||
const ToolbarMenu = styled.div`
|
const ToolbarMenu = styled.div`
|
||||||
@ -291,7 +299,8 @@ const ToolbarButton = styled(Button)`
|
|||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
color: var(--color-icon);
|
color: var(--color-icon);
|
||||||
}
|
}
|
||||||
&:hover {
|
&:hover,
|
||||||
|
&.active {
|
||||||
background-color: var(--color-background-soft);
|
background-color: var(--color-background-soft);
|
||||||
.anticon {
|
.anticon {
|
||||||
color: var(--color-text-1);
|
color: var(--color-text-1);
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import { FONT_FAMILY } from '@renderer/config/constant'
|
|||||||
import { getModelLogo } from '@renderer/config/provider'
|
import { getModelLogo } from '@renderer/config/provider'
|
||||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||||
import useAvatar from '@renderer/hooks/useAvatar'
|
import useAvatar from '@renderer/hooks/useAvatar'
|
||||||
|
import { useModel } from '@renderer/hooks/useModel'
|
||||||
import { useSettings } from '@renderer/hooks/useSettings'
|
import { useSettings } from '@renderer/hooks/useSettings'
|
||||||
import { useRuntime } from '@renderer/hooks/useStore'
|
import { useRuntime } from '@renderer/hooks/useStore'
|
||||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
|
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
|
||||||
@ -25,6 +26,7 @@ import styled from 'styled-components'
|
|||||||
|
|
||||||
import SelectModelDropdown from '../components/SelectModelDropdown'
|
import SelectModelDropdown from '../components/SelectModelDropdown'
|
||||||
import Markdown from '../Markdown/Markdown'
|
import Markdown from '../Markdown/Markdown'
|
||||||
|
import MessageAttachments from './MessageAttachments'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
message: Message
|
message: Message
|
||||||
@ -37,7 +39,8 @@ interface Props {
|
|||||||
const MessageItem: FC<Props> = ({ message, index, showMenu, onDeleteMessage }) => {
|
const MessageItem: FC<Props> = ({ message, index, showMenu, onDeleteMessage }) => {
|
||||||
const avatar = useAvatar()
|
const avatar = useAvatar()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { assistant, model, setModel } = useAssistant(message.assistantId)
|
const { assistant, setModel } = useAssistant(message.assistantId)
|
||||||
|
const model = useModel(message.modelId)
|
||||||
const { userName, showMessageDivider, messageFont, fontSize } = useSettings()
|
const { userName, showMessageDivider, messageFont, fontSize } = useSettings()
|
||||||
const { generating } = useRuntime()
|
const { generating } = useRuntime()
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
@ -67,9 +70,9 @@ const MessageItem: FC<Props> = ({ message, index, showMenu, onDeleteMessage }) =
|
|||||||
|
|
||||||
const getUserName = useCallback(() => {
|
const getUserName = useCallback(() => {
|
||||||
if (message.id === 'assistant') return assistant?.name
|
if (message.id === 'assistant') return assistant?.name
|
||||||
if (message.role === 'assistant') return upperFirst(model.name || model.id)
|
if (message.role === 'assistant') return upperFirst(model?.name || model?.id)
|
||||||
return userName || t('common.you')
|
return userName || t('common.you')
|
||||||
}, [assistant?.name, message.id, message.role, model.id, model.name, t, userName])
|
}, [assistant?.name, message.id, message.role, model?.id, model?.name, t, userName])
|
||||||
|
|
||||||
const fontFamily = useMemo(() => {
|
const fontFamily = useMemo(() => {
|
||||||
return messageFont === 'serif' ? FONT_FAMILY.replace('sans-serif', 'serif').replace('Ubuntu, ', '') : FONT_FAMILY
|
return messageFont === 'serif' ? FONT_FAMILY.replace('sans-serif', 'serif').replace('Ubuntu, ', '') : FONT_FAMILY
|
||||||
@ -115,8 +118,13 @@ const MessageItem: FC<Props> = ({ message, index, showMenu, onDeleteMessage }) =
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Markdown message={message} />
|
return (
|
||||||
}, [message])
|
<>
|
||||||
|
<Markdown message={message} />
|
||||||
|
<MessageAttachments message={message} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}, [message, t])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MessageContainer key={message.id} className="message">
|
<MessageContainer key={message.id} className="message">
|
||||||
|
|||||||
25
src/renderer/src/pages/home/Messages/MessageAttachments.tsx
Normal file
25
src/renderer/src/pages/home/Messages/MessageAttachments.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { Message } from '@renderer/types'
|
||||||
|
import { Image as AntdImage } from 'antd'
|
||||||
|
import { FC } from 'react'
|
||||||
|
import styled from 'styled-components'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
message: Message
|
||||||
|
}
|
||||||
|
|
||||||
|
const MessageAttachments: FC<Props> = ({ message }) => {
|
||||||
|
return <Container>{message.images?.map((image) => <Image src={image} key={image} width="33%" />)}</Container>
|
||||||
|
}
|
||||||
|
|
||||||
|
const Container = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const Image = styled(AntdImage)`
|
||||||
|
border-radius: 10px;
|
||||||
|
`
|
||||||
|
|
||||||
|
export default MessageAttachments
|
||||||
@ -8,7 +8,7 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
interface Props extends DropdownProps {
|
interface Props extends DropdownProps {
|
||||||
model: Model
|
model?: Model
|
||||||
onSelect: (model: Model) => void
|
onSelect: (model: Model) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -37,7 +37,7 @@ const SelectModelDropdown: FC<Props & PropsWithChildren> = ({ children, model, o
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
menu={{ items, style: { maxHeight: '80vh', overflow: 'auto' }, selectedKeys: [model?.id] }}
|
menu={{ items, style: { maxHeight: '80vh', overflow: 'auto' }, selectedKeys: model ? [model.id] : [] }}
|
||||||
trigger={['click']}
|
trigger={['click']}
|
||||||
arrow
|
arrow
|
||||||
placement="bottom"
|
placement="bottom"
|
||||||
|
|||||||
@ -51,10 +51,17 @@ export default class ProviderSDK {
|
|||||||
const { contextCount, maxTokens } = getAssistantSettings(assistant)
|
const { contextCount, maxTokens } = getAssistantSettings(assistant)
|
||||||
|
|
||||||
const systemMessage = assistant.prompt ? { role: 'system', content: assistant.prompt } : undefined
|
const systemMessage = assistant.prompt ? { role: 'system', content: assistant.prompt } : undefined
|
||||||
const userMessages = takeRight(messages, contextCount + 1).map((message) => ({
|
const userMessages = takeRight(messages, contextCount + 1).map((message) => {
|
||||||
role: message.role,
|
return {
|
||||||
content: message.content
|
role: message.role,
|
||||||
}))
|
content: message.images
|
||||||
|
? [
|
||||||
|
{ type: 'text', text: message.content },
|
||||||
|
...message.images!.map((image) => ({ type: 'image_url', image_url: image }))
|
||||||
|
]
|
||||||
|
: message.content
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
if (this.isAnthropic) {
|
if (this.isAnthropic) {
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
|||||||
@ -22,6 +22,7 @@ export type Message = {
|
|||||||
id: string
|
id: string
|
||||||
role: 'user' | 'assistant'
|
role: 'user' | 'assistant'
|
||||||
content: string
|
content: string
|
||||||
|
images?: string[]
|
||||||
assistantId: string
|
assistantId: string
|
||||||
topicId: string
|
topicId: string
|
||||||
modelId?: string
|
modelId?: string
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user