import { useRef, useState, useEffect, useCallback } from 'react' import { Plus, ChevronDown, ArrowRight, Square, Image, AtSign, Workflow, } from 'lucide-react' import { useTranslation } from 'react-i18next' import { useAgentStore, type AgentMode } from '../../../stores/agent-store' import { useLLMStore } from '../../../stores/llm-store' import type { ModelProfile } from '../../../shared/ipc-channels' import { useOutsideClick } from '../../../hooks/useOutsideClick' import SlashCommandMenu from './SlashCommandMenu' import MentionMenu from './MentionMenu' import type { SlashCommand, MentionTarget } from '../../../services/agent/intent-router' /** 输入框最大高度(px),超出后框内滚动 */ const MAX_HEIGHT = 200 /** * Agent 输入框组件(参考 agent1.html 第 69-155 行) * 卡片式圆角容器,底部工具栏含模式/模型/发送 */ export default function AgentInputBox() { const { t } = useTranslation('panels') const [inputText, setInputText] = useState('') const textareaRef = useRef(null) const { generating, sendMessage, cancelGeneration, getActiveConversation, setMode, setModelId } = useAgentStore() const models = useLLMStore(s => s.models) const defaultModelId = useLLMStore(s => s.defaultModelId) // 过滤出非仅限 embedding 专用的模型 const chatModels = models.filter(m => !(m.purposes.length === 1 && m.purposes[0] === 'embedding')) const activeConv = getActiveConversation() const currentMode = activeConv?.mode ?? 'planning' const currentModelId = activeConv?.modelId ?? defaultModelId // 找到当前模型信息 const currentModel = models.find(m => m.id === currentModelId) // 下拉菜单状态 const [showContextMenu, setShowContextMenu] = useState(false) const [showModeMenu, setShowModeMenu] = useState(false) const [showModelMenu, setShowModelMenu] = useState(false) // / 命令和 @ 提及菜单状态 const [showSlashMenu, setShowSlashMenu] = useState(false) const [slashQuery, setSlashQuery] = useState('') const [showMentionMenu, setShowMentionMenu] = useState(false) const [mentionQuery, setMentionQuery] = useState('') // 检测输入是否触发 / 或 @ 菜单 const handleInputChange = useCallback((value: string) => { setInputText(value) // 检测 / 命令 if (value.startsWith('/')) { const q = value.slice(1).split(' ')[0] ?? '' setSlashQuery(q) setShowSlashMenu(true) setShowMentionMenu(false) } else { setShowSlashMenu(false) } // 检测 @ 提及(在光标位置前面找 @) const lastAt = value.lastIndexOf('@') if (lastAt >= 0) { const afterAt = value.slice(lastAt + 1) // 如果 @ 后面没有空格,视为正在输入提及 if (!afterAt.includes(' ')) { setMentionQuery(afterAt) setShowMentionMenu(true) setShowSlashMenu(false) } else { setShowMentionMenu(false) } } else { setShowMentionMenu(false) } }, []) // 选择 / 命令 const handleSlashSelect = useCallback((cmd: SlashCommand) => { setShowSlashMenu(false) if (cmd.source === 'skill') { // Skill 命令:替换为 /skill-name 后面可以加参数 setInputText(`/${cmd.name} `) } else { // 内置命令:直接发送 setInputText('') sendMessage(`/${cmd.name}`) } textareaRef.current?.focus() }, [sendMessage]) // 选择 @ 提及 const handleMentionSelect = useCallback((target: MentionTarget) => { setShowMentionMenu(false) // 替换最后一个 @ 及其后面的文字为 @displayName const lastAt = inputText.lastIndexOf('@') if (lastAt >= 0) { const before = inputText.slice(0, lastAt) setInputText(`${before}@${target.displayName} `) } textareaRef.current?.focus() }, [inputText]) const contextRef = useRef(null) const modeRef = useRef(null) const modelRef = useRef(null) // 调整文本框高度的通用函数 const adjustHeight = useCallback(() => { const ta = textareaRef.current if (!ta) return // 先重置为 0px,让 scrollHeight 正确反映内容高度,避免 flex 布局拉伸导致计算出很大的初始高度 ta.style.height = '0px' const next = Math.min(Math.max(ta.scrollHeight, 36), MAX_HEIGHT) ta.style.height = next + 'px' // 超出最大高度时框内滚动,否则隐藏滚动条 ta.style.overflowY = ta.scrollHeight > MAX_HEIGHT ? 'auto' : 'hidden' }, []) // 监听尺寸变化以重新计算高度,避免刚挂载时宽度未稳定导致的 placeholder 异常换行撑起高度 useEffect(() => { const ta = textareaRef.current if (!ta) { adjustHeight() return } const ro = new ResizeObserver(() => { adjustHeight() }) ro.observe(ta) // 初始化调用一次即可 adjustHeight() return () => ro.disconnect() }, [adjustHeight]) // 内容变化时重新调整高度 useEffect(() => { adjustHeight() }, [inputText, adjustHeight]) // 点击外部关闭下拉(用 useOutsideClick 统一管理三个 ref) useOutsideClick(contextRef, () => setShowContextMenu(false), showContextMenu) useOutsideClick(modeRef, () => setShowModeMenu(false), showModeMenu) useOutsideClick(modelRef, () => setShowModelMenu(false), showModelMenu) /** 发送或停止 */ const handleSendOrStop = useCallback(async () => { if (generating) { await cancelGeneration() return } if (!inputText.trim()) return const text = inputText setInputText('') await sendMessage(text) }, [generating, inputText, sendMessage, cancelGeneration]) /** 键盘事件:Enter 发送,Shift+Enter 换行 */ const handleKeyDown = (e: React.KeyboardEvent) => { // / 或 @ 菜单打开时,由菜单组件处理键盘事件 if (showSlashMenu || showMentionMenu) { if (['ArrowUp', 'ArrowDown', 'Enter'].includes(e.key)) { return // 让菜单组件通过 window 事件处理 } if (e.key === 'Escape') { setShowSlashMenu(false) setShowMentionMenu(false) return } } if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() handleSendOrStop() } } const canSend = !generating && inputText.trim().length > 0 return (
{/* / 命令菜单 */} {showSlashMenu && ( setShowSlashMenu(false)} /> )} {/* @ 提及菜单 */} {showMentionMenu && ( setShowMentionMenu(false)} /> )} {/* 上下文菜单(+ 按钮弹出) */} {showContextMenu && (
{t('agent.addContext')}
} label={t('agent.mediaFiles')} onClick={() => setShowContextMenu(false)} disabled /> } label={t('agent.mention')} onClick={() => { setShowContextMenu(false) // 插入 @ 字符并触发 MentionMenu setInputText(prev => prev + '@') handleInputChange(inputText + '@') textareaRef.current?.focus() }} /> } label={t('agent.workflowCommand')} onClick={() => { setShowContextMenu(false) // 插入 / 字符并触发 SlashCommandMenu setInputText('/') handleInputChange('/') textareaRef.current?.focus() }} />
)} {/* 输入区域 */}