/** * Chat Input Component * * 支持响应式:responsive=true 时适配移动端键盘和触摸操作 * 支持斜杠命令:输入 / 时显示命令菜单 * 支持文件提及:输入 @ 时显示文件搜索菜单 */ import { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import { Send, Square } from 'lucide-react'; import clsx from 'clsx'; import { CommandMenu, type CommandMenuItem } from './CommandMenu.js'; import { FileMenu, type FileMenuItem } from './FileMenu.js'; import { FileMentionTag } from './FileMentionTag.js'; import { useCommands } from '../hooks/useCommands.js'; import { useFileMention } from '../hooks/useFileMention.js'; interface ChatInputProps { onSend: (content: string) => void; onCancel: () => void; isLoading: boolean; disabled?: boolean; /** 是否启用响应式布局(移动端适配) */ responsive?: boolean; /** 是否启用斜杠命令 */ enableCommands?: boolean; /** 是否启用文件提及 (@) */ enableFileMention?: boolean; } export function ChatInput({ onSend, onCancel, isLoading, disabled, responsive = false, enableCommands = true, enableFileMention = true, }: ChatInputProps) { const [input, setInput] = useState(''); const [showCommandMenu, setShowCommandMenu] = useState(false); const [selectedCommandIndex, setSelectedCommandIndex] = useState(0); const textareaRef = useRef(null); // 命令系统 const { filteredCommands, isLoading: commandsLoading, filterCommands, } = useCommands({ autoLoad: enableCommands }); // 文件提及系统 const { isOpen: showFileMenu, files: filteredFiles, isLoading: filesLoading, selectedIndex: fileSelectedIndex, setSelectedIndex: setFileSelectedIndex, checkTrigger: checkFileTrigger, getReplacementText, close: closeFileMenu, mentionStart, } = useFileMention({ enabled: enableFileMention }); // 解析输入中已存在的文件提及 (匹配 @path/to/file 格式) const mentionedFiles = useMemo(() => { const regex = /@([\w./-]+)/g; const files: string[] = []; let match; while ((match = regex.exec(input)) !== null) { files.push(match[1]); } return files; }, [input]); // 移除指定的文件提及 const handleRemoveFile = useCallback( (filePath: string) => { // 移除 @filepath 和后面的空格 const regex = new RegExp(`@${filePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s?`, 'g'); setInput((prev) => prev.replace(regex, '').trim()); textareaRef.current?.focus(); }, [] ); // 自动调整高度 useEffect(() => { const textarea = textareaRef.current; if (textarea) { textarea.style.height = 'auto'; // 响应式模式下移动端最大高度稍小 const maxHeight = responsive && window.innerWidth < 768 ? 120 : 200; textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeight)}px`; } }, [input, responsive]); // 检测斜杠命令输入 const checkCommandTrigger = useCallback( (value: string) => { if (!enableCommands) return; // 检测是否在输入斜杠命令 // 条件:以 / 开头,且 / 后面没有空格(还在输入命令名) const slashMatch = value.match(/^\/(\S*)$/); if (slashMatch) { const query = slashMatch[1]; // / 后面的内容 setShowCommandMenu(true); setSelectedCommandIndex(0); filterCommands(query); } else { setShowCommandMenu(false); } }, [enableCommands, filterCommands] ); // 处理输入变化 const handleInputChange = (e: React.ChangeEvent) => { const value = e.target.value; const cursorPos = e.target.selectionStart; setInput(value); // 检查命令触发 checkCommandTrigger(value); // 检查文件提及触发(只在非命令输入模式下) if (enableFileMention && !value.startsWith('/')) { checkFileTrigger(value, cursorPos); } else { closeFileMenu(); } }; // 选择命令 const handleCommandSelect = useCallback((command: CommandMenuItem) => { // 替换输入内容为 /command + 空格,准备输入参数 setInput(`/${command.name} `); setShowCommandMenu(false); // 聚焦输入框 textareaRef.current?.focus(); }, []); // 选择文件 const handleFileSelect = useCallback( (file: FileMenuItem) => { if (mentionStart === null) return; // 获取当前光标位置 const cursorPos = textareaRef.current?.selectionStart || input.length; // 替换 @ 到光标之间的内容 const before = input.slice(0, mentionStart); const after = input.slice(cursorPos); const replacement = getReplacementText(file); const newInput = before + replacement + after; setInput(newInput); closeFileMenu(); // 聚焦输入框并设置光标位置 setTimeout(() => { textareaRef.current?.focus(); const newPos = before.length + replacement.length; textareaRef.current?.setSelectionRange(newPos, newPos); }, 0); }, [input, mentionStart, getReplacementText, closeFileMenu] ); // 关闭命令菜单 const handleCommandMenuClose = useCallback(() => { setShowCommandMenu(false); }, []); const handleSubmit = () => { const trimmed = input.trim(); if (!trimmed || isLoading || disabled) return; // 关闭菜单 setShowCommandMenu(false); closeFileMenu(); onSend(trimmed); setInput(''); // 重置高度 if (textareaRef.current) { textareaRef.current.style.height = 'auto'; } }; const handleKeyDown = (e: React.KeyboardEvent) => { // 文件菜单优先处理(因为可以在任意位置触发) if (showFileMenu && filteredFiles.length > 0) { if (['ArrowUp', 'ArrowDown', 'Tab', 'Escape'].includes(e.key)) { // 这些键由 FileMenu 处理 return; } if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (filteredFiles[fileSelectedIndex]) { handleFileSelect(filteredFiles[fileSelectedIndex]); } return; } } // 命令菜单处理 if (showCommandMenu && filteredCommands.length > 0) { if (['ArrowUp', 'ArrowDown', 'Tab', 'Escape'].includes(e.key)) { // 这些键由 CommandMenu 处理,阻止默认行为 return; } if (e.key === 'Enter' && !e.shiftKey) { // Enter 选择命令 e.preventDefault(); if (filteredCommands[selectedCommandIndex]) { handleCommandSelect(filteredCommands[selectedCommandIndex]); } return; } } // Enter 发送,Shift+Enter 换行 if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSubmit(); } }; return (
{/* Command Menu */} {enableCommands && ( )} {/* File Menu */} {enableFileMention && ( )}
{/* 已选文件标签 */} {mentionedFiles.length > 0 && (
{mentionedFiles.map((file, index) => ( handleRemoveFile(file)} /> ))}
)} {/* 输入区域 */}