mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-10 07:19:02 +08:00
feat: enable image input in mini assistant
This commit is contained in:
parent
7507443d8b
commit
162bf43a0b
@ -7,12 +7,14 @@ import i18n from '@renderer/i18n'
|
||||
import { fetchChatCompletion } from '@renderer/services/ApiService'
|
||||
import { getDefaultTopic } from '@renderer/services/AssistantService'
|
||||
import { ConversationService } from '@renderer/services/ConversationService'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import { getAssistantMessage, getUserMessage } from '@renderer/services/MessagesService'
|
||||
import PasteService from '@renderer/services/PasteService'
|
||||
import store, { useAppSelector } from '@renderer/store'
|
||||
import { updateOneBlock, upsertManyBlocks, upsertOneBlock } from '@renderer/store/messageBlock'
|
||||
import { newMessagesActions, selectMessagesForTopic } from '@renderer/store/newMessage'
|
||||
import { cancelThrottledBlockUpdate, throttledBlockUpdate } from '@renderer/store/thunk/messageThunk'
|
||||
import type { Topic } from '@renderer/types'
|
||||
import type { FileMetadata, Topic } from '@renderer/types'
|
||||
import { ThemeMode } from '@renderer/types'
|
||||
import type { Chunk } from '@renderer/types/chunk'
|
||||
import { ChunkType } from '@renderer/types/chunk'
|
||||
@ -39,6 +41,7 @@ import type { FeatureMenusRef } from './components/FeatureMenus'
|
||||
import FeatureMenus from './components/FeatureMenus'
|
||||
import Footer from './components/Footer'
|
||||
import InputBar from './components/InputBar'
|
||||
import PastedFilesPreview from './components/PastedFilesPreview'
|
||||
|
||||
const logger = loggerService.withContext('HomeWindow')
|
||||
|
||||
@ -51,6 +54,7 @@ const HomeWindow: FC<{ draggable?: boolean }> = ({ draggable = true }) => {
|
||||
const [isFirstMessage, setIsFirstMessage] = useState(true)
|
||||
|
||||
const [userInputText, setUserInputText] = useState('')
|
||||
const [files, setFiles] = useState<FileMetadata[]>([])
|
||||
|
||||
const [clipboardText, setClipboardText] = useState('')
|
||||
const lastClipboardTextRef = useRef<string | null>(null)
|
||||
@ -73,6 +77,8 @@ const HomeWindow: FC<{ draggable?: boolean }> = ({ draggable = true }) => {
|
||||
const inputBarRef = useRef<HTMLDivElement>(null)
|
||||
const featureMenusRef = useRef<FeatureMenusRef>(null)
|
||||
|
||||
const supportedImageExts = useMemo(() => ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'], [])
|
||||
|
||||
const referenceText = useMemo(() => clipboardText || userInputText, [clipboardText, userInputText])
|
||||
|
||||
const userContent = useMemo(() => {
|
||||
@ -82,6 +88,8 @@ const HomeWindow: FC<{ draggable?: boolean }> = ({ draggable = true }) => {
|
||||
return userInputText.trim()
|
||||
}, [isFirstMessage, referenceText, userInputText])
|
||||
|
||||
const hasChatInput = useMemo(() => Boolean(userContent) || files.length > 0, [files.length, userContent])
|
||||
|
||||
useEffect(() => {
|
||||
i18n.changeLanguage(language || navigator.language || defaultLanguage)
|
||||
}, [language])
|
||||
@ -166,7 +174,7 @@ const HomeWindow: FC<{ draggable?: boolean }> = ({ draggable = true }) => {
|
||||
if (isLoading) return
|
||||
|
||||
e.preventDefault()
|
||||
if (userContent) {
|
||||
if (userContent || files.length > 0) {
|
||||
if (route === 'home') {
|
||||
featureMenusRef.current?.useFeature()
|
||||
} else {
|
||||
@ -213,6 +221,23 @@ const HomeWindow: FC<{ draggable?: boolean }> = ({ draggable = true }) => {
|
||||
setUserInputText(e.target.value)
|
||||
}
|
||||
|
||||
const handlePaste = useCallback(
|
||||
async (event: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
await PasteService.handlePaste(
|
||||
event.nativeEvent,
|
||||
supportedImageExts,
|
||||
setFiles,
|
||||
setUserInputText,
|
||||
false,
|
||||
undefined,
|
||||
userInputText,
|
||||
undefined,
|
||||
t
|
||||
)
|
||||
},
|
||||
[supportedImageExts, t, userInputText]
|
||||
)
|
||||
|
||||
const handleError = (error: Error) => {
|
||||
setIsLoading(false)
|
||||
setError(error.message)
|
||||
@ -220,17 +245,22 @@ const HomeWindow: FC<{ draggable?: boolean }> = ({ draggable = true }) => {
|
||||
|
||||
const handleSendMessage = useCallback(
|
||||
async (prompt?: string) => {
|
||||
if (isEmpty(userContent) || !currentTopic.current) {
|
||||
if ((isEmpty(userContent) && files.length === 0) || !currentTopic.current) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const topicId = currentTopic.current.id
|
||||
|
||||
const uploadedFiles = files.length ? await FileManager.uploadFiles(files) : []
|
||||
|
||||
const content = [prompt, userContent].filter(Boolean).join('\n\n') || undefined
|
||||
|
||||
const { message: userMessage, blocks } = getUserMessage({
|
||||
content: [prompt, userContent].filter(Boolean).join('\n\n'),
|
||||
content,
|
||||
assistant: currentAssistant,
|
||||
topic: currentTopic.current
|
||||
topic: currentTopic.current,
|
||||
files: uploadedFiles
|
||||
})
|
||||
|
||||
store.dispatch(newMessagesActions.addMessage({ topicId, message: userMessage }))
|
||||
@ -272,6 +302,7 @@ const HomeWindow: FC<{ draggable?: boolean }> = ({ draggable = true }) => {
|
||||
|
||||
setIsFirstMessage(false)
|
||||
setUserInputText('')
|
||||
setFiles([])
|
||||
|
||||
const newAssistant = cloneDeep(currentAssistant)
|
||||
if (!newAssistant.settings) {
|
||||
@ -452,9 +483,13 @@ const HomeWindow: FC<{ draggable?: boolean }> = ({ draggable = true }) => {
|
||||
currentAskId.current = ''
|
||||
}
|
||||
},
|
||||
[userContent, currentAssistant]
|
||||
[files, userContent, currentAssistant]
|
||||
)
|
||||
|
||||
const handleRemoveFile = useCallback((filePath: string) => {
|
||||
setFiles((prevFiles) => prevFiles.filter((file) => file.path !== filePath))
|
||||
}, [])
|
||||
|
||||
const handlePause = useCallback(() => {
|
||||
if (currentAskId.current) {
|
||||
abortCompletion(currentAskId.current)
|
||||
@ -546,8 +581,10 @@ const HomeWindow: FC<{ draggable?: boolean }> = ({ draggable = true }) => {
|
||||
loading={isLoading}
|
||||
handleKeyDown={handleKeyDown}
|
||||
handleChange={handleChange}
|
||||
handlePaste={handlePaste}
|
||||
ref={inputBarRef}
|
||||
/>
|
||||
<PastedFilesPreview files={files} onRemove={handleRemoveFile} />
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
</>
|
||||
)}
|
||||
@ -590,8 +627,10 @@ const HomeWindow: FC<{ draggable?: boolean }> = ({ draggable = true }) => {
|
||||
loading={isLoading}
|
||||
handleKeyDown={handleKeyDown}
|
||||
handleChange={handleChange}
|
||||
handlePaste={handlePaste}
|
||||
ref={inputBarRef}
|
||||
/>
|
||||
<PastedFilesPreview files={files} onRemove={handleRemoveFile} />
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<ClipboardPreview referenceText={referenceText} clearClipboard={clearClipboard} t={t} />
|
||||
<Main>
|
||||
@ -599,6 +638,7 @@ const HomeWindow: FC<{ draggable?: boolean }> = ({ draggable = true }) => {
|
||||
setRoute={setRoute}
|
||||
onSendMessage={handleSendMessage}
|
||||
text={userContent}
|
||||
hasChatInput={hasChatInput}
|
||||
ref={featureMenusRef}
|
||||
/>
|
||||
</Main>
|
||||
|
||||
@ -11,6 +11,7 @@ interface FeatureMenusProps {
|
||||
text: string
|
||||
setRoute: Dispatch<SetStateAction<'translate' | 'summary' | 'chat' | 'explanation' | 'home'>>
|
||||
onSendMessage: (prompt?: string) => void
|
||||
hasChatInput: boolean
|
||||
}
|
||||
|
||||
export interface FeatureMenusRef {
|
||||
@ -23,6 +24,7 @@ export interface FeatureMenusRef {
|
||||
const FeatureMenus = ({
|
||||
ref,
|
||||
text,
|
||||
hasChatInput,
|
||||
setRoute,
|
||||
onSendMessage
|
||||
}: FeatureMenusProps & { ref?: React.RefObject<FeatureMenusRef | null> }) => {
|
||||
@ -36,7 +38,7 @@ const FeatureMenus = ({
|
||||
title: t('miniwindow.feature.chat'),
|
||||
active: true,
|
||||
onClick: () => {
|
||||
if (text) {
|
||||
if (hasChatInput) {
|
||||
setRoute('chat')
|
||||
onSendMessage()
|
||||
}
|
||||
@ -68,7 +70,7 @@ const FeatureMenus = ({
|
||||
}
|
||||
}
|
||||
],
|
||||
[onSendMessage, setRoute, t, text]
|
||||
[hasChatInput, onSendMessage, setRoute, t, text]
|
||||
)
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
|
||||
@ -14,6 +14,7 @@ interface InputBarProps {
|
||||
loading: boolean
|
||||
handleKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void
|
||||
handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
handlePaste: (e: React.ClipboardEvent<HTMLInputElement>) => void
|
||||
}
|
||||
|
||||
const InputBar = ({
|
||||
@ -23,7 +24,8 @@ const InputBar = ({
|
||||
placeholder,
|
||||
loading,
|
||||
handleKeyDown,
|
||||
handleChange
|
||||
handleChange,
|
||||
handlePaste
|
||||
}: InputBarProps & { ref?: React.RefObject<HTMLDivElement | null> }) => {
|
||||
const inputRef = useRef<InputRef>(null)
|
||||
const { setTimeoutTimer } = useTimer()
|
||||
@ -40,6 +42,7 @@ const InputBar = ({
|
||||
autoFocus
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleChange}
|
||||
onPaste={handlePaste}
|
||||
ref={inputRef}
|
||||
/>
|
||||
</InputWrapper>
|
||||
|
||||
@ -0,0 +1,82 @@
|
||||
import { CloseOutlined, FileImageOutlined, FileOutlined } from '@ant-design/icons'
|
||||
import type { FileMetadata } from '@renderer/types'
|
||||
import { FileTypes } from '@renderer/types'
|
||||
import { Tooltip } from 'antd'
|
||||
import type { FC } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface PastedFilesPreviewProps {
|
||||
files: FileMetadata[]
|
||||
onRemove: (filePath: string) => void
|
||||
}
|
||||
|
||||
const PastedFilesPreview: FC<PastedFilesPreviewProps> = ({ files, onRemove }) => {
|
||||
if (!files.length) return null
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{files.map((file) => (
|
||||
<FileChip key={file.path} className="nodrag">
|
||||
<IconWrapper>{file.type === FileTypes.IMAGE ? <FileImageOutlined /> : <FileOutlined />}</IconWrapper>
|
||||
<Tooltip title={file.name} placement="topLeft">
|
||||
<FileName>{file.name}</FileName>
|
||||
</Tooltip>
|
||||
<RemoveButton onClick={() => onRemove(file.path)}>
|
||||
<CloseOutlined />
|
||||
</RemoveButton>
|
||||
</FileChip>
|
||||
))}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 8px 0 2px;
|
||||
`
|
||||
|
||||
const FileChip = styled.div`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
background: var(--color-background-opacity);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
max-width: 100%;
|
||||
`
|
||||
|
||||
const IconWrapper = styled.span`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-secondary);
|
||||
`
|
||||
|
||||
const FileName = styled.span`
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 180px;
|
||||
`
|
||||
|
||||
const RemoveButton = styled.button`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
`
|
||||
|
||||
export default PastedFilesPreview
|
||||
Loading…
Reference in New Issue
Block a user