UNPKG

@restnfeel/agentc-starter-kit

Version:

한국어 기업용 CMS 모듈 - Task Master AI와 함께 빠르게 웹사이트를 구현할 수 있는 재사용 가능한 컴포넌트 시스템

684 lines (580 loc) 18.6 kB
import { StorageConfig, Document, ChatbotError, } from "../contexts/ChatbotContext"; import { createClient, SupabaseClient } from "@supabase/supabase-js"; // File processing interfaces export interface FileMetadata { originalName: string; size: number; type: string; uploadedAt: Date; description?: string; tags?: string[]; extractedText?: string; processingStatus: "pending" | "processing" | "completed" | "failed"; } export interface UploadProgress { fileId: string; progress: number; status: "uploading" | "processing" | "completed" | "failed"; error?: string; } export interface UploadOptions { description?: string; tags?: string[]; extractText?: boolean; overwrite?: boolean; } // Abstract base class for storage operations export abstract class BaseStorage { protected config: StorageConfig; protected isConnected: boolean = false; constructor(config: StorageConfig) { this.config = config; } abstract connect(): Promise<void>; abstract disconnect(): Promise<void>; abstract uploadFile(file: File, options?: UploadOptions): Promise<Document>; abstract downloadFile(documentId: string): Promise<Blob>; abstract deleteFile(documentId: string): Promise<void>; abstract listFiles(): Promise<Document[]>; abstract getFileMetadata(documentId: string): Promise<FileMetadata>; abstract healthCheck(): Promise<boolean>; public getStatus(): { isConnected: boolean; config: StorageConfig } { return { isConnected: this.isConnected, config: this.config, }; } public updateConfig(updates: Partial<StorageConfig>): void { this.config = { ...this.config, ...updates }; } } // Supabase Storage Implementation export class SupabaseStorage extends BaseStorage { private client: SupabaseClient | null = null; private progressCallbacks: Map<string, (progress: UploadProgress) => void> = new Map(); constructor(config: StorageConfig) { super(config); } async connect(): Promise<void> { try { if (!this.config.url || !this.config.apiKey) { throw new Error("Supabase URL and API key are required"); } this.client = createClient(this.config.url, this.config.apiKey); // Test connection by listing buckets const { data, error } = await this.client.storage.listBuckets(); if (error) { throw new Error( `Failed to connect to Supabase Storage: ${error.message}` ); } // Check if our bucket exists, create if not const bucketExists = data?.some( (bucket) => bucket.name === this.config.bucket ); if (!bucketExists) { const { error: createError } = await this.client.storage.createBucket( this.config.bucket, { public: false, allowedMimeTypes: this.config.allowedTypes, fileSizeLimit: this.config.maxFileSize, } ); if (createError) { console.warn( `Failed to create bucket ${this.config.bucket}:`, createError ); // Continue anyway, bucket might exist but not be visible to this user } } this.isConnected = true; } catch (error) { this.isConnected = false; throw new Error( `Failed to connect to Supabase Storage: ${ error instanceof Error ? error.message : "Unknown error" }` ); } } async disconnect(): Promise<void> { this.client = null; this.isConnected = false; this.progressCallbacks.clear(); } async uploadFile(file: File, options: UploadOptions = {}): Promise<Document> { if (!this.isConnected || !this.client) { throw new Error("Storage is not connected"); } // Validate file this.validateFile(file); const fileId = `doc_${Date.now()}_${Math.random() .toString(36) .substr(2, 9)}`; const fileName = `${fileId}_${file.name}`; try { // Update progress this.updateProgress(fileId, { fileId, progress: 0, status: "uploading" }); // Upload file to Supabase Storage const { data: uploadData, error: uploadError } = await this.client.storage .from(this.config.bucket) .upload(fileName, file, { cacheControl: "3600", upsert: options.overwrite || false, }); if (uploadError) { throw new Error(`Failed to upload file: ${uploadError.message}`); } this.updateProgress(fileId, { fileId, progress: 50, status: "processing", }); // Extract text content if requested let extractedText = ""; if (options.extractText !== false) { // Default to true try { extractedText = await this.extractTextFromFile(file); } catch (error) { console.warn("Failed to extract text from file:", error); // Continue without text extraction } } this.updateProgress(fileId, { fileId, progress: 80, status: "processing", }); // Create document metadata const document: Document = { id: fileId, title: options.description || file.name, content: extractedText, metadata: { source: fileName, uploadedAt: new Date(), size: file.size, type: file.type, description: options.description, tags: options.tags, }, status: "ready", }; this.updateProgress(fileId, { fileId, progress: 100, status: "completed", }); return document; } catch (error) { this.updateProgress(fileId, { fileId, progress: 0, status: "failed", error: error instanceof Error ? error.message : "Unknown error", }); throw error; } } async downloadFile(documentId: string): Promise<Blob> { if (!this.isConnected || !this.client) { throw new Error("Storage is not connected"); } try { // Find the file by document ID const files = await this.listFiles(); const document = files.find((doc) => doc.id === documentId); if (!document || !document.metadata.source) { throw new Error(`Document ${documentId} not found`); } const { data, error } = await this.client.storage .from(this.config.bucket) .download(document.metadata.source); if (error) { throw new Error(`Failed to download file: ${error.message}`); } return data; } catch (error) { throw new Error( `Failed to download file: ${ error instanceof Error ? error.message : "Unknown error" }` ); } } async deleteFile(documentId: string): Promise<void> { if (!this.isConnected || !this.client) { throw new Error("Storage is not connected"); } try { // Find the file by document ID const files = await this.listFiles(); const document = files.find((doc) => doc.id === documentId); if (!document || !document.metadata.source) { throw new Error(`Document ${documentId} not found`); } const { error } = await this.client.storage .from(this.config.bucket) .remove([document.metadata.source]); if (error) { throw new Error(`Failed to delete file: ${error.message}`); } } catch (error) { throw new Error( `Failed to delete file: ${ error instanceof Error ? error.message : "Unknown error" }` ); } } async listFiles(): Promise<Document[]> { if (!this.isConnected || !this.client) { throw new Error("Storage is not connected"); } try { const { data, error } = await this.client.storage .from(this.config.bucket) .list(); if (error) { throw new Error(`Failed to list files: ${error.message}`); } // Convert storage objects to documents const documents: Document[] = []; for (const file of data || []) { try { // Extract document ID from filename (assuming format: docId_originalName) const parts = file.name.split("_"); if (parts.length < 2) continue; const docId = parts[0]; const originalName = parts.slice(1).join("_"); // Get file URL for content extraction (if needed) const { data: urlData } = this.client.storage .from(this.config.bucket) .getPublicUrl(file.name); const document: Document = { id: docId, title: originalName, content: "", // Content would be extracted separately if needed metadata: { source: file.name, uploadedAt: new Date(file.created_at || Date.now()), size: file.metadata?.size || 0, type: file.metadata?.mimetype || "application/octet-stream", }, status: "ready", }; documents.push(document); } catch (error) { console.warn(`Failed to process file ${file.name}:`, error); continue; } } return documents; } catch (error) { throw new Error( `Failed to list files: ${ error instanceof Error ? error.message : "Unknown error" }` ); } } async getFileMetadata(documentId: string): Promise<FileMetadata> { if (!this.isConnected || !this.client) { throw new Error("Storage is not connected"); } try { const files = await this.listFiles(); const document = files.find((doc) => doc.id === documentId); if (!document) { throw new Error(`Document ${documentId} not found`); } return { originalName: document.title, size: document.metadata.size, type: document.metadata.type, uploadedAt: document.metadata.uploadedAt, description: document.metadata.description, tags: document.metadata.tags, extractedText: document.content, processingStatus: "completed", }; } catch (error) { throw new Error( `Failed to get file metadata: ${ error instanceof Error ? error.message : "Unknown error" }` ); } } async healthCheck(): Promise<boolean> { if (!this.isConnected || !this.client) return false; try { await this.client.storage.listBuckets(); return true; } catch (error) { return false; } } // Progress tracking setProgressCallback( fileId: string, callback: (progress: UploadProgress) => void ): void { this.progressCallbacks.set(fileId, callback); } removeProgressCallback(fileId: string): void { this.progressCallbacks.delete(fileId); } private updateProgress(fileId: string, progress: UploadProgress): void { const callback = this.progressCallbacks.get(fileId); if (callback) { callback(progress); } } private validateFile(file: File): void { // Check file size if (this.config.maxFileSize && file.size > this.config.maxFileSize) { throw new Error( `File size ${file.size} exceeds maximum allowed size ${this.config.maxFileSize}` ); } // Check file type if (this.config.allowedTypes && this.config.allowedTypes.length > 0) { const isAllowed = this.config.allowedTypes.some((type) => { if (type.includes("*")) { const baseType = type.split("/")[0]; return file.type.startsWith(baseType); } return file.type === type; }); if (!isAllowed) { throw new Error( `File type ${ file.type } is not allowed. Allowed types: ${this.config.allowedTypes.join( ", " )}` ); } } } private async extractTextFromFile(file: File): Promise<string> { // Simple text extraction for common file types if (file.type === "text/plain") { return await file.text(); } if (file.type === "application/json") { try { const text = await file.text(); const json = JSON.parse(text); return JSON.stringify(json, null, 2); } catch (error) { return await file.text(); } } if (file.type.startsWith("text/")) { return await file.text(); } // For other file types, we'd need specialized libraries // For now, return empty string and let the user provide content manually return ""; } } // Local Storage Implementation (for development/testing) export class LocalStorage extends BaseStorage { private files: Map<string, { document: Document; blob: Blob }> = new Map(); async connect(): Promise<void> { this.isConnected = true; } async disconnect(): Promise<void> { this.isConnected = false; this.files.clear(); } async uploadFile(file: File, options: UploadOptions = {}): Promise<Document> { if (!this.isConnected) { throw new Error("Storage is not connected"); } this.validateFile(file); const fileId = `doc_${Date.now()}_${Math.random() .toString(36) .substr(2, 9)}`; // Extract text if possible let extractedText = ""; if (options.extractText !== false) { try { if (file.type.startsWith("text/")) { extractedText = await file.text(); } } catch (error) { console.warn("Failed to extract text:", error); } } const document: Document = { id: fileId, title: options.description || file.name, content: extractedText, metadata: { source: file.name, uploadedAt: new Date(), size: file.size, type: file.type, description: options.description, tags: options.tags, }, status: "ready", }; this.files.set(fileId, { document, blob: file }); return document; } async downloadFile(documentId: string): Promise<Blob> { if (!this.isConnected) { throw new Error("Storage is not connected"); } const fileData = this.files.get(documentId); if (!fileData) { throw new Error(`Document ${documentId} not found`); } return fileData.blob; } async deleteFile(documentId: string): Promise<void> { if (!this.isConnected) { throw new Error("Storage is not connected"); } if (!this.files.has(documentId)) { throw new Error(`Document ${documentId} not found`); } this.files.delete(documentId); } async listFiles(): Promise<Document[]> { if (!this.isConnected) { throw new Error("Storage is not connected"); } return Array.from(this.files.values()).map((fileData) => fileData.document); } async getFileMetadata(documentId: string): Promise<FileMetadata> { if (!this.isConnected) { throw new Error("Storage is not connected"); } const fileData = this.files.get(documentId); if (!fileData) { throw new Error(`Document ${documentId} not found`); } const doc = fileData.document; return { originalName: doc.title, size: doc.metadata.size, type: doc.metadata.type, uploadedAt: doc.metadata.uploadedAt, description: doc.metadata.description, tags: doc.metadata.tags, extractedText: doc.content, processingStatus: "completed", }; } async healthCheck(): Promise<boolean> { return this.isConnected; } private validateFile(file: File): void { if (this.config.maxFileSize && file.size > this.config.maxFileSize) { throw new Error(`File size exceeds maximum allowed size`); } if (this.config.allowedTypes && this.config.allowedTypes.length > 0) { const isAllowed = this.config.allowedTypes.some((type) => { if (type.includes("*")) { const baseType = type.split("/")[0]; return file.type.startsWith(baseType); } return file.type === type; }); if (!isAllowed) { throw new Error(`File type ${file.type} is not allowed`); } } } } // Factory function to create storage instances export function createStorage(config: StorageConfig): BaseStorage { // For now, we only have Supabase and Local implementations if (config.url && config.apiKey) { return new SupabaseStorage(config); } else { return new LocalStorage(config); } } // Storage utilities export class StorageUtils { static validateConfig(config: StorageConfig): boolean { if (!config.bucket) return false; // For cloud storage, we need URL and API key if (config.url && !config.apiKey) return false; if (config.apiKey && !config.url) return false; return true; } static getDefaultConfig(): Partial<StorageConfig> { return { maxFileSize: 50 * 1024 * 1024, // 50MB allowedTypes: [ "text/*", "application/pdf", "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/json", "application/csv", "text/csv", ], }; } static 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]; } static getFileExtension(filename: string): string { return filename.slice(((filename.lastIndexOf(".") - 1) >>> 0) + 2); } static generateUniqueFileName(originalName: string): string { const ext = this.getFileExtension(originalName); const baseName = originalName.replace(`.${ext}`, ""); const timestamp = Date.now(); const random = Math.random().toString(36).substr(2, 5); return `${baseName}_${timestamp}_${random}.${ext}`; } static isTextFile(mimeType: string): boolean { return ( mimeType.startsWith("text/") || mimeType === "application/json" || mimeType === "application/xml" || mimeType === "application/csv" ); } static isSupportedFileType( mimeType: string, allowedTypes?: string[] ): boolean { if (!allowedTypes || allowedTypes.length === 0) return true; return allowedTypes.some((type) => { if (type.includes("*")) { const baseType = type.split("/")[0]; return mimeType.startsWith(baseType); } return mimeType === type; }); } } export default { BaseStorage, SupabaseStorage, LocalStorage, createStorage, StorageUtils, };