feat(Hooks): 添加文件上传Hook和扩展流式聊天支持

useFileUpload Hook:
- 实现文件添加、删除、清空功能
- 支持拖拽上传和粘贴上传
- 文件类型验证和大小限制
- 管理上传进度状态

useStreamChat Hook 扩展:
- 新增 UploadedFile 和 UploadedDocument 接口
- 支持图片文件转换为 Base64 格式
- 识别并读取文本类文件内容
- 扩展 sendMessage 参数支持文件数组
- 将文档内容附加到消息中发送给 AI
This commit is contained in:
gaoziman 2025-12-20 12:13:56 +08:00
parent d98e540037
commit acf17557c2
2 changed files with 431 additions and 3 deletions

251
src/hooks/useFileUpload.ts Normal file
View File

@ -0,0 +1,251 @@
'use client';
import { useState, useCallback, useRef } from 'react';
import {
UploadFile,
FileUploadConfig,
DEFAULT_UPLOAD_CONFIG,
getFileTypeFromMime,
getFileTypeFromExtension,
validateFile,
} from '@/types/file-upload';
// 生成唯一ID
function generateId(): string {
return `file_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
interface UseFileUploadOptions {
config?: Partial<FileUploadConfig>;
onUploadComplete?: (file: UploadFile) => void;
onUploadError?: (file: UploadFile, error: string) => void;
}
export interface UseFileUploadReturn {
files: UploadFile[];
isDragging: boolean;
addFiles: (files: FileList | File[]) => void;
removeFile: (fileId: string) => void;
clearFiles: () => void;
uploadFiles: () => Promise<void>;
handleDragEnter: (e: React.DragEvent) => void;
handleDragLeave: (e: React.DragEvent) => void;
handleDragOver: (e: React.DragEvent) => void;
handleDrop: (e: React.DragEvent) => void;
handlePaste: (e: React.ClipboardEvent) => void;
}
export function useFileUpload(options: UseFileUploadOptions = {}): UseFileUploadReturn {
const { config: userConfig, onUploadComplete, onUploadError } = options;
const config: FileUploadConfig = { ...DEFAULT_UPLOAD_CONFIG, ...userConfig };
const [files, setFiles] = useState<UploadFile[]>([]);
const [isDragging, setIsDragging] = useState(false);
const dragCounter = useRef(0);
// 添加文件
const addFiles = useCallback(
(inputFiles: FileList | File[]) => {
const fileArray = Array.from(inputFiles);
// 检查文件数量限制
const remainingSlots = config.maxFiles - files.length;
if (remainingSlots <= 0) {
console.warn(`已达到最大文件数量限制(${config.maxFiles}个)`);
return;
}
const filesToAdd = fileArray.slice(0, remainingSlots);
const newFiles = filesToAdd
.map((file): UploadFile | null => {
// 验证文件
const validation = validateFile(file, config);
if (!validation.valid) {
console.warn(`文件 ${file.name} 验证失败: ${validation.error}`);
return null;
}
// 确定文件类型
const fileType = getFileTypeFromMime(file.type) || getFileTypeFromExtension(file.name);
// 创建预览URL仅图片
let previewUrl: string | undefined;
if (fileType === 'image') {
previewUrl = URL.createObjectURL(file);
}
return {
id: generateId(),
file,
name: file.name,
size: file.size,
type: fileType,
mimeType: file.type,
previewUrl,
uploadProgress: 0,
status: 'pending' as const,
};
})
.filter((f): f is UploadFile => f !== null);
setFiles((prev) => [...prev, ...newFiles]);
},
[files.length, config]
);
// 移除文件
const removeFile = useCallback((fileId: string) => {
setFiles((prev) => {
const file = prev.find((f) => f.id === fileId);
// 释放预览URL
if (file?.previewUrl) {
URL.revokeObjectURL(file.previewUrl);
}
return prev.filter((f) => f.id !== fileId);
});
}, []);
// 清空所有文件
const clearFiles = useCallback(() => {
// 释放所有预览URL
files.forEach((file) => {
if (file.previewUrl) {
URL.revokeObjectURL(file.previewUrl);
}
});
setFiles([]);
}, [files]);
// 上传文件
const uploadFiles = useCallback(async () => {
const pendingFiles = files.filter((f) => f.status === 'pending');
for (const file of pendingFiles) {
// 更新状态为上传中
setFiles((prev) =>
prev.map((f) => (f.id === file.id ? { ...f, status: 'uploading' as const } : f))
);
try {
const formData = new FormData();
formData.append('file', file.file);
const response = await fetch('/api/files/upload', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error(`上传失败: ${response.statusText}`);
}
const result = await response.json();
// 更新状态为成功
setFiles((prev) =>
prev.map((f) =>
f.id === file.id
? {
...f,
status: 'success' as const,
uploadProgress: 100,
uploadedUrl: result.url,
}
: f
)
);
onUploadComplete?.({ ...file, status: 'success', uploadProgress: 100, uploadedUrl: result.url });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '上传失败';
// 更新状态为错误
setFiles((prev) =>
prev.map((f) =>
f.id === file.id ? { ...f, status: 'error' as const, error: errorMessage } : f
)
);
onUploadError?.({ ...file, status: 'error', error: errorMessage }, errorMessage);
}
}
}, [files, onUploadComplete, onUploadError]);
// 拖拽事件处理
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current++;
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
setIsDragging(true);
}
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current--;
if (dragCounter.current === 0) {
setIsDragging(false);
}
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
dragCounter.current = 0;
const droppedFiles = e.dataTransfer.files;
if (droppedFiles && droppedFiles.length > 0) {
addFiles(droppedFiles);
}
},
[addFiles]
);
// 粘贴事件处理
const handlePaste = useCallback(
(e: React.ClipboardEvent) => {
const items = e.clipboardData.items;
const pastedFiles: File[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === 'file') {
const file = item.getAsFile();
if (file) {
pastedFiles.push(file);
}
}
}
if (pastedFiles.length > 0) {
e.preventDefault(); // 阻止默认粘贴行为
addFiles(pastedFiles);
}
},
[addFiles]
);
return {
files,
isDragging,
addFiles,
removeFile,
clearFiles,
uploadFiles,
handleDragEnter,
handleDragLeave,
handleDragOver,
handleDrop,
handlePaste,
};
}

View File

@ -32,6 +32,10 @@ export interface ChatMessage {
outputTokens?: number;
// 工具执行产生的图片
images?: string[];
// 用户上传的图片Base64
uploadedImages?: string[];
// 用户上传的文档
uploadedDocuments?: UploadedDocument[];
// Pyodide 加载状态
pyodideStatus?: {
stage: 'loading' | 'ready' | 'error';
@ -63,6 +67,91 @@ async function saveMessageImages(messageId: string, images: string[]): Promise<v
}
}
/**
* Base64
*/
async function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
// 移除 data:image/xxx;base64, 前缀,只保留 base64 数据
const base64 = result.split(',')[1];
resolve(base64);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
/**
*
*/
async function readTextFile(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
resolve(reader.result as string);
};
reader.onerror = reject;
reader.readAsText(file);
});
}
/**
*
*/
function isTextFile(file: File): boolean {
const textMimeTypes = [
'text/plain',
'text/markdown',
'text/csv',
'text/html',
'text/css',
'text/javascript',
'text/typescript',
'text/xml',
'application/json',
'application/xml',
'application/javascript',
];
// 检查 MIME 类型
if (textMimeTypes.includes(file.type) || file.type.startsWith('text/')) {
return true;
}
// 检查文件扩展名
const textExtensions = [
'.txt', '.md', '.markdown', '.json', '.xml', '.html', '.css',
'.js', '.ts', '.jsx', '.tsx', '.py', '.java', '.c', '.cpp', '.h',
'.go', '.rs', '.rb', '.php', '.sh', '.bash', '.yaml', '.yml',
'.sql', '.csv', '.log', '.ini', '.conf', '.env',
];
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
return textExtensions.includes(ext);
}
/**
*
*/
export interface UploadedFile {
file: File;
type: string;
previewUrl?: string;
}
/**
*
*/
export interface UploadedDocument {
name: string;
size: number;
type: string;
/** 文档内容,用于预览 */
content: string;
}
export function useStreamChat() {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [isStreaming, setIsStreaming] = useState(false);
@ -78,15 +167,81 @@ export function useStreamChat() {
model?: string;
tools?: string[];
enableThinking?: boolean;
files?: UploadedFile[];
}) => {
const { conversationId, message, model, tools, enableThinking } = options;
const { conversationId, message, model, tools, enableThinking, files } = options;
// 处理上传的文件
const uploadedImages: string[] = [];
const uploadedDocuments: UploadedDocument[] = [];
const imageContents: { type: 'image'; media_type: string; data: string }[] = [];
const documentContents: { name: string; content: string }[] = [];
if (files && files.length > 0) {
console.log('[useStreamChat] Processing files:', files.length);
for (const fileInfo of files) {
console.log('[useStreamChat] File info:', {
name: fileInfo.file.name,
type: fileInfo.file.type,
size: fileInfo.file.size,
isImage: fileInfo.file.type.startsWith('image/'),
});
// 处理图片文件
if (fileInfo.file.type.startsWith('image/')) {
try {
const base64 = await fileToBase64(fileInfo.file);
const mediaType = fileInfo.file.type as 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp';
console.log('[useStreamChat] Image converted to base64, length:', base64.length, 'media_type:', mediaType);
imageContents.push({
type: 'image',
media_type: mediaType,
data: base64,
});
// 使用 base64 data URL 而不是 blob URL因为 blob URL 会在 clearFiles 时被释放)
const dataUrl = `data:${fileInfo.file.type};base64,${base64}`;
uploadedImages.push(dataUrl);
} catch (err) {
console.error('Failed to convert image to base64:', err);
}
}
// 处理文本类文件
else if (isTextFile(fileInfo.file)) {
try {
const content = await readTextFile(fileInfo.file);
documentContents.push({
name: fileInfo.file.name,
content: content,
});
uploadedDocuments.push({
name: fileInfo.file.name,
size: fileInfo.file.size,
type: fileInfo.type,
content: content, // 保存内容用于预览
});
} catch (err) {
console.error('Failed to read text file:', err);
}
}
}
}
// 构建最终消息:如果有文档,将文档内容附加到消息中
let finalMessage = message;
if (documentContents.length > 0) {
const docParts = documentContents.map(doc =>
`\n\n--- 文件:${doc.name} ---\n${doc.content}\n--- 文件结束 ---`
).join('\n');
finalMessage = message + docParts;
}
// 添加用户消息
const userMessage: ChatMessage = {
id: `user-${Date.now()}`,
role: 'user',
content: message,
content: message, // 显示原始消息,不包含文档内容
status: 'completed',
uploadedImages: uploadedImages.length > 0 ? uploadedImages : undefined,
uploadedDocuments: uploadedDocuments.length > 0 ? uploadedDocuments : undefined,
};
// 添加 AI 消息占位
@ -106,6 +261,21 @@ export function useStreamChat() {
abortControllerRef.current = new AbortController();
try {
// 调试日志:确认图片数据
console.log('[useStreamChat] Sending request with:', {
conversationId,
messageLength: finalMessage.length,
model,
tools,
enableThinking,
imagesCount: imageContents.length,
images: imageContents.length > 0 ? imageContents.map(img => ({
type: img.type,
media_type: img.media_type,
dataLength: img.data.length,
})) : undefined,
});
const response = await fetch('/api/chat', {
method: 'POST',
headers: {
@ -113,10 +283,17 @@ export function useStreamChat() {
},
body: JSON.stringify({
conversationId,
message,
message: finalMessage, // 包含文档内容的消息(用于 AI 处理)
displayMessage: message, // 原始用户输入(用于数据库存储和显示)
model,
tools,
enableThinking,
// 传递图片内容给后端(用于 AI 识图)
images: imageContents.length > 0 ? imageContents : undefined,
// 传递上传的图片 URL 用于保存到数据库(用于显示)
uploadedImages: uploadedImages.length > 0 ? uploadedImages : undefined,
// 传递上传的文档用于保存到数据库
uploadedDocuments: uploadedDocuments.length > 0 ? uploadedDocuments : undefined,
}),
signal: abortControllerRef.current.signal,
});