diff --git a/src/app/HomePage.module.scss b/src/app/HomePage.module.scss index ac1e3ce3..c79e02a6 100644 --- a/src/app/HomePage.module.scss +++ b/src/app/HomePage.module.scss @@ -213,7 +213,7 @@ $white: #ffffff; @media (min-width: 1440px) { width: 80vw; - padding: 6rem 5rem; + padding: 5rem 4rem; } } @@ -248,11 +248,11 @@ $white: #ffffff; margin-bottom: 1.5rem; @media (min-width: 640px) { - font-size: 3.2rem; + font-size: 2.8rem; } @media (min-width: 1024px) { - font-size: 3.8rem; + font-size: 3rem; } .highlight { diff --git a/src/app/api/chatAPI.js b/src/app/api/chatAPI.js index 9abd5e03..7336730c 100644 --- a/src/app/api/chatAPI.js +++ b/src/app/api/chatAPI.js @@ -1,5 +1,4 @@ // Configuration API 호출 함수들을 관리하는 파일 -import { devLog } from '@/app/utils/logger'; import { API_BASE_URL } from '@/app/config.js'; /** @@ -43,26 +42,35 @@ export const createNewChat = async ({ interaction_id, input_data = null }) => { * @param {string} params.interaction_id - The interaction identifier * @param {string} [params.workflow_id] - Optional workflow ID (defaults to "default_mode") * @param {string} [params.workflow_name] - Optional workflow name (defaults to "default_mode") + * @param {string|null} [params.selectedCollection] - Optional selected collection for default_mode * @returns {Promise} A promise that resolves with the chat response */ -export const executeChatMessage = async ({ - user_input, - interaction_id, - workflow_id = "default_mode", - workflow_name = "default_mode" +export const executeChatMessage = async ({ + user_input, + interaction_id, + workflow_id = "default_mode", + workflow_name = "default_mode", + selectedCollection = null, }) => { try { + const requestBody = { + user_input, + interaction_id, + workflow_id, + workflow_name, + }; + + // selectedCollection이 있으면 body에 추가 + if (selectedCollection) { + requestBody.selected_collection = selectedCollection; + } + const response = await fetch(`${API_BASE_URL}/api/chat/execution`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ - user_input, - interaction_id, - workflow_id, - workflow_name - }), + body: JSON.stringify(requestBody), }); if (!response.ok) { @@ -85,10 +93,10 @@ export const executeChatMessage = async ({ * @param {boolean} [params.isNewChat] - Whether this is a new chat session * @returns {Promise} A promise that resolves with the chat response */ -export const sendMessage = async ({ - message, - interaction_id = null, - isNewChat = false +export const sendMessage = async ({ + message, + interaction_id = null, + isNewChat = false }) => { try { // Generate interaction ID if not provided diff --git a/src/app/api/ragAPI.js b/src/app/api/ragAPI.js deleted file mode 100644 index cdfa8f38..00000000 --- a/src/app/api/ragAPI.js +++ /dev/null @@ -1,259 +0,0 @@ -// ============================================================================= -// 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': { - 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, - "Qwen/Qwen3-Embedding-0.6B": 1024, - }; - return commonModels[model] || 768; // 일반적인 기본값 - } - - case 'custom_http': - case 'vllm': - // VLLM은 모델에 따라 다르므로 일반적인 기본값 반환 - return 1536; - - default: - return 1536; - } -}; - -/** - * 현재 설정된 임베딩 제공자와 모델에 따른 벡터 차원을 조회하는 함수 - * @returns {Promise} 벡터 차원 정보 - */ -export const getCurrentEmbeddingDimension = async (provider, model) => { - try { - try { - const dimension = getEmbeddingDimension(provider, model); - return { - provider, - model, - dimension, - auto_detected: true - }; - } catch (error) { - 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 - }; - } -}; - -// /** -// * 현재 설정된 임베딩 제공자와 모델에 따른 벡터 차원을 조회하는 함수 -// * @returns {Promise} 벡터 차원 정보 -// */ -// export const getCurrentEmbeddingDimension = async () => { -// try { -// const status = await getEmbeddingStatus(); - -// 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/api/retrievalAPI.js b/src/app/api/retrievalAPI.js index b9b66f2a..fc92e491 100644 --- a/src/app/api/retrievalAPI.js +++ b/src/app/api/retrievalAPI.js @@ -55,13 +55,12 @@ export const listCollections = async () => { /** * 새 컬렉션을 생성하는 함수 * @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) => { +export const createCollection = async (collectionName, distance = "Cosine", description = null, metadata = null) => { try { const response = await fetch(`${API_BASE_URL}/api/retrieval/collections`, { method: 'POST', @@ -70,7 +69,6 @@ export const createCollection = async (collectionName, vectorSize, distance = "C }, body: JSON.stringify({ collection_name: collectionName, - vector_size: vectorSize, distance: distance, description: description, metadata: metadata @@ -164,7 +162,7 @@ export const uploadDocument = async (file, collectionName, chunkSize = 1000, chu 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)); } @@ -426,4 +424,229 @@ export const getRagConfig = async () => { devLog.error('Failed to fetch RAG config:', error); throw error; } -}; \ No newline at end of file +}; + +// ============================================================================= +// 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); +}; + +/** + * 임베딩 제공자 이름을 한국어로 변환하는 함수 + * @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': { + 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, + "Qwen/Qwen3-Embedding-0.6B": 1024, + }; + return commonModels[model] || 768; // 일반적인 기본값 + } + + case 'custom_http': + case 'vllm': + // VLLM은 모델에 따라 다르므로 일반적인 기본값 반환 + return 1536; + + default: + return 1536; + } +}; + +/** + * 현재 설정된 임베딩 제공자와 모델에 따른 벡터 차원을 조회하는 함수 + * @returns {Promise} 벡터 차원 정보 + */ +export const getCurrentEmbeddingDimension = async (provider, model) => { + try { + const dimension = getEmbeddingDimension(provider, model); + return { + provider, + model, + dimension, + auto_detected: true + }; + } 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 + }; + } +}; + +// /** +// * 현재 설정된 임베딩 제공자와 모델에 따른 벡터 차원을 조회하는 함수 +// * @returns {Promise} 벡터 차원 정보 +// */ +// export const getCurrentEmbeddingDimension = async () => { +// try { +// const status = await getEmbeddingStatus(); + +// 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/chat/assets/ChatInterface.module.scss b/src/app/chat/assets/ChatInterface.module.scss index 921ad0d9..fa3f971b 100644 --- a/src/app/chat/assets/ChatInterface.module.scss +++ b/src/app/chat/assets/ChatInterface.module.scss @@ -295,6 +295,127 @@ $white: #ffffff; align-items: flex-end; } +.buttonGroup { + display: flex; + gap: 0.5rem; + align-items: flex-end; +} + +.attachmentWrapper { + position: relative; +} + +.attachmentButton { + background: $gray-100; + color: $gray-600; + border: 2px solid $gray-200; + border-radius: 1rem; + padding: 0.75rem; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + + &:hover:not(:disabled) { + background: $gray-200; + color: $gray-700; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba($gray-400, 0.2); + } + + &:active:not(:disabled) { + transform: translateY(0); + } + + &.active { + background: $primary-blue; + color: $white; + border-color: $primary-blue; + } + + &:disabled { + background: $gray-100; + color: $gray-400; + cursor: not-allowed; + } + + svg { + font-size: 1.1rem; + transition: transform 0.2s ease; + } + + &.active svg { + transform: rotate(45deg); + } +} + +.attachmentMenu { + position: absolute; + bottom: 100%; + left: -5rem; + margin-bottom: 0.5rem; + background: $white; + border: 1px solid $gray-200; + border-radius: 0.75rem; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); + padding: 0.5rem; + min-width: 140px; + z-index: 1000; + animation: slideUpFade 0.2s ease; +} + +@keyframes slideUpFade { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.attachmentOption { + display: flex; + align-items: center; + gap: 0.75rem; + width: 100%; + padding: 0.75rem 1rem; + border: none; + background: transparent; + border-radius: 0.5rem; + cursor: pointer; + transition: all 0.2s ease; + font-size: 0.9rem; + color: $gray-700; + + &:hover:not(:disabled) { + background: $gray-50; + color: $gray-900; + } + + &.disabled { + color: $gray-400; + cursor: not-allowed; + + svg { + color: $gray-300; + } + } + + svg { + font-size: 1rem; + color: $gray-500; + } + + span { + font-weight: 500; + } +} + .messageInput { flex: 1; padding: 0.75rem 1rem; @@ -582,6 +703,72 @@ $white: #ffffff; } } +// Selected Collection Display +.selectedCollection { + display: flex; + align-items: center; + gap: 0.5rem; + background: rgba($primary-blue, 0.1); + border: 1px solid rgba($primary-blue, 0.2); + border-radius: 1rem; + padding: 0.75rem 1rem; + height: 48px; + font-size: 0.875rem; + color: $primary-blue; + transition: all 0.2s ease; + box-sizing: border-box; + + &:hover { + background: rgba($primary-blue, 0.15); + border-color: rgba($primary-blue, 0.3); + } + + .collectionIcon { + font-size: 1rem; + color: $primary-blue; + flex-shrink: 0; + } + + .collectionName { + font-weight: 500; + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + } + + .removeCollectionButton { + background: rgba($primary-blue, 0.1); + border: none; + color: $primary-blue; + cursor: pointer; + width: 20px; + height: 20px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + flex-shrink: 0; + margin-left: 0.25rem; + + svg { + font-size: 12px; + } + + &:hover { + background: rgba($primary-blue, 0.8); + color: $white; + transform: scale(1.1); + } + + &:active { + transform: scale(0.95); + } + } +} + // Responsive Design @media (max-width: 768px) { .header { diff --git a/src/app/chat/assets/CollectionModal.module.scss b/src/app/chat/assets/CollectionModal.module.scss new file mode 100644 index 00000000..c4bff360 --- /dev/null +++ b/src/app/chat/assets/CollectionModal.module.scss @@ -0,0 +1,535 @@ +// Color Variables +$primary-blue: #2563eb; +$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; +$black: #000000; +$red-500: #ef4444; + +.modalBackdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + animation: fadeIn 0.2s ease; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.modalContainer { + background: $white; + border: 2px solid $black; + border-radius: 1rem; + width: 90%; + max-width: 650px; + max-height: 780px; + display: flex; + flex-direction: column; + overflow: hidden; + animation: slideIn 0.3s ease; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); +} + +@keyframes slideIn { + from { + opacity: 0; + transform: scale(0.9) translateY(-20px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +.modalHeader { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.5rem; + border-bottom: 1px solid $gray-200; + flex-shrink: 0; +} + +.headerContent { + display: flex; + align-items: center; + gap: 1rem; + flex: 1; + + h3 { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + color: $gray-900; + } +} + +.backButton { + background: transparent; + border: none; + color: $gray-500; + cursor: pointer; + padding: 0.5rem; + border-radius: 0.5rem; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + background: $gray-100; + color: $gray-700; + } + + svg { + font-size: 1.25rem; + } +} + +.refreshButton { + background: transparent; + border: none; + color: $gray-500; + cursor: pointer; + padding: 0.5rem; + border-radius: 0.5rem; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + + &:hover:not(:disabled) { + background: $gray-100; + color: $gray-700; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.6; + } + + svg { + font-size: 1rem; + } + + .spinning { + animation: spin 1s linear infinite; + } +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.closeButton { + background: transparent; + border: none; + color: $gray-500; + cursor: pointer; + padding: 0.5rem; + border-radius: 0.5rem; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + background: $gray-100; + color: $gray-700; + } + + svg { + font-size: 1.25rem; + } +} + +.modalContent { + flex: 1; + padding: 1.5rem; + overflow-y: auto; + + p { + margin: 0; + color: $gray-600; + text-align: center; + padding: 2rem 0; + } + + /* 스크롤바 스타일링 */ + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: $gray-100; + border-radius: 3px; + } + + &::-webkit-scrollbar-thumb { + background: $gray-300; + border-radius: 3px; + + &:hover { + background: $gray-400; + } + } +} + +// 반응형 디자인 +@media (max-width: 768px) { + .modalContainer { + width: 95%; + max-height: 80vh; + margin: 1rem; + } + + .modalHeader { + padding: 1rem; + } + + .modalContent { + padding: 1rem; + } +} + +// Collections List +.collectionsList { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.collectionItem { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem; + border: 1px solid $gray-200; + border-radius: 0.75rem; + transition: all 0.2s ease; + + &:hover { + border-color: $gray-300; + background: $gray-50; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + } +} + +.collectionIcon { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 0.5rem; + background: $primary-blue; + color: $white; + + svg { + font-size: 1.25rem; + } +} + +.collectionInfo { + flex: 1; + + h4 { + margin: 0; + font-size: 1rem; + font-weight: 600; + color: $gray-900; + } +} + +.collectionActions { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.detailsButton { + background: transparent; + border: 1px solid $gray-300; + color: $gray-700; + padding: 0.5rem 1rem; + border-radius: 0.5rem; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background: $gray-100; + border-color: $gray-400; + color: $gray-900; + } + + &:active { + transform: translateY(1px); + } +} + +.selectButton { + background: $primary-blue; + border: 1px solid $primary-blue; + color: $white; + padding: 0.5rem 1rem; + border-radius: 0.5rem; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background: #1d4ed8; + border-color: #1d4ed8; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba($primary-blue, 0.3); + } + + &:active { + transform: translateY(0); + } +} + +// Collection Details +.collectionDetails { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.collectionStats { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} + +.statItem { + display: flex; + flex-direction: column; + align-items: center; + padding: 1rem; + background: $gray-50; + border-radius: 0.75rem; + border: 1px solid $gray-200; + + .statLabel { + font-size: 0.875rem; + color: $gray-600; + margin-bottom: 0.5rem; + } + + .statValue { + font-size: 1.5rem; + font-weight: 700; + color: $primary-blue; + } +} + +.documentsSection { + h4 { + margin: 0 0 1rem 0; + font-size: 1.1rem; + font-weight: 600; + color: $gray-900; + } +} + +.documentsList { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.documentItem { + display: grid; + grid-template-columns: 48px 1fr; + gap: 1rem; + padding: 1.25rem; + border: 1px solid $gray-200; + border-radius: 0.75rem; + background: $white; + transition: all 0.2s ease; + + &:hover { + border-color: $gray-300; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); + transform: translateY(-1px); + } +} + +.documentIcon { + display: flex; + align-items: flex-start; + justify-content: center; + width: 48px; + height: 48px; + border-radius: 0.75rem; + background: linear-gradient(135deg, $gray-100, $gray-200); + color: $gray-600; + padding-top: 0.75rem; + + svg { + font-size: 1.5rem; + } +} + +.documentInfo { + display: grid; + grid-template-rows: auto auto; + gap: 0.5rem; + min-width: 0; + + h5 { + margin: 0; + font-size: 1rem; + font-weight: 600; + color: $gray-900; + line-height: 1.4; + word-break: break-word; + } +} + +.documentMeta { + display: flex; + flex-wrap: wrap; + gap: 1rem; + font-size: 0.875rem; + color: $gray-600; + line-height: 1.5; +} + +.metaItem { + display: flex; + align-items: center; + gap: 0.25rem; + white-space: nowrap; + + &.fileType { + color: $primary-blue; + font-weight: 600; + background: rgba($primary-blue, 0.1); + padding: 0.125rem 0.5rem; + border-radius: 0.25rem; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.025em; + } + + &.chunks { + color: $gray-700; + font-weight: 500; + } + + &.date { + color: $gray-500; + font-size: 0.8rem; + } +} + +.metaSeparator { + color: $gray-400; + font-weight: 300; +} + +.emptyDocuments { + text-align: center; + padding: 2rem; + color: $gray-500; + + p { + margin: 0; + } +} + +// Loading and Error States +.loadingState { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3rem; + color: $gray-500; + gap: 1rem; + + p { + font-size: 1rem; + margin: 0; + } +} + +.loadingSpinner { + width: 32px; + height: 32px; + border: 3px solid $gray-200; + border-top: 3px solid $primary-blue; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +.errorMessage { + background: #fef2f2; + border: 1px solid #fecaca; + color: $red-500; + padding: 1rem; + border-radius: 0.5rem; + margin-bottom: 1rem; + font-size: 0.875rem; + font-weight: 500; +} + +.emptyState { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3rem; + text-align: center; + color: $gray-500; + + .emptyIcon { + width: 3rem; + height: 3rem; + margin-bottom: 1rem; + opacity: 0.5; + } + + h4 { + font-size: 1.125rem; + font-weight: 600; + margin: 0 0 0.5rem 0; + color: $gray-700; + } + + p { + margin: 0; + line-height: 1.6; + } +} diff --git a/src/app/chat/components/ChatContent.tsx b/src/app/chat/components/ChatContent.tsx index 00629e1c..336ab979 100644 --- a/src/app/chat/components/ChatContent.tsx +++ b/src/app/chat/components/ChatContent.tsx @@ -8,7 +8,11 @@ import ChatInterface from './ChatInterface'; import NewChatInterface from './NewChatInterface'; import DefaultChatInterface from './DefaultChatInterface'; -const ChatContentInner: React.FC = () => { +interface ChatContentProps { + onChatStarted?: () => void; // 채팅 시작 후 호출될 콜백 +} + +const ChatContentInner: React.FC = ({ onChatStarted }) => { const searchParams = useSearchParams(); const [currentView, setCurrentView] = useState<'welcome' | 'workflow' | 'newChat' | 'existingChat' | 'defaultChat'>('welcome'); const [selectedWorkflow, setSelectedWorkflow] = useState(null); @@ -60,6 +64,7 @@ const ChatContentInner: React.FC = () => {
setCurrentView('welcome')} + onChatStarted={onChatStarted} />
@@ -74,6 +79,7 @@ const ChatContentInner: React.FC = () => { setCurrentView('workflow')} + onChatStarted={onChatStarted} /> @@ -140,7 +146,7 @@ const ChatContentInner: React.FC = () => { ); }; -const ChatContent: React.FC = () => { +const ChatContent: React.FC = ({ onChatStarted }) => { return ( @@ -148,7 +154,7 @@ const ChatContent: React.FC = () => {

Loading chat...

}> - +
); }; diff --git a/src/app/chat/components/ChatHistory.tsx b/src/app/chat/components/ChatHistory.tsx index 45a8ba52..19d74a31 100644 --- a/src/app/chat/components/ChatHistory.tsx +++ b/src/app/chat/components/ChatHistory.tsx @@ -169,7 +169,7 @@ const ChatHistory: React.FC = ({ onSelectChat }) => {
- {chat.workflow_name} + {chat.workflow_name === 'default_mode' ? '일반 채팅' : chat.workflow_name }
diff --git a/src/app/chat/components/ChatInterface.tsx b/src/app/chat/components/ChatInterface.tsx index 8e2bb51f..1060ce58 100644 --- a/src/app/chat/components/ChatInterface.tsx +++ b/src/app/chat/components/ChatInterface.tsx @@ -5,11 +5,18 @@ import { FiArrowLeft, FiMessageSquare, FiClock, + FiPlus, + FiFolder, + FiImage, + FiMic, + FiBookmark, + FiX, } from 'react-icons/fi'; import styles from '@/app/chat/assets/ChatInterface.module.scss'; import { getWorkflowIOLogs, executeWorkflowById } from '@/app/api/workflowAPI'; import { executeChatMessage } from '@/app/api/chatAPI'; import toast from 'react-hot-toast'; +import CollectionModal from '@/app/chat/components/CollectionModal'; interface Workflow { id: string; @@ -51,8 +58,12 @@ const ChatInterface: React.FC = ({ workflow, onBack, hideBac const [error, setError] = useState(null); const [inputMessage, setInputMessage] = useState(''); const [pendingLogId, setPendingLogId] = useState(null); + const [showAttachmentMenu, setShowAttachmentMenu] = useState(false); + const [showCollectionModal, setShowCollectionModal] = useState(false); + const [selectedCollection, setSelectedCollection] = useState(null); const messagesRef = useRef(null); + const attachmentButtonRef = useRef(null); useEffect(() => { if (workflow?.id && existingChatData?.interactionId) { @@ -64,6 +75,58 @@ const ChatInterface: React.FC = ({ workflow, onBack, hideBac scrollToBottom(); }, [ioLogs]); + // localStorage에서 선택된 컬렉션 정보 가져오기 + useEffect(() => { + const checkSelectedCollection = () => { + try { + const storedCollection = localStorage.getItem('selectedCollection'); + if (storedCollection) { + const collectionData = JSON.parse(storedCollection); + setSelectedCollection(collectionData.name); + } else { + setSelectedCollection(null); + } + } catch (err) { + console.error('Failed to load selected collection:', err); + setSelectedCollection(null); + } + }; + + checkSelectedCollection(); + + const handleStorageChange = (e: StorageEvent) => { + if (e.key === 'selectedCollection') { + checkSelectedCollection(); + } + }; + + window.addEventListener('storage', handleStorageChange); + + return () => { + window.removeEventListener('storage', handleStorageChange); + }; + }, []); + + useEffect(() => { + if (!showCollectionModal) { + const checkSelectedCollection = () => { + try { + const storedCollection = localStorage.getItem('selectedCollection'); + if (storedCollection) { + const collectionData = JSON.parse(storedCollection); + setSelectedCollection(collectionData.name); + } else { + setSelectedCollection(null); + } + } catch (err) { + console.error('Failed to load selected collection:', err); + setSelectedCollection(null); + } + }; + checkSelectedCollection(); + } + }, [showCollectionModal]); + const scrollToBottom = () => { if (messagesRef.current) { messagesRef.current.scrollTop = messagesRef.current.scrollHeight; @@ -139,7 +202,8 @@ const ChatInterface: React.FC = ({ workflow, onBack, hideBac user_input: currentMessage, interaction_id: interactionId, workflow_id: workflowId, - workflow_name: workflowName + workflow_name: workflowName, + selectedCollection: selectedCollection }); // 결과로 임시 메시지 업데이트 (chatAPI 응답 형식) @@ -220,6 +284,42 @@ const ChatInterface: React.FC = ({ workflow, onBack, hideBac } }; + const handleAttachmentClick = () => { + setShowAttachmentMenu(!showAttachmentMenu); + }; + + const handleAttachmentOption = (option: string) => { + console.log('Selected option:', option); + setShowAttachmentMenu(false); + + if (option === 'collection') { + setShowCollectionModal(true); + } + // TODO: 다른 옵션들에 대한 구현 + }; + + const handleRemoveCollection = () => { + localStorage.removeItem('selectedCollection'); + setSelectedCollection(null); + }; + + // 첨부 메뉴 외부 클릭 시 닫기 + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (attachmentButtonRef.current && !attachmentButtonRef.current.contains(event.target as Node)) { + setShowAttachmentMenu(false); + } + }; + + if (showAttachmentMenu) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [showAttachmentMenu]); + return (
{/* Header */} @@ -231,7 +331,7 @@ const ChatInterface: React.FC = ({ workflow, onBack, hideBac )}
-

{workflow.name}

+

{workflow.name === 'default_mode' ? '일반 채팅' : workflow.name}

{hideBackButton ? '현재 채팅을 계속하세요' : '기존 대화를 계속하세요'}

@@ -302,17 +402,73 @@ const ChatInterface: React.FC = ({ workflow, onBack, hideBac disabled={executing} className={styles.messageInput} /> - + )} - +
+ + {showAttachmentMenu && ( +
+ + + + +
+ )} +
+ + {executing && (

@@ -326,6 +482,12 @@ const ChatInterface: React.FC = ({ workflow, onBack, hideBac )} + + {/* Collection Modal */} + setShowCollectionModal(false)} + /> ); }; diff --git a/src/app/chat/components/ChatPageContent.tsx b/src/app/chat/components/ChatPageContent.tsx index f7b6b5ee..ad31f23e 100644 --- a/src/app/chat/components/ChatPageContent.tsx +++ b/src/app/chat/components/ChatPageContent.tsx @@ -71,19 +71,24 @@ const ChatPageContent: React.FC = () => { setActiveSection('current-chat'); }; + const handleChatStarted = () => { + // 채팅 시작 후 current-chat으로 전환 + setActiveSection('current-chat'); + }; + const settingSidebarItems = getSettingSidebarItems(); const chatSidebarItems = getChatSidebarItems(); const renderContent = () => { switch (activeSection) { case 'new-chat': - return + return case 'current-chat': return ; case 'chat-history': return default: - return + return } }; diff --git a/src/app/chat/components/CollectionModal.tsx b/src/app/chat/components/CollectionModal.tsx new file mode 100644 index 00000000..ee626dc4 --- /dev/null +++ b/src/app/chat/components/CollectionModal.tsx @@ -0,0 +1,261 @@ +'use client'; +import React, { useState, useEffect } from 'react'; +import { FiX, FiFolder, FiFileText, FiChevronRight, FiRefreshCw } from 'react-icons/fi'; +import styles from '@/app/chat/assets/CollectionModal.module.scss'; +import { listCollections, listDocumentsInCollection } from '@/app/api/retrievalAPI'; + +interface Collection { + name: string; + total_documents?: number; + total_chunks?: number; + documents?: any[]; +} + +interface CollectionModalProps { + isOpen: boolean; + onClose: () => void; +} + +const CollectionModal: React.FC = ({ isOpen, onClose }) => { + const [collections, setCollections] = useState([]); + const [selectedCollection, setSelectedCollection] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [view, setView] = useState<'list' | 'details'>('list'); + + useEffect(() => { + if (isOpen) { + setView('list'); + setSelectedCollection(null); + setError(null); + fetchCollections(); + } + }, [isOpen]); + + const fetchCollections = async () => { + try { + setLoading(true); + setError(null); + const response = await listCollections() as any; + + if (response.collections) { + const collectionsData = response.collections.map((name: string) => ({ + name + })); + setCollections(collectionsData); + } + } catch (err) { + setError('컬렉션을 불러오는데 실패했습니다.'); + console.error('Failed to fetch collections:', err); + } finally { + setLoading(false); + } + }; + + const fetchCollectionDetails = async (collectionName: string) => { + try { + setLoading(true); + setError(null); + const response = await listDocumentsInCollection(collectionName) as any; + + const collectionWithDetails = { + name: collectionName, + total_documents: response.total_documents, + total_chunks: response.total_chunks, + documents: response.documents + }; + + setSelectedCollection(collectionWithDetails); + setView('details'); + } catch (err) { + setError('컬렉션 상세 정보를 불러오는데 실패했습니다.'); + console.error('Failed to fetch collection details:', err); + } finally { + setLoading(false); + } + }; + + const handleCollectionSelect = (collection: Collection) => { + // localStorage에 선택된 컬렉션 저장 + localStorage.setItem('selectedCollection', JSON.stringify({ + name: collection.name, + selectedAt: new Date().toISOString() + })); + + // 모달 닫기 + onClose(); + }; + + const handleDetailsView = (collectionName: string) => { + // 단순히 상세 정보만 표시 (localStorage 저장하지 않음) + fetchCollectionDetails(collectionName); + }; + + const handleBackToList = () => { + setView('list'); + setSelectedCollection(null); + }; + + const handleBackdropClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onClose(); + } + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleString('ko-KR', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + }; + + if (!isOpen) return null; + + return ( +

+
+
+
+ {view === 'details' && ( + + )} +

+ {view === 'list' ? '컬렉션' : selectedCollection?.name} +

+ {view === 'list' && ( + + )} +
+ +
+ +
+ {error && ( +
+ {error} +
+ )} + + {loading ? ( +
+
+

로딩 중...

+
+ ) : view === 'list' ? ( +
+ {collections.length === 0 ? ( +
+ +

컬렉션이 없습니다

+

아직 생성된 컬렉션이 없습니다.

+
+ ) : ( + collections.map((collection) => ( +
+
+ +
+
+

{collection.name}

+
+
+ + +
+
+ )) + )} +
+ ) : ( +
+ {selectedCollection && ( + <> +
+
+ 문서 수 + + {selectedCollection.total_documents || 0} + +
+
+ 청크 수 + + {selectedCollection.total_chunks || 0} + +
+
+ +
+

문서 목록

+ {selectedCollection.documents && selectedCollection.documents.length > 0 ? ( +
+ {selectedCollection.documents.map((doc: any) => ( +
+
+ +
+
+
{doc.file_name}
+
+ + {doc.file_type?.toUpperCase() || 'FILE'} + + + + 청크 {doc.total_chunks}개 + + + + {formatDate(doc.processed_at)} + +
+
+
+ ))} +
+ ) : ( +
+

이 컬렉션에는 문서가 없습니다.

+
+ )} +
+ + )} +
+ )} +
+
+
+ ); +}; + +export default CollectionModal; diff --git a/src/app/chat/components/DefaultChatInterface.tsx b/src/app/chat/components/DefaultChatInterface.tsx index 5ae26110..6098f560 100644 --- a/src/app/chat/components/DefaultChatInterface.tsx +++ b/src/app/chat/components/DefaultChatInterface.tsx @@ -1,5 +1,5 @@ 'use client'; -import React, { useState, useRef, useEffect } from 'react'; +import React, { useState } from 'react'; import { FiSend, FiMessageSquare, @@ -7,19 +7,10 @@ import { FiArrowLeft, } from 'react-icons/fi'; import styles from '@/app/chat/assets/ChatInterface.module.scss'; -import { createNewChat, executeChatMessage } from '@/app/api/chatAPI'; +import { createNewChat } from '@/app/api/chatAPI'; import { generateInteractionId } from '@/app/api/interactionAPI'; import toast from 'react-hot-toast'; -interface IOLog { - log_id: number | string; - workflow_name: string; - workflow_id: string; - input_data: string; - output_data: string; - updated_at: string; -} - interface ChatNewResponse { status: string; message: string; @@ -36,38 +27,17 @@ interface ChatNewResponse { timestamp: string; } -interface ChatExecutionResponse { - status: string; - message: string; - user_input: string; - ai_response: string; - interaction_id: string; - session_id: string; - execution_meta: { - interaction_id: string; - interaction_count: number; - workflow_id: string; - workflow_name: string; - }; - timestamp: string; -} - interface DefaultChatInterfaceProps { onBack?: () => void; + onChatStarted?: () => void; } -const DefaultChatInterface: React.FC = ({ onBack }) => { - const [ioLogs, setIOLogs] = useState([]); +const DefaultChatInterface: React.FC = ({ onBack, onChatStarted }) => { const [executing, setExecuting] = useState(false); const [error, setError] = useState(null); const [inputMessage, setInputMessage] = useState(''); - const [pendingLogId, setPendingLogId] = useState(null); - const [isFirstMessage, setIsFirstMessage] = useState(true); const [interactionId] = useState(() => generateInteractionId('default_chat')); - const messagesRef = useRef(null); - - // Default workflow 설정 (default_mode) - 일반 채팅용 const defaultWorkflow = { id: 'default_mode', name: 'default_mode', @@ -77,21 +47,6 @@ const DefaultChatInterface: React.FC = ({ onBack }) = status: 'active' as const, }; - const scrollToBottom = () => { - if (messagesRef.current) { - messagesRef.current.scrollTop = messagesRef.current.scrollHeight; - } - }; - - const formatDate = (dateString: string) => { - return new Date(dateString).toLocaleString('ko-KR', { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }); - }; - const executeWorkflow = async () => { if (!inputMessage.trim()) { return; @@ -99,44 +54,24 @@ const DefaultChatInterface: React.FC = ({ onBack }) = setError(null); setExecuting(true); - const tempId = `pending-${Date.now()}`; - setPendingLogId(tempId); - - // 임시 메시지 추가 - setIOLogs((prev) => [ - ...prev, - { - log_id: tempId, - workflow_name: '일반 채팅', - workflow_id: defaultWorkflow.id, - input_data: inputMessage, - output_data: '', - updated_at: new Date().toISOString(), - }, - ]); const currentMessage = inputMessage; setInputMessage(''); - // 스크롤을 하단으로 이동 - setTimeout(scrollToBottom, 100); - try { - let result: ChatNewResponse | ChatExecutionResponse; - - if (isFirstMessage) { - // workflow 검증 - if (defaultWorkflow.id !== 'default_mode' || defaultWorkflow.name !== 'default_mode') { - throw new Error('일반 채팅은 default_mode workflow만 사용 가능합니다.'); - } + // workflow 검증 + if (defaultWorkflow.id !== 'default_mode' || defaultWorkflow.name !== 'default_mode') { + throw new Error('일반 채팅은 default_mode workflow만 사용 가능합니다.'); + } - // 새로운 채팅 세션 생성 - result = await createNewChat({ - interaction_id: interactionId, - input_data: currentMessage - }) as ChatNewResponse; + // 새로운 채팅 세션 생성 + const result = await createNewChat({ + interaction_id: interactionId, + input_data: currentMessage + }) as ChatNewResponse; - // 첫 번째 메시지 전송 시 현재 채팅 데이터를 localStorage에 저장 + if (result.status === 'success') { + // 현재 채팅 데이터를 localStorage에 저장 const currentChatData = { interactionId: result.interaction_id, workflowId: result.workflow_id, @@ -144,73 +79,23 @@ const DefaultChatInterface: React.FC = ({ onBack }) = startedAt: result.timestamp || new Date().toISOString(), }; localStorage.setItem('currentChatData', JSON.stringify(currentChatData)); - setIsFirstMessage(false); - } else { - // workflow 검증 - if (defaultWorkflow.id !== 'default_mode' || defaultWorkflow.name !== 'default_mode') { - throw new Error('일반 채팅은 default_mode workflow만 사용 가능합니다.'); + + // 채팅 시작 후 CurrentChatInterface로 전환하도록 부모에게 알림 + if (onChatStarted) { + onChatStarted(); } - - // 기존 채팅 세션 계속 - result = await executeChatMessage({ - user_input: currentMessage, - interaction_id: interactionId, - workflow_id: defaultWorkflow.id, - workflow_name: defaultWorkflow.name - }) as ChatExecutionResponse; - } - - if (result.status === 'success') { - // 결과로 임시 메시지 업데이트 - setIOLogs((prev) => - prev.map((log) => - String(log.log_id) === tempId - ? { - ...log, - output_data: isFirstMessage - ? (result as ChatNewResponse).chat_response || '채팅 세션이 시작되었습니다.' - : (result as ChatExecutionResponse).ai_response || '', - updated_at: result.timestamp || new Date().toISOString(), - } - : log, - ), - ); - setPendingLogId(null); - setTimeout(scrollToBottom, 100); - toast.success(isFirstMessage ? '일반 채팅이 시작되었습니다!' : '메시지가 성공적으로 전송되었습니다!'); } else { throw new Error(result.message || 'Unknown error occurred'); } } catch (err) { console.error('Default chat execution failed:', err); - - // 에러로 임시 메시지 업데이트 - setIOLogs((prev) => - prev.map((log) => - String(log.log_id) === tempId - ? { - ...log, - output_data: err instanceof Error ? err.message : '처리 중 오류가 발생했습니다.', - updated_at: new Date().toISOString(), - } - : log, - ), - ); - setPendingLogId(null); + setError(err instanceof Error ? err.message : '처리 중 오류가 발생했습니다.'); toast.error('메시지 처리 중 오류가 발생했습니다.'); - setTimeout(scrollToBottom, 100); } finally { setExecuting(false); } }; - const handleSendMessage = async () => { - const trimmedMessage = inputMessage.trim(); - if (!trimmedMessage || executing) return; - - await executeWorkflow(); - }; - const handleKeyPress = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey && !executing) { e.preventDefault(); @@ -230,79 +115,48 @@ const DefaultChatInterface: React.FC = ({ onBack }) = )}

일반 채팅

-

AI와 자유롭게 대화해보세요

+

자유롭게 대화를 시작하세요

- {ioLogs.length}개의 대화 + 새 채팅
{/* Chat Area */}
-
- {isFirstMessage ? ( -
- -

첫 대화를 시작해보세요!

-

일반 채팅 모드가 준비되었습니다.

-
-
- - - -
+
+
+ +

첫 대화를 시작해보세요!

+

일반 채팅 모드로 자유롭게 대화할 수 있습니다.

+
+
+ + +
- ) : ( - ioLogs.map((log) => ( -
- {/* User Message */} -
-
- {log.input_data} -
-
- {formatDate(log.updated_at)} -
-
- - {/* AI Response */} -
-
- {String(log.log_id) === pendingLogId && executing && !log.output_data ? ( -
- - - -
- ) : ( - log.output_data - )} -
-
-
- )) - )} +
{/* Input Area */} @@ -310,7 +164,7 @@ const DefaultChatInterface: React.FC = ({ onBack }) =
setInputMessage(e.target.value)} onKeyPress={handleKeyPress} @@ -331,7 +185,7 @@ const DefaultChatInterface: React.FC = ({ onBack }) =
{executing && (

- 일반 채팅을 실행 중입니다... + 채팅을 시작하는 중입니다...

)} {error && ( diff --git a/src/app/chat/components/NewChatInterface.tsx b/src/app/chat/components/NewChatInterface.tsx index f68b50c1..1a687f20 100644 --- a/src/app/chat/components/NewChatInterface.tsx +++ b/src/app/chat/components/NewChatInterface.tsx @@ -1,5 +1,5 @@ 'use client'; -import React, { useState, useRef } from 'react'; +import React, { useState } from 'react'; import { FiSend, FiArrowLeft, @@ -23,46 +23,18 @@ interface Workflow { error?: string; } -interface IOLog { - log_id: number | string; - workflow_name: string; - workflow_id: string; - input_data: string; - output_data: string; - updated_at: string; -} - interface NewChatInterfaceProps { workflow: Workflow; onBack: () => void; + onChatStarted?: () => void; // 채팅 시작 후 호출될 콜백 } -const NewChatInterface: React.FC = ({ workflow, onBack }) => { - const [ioLogs, setIOLogs] = useState([]); +const NewChatInterface: React.FC = ({ workflow, onBack, onChatStarted }) => { const [executing, setExecuting] = useState(false); const [error, setError] = useState(null); const [inputMessage, setInputMessage] = useState(''); - const [pendingLogId, setPendingLogId] = useState(null); - const [isFirstMessage, setIsFirstMessage] = useState(true); const [interactionId] = useState(() => generateInteractionId()); - const messagesRef = useRef(null); - - const scrollToBottom = () => { - if (messagesRef.current) { - messagesRef.current.scrollTop = messagesRef.current.scrollHeight; - } - }; - - const formatDate = (dateString: string) => { - return new Date(dateString).toLocaleString('ko-KR', { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }); - }; - const executeWorkflow = async () => { if (!inputMessage.trim()) { return; @@ -70,33 +42,10 @@ const NewChatInterface: React.FC = ({ workflow, onBack }) setError(null); setExecuting(true); - const tempId = `pending-${Date.now()}`; - setPendingLogId(tempId); - - // 첫 메시지 후에는 일반 대화 모드로 전환 - if (isFirstMessage) { - setIsFirstMessage(false); - } - - // 임시 메시지 추가 - setIOLogs((prev) => [ - ...prev, - { - log_id: tempId, - workflow_name: workflow.name, - workflow_id: workflow.id, - input_data: inputMessage, - output_data: '', - updated_at: new Date().toISOString(), - }, - ]); const currentMessage = inputMessage; setInputMessage(''); - // 스크롤을 하단으로 이동 - setTimeout(scrollToBottom, 100); - try { const result: any = await executeWorkflowNew({ workflow_name: normalizeWorkflowName(workflow.name), @@ -105,50 +54,22 @@ const NewChatInterface: React.FC = ({ workflow, onBack }) input_data: currentMessage, }); - // 첫 번째 메시지 전송 시 현재 채팅 데이터를 localStorage에 저장 - if (isFirstMessage) { - const currentChatData = { - interactionId: interactionId, - workflowId: workflow.id, - workflowName: normalizeWorkflowName(workflow.name), - startedAt: new Date().toISOString(), - }; - localStorage.setItem('currentChatData', JSON.stringify(currentChatData)); - setIsFirstMessage(false); + // 현재 채팅 데이터를 localStorage에 저장 + const currentChatData = { + interactionId: interactionId, + workflowId: workflow.id, + workflowName: normalizeWorkflowName(workflow.name), + startedAt: new Date().toISOString(), + }; + localStorage.setItem('currentChatData', JSON.stringify(currentChatData)); + + // 채팅 시작 후 CurrentChatInterface로 전환하도록 부모에게 알림 + if (onChatStarted) { + onChatStarted(); } - - // 결과로 임시 메시지 업데이트 - setIOLogs((prev) => - prev.map((log) => - String(log.log_id) === tempId - ? { - ...log, - output_data: result.outputs - ? JSON.stringify(result.outputs) - : result.message || '처리 완료', - updated_at: new Date().toISOString(), - } - : log, - ), - ); - setPendingLogId(null); - setTimeout(scrollToBottom, 100); } catch (err) { - // 에러로 임시 메시지 업데이트 - setIOLogs((prev) => - prev.map((log) => - String(log.log_id) === tempId - ? { - ...log, - output_data: err instanceof Error ? err.message : '처리 중 오류가 발생했습니다.', - updated_at: new Date().toISOString(), - } - : log, - ), - ); - setPendingLogId(null); + setError(err instanceof Error ? err.message : '처리 중 오류가 발생했습니다.'); toast.error('메시지 처리 중 오류가 발생했습니다.'); - setTimeout(scrollToBottom, 100); } finally { setExecuting(false); } @@ -176,74 +97,43 @@ const NewChatInterface: React.FC = ({ workflow, onBack })
- {ioLogs.length}개의 대화 + 새 채팅
{/* Chat Area */}
-
- {isFirstMessage ? ( -
- -

첫 대화를 시작해보세요!

-

"{workflow.name}" 워크플로우가 준비되었습니다.

-
-
- - - -
+
+
+ +

첫 대화를 시작해보세요!

+

"{workflow.name}" 워크플로우가 준비되었습니다.

+
+
+ + +
- ) : ( - ioLogs.map((log) => ( -
- {/* User Message */} -
-
- {log.input_data} -
-
- {formatDate(log.updated_at)} -
-
- - {/* Bot Message */} -
-
- {String(log.log_id) === pendingLogId && executing && !log.output_data ? ( -
- - - -
- ) : ( - log.output_data - )} -
-
-
- )) - )} +
{/* Input Area */} @@ -251,7 +141,7 @@ const NewChatInterface: React.FC = ({ workflow, onBack })
setInputMessage(e.target.value)} onKeyPress={handleKeyPress} diff --git a/src/app/main/assets/Settings.module.scss b/src/app/main/assets/Settings.module.scss index bd264476..ed299306 100644 --- a/src/app/main/assets/Settings.module.scss +++ b/src/app/main/assets/Settings.module.scss @@ -848,8 +848,8 @@ $white: #ffffff; border-color: $primary-green; &:hover:not(:disabled) { - background: darken($primary-green, 10%); - border-color: darken($primary-green, 10%); + background: color.adjust($primary-green, $lightness: -10%); + border-color: color.adjust($primary-green, $lightness: -10%); } } @@ -859,8 +859,8 @@ $white: #ffffff; border-color: $primary-red; &:hover:not(:disabled) { - background: darken($primary-red, 10%); - border-color: darken($primary-red, 10%); + background: color.adjust($primary-red, $lightness: -10%); + border-color: color.adjust($primary-red, $lightness: -10%); } } } @@ -910,8 +910,8 @@ $white: #ffffff; transition: all 0.2s ease; &:hover:not(:disabled) { - background: darken($primary-blue, 10%); - border-color: darken($primary-blue, 10%); + background: color.adjust($primary-blue, $lightness: -10%); + border-color: color.adjust($primary-blue, $lightness: -10%); transform: translateY(-1px); box-shadow: 0 4px 12px rgba($primary-blue, 0.3); } diff --git a/src/app/main/components/Documents.tsx b/src/app/main/components/Documents.tsx index 9e1643fd..15e52da6 100644 --- a/src/app/main/components/Documents.tsx +++ b/src/app/main/components/Documents.tsx @@ -1,18 +1,16 @@ 'use client'; import React, { useState, useEffect } from 'react'; import styles from '../assets/Documents.module.scss'; -import { + +import { isSupportedFileType, isValidFileSize, isValidCollectionName, formatFileSize, - getRelativeTime -} from '@/app/api/ragAPI.js'; - -import { - listCollections, - createCollection, - uploadDocument, + getRelativeTime, + listCollections, + createCollection, + uploadDocument, searchDocuments, deleteCollection, listDocumentsInCollection, @@ -111,18 +109,18 @@ const Documents: React.FC = () => { 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); @@ -226,7 +224,6 @@ const Documents: React.FC = () => { setError(null); await createCollection( newCollectionName, - 1536, "Cosine", newCollectionDescription || undefined ); @@ -257,13 +254,13 @@ const Documents: React.FC = () => { await deleteCollection(collectionToDelete.name); setShowDeleteModal(false); setCollectionToDelete(null); - + if (selectedCollection?.name === collectionToDelete.name) { setSelectedCollection(null); setDocumentsInCollection([]); setViewMode('collections'); } - + await loadCollections(); } catch (err) { setError('컬렉션 삭제에 실패했습니다.'); @@ -288,13 +285,13 @@ const Documents: React.FC = () => { 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('문서 삭제에 실패했습니다.'); @@ -318,7 +315,7 @@ const Documents: React.FC = () => { // 문서 선택 const handleSelectDocument = async (document: DocumentInCollection) => { if (!selectedCollection) return; - + setSelectedDocument(document); setSearchQuery(''); setSearchResults([]); @@ -373,7 +370,7 @@ const Documents: React.FC = () => { for (let i = 0; i < validFiles.length; i++) { const file = validFiles[i]; try { - setUploadProgress(prev => prev.map((item, index) => + setUploadProgress(prev => prev.map((item, index) => index === i ? { ...item, progress: 50 } : item )); @@ -386,14 +383,14 @@ const Documents: React.FC = () => { { upload_type: isFolder ? 'folder' : 'single' } ); - setUploadProgress(prev => prev.map((item, index) => + 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', + setUploadProgress(prev => prev.map((item, index) => + index === i ? { + ...item, + status: 'error', progress: 0, error: '업로드 실패' } : item @@ -478,17 +475,17 @@ const Documents: React.FC = () => { ) : (
{collections.map((collection) => ( -
-
handleSelectCollection(collection)} >

{collection.name}

- - - + +
+
+ ), { + duration: Infinity, + style: { + maxWidth: '420px', + padding: '20px', + backgroundColor: '#f9fafb', + border: '2px solid #374151', + borderRadius: '12px', + boxShadow: + '0 8px 25px rgba(0, 0, 0, 0.15), 0 4px 10px rgba(0, 0, 0, 0.1)', + color: '#374151', + fontFamily: 'system-ui, -apple-system, sans-serif', + }, + }); + }; + + const confirmProviderSwitch = async (providerName: string) => { setSwitching(true); setError(null); try { @@ -241,11 +343,14 @@ const VectordbConfig: React.FC = ({ if (result.success) { setCurrentProvider(providerName); await loadEmbeddingStatus(); // 이 함수가 dimensionInfo도 업데이트함 + toast.success(`임베딩 제공자가 ${EMBEDDING_PROVIDERS.find(p => p.name === providerName)?.displayName || providerName}로 변경되었습니다.`); } else { setError(result.message || '제공자 변경에 실패했습니다.'); + toast.error(result.message || '제공자 변경에 실패했습니다.'); } } catch (err) { setError('제공자 변경 중 오류가 발생했습니다.'); + toast.error('제공자 변경 중 오류가 발생했습니다.'); console.error('Failed to switch provider:', err); } finally { setSwitching(false); @@ -354,7 +459,7 @@ const VectordbConfig: React.FC = ({
- @@ -378,7 +483,7 @@ const VectordbConfig: React.FC = ({ {embeddingStatus.available ? '정상 작동' : '연결 오류'}
- +