cherry-studio/src/renderer/src/pages/home/Inputbar/Inputbar.tsx
2025-04-11 17:06:39 +08:00

1217 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
ClearOutlined,
CodeOutlined,
FileSearchOutlined,
FormOutlined,
FullscreenExitOutlined,
FullscreenOutlined,
GlobalOutlined,
HolderOutlined,
PaperClipOutlined,
PauseCircleOutlined,
QuestionCircleOutlined,
ThunderboltOutlined,
TranslationOutlined
} from '@ant-design/icons'
import ASRButton from '@renderer/components/ASRButton'
import { QuickPanelListItem, QuickPanelView, useQuickPanel } from '@renderer/components/QuickPanel'
import TranslateButton from '@renderer/components/TranslateButton'
import VoiceCallButton from '@renderer/components/VoiceCallButton'
import { isGenerateImageModel, isVisionModel, isWebSearchModel } from '@renderer/config/models'
import db from '@renderer/databases'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useKnowledgeBases } from '@renderer/hooks/useKnowledge'
import { useMCPServers } from '@renderer/hooks/useMCPServers'
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 { getModelUniqId } from '@renderer/services/ModelService'
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, KnowledgeItem, MCPServer, Message, Model, Topic } from '@renderer/types'
import { classNames, delay, formatFileSize, 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 dayjs from 'dayjs'
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, { AttachmentButtonRef } from './AttachmentButton'
import AttachmentPreview from './AttachmentPreview'
import GenerateImageButton from './GenerateImageButton'
import KnowledgeBaseButton, { KnowledgeBaseButtonRef } from './KnowledgeBaseButton'
import KnowledgeBaseInput from './KnowledgeBaseInput'
import MCPToolsButton, { MCPToolsButtonRef } from './MCPToolsButton'
import MentionModelsButton, { MentionModelsButtonRef } from './MentionModelsButton'
import MentionModelsInput from './MentionModelsInput'
import NewContextButton from './NewContextButton'
import QuickPhrasesButton, { QuickPhrasesButtonRef } from './QuickPhrasesButton'
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,
enableQuickPanelTriggers
} = 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 [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 { activedMcpServers } = useMCPServers()
const { bases: knowledgeBases } = useKnowledgeBases()
const quickPanel = useQuickPanel()
const showKnowledgeIcon = useSidebarIconShow('knowledge')
// const showMCPToolsIcon = isFunctionCallingModel(model)
const [tokenCount, setTokenCount] = useState(0)
const quickPhrasesButtonRef = useRef<QuickPhrasesButtonRef>(null)
const mentionModelsButtonRef = useRef<MentionModelsButtonRef>(null)
const knowledgeBaseButtonRef = useRef<KnowledgeBaseButtonRef>(null)
const mcpToolsButtonRef = useRef<MCPToolsButtonRef>(null)
const attachmentButtonRef = useRef<AttachmentButtonRef>(null)
// 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 to assistant knowledge mcp servers
useEffect(() => {
setEnabledMCPs(assistant.mcpServers || [])
}, [assistant.mcpServers])
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 (!isEmpty(enabledMCPs) && !isEmpty(activedMcpServers)) {
userMessage.enabledMCPs = activedMcpServers.filter((server) => enabledMCPs?.some((s) => s.id === server.id))
}
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,
activedMcpServers
])
const translate = useCallback(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)
}
}, [isTranslating, text, targetLanguage, resizeTextArea])
const openKnowledgeFileList = useCallback(
(base: KnowledgeBase) => {
quickPanel.open({
title: base.name,
list: base.items
.filter((file): file is KnowledgeItem => ['file'].includes(file.type))
.map((file) => {
const fileContent = file.content as FileType
return {
label: fileContent.origin_name || fileContent.name,
description:
formatFileSize(fileContent.size) + ' · ' + dayjs(fileContent.created_at).format('YYYY-MM-DD HH:mm'),
icon: <FileSearchOutlined />,
isSelected: files.some((f) => f.path === fileContent.path),
action: async ({ item }) => {
item.isSelected = !item.isSelected
if (fileContent.path) {
setFiles((prevFiles) => {
const fileExists = prevFiles.some((f) => f.path === fileContent.path)
if (fileExists) {
return prevFiles.filter((f) => f.path !== fileContent.path)
} else {
return fileContent ? [...prevFiles, fileContent] : prevFiles
}
})
}
}
}
}),
symbol: 'file',
multiple: true
})
},
[files, quickPanel]
)
const openSelectFileMenu = useCallback(() => {
quickPanel.open({
title: t('chat.input.upload'),
list: [
{
label: t('chat.input.upload.upload_from_local'),
description: '',
icon: <PaperClipOutlined />,
action: () => {
attachmentButtonRef.current?.openQuickPanel()
}
},
...knowledgeBases.map((base) => {
const length = base.items?.filter(
(item): item is KnowledgeItem => ['file', 'note'].includes(item.type) && typeof item.content !== 'string'
).length
return {
label: base.name,
description: `${length} ${t('files.count')}`,
icon: <FileSearchOutlined />,
disabled: length === 0,
isMenu: true,
action: () => openKnowledgeFileList(base)
}
})
],
symbol: 'file'
})
}, [knowledgeBases, openKnowledgeFileList, quickPanel, t])
const quickPanelMenu = useMemo<QuickPanelListItem[]>(() => {
return [
{
label: t('settings.quickPhrase.title'),
description: '',
icon: <ThunderboltOutlined />,
isMenu: true,
action: () => {
quickPhrasesButtonRef.current?.openQuickPanel()
}
},
{
label: t('agents.edit.model.select.title'),
description: '',
icon: '@',
isMenu: true,
action: () => {
mentionModelsButtonRef.current?.openQuickPanel()
}
},
{
label: t('chat.input.knowledge_base'),
description: '',
icon: <FileSearchOutlined />,
isMenu: true,
disabled: files.length > 0,
action: () => {
knowledgeBaseButtonRef.current?.openQuickPanel()
}
},
{
label: t('settings.mcp.title'),
description: t('settings.mcp.not_support'),
icon: <CodeOutlined />,
isMenu: true,
action: () => {
mcpToolsButtonRef.current?.openQuickPanel()
}
},
{
label: isVisionModel(model) ? t('chat.input.upload') : t('chat.input.upload.document'),
description: '',
icon: <PaperClipOutlined />,
isMenu: true,
action: openSelectFileMenu
},
{
label: t('translate.title'),
description: t('translate.menu.description'),
icon: <TranslationOutlined />,
action: () => {
if (!text) return
translate()
}
}
]
}, [files.length, model, openSelectFileMenu, t, text, translate])
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
const isEnterPressed = event.keyCode == 13
// 按下Tab键自动选中${xxx}
if (event.key === 'Tab' && inputFocus) {
event.preventDefault()
const textArea = textareaRef.current?.resizableTextArea?.textArea
if (!textArea) return
const cursorPosition = textArea.selectionStart
const selectionLength = textArea.selectionEnd - textArea.selectionStart
const text = textArea.value
let match = text.slice(cursorPosition + selectionLength).match(/\$\{[^}]+\}/)
let startIndex = -1
if (!match) {
match = text.match(/\$\{[^}]+\}/)
startIndex = match?.index ?? -1
} else {
startIndex = cursorPosition + selectionLength + match.index!
}
if (startIndex !== -1) {
const endIndex = startIndex + match![0].length
textArea.setSelectionRange(startIndex, endIndex)
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 (quickPanel.isVisible) return event.preventDefault()
sendMessage()
return event.preventDefault()
}
if (sendMessageShortcut === 'Shift+Enter' && isEnterPressed && event.shiftKey) {
if (quickPanel.isVisible) return event.preventDefault()
sendMessage()
return event.preventDefault()
}
if (sendMessageShortcut === 'Ctrl+Enter' && isEnterPressed && event.ctrlKey) {
if (quickPanel.isVisible) return event.preventDefault()
sendMessage()
return event.preventDefault()
}
if (sendMessageShortcut === 'Command+Enter' && isEnterPressed && event.metaKey) {
if (quickPanel.isVisible) 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()
}
if (event.key === 'Backspace' && text.trim() === '' && selectedKnowledgeBases.length > 0) {
setSelectedKnowledgeBases((prev) => {
const newSelectedKnowledgeBases = prev.slice(0, -1)
updateAssistant({ ...assistant, knowledge_bases: newSelectedKnowledgeBases })
return newSelectedKnowledgeBases
})
return event.preventDefault()
}
if (event.key === 'Backspace' && text.trim() === '' && files.length > 0) {
setFiles((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
!isEmpty(assistant.mcpServers) && 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 onInput = () => !expended && resizeTextArea()
const onChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newText = e.target.value
setText(newText)
const textArea = textareaRef.current?.resizableTextArea?.textArea
const cursorPosition = textArea?.selectionStart ?? 0
const lastSymbol = newText[cursorPosition - 1]
if (enableQuickPanelTriggers && !quickPanel.isVisible && lastSymbol === '/') {
quickPanel.open({
title: t('settings.quickPanel.title'),
list: quickPanelMenu,
symbol: '/'
})
}
if (enableQuickPanelTriggers && !quickPanel.isVisible && lastSymbol === '@') {
mentionModelsButtonRef.current?.openQuickPanel()
}
}
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(), 50)
}
})
}
}
},
[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()
}),
// 监听语音通话消息
EventEmitter.on(EVENT_NAMES.VOICE_CALL_MESSAGE, (data: { text: string, model: string }) => {
console.log('收到语音通话消息:', data);
// 先设置输入框文本
setText(data.text);
// 如果有指定模型,切换到该模型
if (data.model) {
// 查找对应的模型对象
const modelObj = assistant.model?.id === data.model ? assistant.model : undefined;
if (modelObj) {
setModel(modelObj);
}
}
// 使用延时确保文本已经设置到输入框
setTimeout(() => {
// 直接调用发送消息函数而不检查inputEmpty
console.log('准备自动发送语音识别消息:', data.text);
// 直接使用正确的方式发送消息
// 创建用户消息
const userMessage = getUserMessage({
assistant,
topic,
type: 'text',
content: data.text
});
// 如果有指定模型,设置模型
if (data.model) {
// 查找对应的模型对象
const modelObj = assistant.model?.id === data.model ? assistant.model : undefined;
if (modelObj) {
userMessage.model = modelObj;
}
}
// 分发发送消息的action
dispatch(
_sendMessage(userMessage, assistant, topic, {})
);
// 清空输入框
setText('');
console.log('已触发发送消息事件');
}, 300);
})
]
return () => unsubscribes.forEach((unsub) => unsub())
}, [addNewTopic, resizeTextArea, sendMessage, model, inputEmpty, loading, dispatch, assistant, topic, setText, getUserMessage, _sendMessage])
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 handleRemoveModel = (model: Model) => {
setMentionModels(mentionModels.filter((m) => m.id !== model.id))
}
const handleRemoveKnowledgeBase = (knowledgeBase: KnowledgeBase) => {
const newKnowledgeBases = assistant.knowledge_bases?.filter((kb) => kb.id !== knowledgeBase.id)
updateAssistant({
...assistant,
knowledge_bases: newKnowledgeBases
})
setSelectedKnowledgeBases(newKnowledgeBases ?? [])
}
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 showWebSearchEnableModal = () => {
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')
}
})
}
const shouldShowEnableModal = () => {
// 网络搜索功能是否未启用
const webSearchNotEnabled = !WebSearchService.isWebSearchEnabled()
// 非网络搜索模型:仅当网络搜索功能未启用时显示启用提示
if (!isWebSearchModel(model)) {
return webSearchNotEnabled
}
// 网络搜索模型:当允许覆盖但网络搜索功能未启用时显示启用提示
return WebSearchService.isOverwriteEnabled() && webSearchNotEnabled
}
const onEnableWebSearch = () => {
if (shouldShowEnableModal()) {
showWebSearchEnableModal()
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 onMentionModel = (model: Model) => {
setMentionModels((prev) => {
const modelId = getModelUniqId(model)
const exists = prev.some((m) => getModelUniqId(m) === modelId)
return exists ? prev.filter((m) => getModelUniqId(m) !== modelId) : [...prev, model]
})
}
const onToggleExpended = () => {
if (textareaHeight) {
const textArea = textareaRef.current?.resizableTextArea?.textArea
if (textArea) {
textArea.style.height = 'auto'
setTextareaHeight(undefined)
setTimeout(() => {
textArea.style.height = `${textArea.scrollHeight}px`
}, 200)
return
}
}
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 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`
}
})
}
const isExpended = expended || !!textareaHeight
return (
<Container onDragOver={handleDragOver} onDrop={handleDrop} className="inputbar">
<NarrowLayout style={{ width: '100%' }}>
<QuickPanelView setInputText={setText} />
<InputBarContainer
id="inputbar"
className={classNames('inputbar-container', inputFocus && 'focus')}
ref={containerRef}>
{files.length > 0 && <AttachmentPreview files={files} setFiles={setFiles} />}
{selectedKnowledgeBases.length > 0 && (
<KnowledgeBaseInput
selectedKnowledgeBases={selectedKnowledgeBases}
onRemoveKnowledgeBase={handleRemoveKnowledgeBase}
/>
)}
{mentionModels.length > 0 && (
<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,
minHeight: 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
ref={attachmentButtonRef}
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
ref={knowledgeBaseButtonRef}
selectedBases={selectedKnowledgeBases}
onSelect={handleKnowledgeBaseSelect}
ToolbarButton={ToolbarButton}
disabled={files.length > 0}
/>
)}
<MCPToolsButton
ref={mcpToolsButtonRef}
enabledMCPs={enabledMCPs}
toggelEnableMCP={toggelEnableMCP}
ToolbarButton={ToolbarButton}
/>
<GenerateImageButton
model={model}
assistant={assistant}
onEnableGenerateImage={onEnableGenerateImage}
ToolbarButton={ToolbarButton}
/>
<MentionModelsButton
ref={mentionModelsButtonRef}
mentionModels={mentionModels}
onMentionModel={onMentionModel}
ToolbarButton={ToolbarButton}
/>
<QuickPhrasesButton
ref={quickPhrasesButtonRef}
setInputValue={setText}
resizeTextArea={resizeTextArea}
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={isExpended ? t('chat.input.collapse') : t('chat.input.expand')} arrow>
<ToolbarButton type="text" onClick={onToggleExpended}>
{isExpended ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
</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} />
<ASRButton
onTranscribed={(transcribedText) => {
// 如果是空字符串,不做任何处理
if (!transcribedText) return
// 将识别的文本添加到当前输入框
setText((prevText) => {
// 如果当前有文本,添加空格后再添加识别的文本
if (prevText.trim()) {
return prevText + ' ' + transcribedText
}
return transcribedText
})
}}
/>
<VoiceCallButton disabled={loading} />
{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;
position: relative;
z-index: 2;
`
const InputBarContainer = styled.div`
border: 0.5px solid var(--color-border);
transition: all 0.2s ease;
position: relative;
margin: 14px 20px;
margin-top: 0;
border-radius: 15px;
padding-top: 6px; // 为拖动手柄留出空间
background-color: var(--color-background-opacity);
`
const TextareaStyle: CSSProperties = {
paddingLeft: 0,
padding: '6px 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