479 lines
15 KiB
TypeScript
479 lines
15 KiB
TypeScript
import {
|
|
ClearOutlined,
|
|
ControlOutlined,
|
|
FormOutlined,
|
|
FullscreenExitOutlined,
|
|
FullscreenOutlined,
|
|
PauseCircleOutlined,
|
|
QuestionCircleOutlined
|
|
} from '@ant-design/icons'
|
|
import { PicCenterOutlined } from '@ant-design/icons'
|
|
import { documentExts, imageExts, isMac, textExts } from '@renderer/config/constant'
|
|
import { isVisionModel } from '@renderer/config/models'
|
|
import db from '@renderer/databases'
|
|
import { useAssistant } from '@renderer/hooks/useAssistant'
|
|
import { useRuntime } from '@renderer/hooks/useRuntime'
|
|
import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings'
|
|
import { useShowTopics } from '@renderer/hooks/useStore'
|
|
import { addAssistantMessagesToTopic, getDefaultTopic } from '@renderer/services/AssistantService'
|
|
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
|
import FileManager from '@renderer/services/FileManager'
|
|
import { estimateTextTokens as estimateTxtTokens } from '@renderer/services/TokenService'
|
|
import store, { useAppDispatch, useAppSelector } from '@renderer/store'
|
|
import { setGenerating, setSearching } from '@renderer/store/runtime'
|
|
import { Assistant, FileType, Message, Topic } from '@renderer/types'
|
|
import { delay, getFileExtension, uuid } from '@renderer/utils'
|
|
import { Button, Popconfirm, Tooltip } from 'antd'
|
|
import TextArea, { TextAreaRef } from 'antd/es/input/TextArea'
|
|
import dayjs from 'dayjs'
|
|
import { debounce, isEmpty } from 'lodash'
|
|
import { CSSProperties, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import styled from 'styled-components'
|
|
|
|
import AttachmentButton from './AttachmentButton'
|
|
import AttachmentPreview from './AttachmentPreview'
|
|
import SendMessageButton from './SendMessageButton'
|
|
import TokenCount from './TokenCount'
|
|
|
|
interface Props {
|
|
assistant: Assistant
|
|
setActiveTopic: (topic: Topic) => void
|
|
}
|
|
|
|
let _text = ''
|
|
let _files: FileType[] = []
|
|
|
|
const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
|
|
const [text, setText] = useState(_text)
|
|
const [inputFocus, setInputFocus] = useState(false)
|
|
const { addTopic, model, setModel } = useAssistant(assistant.id)
|
|
const { sendMessageShortcut, fontSize, pasteLongTextAsFile, showInputEstimatedTokens, clickAssistantToShowTopic } =
|
|
useSettings()
|
|
const [expended, setExpend] = useState(false)
|
|
const [estimateTokenCount, setEstimateTokenCount] = useState(0)
|
|
const [contextCount, setContextCount] = useState(0)
|
|
const generating = useAppSelector((state) => state.runtime.generating)
|
|
const textareaRef = useRef<TextAreaRef>(null)
|
|
const [files, setFiles] = useState<FileType[]>(_files)
|
|
const { t } = useTranslation()
|
|
const containerRef = useRef(null)
|
|
const { showTopics, toggleShowTopics } = useShowTopics()
|
|
const { searching } = useRuntime()
|
|
const { isBubbleStyle } = useMessageStyle()
|
|
const dispatch = useAppDispatch()
|
|
|
|
const isVision = useMemo(() => isVisionModel(model), [model])
|
|
const supportExts = useMemo(() => [...textExts, ...documentExts, ...(isVision ? imageExts : [])], [isVision])
|
|
|
|
const estimateTextTokens = useCallback(debounce(estimateTxtTokens, 1000), [])
|
|
const inputTokenCount = useMemo(
|
|
() => (showInputEstimatedTokens ? estimateTextTokens(text) || 0 : 0),
|
|
[estimateTextTokens, showInputEstimatedTokens, text]
|
|
)
|
|
|
|
_text = text
|
|
_files = files
|
|
|
|
const sendMessage = useCallback(async () => {
|
|
if (generating) {
|
|
return
|
|
}
|
|
|
|
if (isEmpty(text.trim())) {
|
|
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 (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)
|
|
}, [assistant.id, assistant.topics, generating, files, text])
|
|
|
|
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
const isEnterPressed = event.keyCode == 13
|
|
|
|
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()
|
|
}
|
|
}
|
|
|
|
const addNewTopic = useCallback(async () => {
|
|
if (generating) {
|
|
window.message.warning({ content: t('message.switch.disabled'), key: 'generating' })
|
|
return
|
|
}
|
|
|
|
const topic = getDefaultTopic(assistant.id)
|
|
|
|
await db.topics.add({ id: topic.id, messages: [] })
|
|
await addAssistantMessagesToTopic({ assistant, topic })
|
|
|
|
// Reset to assistant default model
|
|
if (assistant.settings?.autoResetModel) {
|
|
assistant.defaultModel && setModel(assistant.defaultModel)
|
|
}
|
|
|
|
addTopic(topic)
|
|
setActiveTopic(topic)
|
|
|
|
clickAssistantToShowTopic && setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR), 0)
|
|
}, [addTopic, assistant, clickAssistantToShowTopic, generating, setActiveTopic, setModel, t])
|
|
|
|
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 onNewContext = () => {
|
|
if (generating) return onPause()
|
|
EventEmitter.emit(EVENT_NAMES.NEW_CONTEXT)
|
|
}
|
|
|
|
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 onToggleExpended = () => {
|
|
const isExpended = !expended
|
|
setExpend(isExpended)
|
|
const textArea = textareaRef.current?.resizableTextArea?.textArea
|
|
|
|
if (textArea) {
|
|
if (isExpended) {
|
|
textArea.style.height = '70vh'
|
|
} else {
|
|
resizeTextArea()
|
|
}
|
|
}
|
|
|
|
textareaRef.current?.focus()
|
|
}
|
|
|
|
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 > 1500) {
|
|
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, 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])
|
|
}
|
|
})
|
|
}
|
|
|
|
// Command or Ctrl + N create new topic
|
|
useEffect(() => {
|
|
const onKeydown = (e) => {
|
|
if (!generating) {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'n') {
|
|
addNewTopic()
|
|
EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR)
|
|
textareaRef.current?.focus()
|
|
}
|
|
}
|
|
}
|
|
document.addEventListener('keydown', onKeydown)
|
|
return () => document.removeEventListener('keydown', onKeydown)
|
|
}, [addNewTopic, generating])
|
|
|
|
useEffect(() => {
|
|
const _setEstimateTokenCount = debounce(setEstimateTokenCount, 100, { leading: false, trailing: true })
|
|
const unsubscribes = [
|
|
EventEmitter.on(EVENT_NAMES.EDIT_MESSAGE, (message: Message) => {
|
|
setText(message.content)
|
|
textareaRef.current?.focus()
|
|
setTimeout(() => resizeTextArea(), 0)
|
|
}),
|
|
EventEmitter.on(EVENT_NAMES.ESTIMATED_TOKEN_COUNT, ({ tokensCount, contextCount }) => {
|
|
_setEstimateTokenCount(tokensCount)
|
|
setContextCount(contextCount)
|
|
}),
|
|
EventEmitter.on(EVENT_NAMES.ADD_NEW_TOPIC, addNewTopic)
|
|
]
|
|
return () => unsubscribes.forEach((unsub) => unsub())
|
|
}, [addNewTopic])
|
|
|
|
useEffect(() => {
|
|
textareaRef.current?.focus()
|
|
}, [assistant])
|
|
|
|
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={t('chat.input.placeholder')}
|
|
autoFocus
|
|
contextMenu="true"
|
|
variant="borderless"
|
|
rows={isBubbleStyle ? 2 : 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.new_topic', { Command: isMac ? '⌘' : 'Ctrl' })} arrow>
|
|
<ToolbarButton type="text" onClick={addNewTopic}>
|
|
<FormOutlined />
|
|
</ToolbarButton>
|
|
</Tooltip>
|
|
<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>
|
|
<Tooltip placement="top" title={t('chat.input.settings')} arrow>
|
|
<ToolbarButton
|
|
type="text"
|
|
onClick={() => {
|
|
!showTopics && toggleShowTopics()
|
|
setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_CHAT_SETTINGS), 0)
|
|
}}>
|
|
<ControlOutlined />
|
|
</ToolbarButton>
|
|
</Tooltip>
|
|
<AttachmentButton model={model} files={files} setFiles={setFiles} ToolbarButton={ToolbarButton} />
|
|
<ToolbarButton type="text" onClick={onNewContext}>
|
|
<Tooltip placement="top" title={t('chat.input.new.context')}>
|
|
<PicCenterOutlined />
|
|
</Tooltip>
|
|
</ToolbarButton>
|
|
<Tooltip placement="top" title={expended ? t('chat.input.collapse') : t('chat.input.expand')} arrow>
|
|
<ToolbarButton type="text" onClick={onToggleExpended}>
|
|
{expended ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
|
|
</ToolbarButton>
|
|
</Tooltip>
|
|
<TokenCount
|
|
estimateTokenCount={estimateTokenCount}
|
|
inputTokenCount={inputTokenCount}
|
|
contextCount={contextCount}
|
|
ToolbarButton={ToolbarButton}
|
|
onClick={onNewContext}
|
|
/>
|
|
</ToolbarMenu>
|
|
<ToolbarMenu>
|
|
{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 || !text} />}
|
|
</ToolbarMenu>
|
|
</Toolbar>
|
|
</InputBarContainer>
|
|
</Container>
|
|
)
|
|
}
|
|
|
|
const Container = styled.div`
|
|
display: flex;
|
|
flex-direction: column;
|
|
`
|
|
|
|
const InputBarContainer = styled.div`
|
|
border: 1px solid var(--color-border-soft);
|
|
transition: all 0.3s ease;
|
|
position: relative;
|
|
margin: 0 20px 15px 20px;
|
|
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
|