PicoBot/web/src/components/Chat/MessageInput.tsx
oudecheng c724bbf864 chore(web): prettier 一次性格式化并在 CI 启用 format:check
对 47 个前端文件统一执行 npm run format,消除存量格式差异。
随后在 CI 与 Makefile check 中启用 format:check,确保后续提交强制遵守 prettier 风格。
2026-08-03 23:25:22 +08:00

419 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}