fix: mini window send message

This commit is contained in:
kangfenmao 2025-03-18 14:37:59 +08:00
parent 4939fc8b03
commit 4dacea04a6
3 changed files with 47 additions and 446 deletions

View File

@ -1,443 +0,0 @@
import { ClearOutlined, PauseCircleOutlined, QuestionCircleOutlined } from '@ant-design/icons'
import TranslateButton from '@renderer/components/TranslateButton'
import { isVisionModel } from '@renderer/config/models'
import { useDefaultAssistant, useDefaultModel } from '@renderer/hooks/useAssistant'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import AttachmentButton from '@renderer/pages/home/Inputbar/AttachmentButton'
import AttachmentPreview from '@renderer/pages/home/Inputbar/AttachmentPreview'
import KnowledgeBaseButton from '@renderer/pages/home/Inputbar/KnowledgeBaseButton'
import SendMessageButton from '@renderer/pages/home/Inputbar/SendMessageButton'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import FileManager from '@renderer/services/FileManager'
import { translateText } from '@renderer/services/TranslateService'
import store, { useAppDispatch, useAppSelector } from '@renderer/store'
import { setGenerating, setSearching } from '@renderer/store/runtime'
import { FileType, KnowledgeBase, Message } from '@renderer/types'
import { delay, getFileExtension, uuid } from '@renderer/utils'
import { documentExts, imageExts, textExts } from '@shared/config/constant'
import { Button, Popconfirm, Tooltip } from 'antd'
import TextArea, { TextAreaRef } from 'antd/es/input/TextArea'
import dayjs from 'dayjs'
import { isEmpty } from 'lodash'
import { CSSProperties, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
const Inputbar: FC = () => {
const [text, setText] = useState('')
const [inputFocus, setInputFocus] = useState(false)
const { defaultAssistant } = useDefaultAssistant()
const { defaultModel } = useDefaultModel()
const assistant = defaultAssistant
const model = defaultModel
const {
sendMessageShortcut,
fontSize,
pasteLongTextAsFile,
pasteLongTextThreshold,
language,
autoTranslateWithSpace
} = useSettings()
const [expended, setExpend] = useState(false)
const generating = useAppSelector((state) => state.runtime.generating)
const textareaRef = useRef<TextAreaRef>(null)
const [files, setFiles] = useState<FileType[]>([])
const { t } = useTranslation()
const containerRef = useRef(null)
const { searching } = useRuntime()
const dispatch = useAppDispatch()
const [spaceClickCount, setSpaceClickCount] = useState(0)
const spaceClickTimer = useRef<NodeJS.Timeout>()
const [isTranslating, setIsTranslating] = useState(false)
const [selectedKnowledgeBase, setSelectedKnowledgeBase] = useState<KnowledgeBase | undefined>()
const isVision = useMemo(() => isVisionModel(model), [model])
const supportExts = useMemo(() => [...textExts, ...documentExts, ...(isVision ? imageExts : [])], [isVision])
const inputEmpty = isEmpty(text.trim()) && files.length === 0
const sendMessage = useCallback(async () => {
if (generating) {
return
}
if (inputEmpty) {
return
}
const message: Message = {
id: uuid(),
role: 'user',
content: text,
assistantId: assistant.id,
topicId: assistant.topics[0].id || uuid(),
createdAt: dayjs().format('YYYY-MM-DD HH:mm:ss'),
type: 'text',
status: 'success'
}
if (selectedKnowledgeBase) {
message.knowledgeBaseIds = [selectedKnowledgeBase.id]
}
if (files.length > 0) {
message.files = await FileManager.uploadFiles(files)
}
EventEmitter.emit(EVENT_NAMES.SEND_MESSAGE, message)
setText('')
setFiles([])
setTimeout(() => setText(''), 500)
setTimeout(() => resizeTextArea(), 0)
setExpend(false)
}, [generating, inputEmpty, text, assistant.id, assistant.topics, selectedKnowledgeBase, files])
const translate = async () => {
if (isTranslating) {
return
}
try {
setIsTranslating(true)
const translatedText = await translateText(text, 'english')
translatedText && setText(translatedText)
setTimeout(() => resizeTextArea(), 0)
} catch (error) {
console.error('Translation failed:', error)
} finally {
setIsTranslating(false)
}
}
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
const isEnterPressed = event.keyCode == 13
if (autoTranslateWithSpace) {
if (event.key === ' ') {
setSpaceClickCount((prev) => prev + 1)
if (spaceClickTimer.current) {
clearTimeout(spaceClickTimer.current)
}
spaceClickTimer.current = setTimeout(() => {
setSpaceClickCount(0)
}, 200)
if (spaceClickCount === 2) {
console.log('Triple space detected - trigger translation')
setSpaceClickCount(0)
setIsTranslating(true)
translate()
return
}
}
}
if (expended) {
if (event.key === 'Escape') {
return setExpend(false)
}
}
if (sendMessageShortcut === 'Enter' && isEnterPressed) {
if (event.shiftKey) {
return
}
sendMessage()
return event.preventDefault()
}
if (sendMessageShortcut === 'Shift+Enter' && isEnterPressed && event.shiftKey) {
sendMessage()
return event.preventDefault()
}
if (sendMessageShortcut === 'Ctrl+Enter' && isEnterPressed && event.ctrlKey) {
sendMessage()
return event.preventDefault()
}
if (sendMessageShortcut === 'Command+Enter' && isEnterPressed && event.metaKey) {
sendMessage()
return event.preventDefault()
}
}
const clearTopic = async () => {
if (generating) {
onPause()
await delay(1)
}
EventEmitter.emit(EVENT_NAMES.CLEAR_MESSAGES)
}
const onPause = () => {
window.keyv.set(EVENT_NAMES.CHAT_COMPLETION_PAUSED, true)
store.dispatch(setGenerating(false))
}
const resizeTextArea = () => {
const textArea = textareaRef.current?.resizableTextArea?.textArea
if (textArea) {
textArea.style.height = 'auto'
textArea.style.height = textArea?.scrollHeight > 400 ? '400px' : `${textArea?.scrollHeight}px`
}
}
const onInput = () => !expended && resizeTextArea()
const onPaste = useCallback(
async (event: ClipboardEvent) => {
for (const file of event.clipboardData?.files || []) {
event.preventDefault()
if (file.path === '') {
if (file.type.startsWith('image/')) {
const tempFilePath = await window.api.file.create(file.name)
const arrayBuffer = await file.arrayBuffer()
const uint8Array = new Uint8Array(arrayBuffer)
await window.api.file.write(tempFilePath, uint8Array)
const selectedFile = await window.api.file.get(tempFilePath)
selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile])
break
}
}
if (file.path) {
if (supportExts.includes(getFileExtension(file.path))) {
const selectedFile = await window.api.file.get(file.path)
selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile])
}
}
}
if (pasteLongTextAsFile) {
const item = event.clipboardData?.items[0]
if (item && item.kind === 'string' && item.type === 'text/plain') {
item.getAsString(async (pasteText) => {
if (pasteText.length > pasteLongTextThreshold) {
const tempFilePath = await window.api.file.create('pasted_text.txt')
await window.api.file.write(tempFilePath, pasteText)
const selectedFile = await window.api.file.get(tempFilePath)
selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile])
setText(text)
setTimeout(() => resizeTextArea(), 0)
}
})
}
}
},
[pasteLongTextAsFile, pasteLongTextThreshold, supportExts, text]
)
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault()
e.stopPropagation()
}
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault()
e.stopPropagation()
const files = Array.from(e.dataTransfer.files)
files.forEach(async (file) => {
if (supportExts.includes(getFileExtension(file.path))) {
const selectedFile = await window.api.file.get(file.path)
selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile])
}
})
}
const onTranslated = (translatedText: string) => {
setText(translatedText)
setTimeout(() => resizeTextArea(), 0)
}
useEffect(() => {
textareaRef.current?.focus()
}, [assistant])
useEffect(() => {
setTimeout(() => resizeTextArea(), 0)
}, [])
useEffect(() => {
return () => {
if (spaceClickTimer.current) {
clearTimeout(spaceClickTimer.current)
}
}
}, [])
const handleKnowledgeBaseSelect = (bases: KnowledgeBase[]) => {
setSelectedKnowledgeBase(bases?.[0])
}
return (
<Container onDragOver={handleDragOver} onDrop={handleDrop}>
<AttachmentPreview files={files} setFiles={setFiles} />
<InputBarContainer id="inputbar" className={inputFocus ? 'focus' : ''} ref={containerRef}>
<Textarea
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={isTranslating ? t('chat.input.translating') : t('chat.input.placeholder')}
autoFocus
contextMenu="true"
variant="borderless"
rows={1}
ref={textareaRef}
style={{ fontSize }}
styles={{ textarea: TextareaStyle }}
onFocus={() => setInputFocus(true)}
onBlur={() => setInputFocus(false)}
onInput={onInput}
disabled={searching}
onPaste={(e) => onPaste(e.nativeEvent)}
onClick={() => searching && dispatch(setSearching(false))}
/>
<Toolbar>
<ToolbarMenu>
<Tooltip placement="top" title={t('chat.input.clear')} arrow>
<Popconfirm
title={t('chat.input.clear.content')}
placement="top"
onConfirm={clearTopic}
okButtonProps={{ danger: true }}
icon={<QuestionCircleOutlined style={{ color: 'red' }} />}
okText={t('chat.input.clear')}>
<ToolbarButton type="text">
<ClearOutlined />
</ToolbarButton>
</Popconfirm>
</Tooltip>
<KnowledgeBaseButton
selectedBases={selectedKnowledgeBase ? [selectedKnowledgeBase] : []}
onSelect={handleKnowledgeBaseSelect}
ToolbarButton={ToolbarButton}
disabled={files.length > 0}
/>
<AttachmentButton
model={model}
files={files}
setFiles={setFiles}
ToolbarButton={ToolbarButton}
disabled={!!selectedKnowledgeBase}
/>
</ToolbarMenu>
<ToolbarMenu>
{!language.startsWith('en') && (
<TranslateButton text={text} onTranslated={onTranslated} isLoading={isTranslating} />
)}
{generating && (
<Tooltip placement="top" title={t('chat.input.pause')} arrow>
<ToolbarButton type="text" onClick={onPause} style={{ marginRight: -2, marginTop: 1 }}>
<PauseCircleOutlined style={{ color: 'var(--color-error)', fontSize: 20 }} />
</ToolbarButton>
</Tooltip>
)}
{!generating && <SendMessageButton sendMessage={sendMessage} disabled={generating || inputEmpty} />}
</ToolbarMenu>
</Toolbar>
</InputBarContainer>
</Container>
)
}
const Container = styled.div`
display: flex;
flex-direction: column;
-webkit-app-region: none;
`
const InputBarContainer = styled.div`
border: 1px solid var(--color-border);
transition: all 0.3s ease;
position: relative;
margin: 10px;
border-radius: 10px;
`
const TextareaStyle: CSSProperties = {
paddingLeft: 0,
padding: '10px 15px 8px'
}
const Textarea = styled(TextArea)`
padding: 0;
border-radius: 0;
display: flex;
flex: 1;
font-family: Ubuntu;
resize: none !important;
overflow: auto;
width: 100%;
box-sizing: border-box;
&.ant-input {
line-height: 1.4;
}
`
const Toolbar = styled.div`
display: flex;
flex-direction: row;
justify-content: space-between;
padding: 0 8px;
padding-bottom: 0;
margin-bottom: 4px;
height: 36px;
`
const ToolbarMenu = styled.div`
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
`
const ToolbarButton = styled(Button)`
width: 30px;
height: 30px;
font-size: 17px;
border-radius: 50%;
transition: all 0.3s ease;
color: var(--color-icon);
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
padding: 0;
&.anticon,
&.iconfont {
transition: all 0.3s ease;
color: var(--color-icon);
}
.icon-a-addchat {
font-size: 19px;
margin-bottom: -2px;
}
&:hover {
background-color: var(--color-background-soft);
.anticon,
.iconfont {
color: var(--color-text-1);
}
}
&.active {
background-color: var(--color-primary) !important;
.anticon,
.iconfont {
color: var(--color-white-soft);
}
&:hover {
background-color: var(--color-primary);
}
}
`
export default Inputbar

View File

@ -3,10 +3,12 @@ import { useModel } from '@renderer/hooks/useModel'
import { useSettings } from '@renderer/hooks/useSettings'
import MessageContent from '@renderer/pages/home/Messages/MessageContent'
import MessageErrorBoundary from '@renderer/pages/home/Messages/MessageErrorBoundary'
import { fetchChatCompletion } from '@renderer/services/ApiService'
import { getDefaultAssistant, getDefaultModel } from '@renderer/services/AssistantService'
import { getMessageModelId } from '@renderer/services/MessagesService'
import { Message } from '@renderer/types'
import { isMiniWindow } from '@renderer/utils'
import { Dispatch, FC, memo, SetStateAction, useMemo, useRef } from 'react'
import { Dispatch, FC, memo, SetStateAction, useEffect, useMemo, useRef, useState } from 'react'
import styled from 'styled-components'
interface Props {
@ -21,7 +23,8 @@ interface Props {
const getMessageBackground = (isBubbleStyle: boolean, isAssistantMessage: boolean) =>
isBubbleStyle ? (isAssistantMessage ? 'transparent' : 'var(--chat-background-user)') : undefined
const MessageItem: FC<Props> = ({ message, index, total, route }) => {
const MessageItem: FC<Props> = ({ message: _message, index, total, route, onSetMessages, onGetMessages }) => {
const [message, setMessage] = useState(_message)
const model = useModel(getMessageModelId(message))
const isBubbleStyle = true
const { messageFont, fontSize } = useSettings()
@ -37,6 +40,32 @@ const MessageItem: FC<Props> = ({ message, index, total, route }) => {
const maxWidth = isMiniWindow() ? '480px' : '100%'
useEffect(() => {
if (onGetMessages && onSetMessages) {
if (message.status === 'sending') {
const messages = onGetMessages()
fetchChatCompletion({
message,
messages: messages
.filter((m) => !m.status.includes('ing'))
.slice(
0,
messages.findIndex((m) => m.id === message.id)
),
assistant: { ...getDefaultAssistant(), model: getDefaultModel() },
onResponse: (msg) => {
setMessage(msg)
if (msg.status !== 'pending') {
const _messages = messages.map((m) => (m.id === msg.id ? msg : m))
onSetMessages(_messages)
}
}
})
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [message.status])
if (['summary', 'explanation'].includes(route) && index === total - 1) {
return null
}

View File

@ -23,9 +23,12 @@ const Messages: FC<Props> = ({ assistant, route }) => {
const [messages, setMessages] = useState<Message[]>([])
const containerRef = useRef<HTMLDivElement>(null)
const messagesRef = useRef(messages)
const { t } = useTranslation()
messagesRef.current = messages
const onSendMessage = useCallback(
async (message: Message) => {
setMessages((prev) => {
@ -37,6 +40,10 @@ const Messages: FC<Props> = ({ assistant, route }) => {
[assistant]
)
const onGetMessages = useCallback(() => {
return messagesRef.current
}, [])
useEffect(() => {
const unsubscribes = [EventEmitter.on(EVENT_NAMES.SEND_MESSAGE, onSendMessage)]
return () => unsubscribes.forEach((unsub) => unsub())
@ -53,7 +60,15 @@ const Messages: FC<Props> = ({ assistant, route }) => {
return (
<Container id="messages" key={assistant.id} ref={containerRef}>
{[...messages].reverse().map((message, index) => (
<MessageItem key={message.id} message={message} index={index} total={messages.length} route={route} />
<MessageItem
key={message.id}
message={message}
index={index}
total={messages.length}
onSetMessages={setMessages}
onGetMessages={onGetMessages}
route={route}
/>
))}
</Container>
)