feat(chat): add agent session inputbar component

Implement input bar for agent sessions with message sending functionality
This commit is contained in:
icarus 2025-09-19 17:39:24 +08:00
parent e45231376c
commit 4bd6087dc0
2 changed files with 254 additions and 1 deletions

View File

@ -23,6 +23,7 @@ import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import ChatNavbar from './ChatNavbar'
import AgentSessionInputbar from './Inputbar/AgentSessionInputbar'
import Inputbar from './Inputbar/Inputbar'
import AgentSessionMessages from './Messages/AgentSessionMessages'
import ChatNavigation from './Messages/ChatNavigation'
@ -151,6 +152,17 @@ const Chat: FC<Props> = (props) => {
return <AgentSessionMessages agentId={activeAgentId} sessionId={sessionId} />
}
const SessionInputBar = () => {
if (activeAgentId === null) {
return <div> Active Agent ID is invalid.</div>
}
const sessionId = activeSessionId[activeAgentId]
if (!sessionId) {
return <div> Active Session ID is invalid.</div>
}
return <AgentSessionInputbar agentId={activeAgentId} sessionId={sessionId} />
}
return (
<Container id="chat" className={classNames([messageStyle, { 'multi-select-mode': isMultiSelectMode }])}>
{isTopNavbar && (
@ -192,7 +204,12 @@ const Chat: FC<Props> = (props) => {
<Inputbar assistant={assistant} setActiveTopic={props.setActiveTopic} topic={props.activeTopic} />
</>
)}
{activeTopicOrSession === 'session' && <SessionMessages />}
{activeTopicOrSession === 'session' && (
<>
<SessionMessages />
<SessionInputBar />
</>
)}
{isMultiSelectMode && <MultiSelectActionPopup topic={props.activeTopic} />}
</QuickPanelProvider>
</Main>

View File

@ -0,0 +1,236 @@
import { loggerService } from '@logger'
import { QuickPanelView } from '@renderer/components/QuickPanel'
import { useSession } from '@renderer/hooks/agents/useSession'
import { useSettings } from '@renderer/hooks/useSettings'
import { useTimer } from '@renderer/hooks/useTimer'
import PasteService from '@renderer/services/PasteService'
import { classNames } from '@renderer/utils'
import { getSendMessageShortcutLabel, isSendMessageKeyPressed } from '@renderer/utils/input'
import TextArea, { TextAreaRef } from 'antd/es/input/TextArea'
import { isEmpty } from 'lodash'
import React, { CSSProperties, FC, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import NarrowLayout from '../Messages/NarrowLayout'
import SendMessageButton from './SendMessageButton'
const logger = loggerService.withContext('Inputbar')
type Props = {
agentId: string
sessionId: string
}
const _text = ''
const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
const [text, setText] = useState(_text)
const [inputFocus, setInputFocus] = useState(false)
const { createSessionMessage } = useSession(agentId, sessionId)
const { sendMessageShortcut, fontSize, enableSpellCheck } = useSettings()
const textareaRef = useRef<TextAreaRef>(null)
const { t } = useTranslation()
const containerRef = useRef(null)
const { setTimeoutTimer } = useTimer()
const focusTextarea = useCallback(() => {
textareaRef.current?.focus()
}, [])
const inputEmpty = isEmpty(text)
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
//to check if the SendMessage key is pressed
//other keys should be ignored
const isEnterPressed = event.key === 'Enter' && !event.nativeEvent.isComposing
if (isEnterPressed) {
// 1) 优先判断是否为“发送”(当前仅支持纯 Enter 发送;其余 Enter 组合键均换行)
if (isSendMessageKeyPressed(event, sendMessageShortcut)) {
sendMessage()
return event.preventDefault()
}
// 2) 不再基于 quickPanel.isVisible 主动拦截。
// 纯 Enter 的处理权交由 QuickPanel 的全局捕获(其只在纯 Enter 时拦截),
// 其它带修饰键的 Enter 则由输入框处理为换行。
if (event.shiftKey) {
return
}
event.preventDefault()
const textArea = textareaRef.current?.resizableTextArea?.textArea
if (textArea) {
const start = textArea.selectionStart
const end = textArea.selectionEnd
const text = textArea.value
const newText = text.substring(0, start) + '\n' + text.substring(end)
// update text by setState, not directly modify textarea.value
setText(newText)
// set cursor position in the next render cycle
setTimeoutTimer(
'handleKeyDown',
() => {
textArea.selectionStart = textArea.selectionEnd = start + 1
},
0
)
}
}
}
const sendMessage = useCallback(async () => {
if (inputEmpty) {
return
}
logger.info('Starting to send message')
try {
createSessionMessage(text)
// Clear input
setText('')
setTimeoutTimer('sendMessage_1', () => setText(''), 500)
} catch (error) {
logger.warn('Failed to send message:', error as Error)
}
}, [createSessionMessage, inputEmpty, setTimeoutTimer, text])
const onChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newText = e.target.value
setText(newText)
}, [])
useEffect(() => {
if (!document.querySelector('.topview-fullscreen-container')) {
focusTextarea()
}
}, [focusTextarea])
useEffect(() => {
const onFocus = () => {
if (document.activeElement?.closest('.ant-modal')) {
return
}
const lastFocusedComponent = PasteService.getLastFocusedComponent()
if (!lastFocusedComponent || lastFocusedComponent === 'inputbar') {
focusTextarea()
}
}
window.addEventListener('focus', onFocus)
return () => window.removeEventListener('focus', onFocus)
}, [focusTextarea])
return (
<NarrowLayout style={{ width: '100%' }}>
<Container className="inputbar">
<QuickPanelView setInputText={setText} />
<InputBarContainer
id="inputbar"
className={classNames('inputbar-container', inputFocus && 'focus')}
ref={containerRef}>
<Textarea
value={text}
onChange={onChange}
onKeyDown={handleKeyDown}
placeholder={t('chat.input.placeholder', { key: getSendMessageShortcutLabel(sendMessageShortcut) })}
autoFocus
variant="borderless"
spellCheck={enableSpellCheck}
rows={2}
autoSize={{ minRows: 2, maxRows: 20 }}
ref={textareaRef}
style={{
fontSize,
minHeight: '30px'
}}
styles={{ textarea: TextareaStyle }}
onFocus={(e: React.FocusEvent<HTMLTextAreaElement>) => {
setInputFocus(true)
// 记录当前聚焦的组件
PasteService.setLastFocusedComponent('inputbar')
if (e.target.value.length === 0) {
e.target.setSelectionRange(0, 0)
}
}}
onBlur={() => setInputFocus(false)}
/>
<div className="flex justify-end px-1">
<SendMessageButton sendMessage={sendMessage} disabled={inputEmpty} />
</div>
</InputBarContainer>
</Container>
</NarrowLayout>
)
}
// Add these styled components at the bottom
const Container = styled.div`
display: flex;
flex-direction: column;
position: relative;
z-index: 2;
padding: 0 18px 18px 18px;
[navbar-position='top'] & {
padding: 0 18px 10px 18px;
}
`
const InputBarContainer = styled.div`
border: 0.5px solid var(--color-border);
transition: all 0.2s ease;
position: relative;
border-radius: 17px;
padding-top: 8px; // 为拖动手柄留出空间
background-color: var(--color-background-opacity);
&.file-dragging {
border: 2px dashed #2ecc71;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(46, 204, 113, 0.03);
border-radius: 14px;
z-index: 5;
pointer-events: none;
}
}
`
const TextareaStyle: CSSProperties = {
paddingLeft: 0,
padding: '6px 15px 0px' // 减小顶部padding
}
const Textarea = styled(TextArea)`
padding: 0;
border-radius: 0;
display: flex;
resize: none !important;
overflow: auto;
width: 100%;
box-sizing: border-box;
transition: none !important;
&.ant-input {
line-height: 1.4;
}
&::-webkit-scrollbar {
width: 3px;
}
`
export default AgentSessionInputbar