feat(ui): 添加斜杠命令输入支持

- 新增 useCommands hook 用于加载和搜索命令
- 新增 CommandMenu 组件,支持键盘导航和选择
- ChatInput 支持 / 触发命令菜单
- 导出命令相关 API 和类型
This commit is contained in:
2025-12-12 18:38:43 +08:00
parent c25faa13b5
commit db711648e0
4 changed files with 500 additions and 6 deletions
+99 -6
View File
@@ -2,11 +2,14 @@
* Chat Input Component
*
* 支持响应式:responsive=true 时适配移动端键盘和触摸操作
* 支持斜杠命令:输入 / 时显示命令菜单
*/
import { useState, useRef, useEffect } from 'react';
import { useState, useRef, useEffect, useCallback } from 'react';
import { Send, Square } from 'lucide-react';
import clsx from 'clsx';
import { CommandMenu, type CommandMenuItem } from './CommandMenu.js';
import { useCommands } from '../hooks/useCommands.js';
interface ChatInputProps {
onSend: (content: string) => void;
@@ -15,6 +18,8 @@ interface ChatInputProps {
disabled?: boolean;
/** 是否启用响应式布局(移动端适配) */
responsive?: boolean;
/** 是否启用斜杠命令 */
enableCommands?: boolean;
}
export function ChatInput({
@@ -23,10 +28,20 @@ export function ChatInput({
isLoading,
disabled,
responsive = false,
enableCommands = 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 });
// 自动调整高度
useEffect(() => {
const textarea = textareaRef.current;
@@ -38,10 +53,59 @@ export function ChatInput({
}
}, [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;
setInput(value);
checkCommandTrigger(value);
};
// 选择命令
const handleCommandSelect = useCallback(
(command: CommandMenuItem) => {
// 替换输入内容为 /command + 空格,准备输入参数
setInput(`/${command.name} `);
setShowCommandMenu(false);
// 聚焦输入框
textareaRef.current?.focus();
},
[]
);
// 关闭命令菜单
const handleCommandMenuClose = useCallback(() => {
setShowCommandMenu(false);
}, []);
const handleSubmit = () => {
const trimmed = input.trim();
if (!trimmed || isLoading || disabled) return;
// 关闭命令菜单
setShowCommandMenu(false);
onSend(trimmed);
setInput('');
@@ -52,6 +116,22 @@ export function ChatInput({
};
const handleKeyDown = (e: React.KeyboardEvent) => {
// 如果命令菜单打开,让菜单处理键盘事件
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();
@@ -62,21 +142,34 @@ export function ChatInput({
return (
<div
className={clsx(
'border-t border-gray-700 bg-gray-900',
'border-t border-gray-700 bg-gray-900 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}
/>
)}
<div className="max-w-4xl mx-auto flex gap-2">
<div className="flex-1 relative">
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={
responsive
? 'Type a message...'
: 'Type a message... (Shift+Enter for new line)'
? 'Type a message or /command...'
: 'Type a message or /command... (Shift+Enter for new line)'
}
disabled={disabled}
rows={1}
@@ -110,7 +203,7 @@ export function ChatInput({
{/* 响应式模式下桌面端显示提示文字 */}
{responsive && (
<p className="hidden md:block text-xs text-gray-500 text-center mt-2">
Press Enter to send, Shift+Enter for new line
Press Enter to send, Shift+Enter for new line, / for commands
</p>
)}
</div>