diff --git a/app/api/ocr/route.ts b/app/api/ocr/route.ts new file mode 100644 index 0000000..1a29b1a --- /dev/null +++ b/app/api/ocr/route.ts @@ -0,0 +1,155 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { fileStorageService } from '@/package/file-storage'; +import { OCRResponse } from '@/lib/types'; +import { ocrService } from '@/package/ocr'; + +export async function POST(request: NextRequest): Promise> { + try { + const formData = await request.formData(); + const file = formData.get('file') as File; + const fileId = formData.get('fileId') as string; + const language = formData.get('language') as string || 'chi_sim+eng'; + + let imageData: Buffer; + let fileName: string; + let fileSize: number; + let fileType: string; + + if (fileId) { + // 从文件存储服务获取文件 + try { + const { file, data } = await fileStorageService.getFile(fileId); + + if (!file || !data) { + return NextResponse.json({ + success: false, + error: '文件不存在' + }, { status: 404 }); + } + + if (!file.mimetype.startsWith('image/')) { + return NextResponse.json({ + success: false, + error: '只支持图片文件的OCR识别' + }, { status: 400 }); + } + + imageData = data; + fileName = file.originalName; + fileSize = file.size; + fileType = file.mimetype; + + } catch (error) { + return NextResponse.json({ + success: false, + error: '文件不存在或无法访问' + }, { status: 404 }); + } + } else if (file) { + // 直接处理上传的文件 + if (!file.type.startsWith('image/')) { + return NextResponse.json({ + success: false, + error: '只支持图片文件的OCR识别' + }, { status: 400 }); + } + + // 验证文件大小 (最大10MB) + const MAX_FILE_SIZE = 10 * 1024 * 1024; + if (file.size > MAX_FILE_SIZE) { + return NextResponse.json({ + success: false, + error: '图片文件过大,请选择小于10MB的图片' + }, { status: 400 }); + } + + imageData = Buffer.from(await file.arrayBuffer()); + fileName = file.name; + fileSize = file.size; + fileType = file.type; + } else { + return NextResponse.json({ + success: false, + error: '没有找到要处理的图片文件' + }, { status: 400 }); + } + + const startTime = Date.now(); + + // 使用 OCR 服务进行文字识别 + try { + // 将 Buffer 转换为 Blob 以供 OCR 服务使用 + const blob = new Blob([imageData], { type: fileType }); + + const ocrResult = await ocrService.recognize(blob, { + language: language + }); + + const processingTime = Date.now() - startTime; + + return NextResponse.json({ + success: true, + data: { + text: ocrResult.text, + confidence: ocrResult.confidence, + language: language, + boundingBoxes: ocrResult.words.map(word => ({ + text: word.text, + x: word.bbox.x0, + y: word.bbox.y0, + width: word.bbox.x1 - word.bbox.x0, + height: word.bbox.y1 - word.bbox.y0, + confidence: word.confidence + })), + metadata: { + processingTime, + imageWidth: 0, + imageHeight: 0, + detectedLanguages: [language], + ocrEngine: 'tesseract.js', + version: '6.0.1' + } + } + }); + + } catch (ocrError) { + console.error('OCR 识别失败:', ocrError); + return NextResponse.json({ + success: false, + error: 'OCR 识别失败: ' + (ocrError instanceof Error ? ocrError.message : '未知错误') + }, { status: 500 }); + } + + } catch (error) { + console.error('OCR API错误:', error); + return NextResponse.json({ + success: false, + error: 'OCR服务暂时不可用,请稍后重试' + }, { status: 500 }); + } +} + +// 获取 OCR 统计信息的 API +export async function GET(): Promise { + try { + return NextResponse.json({ + success: true, + data: { + message: 'OCR 服务运行正常', + supportedLanguages: [ + { code: 'chi_sim+eng', name: '中英文' }, + { code: 'eng', name: '英文' }, + { code: 'chi_sim', name: '简体中文' }, + { code: 'chi_tra', name: '繁体中文' } + ], + version: '6.0.1' + } + }); + } catch (error) { + console.error('OCR 服务状态检查失败:', error); + return NextResponse.json({ + success: false, + error: 'OCR 服务不可用' + }, { status: 500 }); + } +} diff --git a/app/api/upload/[fileId]/route.ts b/app/api/upload/[fileId]/route.ts new file mode 100644 index 0000000..1c52939 --- /dev/null +++ b/app/api/upload/[fileId]/route.ts @@ -0,0 +1,101 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { fileStorageService } from '@/package/file-storage'; + +export async function GET( + request: NextRequest, + { params }: { params: { fileId: string } } +): Promise { + try { + const { fileId } = params; + + if (!fileId) { + return NextResponse.json({ + success: false, + error: '缺少文件ID' + }, { status: 400 }); + } + + // 获取文件 + const { file, data } = await fileStorageService.getFile(fileId); + + if (!data) { + return NextResponse.json({ + success: false, + error: '文件数据不存在' + }, { status: 404 }); + } + + // 设置响应头 + const headers = new Headers(); + headers.set('Content-Type', file.mimetype); + headers.set('Content-Length', file.size.toString()); + headers.set('Content-Disposition', `inline; filename="${encodeURIComponent(file.originalName)}"`); + + // 缓存控制 + headers.set('Cache-Control', 'public, max-age=31536000'); // 1年缓存 + headers.set('ETag', `"${file._id}"`); + + // 检查If-None-Match头(ETag缓存) + const ifNoneMatch = request.headers.get('if-none-match'); + if (ifNoneMatch === `"${file._id}"`) { + return new NextResponse(null, { status: 304, headers }); + } + + return new NextResponse(data, { + status: 200, + headers + }); + + } catch (error) { + console.error('文件下载错误:', error); + + if (error instanceof Error && error.message.includes('不存在')) { + return NextResponse.json({ + success: false, + error: '文件不存在' + }, { status: 404 }); + } + + return NextResponse.json({ + success: false, + error: '文件下载失败' + }, { status: 500 }); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: { fileId: string } } +): Promise { + try { + const { fileId } = params; + + if (!fileId) { + return NextResponse.json({ + success: false, + error: '缺少文件ID' + }, { status: 400 }); + } + + const success = await fileStorageService.deleteFile(fileId); + + if (success) { + return NextResponse.json({ + success: true, + message: '文件删除成功' + }); + } else { + return NextResponse.json({ + success: false, + error: '文件不存在或删除失败' + }, { status: 404 }); + } + + } catch (error) { + console.error('文件删除错误:', error); + return NextResponse.json({ + success: false, + error: '文件删除失败' + }, { status: 500 }); + } +} diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts new file mode 100644 index 0000000..4fde8d6 --- /dev/null +++ b/app/api/upload/route.ts @@ -0,0 +1,178 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { fileStorageService } from '@/package/file-storage'; +import { FileUploadResponse } from '@/lib/types'; + +const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB +const ALLOWED_TYPES = [ + 'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/bmp', 'image/webp', + 'text/plain', 'text/csv', 'text/html', + 'application/pdf' +]; + +export async function POST(request: NextRequest): Promise> { + try { + const formData = await request.formData(); + const file = formData.get('file') as File; + const uploadedBy = formData.get('uploadedBy') as string || undefined; + const description = formData.get('description') as string || undefined; + const tags = formData.get('tags') as string; + + if (!file) { + return NextResponse.json({ + success: false, + error: '没有找到上传的文件' + }, { status: 400 }); + } + + // 验证文件大小 + if (file.size > MAX_FILE_SIZE) { + return NextResponse.json({ + success: false, + error: `文件大小超过限制(${Math.round(MAX_FILE_SIZE / 1024 / 1024)}MB)` + }, { status: 400 }); + } + + // 验证文件类型 + if (!ALLOWED_TYPES.includes(file.type)) { + return NextResponse.json({ + success: false, + error: `不支持的文件类型: ${file.type}` + }, { status: 400 }); + } + + // 准备文件数据 + const fileBuffer = Buffer.from(await file.arrayBuffer()); + + // 准备元数据 + const metadata: Record = {}; + if (description) metadata.description = description; + if (tags) { + try { + metadata.tags = JSON.parse(tags); + } catch { + metadata.tags = tags.split(',').map((tag: string) => tag.trim()); + } + } + + // 如果是图片,尝试获取尺寸信息 + if (file.type.startsWith('image/')) { + // 这里可以添加图片尺寸检测逻辑 + // 为了简化,暂时跳过 + } + + // 上传文件到MongoDB + const savedFile = await fileStorageService.uploadFile( + fileBuffer, + file.name, + file.type, + { + maxSize: MAX_FILE_SIZE, + allowedMimeTypes: ALLOWED_TYPES, + metadata, + uploadedBy + } + ); + + const response: FileUploadResponse = { + success: true, + data: { + fileId: savedFile._id!, + fileName: savedFile.originalName, + fileSize: savedFile.size, + uploadedAt: savedFile.uploadedAt.toISOString(), + downloadUrl: `/api/upload/${savedFile._id}` // 下载链接 + } + }; + + return NextResponse.json(response); + + } catch (error) { + console.error('文件上传错误:', error); + return NextResponse.json({ + success: false, + error: error instanceof Error ? error.message : '文件上传失败,请稍后重试' + }, { status: 500 }); + } +} + +export async function GET(request: NextRequest): Promise { + try { + const { searchParams } = new URL(request.url); + const action = searchParams.get('action') || 'info'; + + if (action === 'list') { + // 获取文件列表 + const mimetype = searchParams.get('mimetype') || undefined; + const limit = parseInt(searchParams.get('limit') || '50'); + const skip = parseInt(searchParams.get('skip') || '0'); + + const files = await fileStorageService.queryFiles({ + mimetype, + limit, + skip, + sort: { uploadedAt: -1 } + }); + + return NextResponse.json({ + success: true, + data: { + files: files.map(file => ({ + fileId: file._id, + fileName: file.originalName, + mimetype: file.mimetype, + size: file.size, + uploadedAt: file.uploadedAt, + metadata: file.metadata + })), + count: files.length + } + }); + } + + if (action === 'stats') { + // 获取文件统计信息 + const stats = await fileStorageService.getFileStats(); + return NextResponse.json({ + success: true, + data: stats + }); + } + + // 默认获取文件信息 + const fileId = searchParams.get('fileId'); + if (!fileId) { + return NextResponse.json({ + success: false, + error: '缺少文件ID参数' + }, { status: 400 }); + } + + try { + const { file } = await fileStorageService.getFile(fileId); + return NextResponse.json({ + success: true, + data: { + fileId: file._id, + fileName: file.originalName, + mimetype: file.mimetype, + size: file.size, + uploadedAt: file.uploadedAt, + metadata: file.metadata, + status: file.status + } + }); + } catch (error) { + return NextResponse.json({ + success: false, + error: error instanceof Error ? error.message : '文件不存在' + }, { status: 404 }); + } + + } catch (error) { + console.error('文件查询错误:', error); + return NextResponse.json({ + success: false, + error: '文件查询失败' + }, { status: 500 }); + } +} diff --git a/components/file-upload-input.tsx b/components/file-upload-input.tsx new file mode 100644 index 0000000..55dd03b --- /dev/null +++ b/components/file-upload-input.tsx @@ -0,0 +1,304 @@ +"use client" + +import { useState, useRef } from "react" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Progress } from "@/components/ui/progress" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { Upload, File, X, CheckCircle, AlertCircle } from "lucide-react" +import { useFragmentStore } from "@/lib/stores/fragment-store" +import { cn } from "@/package/utils/utils" + +interface UploadedFile { + file: File + progress: number + status: 'pending' | 'uploading' | 'success' | 'error' + error?: string + fileId?: string +} + +interface FileUploadInputProps { + onOCRResult?: (ocrText: string) => void +} + +export function FileUploadInput({ onOCRResult }: FileUploadInputProps) { + const [files, setFiles] = useState([]) + const [isDragOver, setIsDragOver] = useState(false) + const fileInputRef = useRef(null) + const { addFragment } = useFragmentStore() + + const handleFileSelect = (selectedFiles: FileList | null) => { + if (!selectedFiles) return + + const newFiles: UploadedFile[] = Array.from(selectedFiles).map(file => ({ + file, + progress: 0, + status: 'pending' + })) + + setFiles(prev => [...prev, ...newFiles]) + + // 自动开始上传 + newFiles.forEach((fileItem, index) => { + uploadFile(fileItem, files.length + index) + }) + } + + const uploadFile = async (fileItem: UploadedFile, index: number) => { + const formData = new FormData() + formData.append('file', fileItem.file) + + try { + // 更新状态为上传中 + setFiles(prev => prev.map((f, i) => + i === index ? { ...f, status: 'uploading', progress: 0 } : f + )) + + const response = await fetch('/api/upload', { + method: 'POST', + body: formData + }) + + const result = await response.json() + + if (result.success) { + // 上传成功,处理文件内容 + setFiles(prev => prev.map((f, i) => + i === index ? { + ...f, + status: 'success', + progress: 100, + fileId: result.data.fileId + } : f + )) + + // 如果是文本文件,尝试读取内容并创建知识碎片 + if (fileItem.file.type.startsWith('text/') || + fileItem.file.name.endsWith('.txt') || + fileItem.file.name.endsWith('.md')) { + + const reader = new FileReader() + reader.onload = async (e) => { + const content = e.target?.result as string + if (content) { + await createFragmentFromFile(fileItem.file.name, content, result.data.fileId) + } + } + reader.readAsText(fileItem.file) + } else if (fileItem.file.type.startsWith('image/')) { + // 如果是图片文件,进行 OCR 识别 + try { + const ocrResponse = await fetch('/api/ocr', { + method: 'POST', + body: (() => { + const formData = new FormData() + formData.append('fileId', result.data.fileId) + formData.append('language', 'chi_sim+eng') + return formData + })() + }) + + const ocrResult = await ocrResponse.json() + + if (ocrResult.success && ocrResult.data?.text) { + const ocrText = ocrResult.data.text.trim() + + // 如果有 OCR 结果且有回调函数,将文字传递给父组件 + if (ocrText && onOCRResult) { + onOCRResult(ocrText) + } + + // 创建包含 OCR 文字的知识碎片 + await createFragmentFromFile( + fileItem.file.name, + `图片识别文字:\n\n${ocrText}\n\n---\n文件: ${fileItem.file.name}\n类型: ${fileItem.file.type}\n大小: ${formatFileSize(fileItem.file.size)}\n上传时间: ${new Date().toLocaleString()}`, + result.data.fileId + ) + } else { + // OCR 识别失败,创建普通文件引用 + await createFragmentFromFile( + fileItem.file.name, + `文件: ${fileItem.file.name}\n类型: ${fileItem.file.type}\n大小: ${formatFileSize(fileItem.file.size)}\n上传时间: ${new Date().toLocaleString()}\n\n注意: 图片文字识别失败`, + result.data.fileId + ) + } + } catch (ocrError) { + console.error('OCR 识别失败:', ocrError) + // OCR 识别出错,创建普通文件引用 + await createFragmentFromFile( + fileItem.file.name, + `文件: ${fileItem.file.name}\n类型: ${fileItem.file.type}\n大小: ${formatFileSize(fileItem.file.size)}\n上传时间: ${new Date().toLocaleString()}\n\n注意: 图片文字识别出错`, + result.data.fileId + ) + } + } else { + // 非文本和图片文件,创建文件引用类型的知识碎片 + await createFragmentFromFile( + fileItem.file.name, + `文件: ${fileItem.file.name}\n类型: ${fileItem.file.type}\n大小: ${formatFileSize(fileItem.file.size)}\n上传时间: ${new Date().toLocaleString()}`, + result.data.fileId + ) + } + } else { + setFiles(prev => prev.map((f, i) => + i === index ? { + ...f, + status: 'error', + error: result.error || '上传失败' + } : f + )) + } + } catch (error) { + setFiles(prev => prev.map((f, i) => + i === index ? { + ...f, + status: 'error', + error: '网络错误,请重试' + } : f + )) + } + } + + const createFragmentFromFile = async (fileName: string, content: string, fileId: string) => { + try { + const fragmentData = { + title: fileName, + content: content, + tags: ['文件上传'], + category: '文件', + priority: 'medium' as const, + status: 'active' as const, + createdAt: new Date(), + updatedAt: new Date(), + metadata: { + wordCount: content.length, + readingTime: Math.ceil(content.length / 200), + fileId: fileId, + fileName: fileName + } + } + + await addFragment(fragmentData) + } catch (error) { + console.error('创建知识碎片失败:', error) + } + } + + const formatFileSize = (bytes: number): string => { + if (bytes === 0) return '0 Bytes' + const k = 1024 + const sizes = ['Bytes', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] + } + + const removeFile = (index: number) => { + setFiles(prev => prev.filter((_, i) => i !== index)) + } + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault() + setIsDragOver(true) + } + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault() + setIsDragOver(false) + } + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault() + setIsDragOver(false) + handleFileSelect(e.dataTransfer.files) + } + + return ( + + + 文件上传 + + + {/* 拖拽上传区域 */} +
fileInputRef.current?.click()} + > + +

拖拽文件到此处或点击选择

+

+ 支持文本文件、图片、PDF等格式,最大10MB +

+ handleFileSelect(e.target.files)} + accept=".txt,.md,.pdf,.doc,.docx,.jpg,.jpeg,.png,.gif" + /> +
+ + {/* 文件列表 */} + {files.length > 0 && ( +
+

上传文件

+ {files.map((fileItem, index) => ( +
+ + +
+

{fileItem.file.name}

+

+ {formatFileSize(fileItem.file.size)} +

+ + {fileItem.status === 'uploading' && ( + + )} + + {fileItem.status === 'error' && fileItem.error && ( + + + + {fileItem.error} + + + )} +
+ +
+ {fileItem.status === 'success' && ( + + )} + {fileItem.status === 'error' && ( + + )} + {fileItem.status === 'uploading' && ( +
+ )} + + +
+
+ ))} +
+ )} + + + ) +} \ No newline at end of file diff --git a/components/fragment-card.tsx b/components/fragment-card.tsx index b2db241..b94ac2a 100644 --- a/components/fragment-card.tsx +++ b/components/fragment-card.tsx @@ -47,8 +47,8 @@ const statusLabels = { } export function FragmentCard({ fragment, onEdit, onDelete, onView }: FragmentCardProps) { - const { getCategoryById } = useCategoryStore() - const category = fragment.categoryId ? getCategoryById(fragment.categoryId) : null + const { categories } = useCategoryStore() + const category = fragment.category ? categories.find(c => c.name === fragment.category) : null const truncatedContent = fragment.content.length > 150 ? fragment.content.substring(0, 150) + "..." @@ -58,8 +58,8 @@ export function FragmentCard({ fragment, onEdit, onDelete, onView }: FragmentCar
-
- +
+ {fragment.title} @@ -82,7 +82,7 @@ export function FragmentCard({ fragment, onEdit, onDelete, onView }: FragmentCar diff --git a/components/fragment-detail.tsx b/components/fragment-detail.tsx index eba2257..2a061e6 100644 --- a/components/fragment-detail.tsx +++ b/components/fragment-detail.tsx @@ -148,7 +148,7 @@ export function FragmentDetail({ fragment, isOpen, onClose, onSave, readOnly = f placeholder="输入碎片标题" /> ) : ( -
{fragment.title}
+
{fragment.title}
)}
diff --git a/components/fragment-flow/fragment-node.tsx b/components/fragment-flow/fragment-node.tsx index 1866e55..83bfd60 100644 --- a/components/fragment-flow/fragment-node.tsx +++ b/components/fragment-flow/fragment-node.tsx @@ -95,7 +95,7 @@ export function FragmentNode({ data }: FragmentNodeProps) {
-
+
{fragment.title.substring(0, 6)}
@@ -159,13 +159,13 @@ export function FragmentNode({ data }: FragmentNodeProps) {
- + {fragment.title}