对 47 个前端文件统一执行 npm run format,消除存量格式差异。 随后在 CI 与 Makefile check 中启用 format:check,确保后续提交强制遵守 prettier 风格。
419 lines
14 KiB
TypeScript
419 lines
14 KiB
TypeScript
import {
|
||
Send,
|
||
Loader2,
|
||
Square,
|
||
Sparkles,
|
||
Eye,
|
||
Paperclip,
|
||
X,
|
||
FileIcon,
|
||
ImageIcon,
|
||
MusicIcon,
|
||
VideoIcon,
|
||
} from 'lucide-react';
|
||
import { useState, useRef, useEffect } from 'react';
|
||
import type { Attachment } from '../../types/protocol';
|
||
|
||
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
||
|
||
interface MessageInputProps {
|
||
onSend: (content: string, attachments: Attachment[]) => void;
|
||
onStop?: () => void;
|
||
disabled?: boolean;
|
||
isLoading?: boolean;
|
||
placeholder?: string;
|
||
isReadOnly?: boolean;
|
||
channelName?: string;
|
||
selectedExpert?: { name: string; description: string } | null;
|
||
}
|
||
|
||
interface FileAttachment {
|
||
id: string;
|
||
file: File;
|
||
attachment: Attachment;
|
||
preview?: string; // 用于图片预览
|
||
}
|
||
|
||
// 根据 MIME 类型判断 media_type
|
||
function getMediaType(mimeType: string): string {
|
||
if (mimeType.startsWith('image/')) return 'image';
|
||
if (mimeType.startsWith('audio/')) return 'audio';
|
||
if (mimeType.startsWith('video/')) return 'video';
|
||
return 'file';
|
||
}
|
||
|
||
export function MessageInput({
|
||
onSend,
|
||
onStop,
|
||
disabled = false,
|
||
isLoading = false,
|
||
placeholder,
|
||
isReadOnly = false,
|
||
channelName,
|
||
selectedExpert,
|
||
}: MessageInputProps) {
|
||
const effectivePlaceholder =
|
||
placeholder ??
|
||
(selectedExpert ? `以 ${selectedExpert.name} 专家身份对话...` : '输入消息...按 / 查看命令');
|
||
const [content, setContent] = useState('');
|
||
const [attachments, setAttachments] = useState<FileAttachment[]>([]);
|
||
const [isDragging, setIsDragging] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const wasLoadingRef = useRef(false);
|
||
|
||
useEffect(() => {
|
||
const textarea = textareaRef.current;
|
||
if (textarea) {
|
||
textarea.style.height = 'auto';
|
||
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
|
||
}
|
||
}, [content]);
|
||
|
||
// 当 isLoading 从 true 变为 false 时,自动聚焦输入框
|
||
useEffect(() => {
|
||
if (wasLoadingRef.current && !isLoading && !isReadOnly) {
|
||
textareaRef.current?.focus();
|
||
}
|
||
wasLoadingRef.current = isLoading;
|
||
}, [isLoading, isReadOnly]);
|
||
|
||
// 处理文件选择
|
||
const handleFileSelect = async (files: FileList | null) => {
|
||
if (!files) return;
|
||
setError(null);
|
||
|
||
const newAttachments: FileAttachment[] = [];
|
||
for (const file of Array.from(files)) {
|
||
// 检查文件大小
|
||
if (file.size > MAX_FILE_SIZE) {
|
||
setError(`文件 "${file.name}" 超过 50MB 限制`);
|
||
continue;
|
||
}
|
||
|
||
// 读取文件为 base64
|
||
const base64 = await readFileAsBase64(file);
|
||
const mimeType = file.type || 'application/octet-stream';
|
||
const mediaType = getMediaType(mimeType);
|
||
|
||
const attachment: Attachment = {
|
||
path: file.name,
|
||
media_type: mediaType,
|
||
mime_type: mimeType,
|
||
content_base64: base64,
|
||
file_name: file.name,
|
||
};
|
||
|
||
const fileAttachment: FileAttachment = {
|
||
id: crypto.randomUUID(),
|
||
file,
|
||
attachment,
|
||
preview: mediaType === 'image' ? base64 : undefined,
|
||
};
|
||
|
||
newAttachments.push(fileAttachment);
|
||
}
|
||
|
||
setAttachments((prev) => [...prev, ...newAttachments]);
|
||
};
|
||
|
||
// 读取文件为 base64
|
||
const readFileAsBase64 = (file: File): Promise<string> => {
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
const result = reader.result as string;
|
||
// 移除 data:xxx;base64, 前缀
|
||
const base64 = result.split(',')[1];
|
||
resolve(base64);
|
||
};
|
||
reader.onerror = reject;
|
||
reader.readAsDataURL(file);
|
||
});
|
||
};
|
||
|
||
// 点击附件按钮
|
||
const handleAttachClick = () => {
|
||
fileInputRef.current?.click();
|
||
};
|
||
|
||
// 删除附件
|
||
const handleRemoveAttachment = (index: number) => {
|
||
setAttachments((prev) => prev.filter((_, i) => i !== index));
|
||
};
|
||
|
||
// 粘贴事件处理
|
||
const handlePaste = async (e: React.ClipboardEvent) => {
|
||
if (disabled || isReadOnly) return;
|
||
|
||
const clipboardData = e.clipboardData;
|
||
const items = clipboardData.items;
|
||
|
||
// 检查是否有文件(图片或其他文件)
|
||
const files: File[] = [];
|
||
for (const item of Array.from(items)) {
|
||
if (item.kind === 'file') {
|
||
const file = item.getAsFile();
|
||
if (file) {
|
||
files.push(file);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 如果有文件,处理文件并阻止默认粘贴行为
|
||
if (files.length > 0) {
|
||
e.preventDefault();
|
||
// 直接处理文件数组
|
||
setError(null);
|
||
for (const file of files) {
|
||
if (file.size > MAX_FILE_SIZE) {
|
||
setError(`文件 "${file.name}" 超过 50MB 限制`);
|
||
continue;
|
||
}
|
||
|
||
const base64 = await readFileAsBase64(file);
|
||
const mimeType = file.type || 'application/octet-stream';
|
||
const mediaType = getMediaType(mimeType);
|
||
|
||
const attachment: Attachment = {
|
||
path: file.name,
|
||
media_type: mediaType,
|
||
mime_type: mimeType,
|
||
content_base64: base64,
|
||
file_name: file.name,
|
||
};
|
||
|
||
const fileAttachment: FileAttachment = {
|
||
id: crypto.randomUUID(),
|
||
file,
|
||
attachment,
|
||
preview: mediaType === 'image' ? base64 : undefined,
|
||
};
|
||
|
||
setAttachments((prev) => [...prev, fileAttachment]);
|
||
}
|
||
}
|
||
// 否则让默认的文本粘贴行为继续
|
||
};
|
||
|
||
// 拖拽事件
|
||
const handleDragEnter = (e: React.DragEvent) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
if (!disabled && !isReadOnly) {
|
||
setIsDragging(true);
|
||
}
|
||
};
|
||
|
||
const handleDragLeave = (e: React.DragEvent) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
// 检查是否真的离开了拖拽区域(而不是进入子元素)
|
||
const relatedTarget = e.relatedTarget as Node | null;
|
||
const currentTarget = e.currentTarget;
|
||
if (!relatedTarget || !currentTarget.contains(relatedTarget)) {
|
||
setIsDragging(false);
|
||
}
|
||
};
|
||
|
||
const handleDragOver = (e: React.DragEvent) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
};
|
||
|
||
const handleDrop = (e: React.DragEvent) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setIsDragging(false);
|
||
if (!disabled && !isReadOnly) {
|
||
handleFileSelect(e.dataTransfer.files);
|
||
}
|
||
};
|
||
|
||
const handleSend = () => {
|
||
const hasContent = content.trim() || attachments.length > 0;
|
||
if (hasContent && !disabled && !isReadOnly) {
|
||
onSend(
|
||
content.trim(),
|
||
attachments.map((a) => a.attachment),
|
||
);
|
||
setContent('');
|
||
setAttachments([]);
|
||
setError(null);
|
||
if (textareaRef.current) {
|
||
textareaRef.current.style.height = 'auto';
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
handleSend();
|
||
}
|
||
};
|
||
|
||
// 获取附件图标
|
||
const getAttachmentIcon = (mediaType: string) => {
|
||
switch (mediaType) {
|
||
case 'image':
|
||
return <ImageIcon className="h-4 w-4" />;
|
||
case 'audio':
|
||
return <MusicIcon className="h-4 w-4" />;
|
||
case 'video':
|
||
return <VideoIcon className="h-4 w-4" />;
|
||
default:
|
||
return <FileIcon className="h-4 w-4" />;
|
||
}
|
||
};
|
||
|
||
// 只读模式:显示提示占位符
|
||
if (isReadOnly) {
|
||
return (
|
||
<div className="border-t border-[var(--border-color)] bg-[var(--bg-secondary)]/80 backdrop-blur-md p-4">
|
||
<div className="max-w-5xl mx-auto">
|
||
<div className="rounded-xl border border-[var(--border-color)] bg-[var(--bg-tertiary)]/50 px-4 py-5 text-center">
|
||
<div className="flex flex-col items-center gap-2">
|
||
<div className="flex items-center gap-2 text-[var(--text-secondary)]">
|
||
<Eye className="h-5 w-5" />
|
||
<span className="font-medium">只读模式</span>
|
||
</div>
|
||
<p className="text-sm text-[var(--text-muted)]">
|
||
{channelName ? (
|
||
<>{channelName} 通道仅支持查看历史消息</>
|
||
) : (
|
||
'当前通道仅支持查看历史消息'
|
||
)}
|
||
</p>
|
||
<p className="text-xs text-[var(--text-muted)]">请切换至 WebSocket 通道进行输入</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="border-t border-[var(--border-color)] bg-[var(--bg-secondary)]/80 backdrop-blur-md p-4">
|
||
<div className="max-w-5xl mx-auto">
|
||
{/* 错误提示 */}
|
||
{error && (
|
||
<div className="mb-2 rounded-lg bg-red-500/10 border border-red-500/20 px-3 py-2 text-sm text-red-400">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{/* 附件预览列表 */}
|
||
{attachments.length > 0 && (
|
||
<div className="mb-2 flex flex-wrap gap-2">
|
||
{attachments.map((att, index) => (
|
||
<div
|
||
key={att.id}
|
||
className="flex items-center gap-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-tertiary)] px-2 py-1.5 text-sm"
|
||
>
|
||
{att.preview ? (
|
||
<img
|
||
src={`data:${att.attachment.mime_type};base64,${att.preview}`}
|
||
alt={att.attachment.file_name}
|
||
className="h-8 w-8 rounded object-cover"
|
||
/>
|
||
) : (
|
||
<div className="h-8 w-8 rounded bg-[var(--bg-hover)] flex items-center justify-center text-[var(--text-secondary)]">
|
||
{getAttachmentIcon(att.attachment.media_type)}
|
||
</div>
|
||
)}
|
||
<span className="text-[var(--text-secondary)] max-w-[120px] truncate">
|
||
{att.attachment.file_name}
|
||
</span>
|
||
<button
|
||
onClick={() => handleRemoveAttachment(index)}
|
||
className="text-[var(--text-muted)] hover:text-red-400 transition-colors"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* 输入区域 */}
|
||
<div
|
||
className={`flex gap-3 items-center relative ${isDragging ? 'ring-2 ring-[var(--accent-cyan)]/50 rounded-xl' : ''}`}
|
||
onDragEnter={handleDragEnter}
|
||
onDragLeave={handleDragLeave}
|
||
onDragOver={handleDragOver}
|
||
onDrop={handleDrop}
|
||
>
|
||
{/* 拖拽提示 */}
|
||
{isDragging && (
|
||
<div className="absolute inset-0 rounded-xl bg-[var(--accent-cyan)]/10 border-2 border-[var(--accent-cyan)]/50 flex items-center justify-center z-10">
|
||
<div className="text-[var(--accent-cyan)] text-sm font-medium">拖放文件到这里</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 附件按钮 */}
|
||
<button
|
||
onClick={handleAttachClick}
|
||
disabled={disabled}
|
||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-[var(--border-color)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-color)] disabled:opacity-50 transition-all"
|
||
>
|
||
<Paperclip className="h-5 w-5" />
|
||
</button>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
multiple
|
||
className="hidden"
|
||
onChange={(e) => handleFileSelect(e.target.files)}
|
||
/>
|
||
|
||
{/* 输入框 */}
|
||
<div className="flex-1 relative flex items-center">
|
||
<textarea
|
||
ref={textareaRef}
|
||
value={content}
|
||
onChange={(e) => setContent(e.target.value)}
|
||
onKeyDown={handleKeyDown}
|
||
onPaste={handlePaste}
|
||
placeholder={effectivePlaceholder}
|
||
disabled={disabled}
|
||
rows={1}
|
||
className="w-full resize-none rounded-xl border border-[var(--border-color)] bg-[var(--bg-tertiary)] px-4 py-3 pr-12 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-cyan)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--focus-ring)] disabled:opacity-50 transition-all self-center scrollbar-hide"
|
||
/>
|
||
<Sparkles className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--text-muted)] pointer-events-none" />
|
||
</div>
|
||
|
||
{/* 发送/停止按钮 */}
|
||
{isLoading && onStop ? (
|
||
<button
|
||
onClick={onStop}
|
||
title="停止执行"
|
||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 hover:bg-red-500/20 hover:text-red-300 transition-all"
|
||
>
|
||
<Square className="h-4 w-4" />
|
||
</button>
|
||
) : (
|
||
<button
|
||
onClick={handleSend}
|
||
disabled={disabled || (!content.trim() && attachments.length === 0)}
|
||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-r from-[var(--accent-cyan)] to-[var(--accent-blue)] text-white shadow-lg shadow-[var(--accent-cyan)]/20 hover:shadow-xl hover:shadow-[var(--accent-cyan)]/30 hover:scale-105 disabled:opacity-50 disabled:hover:scale-100 disabled:cursor-not-allowed transition-all"
|
||
>
|
||
{disabled ? (
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Send className="h-4 w-4" />
|
||
)}
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* 提示 */}
|
||
<div className="mt-2 text-center text-xs text-[var(--text-muted)]">
|
||
按 Enter 发送,Shift+Enter 换行 · 支持拖拽/粘贴文件 · 最大 50MB
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|