@restnfeel/agentc-starter-kit
Version:
한국어 기업용 CMS 모듈 - Task Master AI와 함께 빠르게 웹사이트를 구현할 수 있는 재사용 가능한 컴포넌트 시스템
684 lines (580 loc) • 18.6 kB
text/typescript
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,
};