- 添加快捷短语触发按钮和弹出层 - 实现短语内容插入到输入框功能 - 集成快捷短语管理模态框 - 添加点击外部和ESC键关闭弹出层 - 统一按钮圆角样式为 rounded-md
321 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|