e0444a966f
- 新增系统命令模块 (core/system-commands) - 支持 :clear/:cls/:c 清空对话历史 - 命令注册表支持别名 - 可扩展的命令执行器 - Server 端支持 - 新增 /api/system-commands API - WebSocket 处理系统命令消息 - 会话清空 API 端点 - UI 端支持 - 新增 SystemCommandMenu 组件 - 输入 : 时显示命令建议菜单 - 键盘导航和选择 - 底部提示添加 : 快捷键
480 lines
16 KiB
TypeScript
480 lines
16 KiB
TypeScript
/**
|
|
* Chat Input Component
|
|
*
|
|
* 支持响应式:responsive=true 时适配移动端键盘和触摸操作
|
|
* 支持斜杠命令:输入 / 时显示命令菜单
|
|
* 支持系统命令:输入 : 时显示系统命令菜单
|
|
* 支持文件提及:输入 @ 时显示文件搜索菜单
|
|
*/
|
|
|
|
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
|
import { Square, Sparkles } from 'lucide-react';
|
|
import { motion } from 'framer-motion';
|
|
import clsx from 'clsx';
|
|
import { CommandMenu, type CommandMenuItem } from './CommandMenu.js';
|
|
import { SystemCommandMenu, type SystemCommandMenuItem } from './SystemCommandMenu.js';
|
|
import { FileMenu, type FileMenuItem } from './FileMenu.js';
|
|
import { FileMentionTag } from './FileMentionTag.js';
|
|
import { AgentModeSelector, type AgentModeType } from './AgentModeSelector.js';
|
|
import { useCommands } from '../hooks/useCommands.js';
|
|
import { useSystemCommands } from '../hooks/useSystemCommands.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;
|
|
/** Agent 模式 (build/plan) */
|
|
agentMode?: AgentModeType;
|
|
/** Agent 模式变更回调 */
|
|
onAgentModeChange?: (mode: AgentModeType) => void;
|
|
/** 是否自动授权文件写入/编辑 */
|
|
autoApprove?: boolean;
|
|
/** 自动授权变更回调 */
|
|
onAutoApproveChange?: (enabled: boolean) => void;
|
|
}
|
|
|
|
export function ChatInput({
|
|
onSend,
|
|
onCancel,
|
|
isLoading,
|
|
disabled,
|
|
responsive = false,
|
|
enableCommands = true,
|
|
enableFileMention = true,
|
|
agentMode = 'build',
|
|
onAgentModeChange,
|
|
autoApprove = false,
|
|
onAutoApproveChange,
|
|
}: ChatInputProps) {
|
|
const [input, setInput] = useState('');
|
|
const [showCommandMenu, setShowCommandMenu] = useState(false);
|
|
const [selectedCommandIndex, setSelectedCommandIndex] = useState(0);
|
|
const [showSystemCommandMenu, setShowSystemCommandMenu] = useState(false);
|
|
const [selectedSystemCommandIndex, setSelectedSystemCommandIndex] = useState(0);
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
|
|
// 命令系统
|
|
const {
|
|
filteredCommands,
|
|
isLoading: commandsLoading,
|
|
filterCommands,
|
|
} = useCommands({ autoLoad: enableCommands });
|
|
|
|
// 系统命令系统
|
|
const {
|
|
filteredCommands: filteredSystemCommands,
|
|
isLoading: systemCommandsLoading,
|
|
filterCommands: filterSystemCommands,
|
|
} = useSystemCommands({ 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 checkSystemCommandTrigger = useCallback(
|
|
(value: string) => {
|
|
if (!enableCommands) return;
|
|
|
|
// 检测是否在输入系统命令
|
|
// 条件:以 : 开头,且 : 后面没有空格(还在输入命令名)
|
|
const colonMatch = value.match(/^:(\S*)$/);
|
|
|
|
if (colonMatch) {
|
|
const query = colonMatch[1]; // : 后面的内容
|
|
setShowSystemCommandMenu(true);
|
|
setSelectedSystemCommandIndex(0);
|
|
filterSystemCommands(query);
|
|
} else {
|
|
setShowSystemCommandMenu(false);
|
|
}
|
|
},
|
|
[enableCommands, filterSystemCommands]
|
|
);
|
|
|
|
// 处理输入变化
|
|
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
const value = e.target.value;
|
|
const cursorPos = e.target.selectionStart;
|
|
setInput(value);
|
|
|
|
// 检查斜杠命令触发
|
|
checkCommandTrigger(value);
|
|
|
|
// 检查系统命令触发
|
|
checkSystemCommandTrigger(value);
|
|
|
|
// 检查文件提及触发(只在非命令输入模式下)
|
|
if (enableFileMention && !value.startsWith('/') && !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 handleSystemCommandSelect = useCallback((command: SystemCommandMenuItem) => {
|
|
// 直接发送系统命令
|
|
setInput(`:${command.name}`);
|
|
setShowSystemCommandMenu(false);
|
|
|
|
// 聚焦输入框
|
|
textareaRef.current?.focus();
|
|
}, []);
|
|
|
|
// 关闭系统命令菜单
|
|
const handleSystemCommandMenuClose = useCallback(() => {
|
|
setShowSystemCommandMenu(false);
|
|
}, []);
|
|
|
|
const handleSubmit = () => {
|
|
const trimmed = input.trim();
|
|
if (!trimmed || isLoading || disabled) return;
|
|
|
|
// 关闭所有菜单
|
|
setShowCommandMenu(false);
|
|
setShowSystemCommandMenu(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;
|
|
}
|
|
}
|
|
|
|
// 系统命令菜单处理
|
|
if (showSystemCommandMenu && filteredSystemCommands.length > 0) {
|
|
if (['ArrowUp', 'ArrowDown', 'Tab', 'Escape'].includes(e.key)) {
|
|
// 这些键由 SystemCommandMenu 处理,阻止默认行为
|
|
return;
|
|
}
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
// Enter 选择系统命令
|
|
e.preventDefault();
|
|
if (filteredSystemCommands[selectedSystemCommandIndex]) {
|
|
handleSystemCommandSelect(filteredSystemCommands[selectedSystemCommandIndex]);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Enter 发送,Shift+Enter 换行
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleSubmit();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={clsx(
|
|
'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}
|
|
/>
|
|
)}
|
|
|
|
{/* System Command Menu (系统命令) */}
|
|
{enableCommands && (
|
|
<SystemCommandMenu
|
|
commands={filteredSystemCommands}
|
|
isOpen={showSystemCommandMenu}
|
|
selectedIndex={selectedSystemCommandIndex}
|
|
onSelect={handleSystemCommandSelect}
|
|
onClose={handleSystemCommandMenuClose}
|
|
onSelectedIndexChange={setSelectedSystemCommandIndex}
|
|
isLoading={systemCommandsLoading}
|
|
/>
|
|
)}
|
|
|
|
{/* 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">
|
|
{/* 主输入容器 - 现代化卡片设计 */}
|
|
<div
|
|
className={clsx(
|
|
'relative rounded-2xl border transition-all duration-200',
|
|
'bg-surface-subtle',
|
|
disabled
|
|
? 'border-line opacity-60'
|
|
: 'border-line hover:border-fg-subtle/30 focus-within:border-primary-500 focus-within:shadow-lg focus-within:shadow-primary-500/10'
|
|
)}
|
|
>
|
|
{/* 已选文件标签 */}
|
|
{mentionedFiles.length > 0 && (
|
|
<div className="flex flex-wrap gap-1.5 px-4 pt-3">
|
|
{mentionedFiles.map((file, index) => (
|
|
<FileMentionTag
|
|
key={`${file}-${index}`}
|
|
path={file}
|
|
size="sm"
|
|
removable
|
|
onRemove={() => handleRemoveFile(file)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* 输入区域 */}
|
|
<div className="flex items-end gap-2 p-2">
|
|
{/* Agent 模式选择器 */}
|
|
{onAgentModeChange && (
|
|
<div className="flex-shrink-0 mb-1">
|
|
<AgentModeSelector
|
|
mode={agentMode}
|
|
onModeChange={onAgentModeChange}
|
|
autoApprove={autoApprove}
|
|
onAutoApproveChange={onAutoApproveChange ?? (() => {})}
|
|
disabled={disabled || isLoading}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex-1 relative">
|
|
<textarea
|
|
ref={textareaRef}
|
|
value={input}
|
|
onChange={handleInputChange}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder={
|
|
responsive
|
|
? 'Ask anything...'
|
|
: 'Ask anything... (Shift+Enter for new line)'
|
|
}
|
|
disabled={disabled}
|
|
rows={1}
|
|
className={clsx(
|
|
'w-full resize-none bg-transparent border-0',
|
|
responsive ? 'px-2 py-2 md:px-3 md:py-2' : 'px-3 py-2',
|
|
responsive ? 'text-base md:text-sm' : 'text-sm',
|
|
'text-fg placeholder-fg-subtle/60',
|
|
'focus:outline-none focus:ring-0',
|
|
'disabled:cursor-not-allowed'
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
{/* 发送按钮 */}
|
|
<motion.button
|
|
whileHover={{ scale: 1.05 }}
|
|
whileTap={{ scale: 0.95 }}
|
|
onClick={isLoading ? onCancel : handleSubmit}
|
|
disabled={!isLoading && (!input.trim() || disabled)}
|
|
className={clsx(
|
|
'flex-shrink-0 rounded-xl flex items-center justify-center transition-all duration-200',
|
|
responsive
|
|
? 'w-10 h-10 md:w-9 md:h-9'
|
|
: 'w-9 h-9',
|
|
isLoading
|
|
? 'bg-red-500 hover:bg-red-600 text-white shadow-lg shadow-red-500/25'
|
|
: input.trim() && !disabled
|
|
? 'bg-gradient-to-r from-primary-500 to-primary-600 hover:from-primary-600 hover:to-primary-700 text-white shadow-lg shadow-primary-500/25'
|
|
: 'bg-surface-muted text-fg-subtle',
|
|
'disabled:opacity-40 disabled:cursor-not-allowed disabled:shadow-none'
|
|
)}
|
|
>
|
|
{isLoading ? (
|
|
<Square size={18} className="fill-current" />
|
|
) : (
|
|
<Sparkles size={18} className={clsx(input.trim() && !disabled && 'animate-pulse')} />
|
|
)}
|
|
</motion.button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 底部提示 */}
|
|
{responsive && (
|
|
<div className="hidden md:flex items-center justify-center gap-4 mt-3 text-xs text-fg-subtle/60">
|
|
<span className="flex items-center gap-1">
|
|
<kbd className="px-1.5 py-0.5 rounded bg-surface-subtle text-fg-muted font-mono text-[10px]">Enter</kbd>
|
|
<span>send</span>
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<kbd className="px-1.5 py-0.5 rounded bg-surface-subtle text-fg-muted font-mono text-[10px]">/</kbd>
|
|
<span>commands</span>
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<kbd className="px-1.5 py-0.5 rounded bg-surface-subtle text-fg-muted font-mono text-[10px]">:</kbd>
|
|
<span>system</span>
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<kbd className="px-1.5 py-0.5 rounded bg-surface-subtle text-fg-muted font-mono text-[10px]">@</kbd>
|
|
<span>files</span>
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|