claude-code-cchui/src/components/features/ChatInput.tsx
gaoziman 16079af79d feat(ChatInput): 集成快捷短语功能
- 添加快捷短语触发按钮和弹出层
- 实现短语内容插入到输入框功能
- 集成快捷短语管理模态框
- 添加点击外部和ESC键关闭弹出层
- 统一按钮圆角样式为 rounded-md
2025-12-24 00:08:17 +08:00

321 lines
10 KiB
TypeScript

'use client';
import { useState, useRef, useEffect } from 'react';
import { Paperclip, ArrowUp, Upload } from 'lucide-react';
import { ModelSelector } from './ModelSelector';
import { ToolsDropdown } from './ToolsDropdown';
import { FilePreviewList } from './FilePreviewList';
import { QuickPhrasesTrigger, QuickPhrasesPopover } from './QuickPhrasesPopover';
import { QuickPhrasesModal } from './QuickPhrasesModal';
import { Tooltip } from '@/components/ui/Tooltip';
import { useFileUpload } from '@/hooks/useFileUpload';
import { usePromptOptimizer } from '@/providers/PromptOptimizerProvider';
import { useQuickPhrases } from '@/hooks/useQuickPhrases';
import { cn } from '@/lib/utils';
import type { Model, Tool, QuickPhrase } from '@/types';
import type { UploadFile } from '@/types/file-upload';
interface ChatInputProps {
models: Model[];
selectedModel: Model;
onModelSelect: (model: Model) => void;
tools: Tool[];
onToolToggle: (toolId: string) => void;
onEnableAllTools: (enabled: boolean) => void;
onSend: (message: string, files?: UploadFile[]) => void;
placeholder?: string;
className?: string;
}
export function ChatInput({
models,
selectedModel,
onModelSelect,
tools,
onToolToggle,
onEnableAllTools,
onSend,
placeholder = 'How can I help you today?',
className,
}: ChatInputProps) {
const [message, setMessage] = useState('');
const [isManageModalOpen, setIsManageModalOpen] = useState(false);
const [isPhrasesOpen, setIsPhrasesOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const phrasesContainerRef = useRef<HTMLDivElement>(null);
// 使用提示词优化 Hook
const { consumeOptimizedPrompt } = usePromptOptimizer();
// 使用快捷短语 Hook
const {
phrases,
addPhrase,
updatePhrase,
deletePhrase,
} = useQuickPhrases();
// 监听优化后的提示词并填入输入框
useEffect(() => {
const prompt = consumeOptimizedPrompt();
if (prompt) {
setMessage(prompt);
}
}, [consumeOptimizedPrompt]);
// 点击外部关闭快捷短语弹出层
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
phrasesContainerRef.current &&
!phrasesContainerRef.current.contains(event.target as Node)
) {
setIsPhrasesOpen(false);
}
}
if (isPhrasesOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isPhrasesOpen]);
// ESC键关闭快捷短语弹出层
useEffect(() => {
function handleEscKey(event: KeyboardEvent) {
if (event.key === 'Escape' && isPhrasesOpen) {
setIsPhrasesOpen(false);
}
}
if (isPhrasesOpen) {
document.addEventListener('keydown', handleEscKey);
}
return () => document.removeEventListener('keydown', handleEscKey);
}, [isPhrasesOpen]);
// 使用文件上传 Hook
const {
files,
isDragging,
addFiles,
removeFile,
clearFiles,
handleDragEnter,
handleDragLeave,
handleDragOver,
handleDrop,
handlePaste,
} = useFileUpload();
const handleSend = () => {
if (message.trim() || files.length > 0) {
onSend(message, files.length > 0 ? files : undefined);
setMessage('');
clearFiles();
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
// 检查是否正在使用输入法组合(如中文输入法选词时按回车)
// isComposing 为 true 时,表示用户正在用输入法选词,此时不应发送消息
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
handleSend();
}
};
// 处理文件选择
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = e.target.files;
if (selectedFiles && selectedFiles.length > 0) {
addFiles(selectedFiles);
}
// 重置 input 以允许重复选择同一文件
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
// 打开文件选择对话框
const openFileDialog = () => {
fileInputRef.current?.click();
};
// 插入快捷短语内容
const handleInsertPhrase = (content: string) => {
setMessage((prev) => {
// 如果已有内容,在末尾添加空格
if (prev.trim()) {
return prev + ' ' + content;
}
return content;
});
};
// 打开管理模态框
const handleOpenManageModal = () => {
setIsManageModalOpen(true);
};
// 关闭管理模态框
const handleCloseManageModal = () => {
setIsManageModalOpen(false);
};
// 处理添加短语(从管理模态框)
const handleAddFromModal = () => {
handleOpenManageModal();
};
return (
<div className={cn('w-full max-w-[var(--input-max-width)] mx-auto', className)}>
<div
ref={phrasesContainerRef}
className={cn(
'relative flex flex-col bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded-md p-4 shadow-[var(--shadow-input)]',
'transition-all duration-150',
'focus-within:border-[var(--color-border-focus)] focus-within:shadow-[var(--shadow-input-focus)]',
isDragging && 'border-[var(--color-primary)] border-2 bg-[var(--color-primary)]/5'
)}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{/* 快捷短语弹出层 - 横跨在输入框顶部 */}
<QuickPhrasesPopover
phrases={phrases}
isOpen={isPhrasesOpen}
onInsert={handleInsertPhrase}
onEdit={(phrase) => {
setIsManageModalOpen(true);
}}
onDelete={deletePhrase}
onManage={handleOpenManageModal}
onAdd={handleAddFromModal}
onClose={() => setIsPhrasesOpen(false)}
/>
{/* 拖拽覆盖层 */}
{isDragging && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-[var(--color-bg-primary)]/90 rounded-md border-2 border-dashed border-[var(--color-primary)]">
<Upload className="w-8 h-8 text-[var(--color-primary)] mb-2" />
<p className="text-sm font-medium text-[var(--color-primary)]">
</p>
<p className="text-xs text-[var(--color-text-tertiary)] mt-1">
PDF
</p>
</div>
)}
{/* 文件预览区域 */}
{files.length > 0 && (
<div className="mb-3">
<FilePreviewList files={files} onRemove={removeFile} />
</div>
)}
{/* 第一行:输入区域 */}
<div className="w-full mb-3">
<input
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={files.length > 0 ? '添加描述(可选)...' : placeholder}
className="w-full border-none outline-none text-[var(--color-text-primary)] bg-transparent py-2 placeholder:text-[var(--color-text-placeholder)]"
/>
</div>
{/* 第二行:功能按钮区域 */}
<div className="flex items-center justify-between w-full">
{/* 左侧按钮 */}
<div className="flex items-center gap-1">
{/* 添加附件 */}
<Tooltip content="添加附件">
<button
onClick={openFileDialog}
className={cn(
'w-8 h-8 flex items-center justify-center rounded-md transition-colors cursor-pointer',
'text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)]',
files.length > 0 && 'text-[var(--color-primary)]'
)}
>
<Paperclip size={20} />
</button>
</Tooltip>
{/* 隐藏的文件输入 */}
<input
ref={fileInputRef}
type="file"
multiple
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt,.md,.json,.js,.ts,.jsx,.tsx,.py,.java,.c,.cpp,.go,.rs,.html,.css"
onChange={handleFileSelect}
className="hidden"
/>
{/* 快捷短语触发按钮 */}
<QuickPhrasesTrigger
phrasesCount={phrases.length}
onClick={() => setIsPhrasesOpen(!isPhrasesOpen)}
/>
{/* 工具下拉 */}
<ToolsDropdown
tools={tools}
onToolToggle={onToolToggle}
onEnableAllToggle={onEnableAllTools}
/>
</div>
{/* 右侧按钮 */}
<div className="flex items-center gap-3">
{/* 模型选择器 */}
<ModelSelector
models={models}
selectedModel={selectedModel}
onSelect={onModelSelect}
/>
{/* 发送按钮 */}
<Tooltip content="发送消息">
<button
onClick={handleSend}
disabled={!message.trim() && files.length === 0}
className={cn(
'w-[38px] h-[38px] flex items-center justify-center bg-[var(--color-primary)] text-white rounded-md transition-all duration-150 cursor-pointer',
message.trim() || files.length > 0
? 'hover:bg-[var(--color-primary-hover)] hover:-translate-y-0.5'
: 'opacity-50 cursor-not-allowed'
)}
>
<ArrowUp size={18} />
</button>
</Tooltip>
</div>
</div>
</div>
{/* 提示文字 */}
<p className="text-xs text-[var(--color-text-tertiary)] text-center mt-2">
使 Ctrl+V
</p>
{/* 快捷短语管理模态框 */}
<QuickPhrasesModal
isOpen={isManageModalOpen}
onClose={handleCloseManageModal}
phrases={phrases}
onAdd={addPhrase}
onUpdate={updatePhrase}
onDelete={deletePhrase}
/>
</div>
);
}