Files
ai-terminal-assistant/packages/ui/src/components/ChatInput.tsx
T
kurihada 865e0906b9 feat(ui): 实现 @ 文件提及自动补全功能
- Core: 添加 file-index 模块,使用 ripgrep 索引文件,fuzzysort 模糊搜索
- Server: 添加 /api/files/search 端点,支持文件模糊搜索
- Server: WebSocket 消息处理中将 @filepath 转换为 ./filepath 格式
- UI: 新增 FileMenu 组件,显示文件搜索结果列表
- UI: 新增 FileMentionTag 组件,高亮显示文件提及
- UI: 新增 useFileMention hook,管理文件提及状态
- UI: ChatInput 集成 @ 触发的文件自动补全
- UI: ChatMessage 用户消息中高亮显示 @filepath
2025-12-15 16:32:59 +08:00

335 lines
10 KiB
TypeScript

/**
* 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<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => {
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 (
<div
className={clsx(
'border-t border-line bg-surface-base relative',
responsive ? 'p-3 md:p-4 safe-area-pb' : 'p-4'
)}
>
{/* Command Menu */}
{enableCommands && (
<CommandMenu
commands={filteredCommands}
isOpen={showCommandMenu}
selectedIndex={selectedCommandIndex}
onSelect={handleCommandSelect}
onClose={handleCommandMenuClose}
onSelectedIndexChange={setSelectedCommandIndex}
isLoading={commandsLoading}
/>
)}
{/* File Menu */}
{enableFileMention && (
<FileMenu
files={filteredFiles}
isOpen={showFileMenu}
selectedIndex={fileSelectedIndex}
onSelect={handleFileSelect}
onClose={closeFileMenu}
onSelectedIndexChange={setFileSelectedIndex}
isLoading={filesLoading}
/>
)}
<div className="max-w-4xl mx-auto">
{/* 已选文件标签 */}
{mentionedFiles.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{mentionedFiles.map((file, index) => (
<FileMentionTag
key={`${file}-${index}`}
path={file}
size="sm"
removable
onRemove={() => handleRemoveFile(file)}
/>
))}
</div>
)}
{/* 输入区域 */}
<div className="flex gap-2">
<div className="flex-1 relative">
<textarea
ref={textareaRef}
value={input}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={
responsive
? 'Type a message, /command, or @file...'
: 'Type a message, /command, or @file... (Shift+Enter for new line)'
}
disabled={disabled}
rows={1}
className={clsx(
'w-full resize-none rounded-lg border border-line bg-surface-subtle',
responsive ? 'px-3 py-2.5 md:px-4 md:py-3' : 'px-4 py-3',
responsive ? 'text-base md:text-sm' : 'text-sm', // 移动端使用 16px 防止缩放
'text-fg placeholder-fg-subtle',
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
/>
</div>
<button
onClick={isLoading ? onCancel : handleSubmit}
disabled={!isLoading && (!input.trim() || disabled)}
className={clsx(
'rounded-lg flex items-center justify-center transition-colors self-end',
responsive
? 'px-3 py-2.5 md:px-4 md:py-3 min-w-[44px] min-h-[44px]' // 最小触摸目标 44x44
: 'px-4 py-3',
isLoading
? 'bg-red-600 hover:bg-red-700 active:bg-red-800 text-white'
: 'bg-primary-600 hover:bg-primary-700 active:bg-primary-800 text-white',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{isLoading ? <Square size={20} /> : <Send size={20} />}
</button>
</div>
</div>
{/* 响应式模式下桌面端显示提示文字 */}
{responsive && (
<p className="hidden md:block text-xs text-fg-subtle text-center mt-2">
Press Enter to send, Shift+Enter for new line, / for commands, @ for files
</p>
)}
</div>
);
}