mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-27 21:01:32 +08:00
944 lines
30 KiB
TypeScript
944 lines
30 KiB
TypeScript
import {
|
|
ClearOutlined,
|
|
ColumnHeightOutlined,
|
|
FormOutlined,
|
|
FullscreenExitOutlined,
|
|
FullscreenOutlined,
|
|
GlobalOutlined,
|
|
HolderOutlined,
|
|
PauseCircleOutlined,
|
|
QuestionCircleOutlined
|
|
} from '@ant-design/icons'
|
|
import TranslateButton from '@renderer/components/TranslateButton'
|
|
import { isFunctionCallingModel, isGenerateImageModel, isVisionModel, isWebSearchModel } from '@renderer/config/models'
|
|
import db from '@renderer/databases'
|
|
import { useAssistant } from '@renderer/hooks/useAssistant'
|
|
import { useMessageOperations, useTopicLoading } from '@renderer/hooks/useMessageOperations'
|
|
import { modelGenerating, useRuntime } from '@renderer/hooks/useRuntime'
|
|
import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings'
|
|
import { useShortcut, useShortcutDisplay } from '@renderer/hooks/useShortcuts'
|
|
import { useSidebarIconShow } from '@renderer/hooks/useSidebarIcon'
|
|
import { addAssistantMessagesToTopic, getDefaultTopic } from '@renderer/services/AssistantService'
|
|
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
|
import FileManager from '@renderer/services/FileManager'
|
|
import { checkRateLimit, getUserMessage } from '@renderer/services/MessagesService'
|
|
import { estimateMessageUsage, estimateTextTokens as estimateTxtTokens } from '@renderer/services/TokenService'
|
|
import { translateText } from '@renderer/services/TranslateService'
|
|
import WebSearchService from '@renderer/services/WebSearchService'
|
|
import { useAppDispatch } from '@renderer/store'
|
|
import { sendMessage as _sendMessage } from '@renderer/store/messages'
|
|
import { setSearching } from '@renderer/store/runtime'
|
|
import { Assistant, FileType, KnowledgeBase, MCPServer, Message, Model, Topic } from '@renderer/types'
|
|
import { classNames, delay, getFileExtension } from '@renderer/utils'
|
|
import { getFilesFromDropEvent } from '@renderer/utils/input'
|
|
import { documentExts, imageExts, textExts } from '@shared/config/constant'
|
|
import { Button, Popconfirm, Tooltip } from 'antd'
|
|
import TextArea, { TextAreaRef } from 'antd/es/input/TextArea'
|
|
import Logger from 'electron-log/renderer'
|
|
import { debounce, isEmpty } from 'lodash'
|
|
import React, { CSSProperties, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import styled from 'styled-components'
|
|
|
|
import NarrowLayout from '../Messages/NarrowLayout'
|
|
import AttachmentButton from './AttachmentButton'
|
|
import AttachmentPreview from './AttachmentPreview'
|
|
import GenerateImageButton from './GenerateImageButton'
|
|
import KnowledgeBaseButton from './KnowledgeBaseButton'
|
|
import MCPToolsButton from './MCPToolsButton'
|
|
import MentionModelsButton from './MentionModelsButton'
|
|
import MentionModelsInput from './MentionModelsInput'
|
|
import NewContextButton from './NewContextButton'
|
|
import SendMessageButton from './SendMessageButton'
|
|
import TokenCount from './TokenCount'
|
|
interface Props {
|
|
assistant: Assistant
|
|
setActiveTopic: (topic: Topic) => void
|
|
topic: Topic
|
|
}
|
|
|
|
let _text = ''
|
|
let _files: FileType[] = []
|
|
|
|
const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) => {
|
|
const [text, setText] = useState(_text)
|
|
const [inputFocus, setInputFocus] = useState(false)
|
|
const { assistant, addTopic, model, setModel, updateAssistant } = useAssistant(_assistant.id)
|
|
const {
|
|
targetLanguage,
|
|
sendMessageShortcut,
|
|
fontSize,
|
|
pasteLongTextAsFile,
|
|
pasteLongTextThreshold,
|
|
showInputEstimatedTokens,
|
|
autoTranslateWithSpace
|
|
} = useSettings()
|
|
const [expended, setExpend] = useState(false)
|
|
const [estimateTokenCount, setEstimateTokenCount] = useState(0)
|
|
const [contextCount, setContextCount] = useState({ current: 0, max: 0 })
|
|
const textareaRef = useRef<TextAreaRef>(null)
|
|
const [files, setFiles] = useState<FileType[]>(_files)
|
|
const { t } = useTranslation()
|
|
const containerRef = useRef(null)
|
|
const { searching } = useRuntime()
|
|
const { isBubbleStyle } = useMessageStyle()
|
|
const { pauseMessages } = useMessageOperations(topic)
|
|
const loading = useTopicLoading(topic)
|
|
const dispatch = useAppDispatch()
|
|
const [spaceClickCount, setSpaceClickCount] = useState(0)
|
|
const spaceClickTimer = useRef<NodeJS.Timeout>(null)
|
|
const [isTranslating, setIsTranslating] = useState(false)
|
|
const [selectedKnowledgeBases, setSelectedKnowledgeBases] = useState<KnowledgeBase[]>([])
|
|
const [mentionModels, setMentionModels] = useState<Model[]>([])
|
|
const [enabledMCPs, setEnabledMCPs] = useState<MCPServer[]>(assistant.mcpServers || [])
|
|
const [isMentionPopupOpen, setIsMentionPopupOpen] = useState(false)
|
|
const [isDragging, setIsDragging] = useState(false)
|
|
const [textareaHeight, setTextareaHeight] = useState<number>()
|
|
const startDragY = useRef<number>(0)
|
|
const startHeight = useRef<number>(0)
|
|
const currentMessageId = useRef<string>('')
|
|
const isVision = useMemo(() => isVisionModel(model), [model])
|
|
const supportExts = useMemo(() => [...textExts, ...documentExts, ...(isVision ? imageExts : [])], [isVision])
|
|
const navigate = useNavigate()
|
|
|
|
const showKnowledgeIcon = useSidebarIconShow('knowledge')
|
|
const showMCPToolsIcon = isFunctionCallingModel(model)
|
|
|
|
const [tokenCount, setTokenCount] = useState(0)
|
|
|
|
const [mentionFromKeyboard, setMentionFromKeyboard] = useState(false)
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
const debouncedEstimate = useCallback(
|
|
debounce((newText) => {
|
|
if (showInputEstimatedTokens) {
|
|
const count = estimateTxtTokens(newText) || 0
|
|
setTokenCount(count)
|
|
}
|
|
}, 500),
|
|
[showInputEstimatedTokens]
|
|
)
|
|
|
|
useEffect(() => {
|
|
debouncedEstimate(text)
|
|
}, [text, debouncedEstimate])
|
|
|
|
const inputTokenCount = showInputEstimatedTokens ? tokenCount : 0
|
|
|
|
const newTopicShortcut = useShortcutDisplay('new_topic')
|
|
const cleanTopicShortcut = useShortcutDisplay('clear_topic')
|
|
const inputEmpty = isEmpty(text.trim()) && files.length === 0
|
|
|
|
_text = text
|
|
_files = files
|
|
|
|
const resizeTextArea = useCallback(() => {
|
|
const textArea = textareaRef.current?.resizableTextArea?.textArea
|
|
if (textArea) {
|
|
// 如果已经手动设置了高度,则不自动调整
|
|
if (textareaHeight) {
|
|
return
|
|
}
|
|
textArea.style.height = 'auto'
|
|
textArea.style.height = textArea?.scrollHeight > 400 ? '400px' : `${textArea?.scrollHeight}px`
|
|
}
|
|
}, [textareaHeight])
|
|
|
|
// reset state when assistant changes
|
|
useEffect(() => {
|
|
// Reset to assistant default model
|
|
assistant.defaultModel && setModel(assistant.defaultModel)
|
|
|
|
// Reset to assistant knowledge mcp servers
|
|
setEnabledMCPs(assistant.mcpServers || [])
|
|
}, [assistant, setModel])
|
|
|
|
const sendMessage = useCallback(async () => {
|
|
if (inputEmpty || loading) {
|
|
return
|
|
}
|
|
if (checkRateLimit(assistant)) {
|
|
return
|
|
}
|
|
|
|
EventEmitter.emit(EVENT_NAMES.SEND_MESSAGE)
|
|
|
|
try {
|
|
// Dispatch the sendMessage action with all options
|
|
const uploadedFiles = await FileManager.uploadFiles(files)
|
|
const userMessage = getUserMessage({ assistant, topic, type: 'text', content: text })
|
|
|
|
if (uploadedFiles) {
|
|
userMessage.files = uploadedFiles
|
|
}
|
|
|
|
const knowledgeBaseIds = selectedKnowledgeBases?.map((base) => base.id)
|
|
|
|
if (knowledgeBaseIds) {
|
|
userMessage.knowledgeBaseIds = knowledgeBaseIds
|
|
}
|
|
|
|
if (mentionModels) {
|
|
userMessage.mentions = mentionModels
|
|
}
|
|
|
|
if (enabledMCPs) {
|
|
userMessage.enabledMCPs = enabledMCPs
|
|
}
|
|
|
|
userMessage.usage = await estimateMessageUsage(userMessage)
|
|
currentMessageId.current = userMessage.id
|
|
|
|
dispatch(
|
|
_sendMessage(userMessage, assistant, topic, {
|
|
mentions: mentionModels
|
|
})
|
|
)
|
|
|
|
// Clear input
|
|
setText('')
|
|
setFiles([])
|
|
setTimeout(() => setText(''), 500)
|
|
setTimeout(() => resizeTextArea(), 0)
|
|
setExpend(false)
|
|
} catch (error) {
|
|
console.error('Failed to send message:', error)
|
|
}
|
|
}, [
|
|
assistant,
|
|
dispatch,
|
|
enabledMCPs,
|
|
files,
|
|
inputEmpty,
|
|
loading,
|
|
mentionModels,
|
|
resizeTextArea,
|
|
selectedKnowledgeBases,
|
|
text,
|
|
topic
|
|
])
|
|
|
|
const translate = async () => {
|
|
if (isTranslating) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
setIsTranslating(true)
|
|
const translatedText = await translateText(text, targetLanguage)
|
|
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 (event.key === '@') {
|
|
const textArea = textareaRef.current?.resizableTextArea?.textArea
|
|
if (textArea) {
|
|
const cursorPosition = textArea.selectionStart
|
|
const textBeforeCursor = text.substring(0, cursorPosition)
|
|
if (cursorPosition === 0 || textBeforeCursor.endsWith(' ')) {
|
|
setMentionFromKeyboard(true)
|
|
EventEmitter.emit(EVENT_NAMES.SHOW_MODEL_SELECTOR)
|
|
setIsMentionPopupOpen(true)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
if (event.key === 'Escape' && isMentionPopupOpen) {
|
|
setIsMentionPopupOpen(false)
|
|
return
|
|
}
|
|
|
|
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 onToggleExpended()
|
|
}
|
|
}
|
|
|
|
if (isEnterPressed && !event.shiftKey && sendMessageShortcut === 'Enter') {
|
|
if (isMentionPopupOpen) {
|
|
return event.preventDefault()
|
|
}
|
|
sendMessage()
|
|
return event.preventDefault()
|
|
}
|
|
|
|
if (sendMessageShortcut === 'Shift+Enter' && isEnterPressed && event.shiftKey) {
|
|
if (isMentionPopupOpen) {
|
|
return event.preventDefault()
|
|
}
|
|
sendMessage()
|
|
return event.preventDefault()
|
|
}
|
|
|
|
if (sendMessageShortcut === 'Ctrl+Enter' && isEnterPressed && event.ctrlKey) {
|
|
if (isMentionPopupOpen) {
|
|
return event.preventDefault()
|
|
}
|
|
sendMessage()
|
|
return event.preventDefault()
|
|
}
|
|
|
|
if (sendMessageShortcut === 'Command+Enter' && isEnterPressed && event.metaKey) {
|
|
if (isMentionPopupOpen) {
|
|
return event.preventDefault()
|
|
}
|
|
sendMessage()
|
|
return event.preventDefault()
|
|
}
|
|
|
|
if (event.key === 'Backspace' && text.trim() === '' && mentionModels.length > 0) {
|
|
setMentionModels((prev) => prev.slice(0, -1))
|
|
return event.preventDefault()
|
|
}
|
|
}
|
|
|
|
const addNewTopic = useCallback(async () => {
|
|
await modelGenerating()
|
|
|
|
const topic = getDefaultTopic(assistant.id)
|
|
|
|
await db.topics.add({ id: topic.id, messages: [] })
|
|
await addAssistantMessagesToTopic({ assistant, topic })
|
|
|
|
// Clear previous state
|
|
// Reset to assistant default model
|
|
assistant.defaultModel && setModel(assistant.defaultModel)
|
|
// Reset to assistant knowledge mcp servers
|
|
setEnabledMCPs(assistant.mcpServers || [])
|
|
|
|
addTopic(topic)
|
|
setActiveTopic(topic)
|
|
|
|
setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR), 0)
|
|
}, [addTopic, assistant, setActiveTopic, setModel])
|
|
|
|
const onPause = async () => {
|
|
await pauseMessages()
|
|
}
|
|
|
|
const clearTopic = async () => {
|
|
if (loading) {
|
|
await onPause()
|
|
await delay(1)
|
|
}
|
|
EventEmitter.emit(EVENT_NAMES.CLEAR_MESSAGES)
|
|
}
|
|
|
|
const onNewContext = () => {
|
|
if (loading) {
|
|
onPause()
|
|
return
|
|
}
|
|
EventEmitter.emit(EVENT_NAMES.NEW_CONTEXT)
|
|
}
|
|
|
|
const onToggleExpended = () => {
|
|
const isExpended = !expended
|
|
setExpend(isExpended)
|
|
const textArea = textareaRef.current?.resizableTextArea?.textArea
|
|
|
|
if (textArea) {
|
|
if (isExpended) {
|
|
textArea.style.height = '70vh'
|
|
} else {
|
|
resetHeight()
|
|
}
|
|
}
|
|
|
|
textareaRef.current?.focus()
|
|
}
|
|
|
|
const onInput = () => !expended && resizeTextArea()
|
|
|
|
const onChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
const newText = e.target.value
|
|
setText(newText)
|
|
|
|
// Check if @ was deleted
|
|
const textArea = textareaRef.current?.resizableTextArea?.textArea
|
|
if (textArea) {
|
|
const cursorPosition = textArea.selectionStart
|
|
const textBeforeCursor = newText.substring(0, cursorPosition)
|
|
const lastAtIndex = textBeforeCursor.lastIndexOf('@')
|
|
|
|
if (lastAtIndex === -1 || textBeforeCursor.slice(lastAtIndex + 1).includes(' ')) {
|
|
setIsMentionPopupOpen(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
const onPaste = useCallback(
|
|
async (event: ClipboardEvent) => {
|
|
const clipboardText = event.clipboardData?.getData('text')
|
|
if (clipboardText) {
|
|
// Prioritize the text when pasting.
|
|
// handled by the default event
|
|
} else {
|
|
for (const file of event.clipboardData?.files || []) {
|
|
event.preventDefault()
|
|
|
|
if (file.path === '') {
|
|
if (file.type.startsWith('image/') && isVisionModel(model)) {
|
|
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
|
|
} else {
|
|
window.message.info({
|
|
key: 'file_not_supported',
|
|
content: t('chat.input.file_not_supported')
|
|
})
|
|
}
|
|
}
|
|
|
|
if (file.path) {
|
|
if (supportExts.includes(getFileExtension(file.path))) {
|
|
const selectedFile = await window.api.file.get(file.path)
|
|
selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile])
|
|
} else {
|
|
window.message.info({
|
|
key: 'file_not_supported',
|
|
content: t('chat.input.file_not_supported')
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
},
|
|
[model, pasteLongTextAsFile, pasteLongTextThreshold, resizeTextArea, supportExts, t, text]
|
|
)
|
|
|
|
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
}
|
|
|
|
const handleDrop = async (e: React.DragEvent<HTMLDivElement>) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
|
|
const files = await getFilesFromDropEvent(e).catch((err) => {
|
|
Logger.error('[src/renderer/src/pages/home/Inputbar/Inputbar.tsx] handleDrop:', err)
|
|
return null
|
|
})
|
|
|
|
if (files) {
|
|
files.forEach((file) => {
|
|
if (supportExts.includes(getFileExtension(file.path))) {
|
|
setFiles((prevFiles) => [...prevFiles, file])
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
const onTranslated = (translatedText: string) => {
|
|
setText(translatedText)
|
|
setTimeout(() => resizeTextArea(), 0)
|
|
}
|
|
|
|
const handleDragStart = (e: React.MouseEvent) => {
|
|
e.preventDefault()
|
|
setIsDragging(true)
|
|
startDragY.current = e.clientY
|
|
const textArea = textareaRef.current?.resizableTextArea?.textArea
|
|
if (textArea) {
|
|
startHeight.current = textArea.offsetHeight
|
|
}
|
|
}
|
|
|
|
const handleDrag = useCallback(
|
|
(e: MouseEvent) => {
|
|
if (!isDragging) return
|
|
|
|
const delta = startDragY.current - e.clientY // 改变计算方向
|
|
const viewportHeight = window.innerHeight
|
|
const maxHeightInPixels = viewportHeight * 0.7
|
|
|
|
const newHeight = Math.min(maxHeightInPixels, Math.max(startHeight.current + delta, 30))
|
|
const textArea = textareaRef.current?.resizableTextArea?.textArea
|
|
if (textArea) {
|
|
textArea.style.height = `${newHeight}px`
|
|
setExpend(newHeight == maxHeightInPixels)
|
|
setTextareaHeight(newHeight)
|
|
}
|
|
},
|
|
[isDragging]
|
|
)
|
|
|
|
const handleDragEnd = useCallback(() => {
|
|
setIsDragging(false)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (isDragging) {
|
|
document.addEventListener('mousemove', handleDrag)
|
|
document.addEventListener('mouseup', handleDragEnd)
|
|
}
|
|
return () => {
|
|
document.removeEventListener('mousemove', handleDrag)
|
|
document.removeEventListener('mouseup', handleDragEnd)
|
|
}
|
|
}, [isDragging, handleDrag, handleDragEnd])
|
|
|
|
useShortcut('new_topic', () => {
|
|
addNewTopic()
|
|
EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR)
|
|
textareaRef.current?.focus()
|
|
})
|
|
|
|
useShortcut('clear_topic', () => {
|
|
clearTopic()
|
|
})
|
|
|
|
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({ current: contextCount.current, max: contextCount.max }) // 现在contextCount是一个对象而不是单个数值
|
|
}),
|
|
EventEmitter.on(EVENT_NAMES.ADD_NEW_TOPIC, addNewTopic),
|
|
EventEmitter.on(EVENT_NAMES.QUOTE_TEXT, (quotedText: string) => {
|
|
setText((prevText) => {
|
|
const newText = prevText ? `${prevText}\n${quotedText}\n` : `${quotedText}\n`
|
|
setTimeout(() => resizeTextArea(), 0)
|
|
return newText
|
|
})
|
|
textareaRef.current?.focus()
|
|
})
|
|
]
|
|
return () => unsubscribes.forEach((unsub) => unsub())
|
|
}, [addNewTopic, resizeTextArea])
|
|
|
|
useEffect(() => {
|
|
textareaRef.current?.focus()
|
|
}, [assistant])
|
|
|
|
useEffect(() => {
|
|
setTimeout(() => resizeTextArea(), 0)
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (spaceClickTimer.current) {
|
|
clearTimeout(spaceClickTimer.current)
|
|
}
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
window.addEventListener('focus', () => {
|
|
textareaRef.current?.focus()
|
|
})
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
// if assistant knowledge bases are undefined return []
|
|
setSelectedKnowledgeBases(showKnowledgeIcon ? (assistant.knowledge_bases ?? []) : [])
|
|
}, [assistant.id, assistant.knowledge_bases, showKnowledgeIcon])
|
|
|
|
const textareaRows = window.innerHeight >= 1000 || isBubbleStyle ? 2 : 1
|
|
|
|
const handleKnowledgeBaseSelect = (bases?: KnowledgeBase[]) => {
|
|
updateAssistant({ ...assistant, knowledge_bases: bases })
|
|
setSelectedKnowledgeBases(bases ?? [])
|
|
}
|
|
|
|
const onMentionModel = (model: Model, fromKeyboard: boolean = false) => {
|
|
const textArea = textareaRef.current?.resizableTextArea?.textArea
|
|
if (textArea) {
|
|
if (fromKeyboard) {
|
|
const cursorPosition = textArea.selectionStart
|
|
const textBeforeCursor = text.substring(0, cursorPosition)
|
|
const lastAtIndex = textBeforeCursor.lastIndexOf('@')
|
|
|
|
if (lastAtIndex !== -1) {
|
|
const newText = text.substring(0, lastAtIndex) + text.substring(cursorPosition)
|
|
setText(newText)
|
|
}
|
|
}
|
|
|
|
setMentionModels((prev) => [...prev, model])
|
|
setIsMentionPopupOpen(false)
|
|
setTimeout(() => {
|
|
textareaRef.current?.focus()
|
|
}, 0)
|
|
setMentionFromKeyboard(false)
|
|
}
|
|
}
|
|
|
|
const handleRemoveModel = (model: Model) => {
|
|
setMentionModels(mentionModels.filter((m) => m.id !== model.id))
|
|
}
|
|
|
|
const toggelEnableMCP = (mcp: MCPServer) => {
|
|
setEnabledMCPs((prev) => {
|
|
const exists = prev.some((item) => item.id === mcp.id)
|
|
if (exists) {
|
|
return prev.filter((item) => item.id !== mcp.id)
|
|
} else {
|
|
return [...prev, mcp]
|
|
}
|
|
})
|
|
}
|
|
|
|
const onEnableWebSearch = () => {
|
|
if (!isWebSearchModel(model)) {
|
|
if (!WebSearchService.isWebSearchEnabled()) {
|
|
window.modal.confirm({
|
|
title: t('chat.input.web_search.enable'),
|
|
content: t('chat.input.web_search.enable_content'),
|
|
centered: true,
|
|
okText: t('chat.input.web_search.button.ok'),
|
|
onOk: () => {
|
|
navigate('/settings/web-search')
|
|
}
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
updateAssistant({ ...assistant, enableWebSearch: !assistant.enableWebSearch })
|
|
}
|
|
|
|
const onEnableGenerateImage = () => {
|
|
updateAssistant({ ...assistant, enableGenerateImage: !assistant.enableGenerateImage })
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!isWebSearchModel(model) && !WebSearchService.isWebSearchEnabled() && assistant.enableWebSearch) {
|
|
updateAssistant({ ...assistant, enableWebSearch: false })
|
|
}
|
|
if (!isGenerateImageModel(model) && assistant.enableGenerateImage) {
|
|
updateAssistant({ ...assistant, enableGenerateImage: false })
|
|
}
|
|
}, [assistant, model, updateAssistant])
|
|
|
|
const resetHeight = () => {
|
|
if (expended) {
|
|
setExpend(false)
|
|
}
|
|
setTextareaHeight(undefined)
|
|
requestAnimationFrame(() => {
|
|
const textArea = textareaRef.current?.resizableTextArea?.textArea
|
|
if (textArea) {
|
|
textArea.style.height = 'auto'
|
|
const contentHeight = textArea.scrollHeight
|
|
textArea.style.height = contentHeight > 400 ? '400px' : `${contentHeight}px`
|
|
}
|
|
})
|
|
}
|
|
|
|
return (
|
|
<Container onDragOver={handleDragOver} onDrop={handleDrop} className="inputbar">
|
|
<NarrowLayout style={{ width: '100%' }}>
|
|
<InputBarContainer
|
|
id="inputbar"
|
|
className={classNames('inputbar-container', inputFocus && 'focus')}
|
|
ref={containerRef}>
|
|
<AttachmentPreview files={files} setFiles={setFiles} />
|
|
<MentionModelsInput selectedModels={mentionModels} onRemoveModel={handleRemoveModel} />
|
|
<Textarea
|
|
value={text}
|
|
onChange={onChange}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder={isTranslating ? t('chat.input.translating') : t('chat.input.placeholder')}
|
|
autoFocus
|
|
contextMenu="true"
|
|
variant="borderless"
|
|
spellCheck={false}
|
|
rows={textareaRows}
|
|
ref={textareaRef}
|
|
style={{
|
|
fontSize,
|
|
height: textareaHeight ? `${textareaHeight}px` : undefined
|
|
}}
|
|
styles={{ textarea: TextareaStyle }}
|
|
onFocus={(e: React.FocusEvent<HTMLTextAreaElement>) => {
|
|
setInputFocus(true)
|
|
const textArea = e.target
|
|
if (textArea) {
|
|
const length = textArea.value.length
|
|
textArea.setSelectionRange(length, length)
|
|
}
|
|
}}
|
|
onBlur={() => setInputFocus(false)}
|
|
onInput={onInput}
|
|
disabled={searching}
|
|
onPaste={(e) => onPaste(e.nativeEvent)}
|
|
onClick={() => searching && dispatch(setSearching(false))}
|
|
/>
|
|
<DragHandle onMouseDown={handleDragStart}>
|
|
<HolderOutlined />
|
|
</DragHandle>
|
|
<Toolbar>
|
|
<ToolbarMenu>
|
|
<Tooltip placement="top" title={t('chat.input.new_topic', { Command: newTopicShortcut })} arrow>
|
|
<ToolbarButton type="text" onClick={addNewTopic}>
|
|
<FormOutlined />
|
|
</ToolbarButton>
|
|
</Tooltip>
|
|
<AttachmentButton model={model} files={files} setFiles={setFiles} ToolbarButton={ToolbarButton} />
|
|
<Tooltip placement="top" title={t('chat.input.web_search')} arrow>
|
|
<ToolbarButton type="text" onClick={onEnableWebSearch}>
|
|
<GlobalOutlined
|
|
style={{ color: assistant.enableWebSearch ? 'var(--color-link)' : 'var(--color-icon)' }}
|
|
/>
|
|
</ToolbarButton>
|
|
</Tooltip>
|
|
{showKnowledgeIcon && (
|
|
<KnowledgeBaseButton
|
|
selectedBases={selectedKnowledgeBases}
|
|
onSelect={handleKnowledgeBaseSelect}
|
|
ToolbarButton={ToolbarButton}
|
|
disabled={files.length > 0}
|
|
/>
|
|
)}
|
|
{showMCPToolsIcon && (
|
|
<MCPToolsButton
|
|
enabledMCPs={enabledMCPs}
|
|
toggelEnableMCP={toggelEnableMCP}
|
|
ToolbarButton={ToolbarButton}
|
|
/>
|
|
)}
|
|
<GenerateImageButton
|
|
model={model}
|
|
assistant={assistant}
|
|
onEnableGenerateImage={onEnableGenerateImage}
|
|
ToolbarButton={ToolbarButton}
|
|
/>
|
|
<MentionModelsButton
|
|
mentionModels={mentionModels}
|
|
onMentionModel={(model) => onMentionModel(model, mentionFromKeyboard)}
|
|
ToolbarButton={ToolbarButton}
|
|
/>
|
|
<Tooltip placement="top" title={t('chat.input.clear', { Command: cleanTopicShortcut })} 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.title')}>
|
|
<ToolbarButton type="text">
|
|
<ClearOutlined style={{ fontSize: 17 }} />
|
|
</ToolbarButton>
|
|
</Popconfirm>
|
|
</Tooltip>
|
|
<Tooltip placement="top" title={expended ? t('chat.input.collapse') : t('chat.input.expand')} arrow>
|
|
<ToolbarButton type="text" onClick={onToggleExpended}>
|
|
{expended ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
|
|
</ToolbarButton>
|
|
</Tooltip>
|
|
{textareaHeight && (
|
|
<Tooltip placement="top" title={t('chat.input.auto_resize')} arrow>
|
|
<ToolbarButton type="text" onClick={resetHeight}>
|
|
<ColumnHeightOutlined />
|
|
</ToolbarButton>
|
|
</Tooltip>
|
|
)}
|
|
<NewContextButton onNewContext={onNewContext} ToolbarButton={ToolbarButton} />
|
|
<TokenCount
|
|
estimateTokenCount={estimateTokenCount}
|
|
inputTokenCount={inputTokenCount}
|
|
contextCount={contextCount}
|
|
ToolbarButton={ToolbarButton}
|
|
onClick={onNewContext}
|
|
/>
|
|
</ToolbarMenu>
|
|
<ToolbarMenu>
|
|
<TranslateButton text={text} onTranslated={onTranslated} isLoading={isTranslating} />
|
|
{loading && (
|
|
<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>
|
|
)}
|
|
{!loading && <SendMessageButton sendMessage={sendMessage} disabled={loading || inputEmpty} />}
|
|
</ToolbarMenu>
|
|
</Toolbar>
|
|
</InputBarContainer>
|
|
</NarrowLayout>
|
|
</Container>
|
|
)
|
|
}
|
|
|
|
// Add these styled components at the bottom
|
|
const DragHandle = styled.div`
|
|
position: absolute;
|
|
top: -3px;
|
|
left: 0;
|
|
right: 0;
|
|
height: 6px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
cursor: row-resize;
|
|
color: var(--color-icon);
|
|
opacity: 0;
|
|
transition: opacity 0.2s;
|
|
z-index: 1;
|
|
|
|
&:hover {
|
|
opacity: 1;
|
|
}
|
|
|
|
.anticon {
|
|
transform: rotate(90deg);
|
|
font-size: 14px;
|
|
}
|
|
`
|
|
|
|
const Container = styled.div`
|
|
display: flex;
|
|
flex-direction: column;
|
|
`
|
|
|
|
const InputBarContainer = styled.div`
|
|
border: 0.5px solid var(--color-border);
|
|
transition: all 0.3s ease;
|
|
position: relative;
|
|
margin: 14px 20px;
|
|
margin-top: 12px;
|
|
border-radius: 15px;
|
|
padding-top: 6px; // 为拖动手柄留出空间
|
|
background-color: var(--color-background-opacity);
|
|
`
|
|
|
|
const TextareaStyle: CSSProperties = {
|
|
paddingLeft: 0,
|
|
padding: '4px 15px 8px' // 减小顶部padding
|
|
}
|
|
|
|
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: 16px;
|
|
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: 18px;
|
|
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
|