feat(Hooks): 添加文件上传Hook和扩展流式聊天支持
useFileUpload Hook: - 实现文件添加、删除、清空功能 - 支持拖拽上传和粘贴上传 - 文件类型验证和大小限制 - 管理上传进度状态 useStreamChat Hook 扩展: - 新增 UploadedFile 和 UploadedDocument 接口 - 支持图片文件转换为 Base64 格式 - 识别并读取文本类文件内容 - 扩展 sendMessage 参数支持文件数组 - 将文档内容附加到消息中发送给 AI
This commit is contained in:
parent
d98e540037
commit
acf17557c2
251
src/hooks/useFileUpload.ts
Normal file
251
src/hooks/useFileUpload.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user