diff --git a/src/app/_common/components/sidebarConfig.ts b/src/app/_common/components/sidebarConfig.ts index 64e85bfd..fe176c64 100644 --- a/src/app/_common/components/sidebarConfig.ts +++ b/src/app/_common/components/sidebarConfig.ts @@ -5,6 +5,7 @@ import { FiCpu, FiSettings, FiEye, + FiFile, } from 'react-icons/fi'; import { SidebarItem } from '@/app/main/components/types'; @@ -40,6 +41,12 @@ export const getSidebarItems = (): SidebarItem[] => [ description: '백엔드 환경변수 및 설정 확인', icon: React.createElement(FiEye), }, + { + id: 'documents', + title: '문서', + description: '문서 저장소', + icon: React.createElement(FiFile), + }, ]; // 공통 아이템 클릭 핸들러 (localStorage 사용) diff --git a/src/app/api/ragAPI.js b/src/app/api/ragAPI.js new file mode 100644 index 00000000..bc3f81e0 --- /dev/null +++ b/src/app/api/ragAPI.js @@ -0,0 +1,873 @@ +// RAG API 호출 함수들을 관리하는 파일 +import { devLog } from '@/app/utils/logger'; +import { API_BASE_URL } from '@/app/config.js'; + +// ============================================================================= +// Health Check +// ============================================================================= + +/** + * RAG 시스템의 연결 상태를 확인하는 함수 + * @returns {Promise} 헬스 체크 결과 + */ +export const checkRagHealth = async () => { + try { + const response = await fetch(`${API_BASE_URL}/rag/health`); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + devLog.info('RAG health check completed:', data); + return data; + } catch (error) { + devLog.error('Failed to check RAG health:', error); + throw error; + } +}; + +// ============================================================================= +// Collection Management +// ============================================================================= + +/** + * 모든 컬렉션 목록을 조회하는 함수 + * @returns {Promise} 컬렉션 목록 + */ +export const listCollections = async () => { + try { + const response = await fetch(`${API_BASE_URL}/rag/collections`); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + devLog.info('Collections fetched:', data); + return data; + } catch (error) { + devLog.error('Failed to fetch collections:', error); + throw error; + } +}; + +/** + * 새 컬렉션을 생성하는 함수 + * @param {string} collectionName - 컬렉션 이름 + * @param {number} vectorSize - 벡터 차원 수 + * @param {string} distance - 거리 메트릭 ("Cosine", "Euclidean", "Dot") + * @param {string} description - 컬렉션 설명 (선택사항) + * @param {Object} metadata - 커스텀 메타데이터 (선택사항) + * @returns {Promise} 생성된 컬렉션 정보 + */ +export const createCollection = async (collectionName, vectorSize, distance = "Cosine", description = null, metadata = null) => { + try { + const response = await fetch(`${API_BASE_URL}/rag/collections`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + collection_name: collectionName, + vector_size: vectorSize, + distance: distance, + description: description, + metadata: metadata + }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + devLog.info('Collection created:', data); + return data; + } catch (error) { + devLog.error('Failed to create collection:', error); + throw error; + } +}; + +/** + * 컬렉션을 삭제하는 함수 + * @param {string} collectionName - 삭제할 컬렉션 이름 + * @returns {Promise} 삭제 결과 + */ +export const deleteCollection = async (collectionName) => { + try { + const response = await fetch(`${API_BASE_URL}/rag/collections`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + collection_name: collectionName + }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + devLog.info('Collection deleted:', data); + return data; + } catch (error) { + devLog.error('Failed to delete collection:', error); + throw error; + } +}; + +/** + * 특정 컬렉션의 정보를 조회하는 함수 + * @param {string} collectionName - 조회할 컬렉션 이름 + * @returns {Promise} 컬렉션 정보 + */ +export const getCollectionInfo = async (collectionName) => { + try { + const response = await fetch(`${API_BASE_URL}/rag/collections/${collectionName}`); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + devLog.info('Collection info fetched:', data); + return data; + } catch (error) { + devLog.error('Failed to fetch collection info:', error); + throw error; + } +}; + +// ============================================================================= +// Document Management +// ============================================================================= + +/** + * 문서를 업로드하고 처리하는 함수 + * @param {File} file - 업로드할 파일 + * @param {string} collectionName - 대상 컬렉션 이름 + * @param {number} chunkSize - 청크 크기 (기본값: 1000) + * @param {number} chunkOverlap - 청크 겹침 크기 (기본값: 200) + * @param {boolean} processChunks - 청크 처리 여부 (기본값: true) + * @param {Object} metadata - 문서 메타데이터 (선택사항) + * @returns {Promise} 업로드 결과 + */ +export const uploadDocument = async (file, collectionName, chunkSize = 1000, chunkOverlap = 200, processChunks = true, metadata = null) => { + try { + const formData = new FormData(); + formData.append('file', file); + formData.append('collection_name', collectionName); + formData.append('chunk_size', chunkSize.toString()); + formData.append('chunk_overlap', chunkOverlap.toString()); + formData.append('process_chunks', processChunks.toString()); + + if (metadata) { + formData.append('metadata', JSON.stringify(metadata)); + } + + const response = await fetch(`${API_BASE_URL}/rag/documents/upload`, { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + devLog.info('Document uploaded:', data); + return data; + } catch (error) { + devLog.error('Failed to upload document:', error); + throw error; + } +}; + +/** + * 문서를 검색하는 함수 + * @param {string} collectionName - 검색할 컬렉션 이름 + * @param {string} queryText - 검색 쿼리 텍스트 + * @param {number} limit - 반환할 결과 수 (기본값: 5) + * @param {number} scoreThreshold - 점수 임계값 (기본값: 0.7) + * @param {Object} filter - 검색 필터 (선택사항) + * @returns {Promise} 검색 결과 + */ +export const searchDocuments = async (collectionName, queryText, limit = 5, scoreThreshold = 0.7, filter = null) => { + try { + const response = await fetch(`${API_BASE_URL}/rag/documents/search`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + collection_name: collectionName, + query_text: queryText, + limit: limit, + score_threshold: scoreThreshold, + filter: filter + }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + devLog.info('Document search completed:', data); + return data; + } catch (error) { + devLog.error('Failed to search documents:', error); + throw error; + } +}; + +/** + * 컬렉션 내 모든 문서 목록을 조회하는 함수 + * @param {string} collectionName - 컬렉션 이름 + * @returns {Promise} 문서 목록 + */ +export const listDocumentsInCollection = async (collectionName) => { + try { + const response = await fetch(`${API_BASE_URL}/rag/collections/${collectionName}/documents`); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + devLog.info('Documents in collection fetched:', data); + return data; + } catch (error) { + devLog.error('Failed to fetch documents in collection:', error); + throw error; + } +}; + +/** + * 특정 문서의 상세 정보를 조회하는 함수 + * @param {string} collectionName - 컬렉션 이름 + * @param {string} documentId - 문서 ID + * @returns {Promise} 문서 상세 정보 + */ +export const getDocumentDetails = async (collectionName, documentId) => { + try { + const response = await fetch(`${API_BASE_URL}/rag/collections/${collectionName}/documents/${documentId}`); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + devLog.info('Document details fetched:', data); + return data; + } catch (error) { + devLog.error('Failed to fetch document details:', error); + throw error; + } +}; + +/** + * 컬렉션에서 특정 문서를 삭제하는 함수 + * @param {string} collectionName - 컬렉션 이름 + * @param {string} documentId - 삭제할 문서 ID + * @returns {Promise} 삭제 결과 + */ +export const deleteDocumentFromCollection = async (collectionName, documentId) => { + try { + const response = await fetch(`${API_BASE_URL}/rag/collections/${collectionName}/documents/${documentId}`, { + method: 'DELETE', + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + devLog.info('Document deleted from collection:', data); + return data; + } catch (error) { + devLog.error('Failed to delete document from collection:', error); + throw error; + } +}; + +// ============================================================================= +// Vector Operations (Legacy Support) +// ============================================================================= + +/** + * 벡터 포인트를 삽입하는 함수 + * @param {string} collectionName - 대상 컬렉션 이름 + * @param {Array} points - 삽입할 포인트 배열 + * @returns {Promise} 삽입 결과 + */ +export const insertPoints = async (collectionName, points) => { + try { + const response = await fetch(`${API_BASE_URL}/rag/points`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + collection_name: collectionName, + points: points + }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + devLog.info('Points inserted:', data); + return data; + } catch (error) { + devLog.error('Failed to insert points:', error); + throw error; + } +}; + +/** + * 벡터 유사도 검색을 수행하는 함수 + * @param {string} collectionName - 검색할 컬렉션 이름 + * @param {Array} queryVector - 검색 벡터 + * @param {number} limit - 반환할 결과 수 (기본값: 10) + * @param {number} scoreThreshold - 점수 임계값 (선택사항) + * @param {Object} filter - 검색 필터 (선택사항) + * @returns {Promise} 검색 결과 + */ +export const searchPoints = async (collectionName, queryVector, limit = 10, scoreThreshold = null, filter = null) => { + try { + const response = await fetch(`${API_BASE_URL}/rag/search`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + collection_name: collectionName, + query: { + vector: queryVector, + limit: limit, + score_threshold: scoreThreshold, + filter: filter + } + }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + devLog.info('Points search completed:', data); + return data; + } catch (error) { + devLog.error('Failed to search points:', error); + throw error; + } +}; + +/** + * 벡터 포인트를 삭제하는 함수 + * @param {string} collectionName - 대상 컬렉션 이름 + * @param {Array} pointIds - 삭제할 포인트 ID 배열 + * @returns {Promise} 삭제 결과 + */ +export const deletePoints = async (collectionName, pointIds) => { + try { + const response = await fetch(`${API_BASE_URL}/rag/points`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + collection_name: collectionName, + point_ids: pointIds + }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + devLog.info('Points deleted:', data); + return data; + } catch (error) { + devLog.error('Failed to delete points:', error); + throw error; + } +}; + +// ============================================================================= +// Configuration +// ============================================================================= + +/** + * RAG 시스템 설정을 조회하는 함수 + * @returns {Promise} RAG 설정 정보 + */ +export const getRagConfig = async () => { + try { + const response = await fetch(`${API_BASE_URL}/rag/config`); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + devLog.info('RAG config fetched:', data); + return data; + } catch (error) { + devLog.error('Failed to fetch RAG config:', error); + throw error; + } +}; + +// ============================================================================= +// Embedding Provider Management +// ============================================================================= + +/** + * 사용 가능한 임베딩 제공자 목록을 조회하는 함수 + * @returns {Promise} 임베딩 제공자 목록 + */ +export const getEmbeddingProviders = async () => { + try { + const response = await fetch(`${API_BASE_URL}/rag/embedding/providers`); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + devLog.info('Embedding providers fetched:', data); + return data; + } catch (error) { + devLog.error('Failed to fetch embedding providers:', error); + throw error; + } +}; + +/** + * 모든 임베딩 제공자를 테스트하는 함수 + * @returns {Promise} 테스트 결과 + */ +export const testEmbeddingProviders = async () => { + try { + const response = await fetch(`${API_BASE_URL}/rag/embedding/test`); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + devLog.info('Embedding providers tested:', data); + return data; + } catch (error) { + devLog.error('Failed to test embedding providers:', error); + throw error; + } +}; + +/** + * 현재 임베딩 클라이언트 상태를 조회하는 함수 + * @returns {Promise} 임베딩 클라이언트 상태 + */ +export const getEmbeddingStatus = async () => { + try { + const response = await fetch(`${API_BASE_URL}/rag/embedding/status`); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + devLog.info('Embedding status fetched:', data); + return data; + } catch (error) { + devLog.error('Failed to fetch embedding status:', error); + throw error; + } +}; + +/** + * 임베딩 생성을 테스트하는 함수 + * @param {string} queryText - 테스트할 쿼리 텍스트 (기본값: "Hello, world!") + * @returns {Promise} 임베딩 테스트 결과 + */ +export const testEmbeddingQuery = async (queryText = "Hello, world!") => { + try { + const response = await fetch(`${API_BASE_URL}/rag/embedding/test-query`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query_text: queryText + }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + devLog.info('Embedding query test completed:', data); + return data; + } catch (error) { + devLog.error('Failed to test embedding query:', error); + throw error; + } +}; + +/** + * 임베딩 클라이언트를 강제로 재로드하는 함수 + * @returns {Promise} 재로드 결과 + */ +export const reloadEmbeddingClient = async () => { + try { + const response = await fetch(`${API_BASE_URL}/rag/embedding/reload`, { + method: 'POST', + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + devLog.info('Embedding client reloaded:', data); + return data; + } catch (error) { + devLog.error('Failed to reload embedding client:', error); + throw error; + } +}; + +/** + * 임베딩 제공자를 변경하는 함수 + * @param {string} newProvider - 새로운 제공자 이름 ("openai", "huggingface", "custom_http") + * @returns {Promise} 제공자 변경 결과 + */ +export const switchEmbeddingProvider = async (newProvider) => { + try { + const response = await fetch(`${API_BASE_URL}/rag/embedding/switch-provider`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + new_provider: newProvider + }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + devLog.info('Embedding provider switched:', data); + return data; + } catch (error) { + devLog.error('Failed to switch embedding provider:', error); + throw error; + } +}; + +/** + * 자동으로 최적의 임베딩 제공자로 전환하는 함수 + * @returns {Promise} 자동 전환 결과 + */ +export const autoSwitchEmbeddingProvider = async () => { + try { + const response = await fetch(`${API_BASE_URL}/rag/embedding/auto-switch`, { + method: 'POST', + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + devLog.info('Embedding provider auto-switched:', data); + return data; + } catch (error) { + devLog.error('Failed to auto-switch embedding provider:', error); + throw error; + } +}; + +/** + * 임베딩 설정 상태를 조회하는 함수 + * @returns {Promise} 임베딩 설정 상태 + */ +export const getEmbeddingConfigStatus = async () => { + try { + const response = await fetch(`${API_BASE_URL}/rag/embedding/config-status`); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + devLog.info('Embedding config status fetched:', data); + return data; + } catch (error) { + devLog.error('Failed to fetch embedding config status:', error); + throw error; + } +}; + +// ============================================================================= +// Debug Functions +// ============================================================================= + +/** + * 디버깅을 위한 임베딩 상세 정보를 조회하는 함수 + * @returns {Promise} 임베딩 디버그 정보 + */ +export const getEmbeddingDebugInfo = async () => { + try { + const response = await fetch(`${API_BASE_URL}/rag/debug/embedding-info`); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + devLog.info('Embedding debug info fetched:', data); + return data; + } catch (error) { + devLog.error('Failed to fetch embedding debug info:', error); + throw error; + } +}; + +// ============================================================================= +// Utility Functions +// ============================================================================= + +/** + * 파일 타입이 지원되는지 확인하는 함수 + * @param {File} file - 확인할 파일 + * @returns {boolean} 지원 여부 + */ +export const isSupportedFileType = (file) => { + const supportedTypes = [ + 'application/pdf', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/msword', + 'text/plain' + ]; + return supportedTypes.includes(file.type); +}; + +/** + * 파일 크기가 허용 범위인지 확인하는 함수 + * @param {File} file - 확인할 파일 + * @param {number} maxSizeMB - 최대 크기 (MB, 기본값: 50) + * @returns {boolean} 허용 여부 + */ +export const isValidFileSize = (file, maxSizeMB = 50) => { + const maxSizeBytes = maxSizeMB * 1024 * 1024; + return file.size <= maxSizeBytes; +}; + +/** + * 컬렉션 이름의 유효성을 검사하는 함수 + * @param {string} name - 컬렉션 이름 + * @returns {boolean} 유효성 여부 + */ +export const isValidCollectionName = (name) => { + // 영문자, 숫자, 언더스코어, 하이픈만 허용, 3-63자 + const regex = /^[a-zA-Z0-9_-]{3,63}$/; + return regex.test(name); +}; + +/** + * RAG 시스템의 전체 상태를 확인하는 함수 + * @returns {Promise} 전체 시스템 상태 + */ +export const getRagSystemStatus = async () => { + try { + const [healthData, configData, collectionsData] = await Promise.all([ + checkRagHealth(), + getRagConfig(), + listCollections() + ]); + + return { + health: healthData, + config: configData, + collections: collectionsData, + timestamp: new Date().toISOString() + }; + } catch (error) { + devLog.error('Failed to get RAG system status:', error); + throw error; + } +}; + +/** + * 임베딩 제공자 이름을 한국어로 변환하는 함수 + * @param {string} provider - 제공자 이름 + * @returns {string} 한국어 제공자 이름 + */ +export const getProviderDisplayName = (provider) => { + const providerNames = { + 'openai': 'OpenAI', + 'huggingface': 'HuggingFace', + 'custom_http': '커스텀 HTTP', + 'local': '로컬' + }; + return providerNames[provider?.toLowerCase()] || provider; +}; + +/** + * 파일 확장자에서 MIME 타입을 추출하는 함수 + * @param {string} filename - 파일명 + * @returns {string} MIME 타입 + */ +export const getMimeTypeFromFilename = (filename) => { + const extension = filename.split('.').pop().toLowerCase(); + const mimeTypes = { + 'pdf': 'application/pdf', + 'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'doc': 'application/msword', + 'txt': 'text/plain' + }; + return mimeTypes[extension] || 'application/octet-stream'; +}; + +/** + * 바이트 크기를 사람이 읽기 쉬운 형태로 변환하는 함수 + * @param {number} bytes - 바이트 크기 + * @param {number} decimals - 소수점 자릿수 + * @returns {string} 포맷된 크기 문자열 + */ +export const formatFileSize = (bytes, decimals = 2) => { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; +}; + +/** + * 날짜를 상대적 시간으로 표시하는 함수 + * @param {string} dateString - ISO 날짜 문자열 + * @returns {string} 상대적 시간 문자열 + */ +export const getRelativeTime = (dateString) => { + if (!dateString) return '알 수 없음'; + + const date = new Date(dateString); + const now = new Date(); + const diffInSeconds = Math.floor((now - date) / 1000); + + if (diffInSeconds < 60) { + return '방금 전'; + } else if (diffInSeconds < 3600) { + const minutes = Math.floor(diffInSeconds / 60); + return `${minutes}분 전`; + } else if (diffInSeconds < 86400) { + const hours = Math.floor(diffInSeconds / 3600); + return `${hours}시간 전`; + } else { + const days = Math.floor(diffInSeconds / 86400); + return `${days}일 전`; + } +}; + +/** + * 임베딩 모델명에 따른 벡터 차원을 자동으로 반환하는 함수 + * @param {string} provider - 임베딩 제공자 ('openai', 'huggingface', 'custom_http') + * @param {string} model - 모델명 + * @returns {number} 벡터 차원 + */ +export const getEmbeddingDimension = (provider, model) => { + if (!provider || !model) return 1536; // 기본값 + + switch (provider.toLowerCase()) { + case 'openai': + switch (model) { + case 'text-embedding-3-large': + return 3072; + case 'text-embedding-3-small': + case 'text-embedding-ada-002': + default: + return 1536; + } + + case 'huggingface': + // 일반적인 HuggingFace 모델 차원 + const commonModels = { + 'sentence-transformers/all-MiniLM-L6-v2': 384, + 'sentence-transformers/all-MiniLM-L12-v2': 384, + 'sentence-transformers/all-mpnet-base-v2': 768, + 'sentence-transformers/all-distilroberta-v1': 768, + 'sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2': 384, + 'BAAI/bge-large-en-v1.5': 1024, + 'BAAI/bge-base-en-v1.5': 768, + 'BAAI/bge-small-en-v1.5': 384, + }; + return commonModels[model] || 768; // 일반적인 기본값 + + case 'custom_http': + case 'vllm': + // VLLM은 모델에 따라 다르므로 일반적인 기본값 반환 + return 1536; + + default: + return 1536; + } +}; + +/** + * 현재 설정된 임베딩 제공자와 모델에 따른 벡터 차원을 조회하는 함수 + * @returns {Promise} 벡터 차원 정보 + */ +export const getCurrentEmbeddingDimension = async () => { + try { + const status = await getEmbeddingStatus(); + const config = await getRagConfig(); + + if (status && status.provider_info) { + const provider = status.provider_info.provider || 'openai'; + const model = status.provider_info.model || 'text-embedding-3-small'; + const dimension = getEmbeddingDimension(provider, model); + + return { + provider, + model, + dimension, + auto_detected: true + }; + } + + return { + provider: 'openai', + model: 'text-embedding-3-small', + dimension: 1536, + auto_detected: false + }; + } catch (error) { + devLog.error('Failed to get current embedding dimension:', error); + return { + provider: 'openai', + model: 'text-embedding-3-small', + dimension: 1536, + auto_detected: false, + error: error.message + }; + } +}; + diff --git a/src/app/main/assets/Documents.module.scss b/src/app/main/assets/Documents.module.scss new file mode 100644 index 00000000..219d1ca8 --- /dev/null +++ b/src/app/main/assets/Documents.module.scss @@ -0,0 +1,635 @@ +@use "sass:color"; + +// Color Variables from Settings.module.scss +$primary-blue: #2563eb; +$primary-green: #059669; +$primary-red: #dc2626; +$gray-50: #f9fafb; +$gray-100: #f3f4f6; +$gray-200: #e5e7eb; +$gray-300: #d1d5db; +$gray-400: #9ca3af; +$gray-500: #6b7280; +$gray-600: #4b5563; +$gray-700: #374151; +$gray-800: #1f2937; +$gray-900: #111827; +$white: #ffffff; + +.container { + padding: 2rem; + max-width: 1200px; + margin: 0 auto; + font-family: 'Pretendard', sans-serif; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; + gap: 1rem; + flex-wrap: wrap; + + h2 { + font-size: 1.875rem; + font-weight: 700; + color: $gray-900; + } + + .headerLeft { + display: flex; + align-items: center; + gap: 1rem; + + h2 { + margin: 0; + } + } + + .headerRight { + display: flex; + align-items: center; + gap: 1rem; + } +} + +.button { + padding: 0.625rem 1.25rem; + border-radius: 0.5rem; + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease-in-out; + border: 1px solid transparent; + display: inline-flex; + align-items: center; + gap: 0.5rem; + + &:disabled { + cursor: not-allowed; + opacity: 0.6; + } + + &.primary { + background-color: $primary-blue; + color: $white; + &:hover:not(:disabled) { + background-color: color.adjust($primary-blue, $lightness: -7%); + } + } + + &.secondary { + background-color: $white; + color: $gray-700; + border-color: $gray-300; + &:hover:not(:disabled) { + background-color: $gray-50; + } + } + + &.danger { + background-color: $primary-red; + color: $white; + &:hover:not(:disabled) { + background-color: color.adjust($primary-red, $lightness: -7%); + } + } +} + +.error { + background-color: color.adjust($primary-red, $alpha: -0.9); + color: $primary-red; + padding: 1rem; + border-radius: 0.5rem; + margin-bottom: 1.5rem; + font-weight: 500; +} + +.loading { + text-align: center; + padding: 3rem; + color: $gray-500; + font-size: 1.125rem; +} + +// Collection List +.collectionListContainer { + h3 { + font-size: 1.5rem; + font-weight: 600; + color: $gray-800; + margin-bottom: 1rem; + } +} + +.collectionGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1.5rem; +} + +.collectionCard { + background: $white; + border: 1px solid $gray-200; + border-radius: 0.75rem; + padding: 1.5rem; + transition: all 0.2s ease-in-out; + display: flex; + flex-direction: column; + position: relative; + + &:hover { + transform: translateY(-5px); + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.08); + border-color: $primary-blue; + } + + .collectionContent { + cursor: pointer; + flex-grow: 1; + } + + h4 { + font-size: 1.25rem; + font-weight: 600; + color: $primary-blue; + margin: 0 0 0.5rem 0; + } + + p { + color: $gray-600; + margin: 0 0 1rem 0; + font-size: 0.9rem; + flex-grow: 1; + } + + .collectionMeta { + color: $gray-500; + font-size: 0.875rem; + font-weight: 500; + } +} + +.deleteButton { + position: absolute; + top: 1rem; + right: 1rem; + background: $gray-100; + border: 1px solid $gray-200; + border-radius: 0.375rem; + padding: 0.375rem; + cursor: pointer; + font-size: 0.875rem; + transition: all 0.2s ease-in-out; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.7; + + &:hover { + background: $primary-red; + border-color: $primary-red; + opacity: 1; + transform: scale(1.05); + } +} + +// Document View +.documentViewContainer { + .viewHeader { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + flex-wrap: wrap; + margin-bottom: 1.5rem; + + h3 { + font-size: 1.5rem; + font-weight: 600; + color: $gray-800; + margin: 0; + } + + .actions { + display: flex; + gap: 1rem; + } + } +} + +.uploadProgressContainer { + background: $gray-50; + border: 1px solid $gray-200; + border-radius: 0.75rem; + padding: 1.5rem; + margin-bottom: 2rem; + + h4 { + font-size: 1.125rem; + font-weight: 600; + margin: 0 0 1rem 0; + color: $gray-800; + } +} + +.progressItem { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 0; + border-bottom: 1px solid $gray-200; + + &:last-child { + border-bottom: none; + } + + .fileName { + font-weight: 500; + color: $gray-700; + } + + .status { + font-size: 0.875rem; + font-weight: 600; + + &.uploading { color: $primary-blue; } + &.success { color: $primary-green; } + &.error { color: $primary-red; } + } + + .progressBar { + width: 100px; + height: 8px; + background: $gray-200; + border-radius: 4px; + overflow: hidden; + + div { + height: 100%; + background: $primary-green; + transition: width 0.3s ease; + } + } +} + +.documentListContainer { + h4 { + font-size: 1.25rem; + font-weight: 600; + color: $gray-800; + margin-bottom: 1rem; + } +} + +.documentGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1.5rem; +} + +.documentCard { + background: $white; + border: 1px solid $gray-200; + border-radius: 0.75rem; + padding: 1.5rem; + position: relative; + transition: all 0.2s ease-in-out; + + &:hover { + transform: translateY(-2px); + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.08); + border-color: $primary-blue; + } + + .documentContent { + cursor: pointer; + + h4 { + font-size: 1.125rem; + font-weight: 600; + color: $primary-blue; + margin: 0 0 0.5rem 0; + } + + .docInfo { + font-size: 0.875rem; + color: $gray-500; + line-height: 1.4; + } + } + + .docId { + font-size: 0.8rem; + font-weight: 600; + color: $gray-500; + margin-bottom: 0.5rem; + } + + .docContent { + font-size: 0.9rem; + color: $gray-700; + line-height: 1.5; + margin-bottom: 1rem; + } + + .docScore { + font-size: 0.875rem; + font-weight: 600; + color: $primary-green; + } +} + +.emptyState { + text-align: center; + padding: 3rem; + background: $gray-50; + border-radius: 0.75rem; + color: $gray-500; +} + +// Document Detail View +.documentDetailContainer { + .searchContainer { + background: $white; + border: 1px solid $gray-200; + border-radius: 0.75rem; + padding: 1.5rem; + margin-bottom: 2rem; + + .searchBox { + display: flex; + gap: 1rem; + align-items: center; + + .searchInput { + flex: 1; + padding: 0.75rem 1rem; + border: 1px solid $gray-300; + border-radius: 0.5rem; + font-size: 1rem; + transition: border-color 0.2s ease-in-out; + + &:focus { + outline: none; + border-color: $primary-blue; + box-shadow: 0 0 0 3px rgba($primary-blue, 0.1); + } + } + } + } + + .searchResultsContainer { + margin-bottom: 2rem; + + h4 { + font-size: 1.25rem; + font-weight: 600; + color: $gray-800; + margin-bottom: 1rem; + } + + .searchResults { + space-y: 1rem; + } + + .searchResultItem { + background: $white; + border: 1px solid $gray-200; + border-radius: 0.75rem; + padding: 1.5rem; + margin-bottom: 1rem; + transition: box-shadow 0.2s ease-in-out; + + &:hover { + box-shadow: 0 4px 12px rgba(0,0,0,0.05); + } + + .resultHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.75rem; + + .resultScore { + font-size: 0.875rem; + font-weight: 600; + color: $primary-green; + background: color.adjust($primary-green, $alpha: -0.9); + padding: 0.25rem 0.5rem; + border-radius: 0.375rem; + } + + .resultChunk { + font-size: 0.875rem; + font-weight: 500; + color: $gray-500; + } + } + + .resultText { + color: $gray-700; + line-height: 1.6; + margin: 0; + } + } + } + + .documentDetailContent { + .documentMeta { + background: $white; + border: 1px solid $gray-200; + border-radius: 0.75rem; + padding: 1.5rem; + margin-bottom: 2rem; + + h3 { + font-size: 1.5rem; + font-weight: 700; + color: $gray-900; + margin: 0 0 1rem 0; + } + + .metaInfo { + display: flex; + gap: 1.5rem; + font-size: 0.875rem; + color: $gray-600; + flex-wrap: wrap; + + span { + font-weight: 500; + } + } + } + + .chunksContainer { + h4 { + font-size: 1.25rem; + font-weight: 600; + color: $gray-800; + margin-bottom: 1rem; + } + + .chunksList { + space-y: 1rem; + } + + .chunkItem { + background: $white; + border: 1px solid $gray-200; + border-radius: 0.75rem; + padding: 1.5rem; + margin-bottom: 1rem; + + .chunkHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid $gray-200; + + .chunkIndex { + font-size: 0.875rem; + font-weight: 600; + color: $primary-blue; + background: color.adjust($primary-blue, $alpha: -0.9); + padding: 0.25rem 0.5rem; + border-radius: 0.375rem; + } + + .chunkSize { + font-size: 0.75rem; + font-weight: 500; + color: $gray-500; + } + } + + .chunkText { + color: $gray-700; + line-height: 1.7; + white-space: pre-wrap; + word-break: break-word; + } + } + } + } +} + +// Modal Styles +.modalBackdrop { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.modalContent { + background: $white; + padding: 2rem; + border-radius: 0.75rem; + width: 90%; + max-width: 500px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1); + + h3 { + font-size: 1.5rem; + font-weight: 600; + color: $gray-800; + margin: 0 0 1.5rem 0; + } +} + +.formGroup { + margin-bottom: 1.5rem; + + label { + display: block; + font-size: 0.875rem; + font-weight: 500; + color: $gray-700; + margin-bottom: 0.5rem; + } + + input, + textarea { + width: 100%; + padding: 0.75rem; + border: 1px solid $gray-300; + border-radius: 0.5rem; + font-size: 1rem; + transition: border-color 0.2s ease-in-out; + + &:focus { + outline: none; + border-color: $primary-blue; + box-shadow: 0 0 0 3px rgba($primary-blue, 0.1); + } + } + + textarea { + resize: vertical; + min-height: 100px; + } +} + +.modalActions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + margin-top: 2rem; +} + +// Responsive Design +@media (max-width: 768px) { + .header { + flex-direction: column; + align-items: flex-start; + + .headerLeft, + .headerRight { + width: 100%; + justify-content: space-between; + } + + .headerRight { + justify-content: flex-start; + } + } + + .collectionGrid, + .documentGrid { + grid-template-columns: 1fr; + } + + .documentDetailContainer { + .searchBox { + flex-direction: column; + align-items: stretch; + } + + .documentMeta .metaInfo { + flex-direction: column; + gap: 0.5rem; + } + + .chunkItem .chunkHeader { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } + } + + .modalContent { + width: 95%; + padding: 1.5rem; + } +} diff --git a/src/app/main/assets/Settings.module.scss b/src/app/main/assets/Settings.module.scss index 40b7de9e..bd264476 100644 --- a/src/app/main/assets/Settings.module.scss +++ b/src/app/main/assets/Settings.module.scss @@ -49,107 +49,57 @@ $white: #ffffff; } p { + font-size: 1rem; color: $gray-600; margin: 0; - line-height: 1.6; - font-size: 1.125rem; + line-height: 1.5; } } .headerActions { display: flex; gap: 1rem; - flex-shrink: 0; -} - -// Mode Toggle Styles -.headerTop { - display: flex; - align-items: center; - gap: 1rem; - margin-bottom: 1rem; -} - -.modeToggle { - display: flex; align-items: center; - gap: 0.75rem; - padding: 0.75rem 1rem; - background: $gray-50; - border: 1px solid $gray-200; - border-radius: 0.5rem; - - .modeLabel { - font-weight: 600; - color: $gray-700; - font-size: 0.875rem; - } - - .toggleButton { - background: none; - border: none; - cursor: pointer; - color: $primary-blue; - font-size: 1.5rem; - transition: color 0.2s ease-in-out; - - &:hover { - color: color.adjust($primary-blue, $lightness: -10%); - } - } - - .modeDescription { - color: $gray-500; - font-size: 0.75rem; - max-width: 200px; - line-height: 1.4; - } } // Categories Grid .categoriesGrid { - display: flex; - flex-direction: column; - gap: 1rem; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 1.5rem; + + @media (max-width: 640px) { + grid-template-columns: 1fr; + } } .categoryWrapper { - background: $white; - border-radius: 0.75rem; - border: 1px solid $gray-200; - overflow: hidden; - transition: all 0.2s ease-in-out; - - &:hover { - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); - border-color: rgba($primary-blue, 0.3); - } + position: relative; } .categoryCard { + background: $white; + border: 1px solid $gray-200; + border-radius: 0.75rem; + padding: 1.5rem; cursor: pointer; transition: all 0.2s ease-in-out; + height: 100%; - &.active { - background-color: rgba($primary-blue, 0.02); + &:hover { + transform: translateY(-4px); + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.08); + border-color: $primary-blue; } } .categoryHeader { display: flex; align-items: center; - padding: 1.5rem; gap: 1rem; } .categoryIcon { - width: 3rem; - height: 3rem; - display: flex; - align-items: center; - justify-content: center; - background: $gray-50; - border-radius: 0.75rem; font-size: 1.5rem; flex-shrink: 0; } @@ -158,17 +108,17 @@ $white: #ffffff; flex: 1; h3 { - font-size: 1.25rem; + font-size: 1.125rem; font-weight: 600; color: $gray-900; margin: 0 0 0.25rem 0; } p { + font-size: 0.875rem; color: $gray-600; margin: 0; - font-size: 0.925rem; - line-height: 1.5; + line-height: 1.4; } } @@ -177,161 +127,108 @@ $white: #ffffff; align-items: center; gap: 0.5rem; flex-shrink: 0; -} -.statusText { - font-size: 0.875rem; - font-weight: 500; - color: $gray-600; + .statusText { + font-size: 0.75rem; + font-weight: 500; + color: $gray-500; + } + + .chevron { + font-size: 1rem; + color: $gray-400; + } } .statusConnected { color: $primary-green; - width: 1.25rem; - height: 1.25rem; } .statusError { color: $primary-red; - width: 1.25rem; - height: 1.25rem; -} - -.chevron { - width: 1.25rem; - height: 1.25rem; - color: $gray-400; - transition: transform 0.2s ease-in-out; - - &.expanded { - transform: rotate(90deg); - } -} - -// Configuration Panel -.configPanel { - border-top: 1px solid $gray-200; - background: $gray-50; - animation: slideDown 0.3s ease-in-out; } -@keyframes slideDown { - from { - opacity: 0; - max-height: 0; - } - - to { - opacity: 1; - max-height: 500px; - } +.statusWarning { + color: $primary-yellow; } -.configContent { - padding: 1.5rem; - - h4 { - font-size: 1.125rem; - font-weight: 600; - color: $gray-900; - margin: 0 0 1rem 0; - } - - p { - color: $gray-600; - margin: 0; - line-height: 1.6; +// Detail View +.detailView { + .detailHeader { + margin-bottom: 2rem; } -} - -// Form Styles (for next step) -.formGroup { - margin-bottom: 1.5rem; - label { - display: block; - font-size: 0.875rem; - font-weight: 500; - color: $gray-700; - margin-bottom: 0.5rem; + .headerTop { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; } - input, - select, - textarea { - width: 100%; - padding: 0.75rem; + .backButton { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: transparent; border: 1px solid $gray-300; border-radius: 0.5rem; - font-size: 0.925rem; - transition: border-color 0.2s ease-in-out; - - &:focus { - outline: none; - border-color: $primary-blue; - box-shadow: 0 0 0 3px rgba($primary-blue, 0.1); - } + color: $gray-700; + cursor: pointer; + transition: all 0.2s ease-in-out; - &::placeholder { - color: $gray-400; + &:hover { + background: $gray-50; + border-color: $gray-400; } } - textarea { - resize: vertical; - min-height: 80px; - } - - small { - display: block; - margin-top: 0.25rem; - font-size: 0.8rem; - color: $gray-500; - line-height: 1.4; - } - - .error { - color: $primary-red; - font-weight: 500; - } + .detailTitle { + display: flex; + align-items: center; + gap: 1rem; - .success { - color: $primary-green; - font-weight: 500; - } + .detailIcon { + font-size: 1.5rem; + } - input.error, - select.error { - border-color: $primary-red; - box-shadow: 0 0 0 3px rgba($primary-red, 0.1); - } + h2 { + font-size: 1.5rem; + font-weight: 600; + color: $gray-900; + margin: 0; + } - input.success, - select.success { - border-color: $primary-green; - box-shadow: 0 0 0 3px rgba($primary-green, 0.1); + p { + font-size: 0.875rem; + color: $gray-600; + margin: 0.25rem 0 0 0; + } } } -.formActions { - display: flex; - gap: 0.75rem; - justify-content: flex-end; - margin-top: 1.5rem; - padding-top: 1.5rem; - border-top: 1px solid $gray-200; +.configWrapper { + background: $white; + border: 1px solid $gray-200; + border-radius: 0.75rem; + padding: 2rem; } +// Button Styles .button { + display: inline-flex; + align-items: center; + gap: 0.5rem; padding: 0.625rem 1.25rem; border-radius: 0.5rem; font-size: 0.875rem; - font-weight: 500; + font-weight: 600; cursor: pointer; transition: all 0.2s ease-in-out; border: 1px solid transparent; &:disabled { + opacity: 0.6; cursor: not-allowed; } @@ -340,12 +237,7 @@ $white: #ffffff; color: $white; &:hover:not(:disabled) { - background-color: color.adjust($primary-blue, $lightness: -10%); - } - - &:disabled { - background-color: $gray-300; - color: $gray-500; + background-color: color.adjust($primary-blue, $lightness: -7%); } } @@ -356,487 +248,702 @@ $white: #ffffff; &:hover:not(:disabled) { background-color: $gray-50; - } - - &:disabled { - background-color: $gray-100; - color: $gray-400; - border-color: $gray-200; + border-color: $gray-400; } } - &.test { - background-color: $primary-green; + &.danger { + background-color: $primary-red; color: $white; &:hover:not(:disabled) { - background-color: color.adjust($primary-green, $lightness: -10%); + background-color: color.adjust($primary-red, $lightness: -7%); } + } - &:disabled { - background-color: $gray-300; - color: $gray-500; - } + &.small { + padding: 0.5rem 0.875rem; + font-size: 0.75rem; } +} - &.danger { - background-color: $primary-red; - color: $white; +// Loading and Error States +.loadingState, +.errorState { + text-align: center; + padding: 3rem 2rem; + color: $gray-500; - &:hover:not(:disabled) { - background-color: color.adjust($primary-red, $lightness: -10%); - } + p { + margin: 0 0 1rem 0; + } +} - &:disabled { - background-color: $gray-300; - color: $gray-500; - } +.errorState { + color: $primary-red; +} + +// Special animations +.spinning { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); } } -// Detail View Styles -.detailView { - max-width: 800px; - margin: 0 auto; +// ===== VectorDB Config Specific Styles ===== + +// Container +.vectordbContainer { + background: $white; + border-radius: 0.75rem; + overflow: hidden; } -.detailHeader { +// Tab Navigation +.tabNavigation { display: flex; - align-items: flex-start; - gap: 1rem; - margin-bottom: 2rem; - padding-bottom: 1.5rem; + background: $gray-50; border-bottom: 1px solid $gray-200; } -.backButton { +.tabButton { + flex: 1; display: flex; align-items: center; + justify-content: center; gap: 0.5rem; - padding: 0.625rem 1rem; - background: $white; - border: 1px solid $gray-300; - border-radius: 0.5rem; - color: $gray-700; + padding: 1rem 1.5rem; + background: transparent; + border: none; + color: $gray-600; font-size: 0.875rem; font-weight: 500; cursor: pointer; transition: all 0.2s ease-in-out; + border-bottom: 2px solid transparent; &:hover { - background: $gray-50; - border-color: $gray-400; + background: $gray-100; + color: $gray-800; + } + + &.active { + background: $white; + color: $primary-blue; + border-bottom-color: $primary-blue; } svg { - width: 1rem; - height: 1rem; + font-size: 1rem; } } -.detailTitle { +// Error Banner +.errorBanner { display: flex; - align-items: flex-start; - gap: 1rem; - flex: 1; + align-items: center; + gap: 0.75rem; + padding: 1rem 1.5rem; + background: color.adjust($primary-red, $alpha: -0.9); + color: $primary-red; + border-bottom: 1px solid color.adjust($primary-red, $alpha: -0.8); - h2 { - font-size: 1.875rem; - font-weight: 700; - color: $gray-900; - margin: 0 0 0.25rem 0; + svg { + font-size: 1.125rem; } - p { - color: $gray-600; - margin: 0; - line-height: 1.6; + span { + flex: 1; + font-weight: 500; } -} -.detailIcon { - width: 4rem; - height: 4rem; - display: flex; - align-items: center; - justify-content: center; - background: $gray-50; - border-radius: 1rem; - font-size: 2rem; - flex-shrink: 0; + button { + background: none; + border: none; + color: $primary-red; + cursor: pointer; + padding: 0.25rem; + border-radius: 0.25rem; + transition: background-color 0.2s ease-in-out; + + &:hover { + background: color.adjust($primary-red, $alpha: -0.8); + } + } } -.configWrapper { - background: $white; - border-radius: 0.75rem; - border: 1px solid $gray-200; +// Tab Content +.tabContent { padding: 2rem; } -.configForm { - display: grid; - gap: 1.5rem; +// Embedding Config Tab +.embeddingConfig { + display: flex; + flex-direction: column; + gap: 2rem; +} - .formGroup { - label { - display: block; - font-size: 0.875rem; +// Status Section +.statusSection { + .sectionHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + + h3 { + font-size: 1.25rem; font-weight: 600; - color: $gray-700; - margin-bottom: 0.5rem; + color: $gray-900; + margin: 0; } - input, - select { - width: 100%; - padding: 0.25rem 0.8rem; - border: 1px solid $gray-300; - border-radius: 0.5rem; - font-size: 0.875rem; - background: $white; - transition: all 0.2s ease-in-out; + .headerActions { + display: flex; + gap: 0.75rem; + } + } - &:focus { - outline: none; - border-color: $primary-blue; - box-shadow: 0 0 0 3px rgba($primary-blue, 0.1); + .currentStatus { + .statusCard { + background: $gray-50; + border: 1px solid $gray-200; + border-radius: 0.75rem; + padding: 1.5rem; + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + + .statusInfo { + display: flex; + align-items: center; + gap: 1rem; + flex: 1; + + .providerInfo { + display: flex; + align-items: center; + gap: 0.75rem; + + .providerIcon { + font-size: 1.5rem; + } + + h4 { + font-size: 1.125rem; + font-weight: 600; + color: $gray-900; + margin: 0; + } + + p { + font-size: 0.875rem; + color: $gray-500; + margin: 0.25rem 0 0 0; + } + + .dimensionInfo { + font-size: 0.8rem; + color: $gray-600; + font-weight: 500; + margin: 0.375rem 0 0 0 !important; + + .autoDetected { + color: $primary-green; + font-weight: 600; + } + } + } + + .statusIndicator { + display: flex; + align-items: center; + gap: 0.5rem; + margin-left: auto; + + svg { + font-size: 1.125rem; + } + + span { + font-size: 0.875rem; + font-weight: 500; + color: $gray-700; + } + } } - &::placeholder { - color: $gray-400; + .statusActions { + display: flex; + gap: 0.75rem; } } + } +} + +// Providers Section +.providersSection { + h3 { + font-size: 1.25rem; + font-weight: 600; + color: $gray-900; + margin: 0 0 1rem 0; + } + + .providersGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 1rem; + } + + .providerCard { + background: $white; + border: 1px solid $gray-200; + border-radius: 0.75rem; + padding: 1.25rem; + cursor: pointer; + transition: all 0.2s ease-in-out; - select { - cursor: pointer; + &:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + border-color: $gray-300; } - input[type="password"] { - font-family: monospace; - letter-spacing: 2px; + &.active { + border-color: $primary-blue; + box-shadow: 0 0 0 2px rgba($primary-blue, 0.1); - &::placeholder { - font-family: inherit; - letter-spacing: normal; + .providerHeader { + .providerInfo h4 { + color: $primary-blue; + } } } - } -} -// Special styling for select dropdowns -.editSelect { - appearance: none; /* Remove default browser styling */ - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e"); - background-position: right 0.875rem center; - background-repeat: no-repeat; - background-size: 1.125rem; - padding-right: 2.75rem; - cursor: pointer; - transition: all 0.2s ease-in-out; - border: 2px solid $gray-300; - height: 2.5rem; // editInput과 동일한 높이 - box-sizing: border-box; // border를 포함한 박스 크기 계산 - font-size: 0.875rem; // currentValue와 동일한 폰트 크기 - - &:hover { - border-color: $gray-400; - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%234b5563' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e"); - } - - &:focus { - border-color: $primary-blue; - box-shadow: 0 0 0 3px rgba($primary-blue, 0.1); - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%232563eb' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e"); - } + .providerHeader { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.75rem; - &:disabled { - background-color: $gray-100; - border-color: $gray-200; - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%239ca3af' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e"); - cursor: not-allowed; - color: $gray-500; - } + .providerIcon { + font-size: 1.25rem; + flex-shrink: 0; + } - // Style for option elements - option { - padding: 0.75rem; - background-color: $white; - color: $gray-700; - border: none; - font-size: 0.875rem; - - &:hover { - background-color: $gray-50; + .providerInfo { + flex: 1; + + h4 { + font-size: 1rem; + font-weight: 600; + color: $gray-900; + margin: 0 0 0.25rem 0; + } + + p { + font-size: 0.75rem; + color: $gray-600; + margin: 0; + line-height: 1.3; + } + } + + .providerStatus { + display: flex; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; + + svg { + font-size: 1rem; + } + + .activeBadge { + background: $primary-blue; + color: $white; + font-size: 0.625rem; + font-weight: 600; + padding: 0.25rem 0.5rem; + border-radius: 0.375rem; + } + } } - - &:checked { - background-color: $primary-blue; - color: $white; + + .providerDetails { + display: flex; + justify-content: space-between; + align-items: center; + padding-top: 0.75rem; + border-top: 1px solid $gray-200; + + .statusText { + font-size: 0.75rem; + font-weight: 500; + color: $gray-600; + } + + .errorText { + font-size: 0.75rem; + color: $primary-red; + text-align: right; + flex: 1; + margin-left: 0.5rem; + } } } } -// Loading and Error States -.loadingState, .errorState { - padding: 2rem; - text-align: center; - color: $gray-600; - - p { +// Config Section +.configSection { + h3 { + font-size: 1.25rem; + font-weight: 600; + color: $gray-900; margin: 0 0 1rem 0; - font-size: 1rem; } } -.errorState { - background: color.adjust($primary-red, $lightness: 45%); - border: 1px solid color.adjust($primary-red, $lightness: 35%); - border-radius: 0.5rem; - color: color.adjust($primary-red, $lightness: -20%); -} +// Database Config Tab +.databaseConfig { + .sectionHeader { + margin-bottom: 1.5rem; -// Advanced Config Styles -.configPath { - font-family: 'Courier New', monospace; - font-size: 0.75rem; - color: $gray-400; - display: block; - margin-top: 0.25rem; -} + h3 { + font-size: 1.25rem; + font-weight: 600; + color: $gray-900; + margin: 0 0 0.5rem 0; + } -.required { - color: $primary-red; - margin-left: 0.25rem; + p { + font-size: 0.875rem; + color: $gray-600; + margin: 0; + } + } } -.unsaved { - color: $primary-yellow; - font-weight: 600; -} +// Responsive adjustments for VectorDB Config +@media (max-width: 768px) { + .container { + padding: 1rem; + } -.saving { - color: $primary-blue; - font-weight: 600; -} + .tabNavigation { + .tabButton { + padding: 0.75rem 1rem; + font-size: 0.8rem; -// Config item header -.configHeader { - margin-bottom: 0.5rem; + svg { + font-size: 0.875rem; + } + } + } - label { - display: block; - font-size: 0.875rem; - font-weight: 600; - color: $gray-700; + .tabContent { + padding: 1.5rem; } -} -.editButton { - background: none; - border: 1px solid $gray-300; - border-radius: 0.25rem; - width: 2rem; - height: 2rem; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - color: $gray-600; - transition: all 0.2s ease-in-out; + .statusSection { + .sectionHeader { + flex-direction: column; + align-items: flex-start; + gap: 1rem; - &:hover { - background-color: $gray-50; - border-color: $gray-400; - color: $gray-700; - } + .headerActions { + width: 100%; + justify-content: flex-start; + } + } - svg { - width: 0.875rem; - height: 0.875rem; + .currentStatus { + .statusCard { + flex-direction: column; + align-items: stretch; + gap: 1.5rem; + + .statusInfo { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + + .statusIndicator { + margin-left: 0; + } + } + + .statusActions { + justify-content: flex-start; + } + } + } } -} -.saveButton { - background-color: $primary-green; - border-color: $primary-green; - color: $white; + .providersSection { + .providersGrid { + grid-template-columns: 1fr; + } - &:hover { - background-color: color.adjust($primary-green, $lightness: -10%); - border-color: color.adjust($primary-green, $lightness: -10%); + .providerCard { + .providerHeader { + .providerInfo p { + font-size: 0.8rem; + } + } + } } +} - &:disabled { - background-color: $gray-300; - border-color: $gray-300; - color: $gray-500; - cursor: not-allowed; +// ============================================================================= +// BaseConfigPanel Styles +// ============================================================================= + +.configForm { + display: flex; + flex-direction: column; + gap: 1.5rem; + padding: 0; + background: transparent; + border: none; + border-radius: 0; +} + +.formGroup { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 1rem 0; + border-bottom: 1px solid $gray-200; + + &:last-child { + border-bottom: none; } } -.cancelButton { - background-color: $primary-red; - border-color: $primary-red; - color: $white; +.configHeader { + display: flex; + align-items: center; + gap: 0.5rem; - &:hover { - background-color: color.adjust($primary-red, $lightness: -10%); - border-color: color.adjust($primary-red, $lightness: -10%); + label { + font-size: 0.875rem; + font-weight: 600; + color: $gray-700; + margin: 0; } - &:disabled { - background-color: $gray-300; - border-color: $gray-300; - color: $gray-500; - cursor: not-allowed; + .required { + color: $primary-red; + font-weight: 700; } } .configValue { - margin-bottom: 0.75rem; + display: flex; + flex-direction: column; + gap: 0.5rem; } .editContainer { display: flex; align-items: center; - gap: 0.5rem; + gap: 0.75rem; + min-height: 2.5rem; +} + +.valueDisplay { + flex: 1; + padding: 0.5rem 0; + background: transparent; + border: none; + border-radius: 0; + min-height: 2.5rem; + display: flex; + align-items: center; +} + +.currentValue { + color: $gray-900; + font-size: 0.875rem; + font-weight: 500; + word-break: break-all; } .editInput { flex: 1; padding: 0.5rem 0.75rem; - border: 2px solid $gray-300; + border: 1px solid $gray-300; border-radius: 0.375rem; - font-size: 0.875rem; // currentValue와 동일한 폰트 크기 - transition: all 0.2s ease-in-out; - height: 2.5rem; // 고정된 높이로 valueDisplay와 맞춤 - box-sizing: border-box; // border를 포함한 박스 크기 계산 - - &:hover { - border-color: $gray-400; - } + font-size: 0.875rem; + outline: none; + transition: border-color 0.2s ease; &:focus { - outline: none; border-color: $primary-blue; - box-shadow: 0 0 0 3px rgba($primary-blue, 0.1); + box-shadow: 0 0 0 2px rgba($primary-blue, 0.1); } &:disabled { - background-color: $gray-100; - border-color: $gray-200; + background: $gray-100; color: $gray-500; cursor: not-allowed; + border-color: $gray-300; + } +} + +.editSelect { + @extend .editInput; + cursor: pointer; + + &:disabled { + cursor: not-allowed; } } .editButtons { display: flex; - gap: 0.25rem; + gap: 0.5rem; + flex-shrink: 0; } -.valueDisplay { - padding: 0.5rem 0.75rem; // editInput과 동일한 padding - background-color: $gray-50; - border: 2px solid $gray-200; // editInput과 동일한 border 두께 - border-radius: 0.375rem; // editInput과 동일한 border-radius - height: 2.5rem; // editInput과 동일한 높이 - box-sizing: border-box; // border를 포함한 박스 크기 계산 +.editButton { display: flex; align-items: center; - flex: 1; // editContainer 내에서 최대 공간 차지 -} - -.currentValue { + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 0.375rem; + border: 1px solid $gray-300; + background: $white; + color: $gray-600; + cursor: pointer; + transition: all 0.2s ease; font-size: 0.875rem; - color: $gray-700; - word-break: break-all; -} - -.fieldDescription { - display: block; - margin-top: 0.5rem; - line-height: 1.4; -} -// Responsive Design -@media (max-width: 768px) { - .categoryHeader { - padding: 1rem; - flex-direction: column; - align-items: flex-start; - gap: 0.75rem; + &:hover:not(:disabled) { + background: $gray-50; + color: $gray-800; + border-color: $gray-400; } - .categoryStatus { - align-self: flex-end; + &:disabled { + opacity: 0.5; + cursor: not-allowed; } - .configContent { - padding: 1rem; + &.saveButton { + background: $primary-green; + color: $white; + border-color: $primary-green; + + &:hover:not(:disabled) { + background: darken($primary-green, 10%); + border-color: darken($primary-green, 10%); + } } - .formActions { - flex-direction: column; + &.cancelButton { + background: $primary-red; + color: $white; + border-color: $primary-red; - .button { - width: 100%; + &:hover:not(:disabled) { + background: darken($primary-red, 10%); + border-color: darken($primary-red, 10%); } } +} - .detailHeader { - flex-direction: column; - gap: 1rem; - } +.fieldDescription { + font-size: 0.75rem; + color: $gray-500; + line-height: 1.4; + margin-top: 0.5rem; - .detailTitle { - flex-direction: column; - gap: 0.75rem; + .configPath { + color: $gray-400; + font-style: italic; + display: block; + margin-top: 0.25rem; } - .detailIcon { - width: 3rem; - height: 3rem; - font-size: 1.5rem; + .unsaved { + color: $primary-yellow; + font-weight: 600; } - .configWrapper { - padding: 1.5rem; + .saving { + color: $primary-blue; + font-weight: 600; } +} - .backButton { - align-self: flex-start; - } +.formActions { + display: flex; + justify-content: flex-end; + gap: 1rem; + padding-top: 1.5rem; + margin-top: 1rem; + border-top: 1px solid $gray-200; +} - .headerTop { - flex-direction: column; - align-items: flex-start; - gap: 0.75rem; - } +.button.test { + background: $primary-blue; + color: $white; + border: 1px solid $primary-blue; + padding: 0.75rem 1.5rem; + border-radius: 0.5rem; + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; - .modeToggle { - width: 100%; - flex-direction: column; - align-items: flex-start; - gap: 0.5rem; + &:hover:not(:disabled) { + background: darken($primary-blue, 10%); + border-color: darken($primary-blue, 10%); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba($primary-blue, 0.3); + } - .modeDescription { - max-width: 100%; - } + &:disabled { + background: $gray-300; + border-color: $gray-300; + color: $gray-500; + cursor: not-allowed; + transform: none; + box-shadow: none; } +} - .configHeader { - flex-direction: column; - align-items: flex-start; - gap: 0.5rem; +// Responsive adjustments for config forms +@media (max-width: 768px) { + .configForm { + padding: 0.75rem; + gap: 1rem; } .editContainer { flex-direction: column; align-items: stretch; + gap: 0.5rem; + + .editButtons { + justify-content: center; + } } - .editButtons { - justify-content: flex-end; + .formActions { + justify-content: center; } -} \ No newline at end of file +} \ No newline at end of file diff --git a/src/app/main/components/Documents.tsx b/src/app/main/components/Documents.tsx new file mode 100644 index 00000000..4403f3da --- /dev/null +++ b/src/app/main/components/Documents.tsx @@ -0,0 +1,753 @@ +'use client'; +import React, { useState, useEffect } from 'react'; +import styles from '../assets/Documents.module.scss'; +import { + listCollections, + createCollection, + uploadDocument, + searchDocuments, + deleteCollection, + listDocumentsInCollection, + getDocumentDetails, + deleteDocumentFromCollection, + isSupportedFileType, + isValidFileSize, + isValidCollectionName, + formatFileSize, + getRelativeTime +} from '@/app/api/ragAPI.js'; + +interface Collection { + name: string; + vector_size?: number; + points_count?: number; + description?: string; +} + +interface DocumentInCollection { + document_id: string; + file_name: string; + file_type: string; + processed_at: string; + total_chunks: number; + actual_chunks: number; + metadata: any; + chunks: ChunkInfo[]; +} + +interface ChunkInfo { + chunk_id: string; + chunk_index: number; + chunk_size: number; + chunk_text_preview: string; +} + +interface DocumentDetails { + document_id: string; + file_name: string; + file_type: string; + processed_at: string; + total_chunks: number; + metadata: any; + chunks: DetailedChunk[]; +} + +interface DetailedChunk { + chunk_id: string; + chunk_index: number; + chunk_size: number; + chunk_text: string; +} + +interface SearchResult { + id: string; + score: number; + document_id: string; + chunk_index: number; + chunk_text: string; + file_name: string; + file_type: string; + metadata: any; +} + +interface UploadProgress { + fileName: string; + status: 'uploading' | 'success' | 'error'; + progress: number; + error?: string; +} + +interface CollectionsResponse { + collections: string[]; +} + +interface DocumentsInCollectionResponse { + collection_name: string; + total_documents: number; + total_chunks: number; + documents: DocumentInCollection[]; +} + +interface SearchResponse { + query: string; + results: SearchResult[]; + total: number; + search_params: any; +} + +type ViewMode = 'collections' | 'documents' | 'document-detail'; + +const Documents: React.FC = () => { + const [viewMode, setViewMode] = useState('collections'); + const [collections, setCollections] = useState([]); + const [selectedCollection, setSelectedCollection] = useState(null); + const [documentsInCollection, setDocumentsInCollection] = useState([]); + const [selectedDocument, setSelectedDocument] = useState(null); + const [documentDetails, setDocumentDetails] = useState(null); + const [searchResults, setSearchResults] = useState([]); + const [searchQuery, setSearchQuery] = useState(''); + const [isSearching, setIsSearching] = useState(false); + const [uploadProgress, setUploadProgress] = useState([]); + + // 모달 상태 + const [showCreateModal, setShowCreateModal] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [showDeleteDocModal, setShowDeleteDocModal] = useState(false); + const [collectionToDelete, setCollectionToDelete] = useState(null); + const [documentToDelete, setDocumentToDelete] = useState(null); + + // 폼 상태 + const [newCollectionName, setNewCollectionName] = useState(''); + const [newCollectionDescription, setNewCollectionDescription] = useState(''); + + // 로딩 및 에러 상태 + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // 컬렉션 목록 로드 + useEffect(() => { + loadCollections(); + }, []); + + const loadCollections = async () => { + try { + setLoading(true); + setError(null); + const response = await listCollections() as CollectionsResponse; + const collectionObjects = (response.collections || []).map(name => ({ name })); + setCollections(collectionObjects); + } catch (err) { + setError('컬렉션 목록을 불러오는데 실패했습니다.'); + console.error('Failed to load collections:', err); + } finally { + setLoading(false); + } + }; + + // 컬렉션 내 문서 목록 로드 + const loadDocumentsInCollection = async (collectionName: string) => { + try { + setLoading(true); + setError(null); + const response = await listDocumentsInCollection(collectionName) as DocumentsInCollectionResponse; + setDocumentsInCollection(response.documents || []); + } catch (err) { + setError('문서 목록을 불러오는데 실패했습니다.'); + console.error('Failed to load documents in collection:', err); + setDocumentsInCollection([]); + } finally { + setLoading(false); + } + }; + + // 문서 상세 정보 로드 + const loadDocumentDetails = async (collectionName: string, documentId: string) => { + try { + setLoading(true); + setError(null); + const response = await getDocumentDetails(collectionName, documentId) as DocumentDetails; + setDocumentDetails(response); + } catch (err) { + setError('문서 상세 정보를 불러오는데 실패했습니다.'); + console.error('Failed to load document details:', err); + } finally { + setLoading(false); + } + }; + + // 문서 내 검색 + const handleDocumentSearch = async () => { + if (!selectedCollection || !searchQuery.trim()) return; + + try { + setIsSearching(true); + setError(null); + const response = await searchDocuments( + selectedCollection.name, + searchQuery, + 10, // limit + 0.0, + selectedDocument ? { document_id: selectedDocument.document_id } : undefined + ) as SearchResponse; + setSearchResults(response.results || []); + } catch (err) { + setError('검색에 실패했습니다.'); + console.error('Failed to search documents:', err); + setSearchResults([]); + } finally { + setIsSearching(false); + } + }; + + // 검색 쿼리 변경 시 자동 검색 (디바운싱) + useEffect(() => { + if (viewMode === 'document-detail' && searchQuery) { + const timer = setTimeout(() => { + handleDocumentSearch(); + }, 500); + return () => clearTimeout(timer); + } else { + setSearchResults([]); + } + }, [searchQuery, viewMode, selectedCollection, selectedDocument]); + + // 컬렉션 생성 + const handleCreateCollection = async () => { + if (!isValidCollectionName(newCollectionName)) { + setError('컬렉션 이름은 3-63자의 영문자, 숫자, 언더스코어, 하이픈만 허용됩니다.'); + return; + } + + try { + setLoading(true); + setError(null); + await createCollection( + newCollectionName, + 1536, + "Cosine", + newCollectionDescription || undefined + ); + setShowCreateModal(false); + setNewCollectionName(''); + setNewCollectionDescription(''); + await loadCollections(); + } catch (err) { + setError('컬렉션 생성에 실패했습니다.'); + console.error('Failed to create collection:', err); + } finally { + setLoading(false); + } + }; + + // 컬렉션 삭제 + const handleDeleteCollectionRequest = (collection: Collection) => { + setCollectionToDelete(collection); + setShowDeleteModal(true); + }; + + const handleConfirmDeleteCollection = async () => { + if (!collectionToDelete) return; + + try { + setLoading(true); + setError(null); + await deleteCollection(collectionToDelete.name); + setShowDeleteModal(false); + setCollectionToDelete(null); + + if (selectedCollection?.name === collectionToDelete.name) { + setSelectedCollection(null); + setDocumentsInCollection([]); + setViewMode('collections'); + } + + await loadCollections(); + } catch (err) { + setError('컬렉션 삭제에 실패했습니다.'); + console.error('Failed to delete collection:', err); + } finally { + setLoading(false); + } + }; + + // 문서 삭제 + const handleDeleteDocumentRequest = (document: DocumentInCollection) => { + setDocumentToDelete(document); + setShowDeleteDocModal(true); + }; + + const handleConfirmDeleteDocument = async () => { + if (!documentToDelete || !selectedCollection) return; + + try { + setLoading(true); + setError(null); + await deleteDocumentFromCollection(selectedCollection.name, documentToDelete.document_id); + setShowDeleteDocModal(false); + setDocumentToDelete(null); + + if (selectedDocument?.document_id === documentToDelete.document_id) { + setSelectedDocument(null); + setDocumentDetails(null); + setViewMode('documents'); + } + + await loadDocumentsInCollection(selectedCollection.name); + } catch (err) { + setError('문서 삭제에 실패했습니다.'); + console.error('Failed to delete document:', err); + } finally { + setLoading(false); + } + }; + + // 컬렉션 선택 + const handleSelectCollection = async (collection: Collection) => { + setSelectedCollection(collection); + setSelectedDocument(null); + setDocumentDetails(null); + setSearchQuery(''); + setSearchResults([]); + setViewMode('documents'); + await loadDocumentsInCollection(collection.name); + }; + + // 문서 선택 + const handleSelectDocument = async (document: DocumentInCollection) => { + if (!selectedCollection) return; + + setSelectedDocument(document); + setSearchQuery(''); + setSearchResults([]); + setViewMode('document-detail'); + await loadDocumentDetails(selectedCollection.name, document.document_id); + }; + + // 뒤로 가기 + const handleGoBack = () => { + if (viewMode === 'document-detail') { + setViewMode('documents'); + setSelectedDocument(null); + setDocumentDetails(null); + setSearchQuery(''); + setSearchResults([]); + } else if (viewMode === 'documents') { + setViewMode('collections'); + setSelectedCollection(null); + setDocumentsInCollection([]); + } + }; + + // 파일 업로드 처리 + const handleFileUpload = async (files: FileList, isFolder: boolean = false) => { + if (!selectedCollection) { + setError('컬렉션을 먼저 선택해주세요.'); + return; + } + + const fileArray = Array.from(files); + const validFiles = fileArray.filter(file => { + if (!isSupportedFileType(file)) { + setError(`지원되지 않는 파일 형식입니다: ${file.name}`); + return false; + } + if (!isValidFileSize(file, 50)) { + setError(`파일 크기가 50MB를 초과합니다: ${file.name}`); + return false; + } + return true; + }); + + if (validFiles.length === 0) return; + + const initialProgress: UploadProgress[] = validFiles.map(file => ({ + fileName: file.name, + status: 'uploading', + progress: 0 + })); + setUploadProgress(initialProgress); + + for (let i = 0; i < validFiles.length; i++) { + const file = validFiles[i]; + try { + setUploadProgress(prev => prev.map((item, index) => + index === i ? { ...item, progress: 50 } : item + )); + + await uploadDocument( + file, + selectedCollection.name, + 1000, + 200, + true, + { upload_type: isFolder ? 'folder' : 'single' } + ); + + setUploadProgress(prev => prev.map((item, index) => + index === i ? { ...item, status: 'success', progress: 100 } : item + )); + } catch (err) { + setUploadProgress(prev => prev.map((item, index) => + index === i ? { + ...item, + status: 'error', + progress: 0, + error: '업로드 실패' + } : item + )); + console.error(`Failed to upload file ${file.name}:`, err); + } + } + + setTimeout(() => { + if (selectedCollection) { + loadDocumentsInCollection(selectedCollection.name); + } + setUploadProgress([]); + }, 2000); + }; + + const handleSingleFileUpload = () => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.pdf,.docx,.doc,.txt'; + input.onchange = (e) => { + const files = (e.target as HTMLInputElement).files; + if (files) handleFileUpload(files, false); + }; + input.click(); + }; + + const handleFolderUpload = () => { + const input = document.createElement('input'); + input.type = 'file'; + input.webkitdirectory = true; + input.multiple = true; + input.onchange = (e) => { + const files = (e.target as HTMLInputElement).files; + if (files) handleFileUpload(files, true); + }; + input.click(); + }; + + return ( +
+ {/* 헤더 */} +
+
+ {viewMode !== 'collections' && ( + + )} +

+ {viewMode === 'collections' && '컬렉션 관리'} + {viewMode === 'documents' && `${selectedCollection?.name} - 문서 목록`} + {viewMode === 'document-detail' && `${selectedDocument?.file_name} - 문서 상세`} +

+
+
+ {viewMode === 'collections' && ( + + )} + {viewMode === 'documents' && ( + <> + + + + )} +
+
+ + {error &&
{error}
} + + {/* 컬렉션 목록 보기 */} + {viewMode === 'collections' && ( +
+ {loading ? ( +
로딩 중...
+ ) : ( +
+ {collections.map((collection) => ( +
+
handleSelectCollection(collection)} + > +

{collection.name}

+
+ +
+ ))} +
+ )} +
+ )} + + {/* 문서 목록 보기 */} + {viewMode === 'documents' && ( +
+ {uploadProgress.length > 0 && ( +
+

업로드 진행 상태

+ {uploadProgress.map((item, index) => ( +
+ {item.fileName} +
+ {item.status === 'uploading' && ( +
+
+
+ )} + + {item.status === 'uploading' && '업로드 중...'} + {item.status === 'success' && '완료'} + {item.status === 'error' && (item.error || '실패')} + +
+
+ ))} +
+ )} + +
+ {loading ? ( +
로딩 중...
+ ) : documentsInCollection.length === 0 ? ( +
이 컬렉션에는 문서가 없습니다.
+ ) : ( +
+ {documentsInCollection.map((doc) => ( +
+
handleSelectDocument(doc)} + > +

{doc.file_name}

+

+ 타입: {doc.file_type.toUpperCase()} | + 청크: {doc.actual_chunks}개 | + 업로드: {getRelativeTime(doc.processed_at)} +

+
+ +
+ ))} +
+ )} +
+
+ )} + + {/* 문서 상세 보기 */} + {viewMode === 'document-detail' && ( +
+ {/* 검색 영역 */} +
+
+ setSearchQuery(e.target.value)} + className={styles.searchInput} + /> + +
+
+ + {/* 검색 결과 */} + {searchQuery && ( +
+

검색 결과 ({searchResults.length}개)

+ {searchResults.length === 0 ? ( +
검색 결과가 없습니다.
+ ) : ( +
+ {searchResults.map((result) => ( +
+
+ + 유사도: {(result.score * 100).toFixed(1)}% + + + 청크 #{result.chunk_index + 1} + +
+

+ {result.chunk_text} +

+
+ ))} +
+ )} +
+ )} + + {/* 문서 상세 정보 */} + {!searchQuery && documentDetails && ( +
+
+

{documentDetails.file_name}

+
+ 파일 타입: {documentDetails.file_type.toUpperCase()} + 전체 청크: {documentDetails.total_chunks}개 + 업로드 시간: {getRelativeTime(documentDetails.processed_at)} +
+
+ +
+

문서 내용

+
+ {documentDetails.chunks.map((chunk) => ( +
+
+ 청크 #{chunk.chunk_index + 1} + + {formatFileSize(chunk.chunk_size)} + +
+
+ {chunk.chunk_text} +
+
+ ))} +
+
+
+ )} + + {loading &&
로딩 중...
} +
+ )} + + {/* 컬렉션 생성 모달 */} + {showCreateModal && ( +
setShowCreateModal(false)}> +
e.stopPropagation()}> +

새 컬렉션 생성

+
+ + setNewCollectionName(e.target.value)} + placeholder="예: project_documents" + /> +
+
+ +