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;
|
outputTokens?: number;
|
||||||
// 工具执行产生的图片
|
// 工具执行产生的图片
|
||||||
images?: string[];
|
images?: string[];
|
||||||
|
// 用户上传的图片(Base64)
|
||||||
|
uploadedImages?: string[];
|
||||||
|
// 用户上传的文档
|
||||||
|
uploadedDocuments?: UploadedDocument[];
|
||||||
// Pyodide 加载状态
|
// Pyodide 加载状态
|
||||||
pyodideStatus?: {
|
pyodideStatus?: {
|
||||||
stage: 'loading' | 'ready' | 'error';
|
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() {
|
export function useStreamChat() {
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
const [isStreaming, setIsStreaming] = useState(false);
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
@ -78,15 +167,81 @@ export function useStreamChat() {
|
|||||||
model?: string;
|
model?: string;
|
||||||
tools?: string[];
|
tools?: string[];
|
||||||
enableThinking?: boolean;
|
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 = {
|
const userMessage: ChatMessage = {
|
||||||
id: `user-${Date.now()}`,
|
id: `user-${Date.now()}`,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: message,
|
content: message, // 显示原始消息,不包含文档内容
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
|
uploadedImages: uploadedImages.length > 0 ? uploadedImages : undefined,
|
||||||
|
uploadedDocuments: uploadedDocuments.length > 0 ? uploadedDocuments : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 添加 AI 消息占位
|
// 添加 AI 消息占位
|
||||||
@ -106,6 +261,21 @@ export function useStreamChat() {
|
|||||||
abortControllerRef.current = new AbortController();
|
abortControllerRef.current = new AbortController();
|
||||||
|
|
||||||
try {
|
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', {
|
const response = await fetch('/api/chat', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@ -113,10 +283,17 @@ export function useStreamChat() {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
conversationId,
|
conversationId,
|
||||||
message,
|
message: finalMessage, // 包含文档内容的消息(用于 AI 处理)
|
||||||
|
displayMessage: message, // 原始用户输入(用于数据库存储和显示)
|
||||||
model,
|
model,
|
||||||
tools,
|
tools,
|
||||||
enableThinking,
|
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,
|
signal: abortControllerRef.current.signal,
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user