From 162bf43a0b31359644bccec34a56d6ae7f624415 Mon Sep 17 00:00:00 2001 From: who is Date: Wed, 10 Dec 2025 09:16:17 +0000 Subject: [PATCH] feat: enable image input in mini assistant --- .../src/windows/mini/home/HomeWindow.tsx | 52 ++++++++++-- .../mini/home/components/FeatureMenus.tsx | 6 +- .../windows/mini/home/components/InputBar.tsx | 5 +- .../home/components/PastedFilesPreview.tsx | 82 +++++++++++++++++++ 4 files changed, 136 insertions(+), 9 deletions(-) create mode 100644 src/renderer/src/windows/mini/home/components/PastedFilesPreview.tsx diff --git a/src/renderer/src/windows/mini/home/HomeWindow.tsx b/src/renderer/src/windows/mini/home/HomeWindow.tsx index 23787066e8..d694783050 100644 --- a/src/renderer/src/windows/mini/home/HomeWindow.tsx +++ b/src/renderer/src/windows/mini/home/HomeWindow.tsx @@ -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([]) const [clipboardText, setClipboardText] = useState('') const lastClipboardTextRef = useRef(null) @@ -73,6 +77,8 @@ const HomeWindow: FC<{ draggable?: boolean }> = ({ draggable = true }) => { const inputBarRef = useRef(null) const featureMenusRef = useRef(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) => { + 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} /> + )} @@ -590,8 +627,10 @@ const HomeWindow: FC<{ draggable?: boolean }> = ({ draggable = true }) => { loading={isLoading} handleKeyDown={handleKeyDown} handleChange={handleChange} + handlePaste={handlePaste} ref={inputBarRef} /> +
@@ -599,6 +638,7 @@ const HomeWindow: FC<{ draggable?: boolean }> = ({ draggable = true }) => { setRoute={setRoute} onSendMessage={handleSendMessage} text={userContent} + hasChatInput={hasChatInput} ref={featureMenusRef} />
diff --git a/src/renderer/src/windows/mini/home/components/FeatureMenus.tsx b/src/renderer/src/windows/mini/home/components/FeatureMenus.tsx index ebe878644e..e83206cf5b 100644 --- a/src/renderer/src/windows/mini/home/components/FeatureMenus.tsx +++ b/src/renderer/src/windows/mini/home/components/FeatureMenus.tsx @@ -11,6 +11,7 @@ interface FeatureMenusProps { text: string setRoute: Dispatch> 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 }) => { @@ -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, () => ({ diff --git a/src/renderer/src/windows/mini/home/components/InputBar.tsx b/src/renderer/src/windows/mini/home/components/InputBar.tsx index 648b72080e..3b3597f084 100644 --- a/src/renderer/src/windows/mini/home/components/InputBar.tsx +++ b/src/renderer/src/windows/mini/home/components/InputBar.tsx @@ -14,6 +14,7 @@ interface InputBarProps { loading: boolean handleKeyDown: (e: React.KeyboardEvent) => void handleChange: (e: React.ChangeEvent) => void + handlePaste: (e: React.ClipboardEvent) => void } const InputBar = ({ @@ -23,7 +24,8 @@ const InputBar = ({ placeholder, loading, handleKeyDown, - handleChange + handleChange, + handlePaste }: InputBarProps & { ref?: React.RefObject }) => { const inputRef = useRef(null) const { setTimeoutTimer } = useTimer() @@ -40,6 +42,7 @@ const InputBar = ({ autoFocus onKeyDown={handleKeyDown} onChange={handleChange} + onPaste={handlePaste} ref={inputRef} /> diff --git a/src/renderer/src/windows/mini/home/components/PastedFilesPreview.tsx b/src/renderer/src/windows/mini/home/components/PastedFilesPreview.tsx new file mode 100644 index 0000000000..28f94ba019 --- /dev/null +++ b/src/renderer/src/windows/mini/home/components/PastedFilesPreview.tsx @@ -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 = ({ files, onRemove }) => { + if (!files.length) return null + + return ( + + {files.map((file) => ( + + {file.type === FileTypes.IMAGE ? : } + + {file.name} + + onRemove(file.path)}> + + + + ))} + + ) +} + +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