diff --git a/src/api/index.js b/src/api/index.js index 5db92ef..9a38610 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -2,7 +2,7 @@ import axios from 'axios' const api = axios.create({ baseURL: '/api', - timeout: 60000, + timeout: 60000, // 기본 1분 headers: { 'Content-Type': 'application/json' } @@ -20,16 +20,21 @@ export const topicApi = { // 문서 API export const docApi = { getList: (topicId) => api.get(`/topics/${topicId}/documents`), + // 업로드는 Vision 처리로 오래 걸릴 수 있으므로 10분 타임아웃 upload: (topicId, formData) => api.post(`/topics/${topicId}/documents/upload`, formData, { - headers: { 'Content-Type': 'multipart/form-data' } + headers: { 'Content-Type': 'multipart/form-data' }, + timeout: 600000 // 10분 (600초) }), delete: (docId) => api.delete(`/documents/${docId}`), - deleteAll: (topicId) => api.delete(`/topics/${topicId}/documents`) + deleteAll: (topicId) => api.delete(`/topics/${topicId}/documents`), + getChunks: (docId) => api.get(`/documents/${docId}/chunks`) } -// 채팅 API +// 채팅 API (GPT 응답 대기) export const chatApi = { - send: (data) => api.post('/chat', data) + send: (data) => api.post('/chat', data, { + timeout: 180000 // 3분 (복잡한 질문 대응) + }) } export default api diff --git a/src/views/AdminView.vue b/src/views/AdminView.vue index 09a3283..450092a 100644 --- a/src/views/AdminView.vue +++ b/src/views/AdminView.vue @@ -90,6 +90,7 @@
{{ getStatusLabel(doc.docStatus) }}
+ @@ -167,6 +168,63 @@ + + + @@ -180,6 +238,12 @@ const selectedTopic = ref(null) const documents = ref([]) const uploading = ref(false) +// 청크 모달 상태 +const showChunkModal = ref(false) +const chunkDocument = ref(null) +const chunks = ref([]) +const chunksLoading = ref(false) + // 주제 모달 상태 const showTopicModal = ref(false) const editingTopic = ref(null) @@ -358,6 +422,41 @@ const deleteAllDocuments = async () => { } } +// 청크 모달 열기 +const openChunkModal = async (doc) => { + chunkDocument.value = doc + showChunkModal.value = true + chunksLoading.value = true + chunks.value = [] + + try { + const { data } = await docApi.getChunks(doc.docId) + chunks.value = data + } catch (error) { + console.error('청크 로드 실패:', error) + alert('청크 로드 중 오류가 발생했습니다.') + } finally { + chunksLoading.value = false + } +} + +// 청크 모달 닫기 +const closeChunkModal = () => { + showChunkModal.value = false + chunkDocument.value = null + chunks.value = [] +} + +// 예상 질문 JSON 파싱 +const parseQuestions = (jsonStr) => { + if (!jsonStr) return [] + try { + return JSON.parse(jsonStr) + } catch { + return [] + } +} + // 다운로드 URL 생성 const getDownloadUrl = (docId) => { return `/api/documents/${docId}/download` @@ -654,6 +753,7 @@ onMounted(() => { &.failed { background: #ffebee; color: #c62828; } } + .chunk-btn, .delete-btn { padding: 8px; background: none; @@ -665,6 +765,10 @@ onMounted(() => { opacity: 1; } } + + .chunk-btn:hover { + color: #667eea; + } } // 업로드 오버레이 @@ -867,4 +971,167 @@ onMounted(() => { cursor: not-allowed; } } + +// 청크 모달 +.chunk-modal { + background: white; + border-radius: 16px; + width: 800px; + max-width: 90%; + max-height: 80vh; + display: flex; + flex-direction: column; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + + .modal-header { + display: flex; + align-items: center; + gap: 12px; + + h3 { + flex-shrink: 0; + } + + .chunk-doc-name { + flex: 1; + font-size: 14px; + color: #666; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + .chunk-body { + flex: 1; + overflow-y: auto; + max-height: 60vh; + } + + .modal-footer { + justify-content: space-between; + + .chunk-count { + font-size: 14px; + color: #666; + } + } +} + +.chunks-loading, +.chunks-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 0; + color: #888; +} + +.chunk-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.chunk-item { + border: 1px solid #e8e8e8; + border-radius: 8px; + overflow: hidden; + + .chunk-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + background: #f5f5f5; + border-bottom: 1px solid #e8e8e8; + gap: 8px; + + .chunk-index { + font-weight: 600; + color: #667eea; + } + + .chunk-type { + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; + background: #e3f2fd; + color: #1565c0; + } + + .chunk-tokens { + font-size: 12px; + color: #888; + margin-left: auto; + } + } + + // 스마트 청킹 메타데이터 + .chunk-metadata { + padding: 12px; + background: linear-gradient(135deg, #f8f9ff 0%, #f0f4ff 100%); + border-bottom: 1px solid #e8e8e8; + + .meta-section { + margin-bottom: 10px; + + &:last-child { + margin-bottom: 0; + } + + strong { + display: block; + font-size: 12px; + color: #667eea; + margin-bottom: 4px; + } + + p { + font-size: 13px; + color: #333; + line-height: 1.5; + margin: 0; + } + } + + .keyword-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + } + + .keyword-tag { + padding: 2px 8px; + border-radius: 12px; + font-size: 12px; + background: #667eea; + color: white; + } + + .question-list { + margin: 0; + padding-left: 20px; + font-size: 13px; + color: #555; + + li { + margin-bottom: 4px; + } + } + } + + .chunk-content { + padding: 12px; + font-size: 13px; + line-height: 1.6; + color: #333; + white-space: pre-wrap; + word-break: break-word; + max-height: 200px; + overflow-y: auto; + background: #fafafa; + } +} diff --git a/src/views/ChatView.vue b/src/views/ChatView.vue index 4027708..d08941f 100644 --- a/src/views/ChatView.vue +++ b/src/views/ChatView.vue @@ -53,17 +53,57 @@
- +
-
📎 참조 문서
- {{ (source.similarity * 100).toFixed(0) }}% - {{ source.content }} + {{ msg.sourcesExpanded ? '▼' : '▶' }} + 📎 참조 문서 ({{ msg.sources.length }}개)
+ + +
+
+
+
+
+ {{ (source.similarity * 100).toFixed(0) }}% +
+ + 📄 {{ source.docName }} + + ({{ source.chunkIndex + 1 }}/{{ source.totalChunks }}) + + + + {{ source.fileType.toUpperCase() }} + +
+
+ {{ truncateText(source.content, 200) }} +
+ +
+ {{ source.content }} +
+
+
+
@@ -71,7 +111,8 @@
🤖
-
+
+
답변을 생성하고 있습니다
@@ -96,6 +137,7 @@ @click="sendMessage" :disabled="!inputText.trim() || isLoading" > + 전송
@@ -109,6 +151,12 @@ import { ref, onMounted, nextTick } from 'vue' import { marked } from 'marked' import { topicApi, chatApi } from '@/api' +// marked 옵션 설정 +marked.setOptions({ + breaks: true, + gfm: true +}) + // 상태 const topics = ref([]) const selectedTopics = ref([]) @@ -116,7 +164,7 @@ const messages = ref([]) const inputText = ref('') const isLoading = ref(false) const messageListRef = ref(null) -const sessionKey = ref(null) // 세션 키 +const sessionKey = ref(null) // 주제 목록 로드 const loadTopics = async () => { @@ -158,19 +206,19 @@ const sendMessage = async () => { const { data } = await chatApi.send({ question, topicIds: selectedTopics.value.length > 0 ? selectedTopics.value : null, - sessionKey: sessionKey.value // 세션 키 전달 + sessionKey: sessionKey.value }) - // 세션 키 저장 (첫 응답에서 받음) if (data.sessionKey) { sessionKey.value = data.sessionKey } - // AI 응답 추가 + // AI 응답 추가 (sourcesExpanded 기본값 false) messages.value.push({ role: 'assistant', content: data.answer, - sources: data.sources + sources: data.sources?.map(s => ({ ...s, expanded: false })) || [], + sourcesExpanded: false }) } catch (error) { console.error('채팅 오류:', error) @@ -189,6 +237,21 @@ const renderMarkdown = (text) => { return marked(text || '') } +// 텍스트 자르기 +const truncateText = (text, maxLength) => { + if (!text) return '' + if (text.length <= maxLength) return text + return text.substring(0, maxLength) + '...' +} + +// 유사도에 따른 클래스 +const getSimilarityClass = (similarity) => { + const percent = similarity * 100 + if (percent >= 50) return 'high' + if (percent >= 40) return 'medium' + return 'low' +} + // 스크롤 맨 아래로 const scrollToBottom = async () => { await nextTick() @@ -266,6 +329,7 @@ onMounted(() => { width: 18px; height: 18px; cursor: pointer; + accent-color: #667eea; } .topic-icon { @@ -283,7 +347,7 @@ onMounted(() => { flex: 1; display: flex; flex-direction: column; - background: #f9fafb; + background: linear-gradient(180deg, #f8f9fc 0%, #eef1f8 100%); } .message-list { @@ -319,122 +383,354 @@ onMounted(() => { .message { display: flex; gap: 12px; - margin-bottom: 20px; + margin-bottom: 24px; + animation: fadeIn 0.3s ease; &.user { flex-direction: row-reverse; .message-content { - background: #667eea; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border-radius: 20px 20px 4px 20px; + max-width: 60%; + } + + .message-avatar { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; - border-radius: 16px 16px 4px 16px; } } &.assistant { .message-content { background: white; - border-radius: 16px 16px 16px 4px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + border-radius: 20px 20px 20px 4px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); + max-width: 80%; + } + + .message-avatar { + background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%); + color: white; } } } +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + .message-avatar { - width: 40px; - height: 40px; + width: 42px; + height: 42px; border-radius: 50%; - background: #f0f0f0; display: flex; align-items: center; justify-content: center; font-size: 20px; flex-shrink: 0; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); } .message-content { - max-width: 70%; - padding: 12px 16px; + padding: 16px 20px; + + &.loading-content { + display: flex; + align-items: center; + gap: 12px; + } +} + +.loading-text { + color: #666; + font-size: 14px; } .message-text { - line-height: 1.6; + line-height: 1.7; + font-size: 15px; :deep(p) { - margin-bottom: 8px; + margin-bottom: 12px; &:last-child { margin-bottom: 0; } } + :deep(ul), :deep(ol) { + margin: 12px 0; + padding-left: 24px; + + li { + margin-bottom: 6px; + } + } + + :deep(strong) { + font-weight: 600; + color: #333; + } + :deep(code) { - background: rgba(0, 0, 0, 0.1); - padding: 2px 6px; + background: rgba(102, 126, 234, 0.1); + color: #667eea; + padding: 2px 8px; border-radius: 4px; - font-family: monospace; + font-family: 'Consolas', monospace; + font-size: 13px; } :deep(pre) { - background: #1e1e1e; - color: #d4d4d4; - padding: 12px; - border-radius: 8px; + background: #1e1e2e; + color: #cdd6f4; + padding: 16px; + border-radius: 12px; overflow-x: auto; - margin: 8px 0; + margin: 12px 0; code { background: none; + color: inherit; padding: 0; } } + + :deep(blockquote) { + border-left: 4px solid #667eea; + margin: 12px 0; + padding: 8px 16px; + background: rgba(102, 126, 234, 0.05); + border-radius: 0 8px 8px 0; + } + + :deep(table) { + width: 100%; + border-collapse: collapse; + margin: 12px 0; + font-size: 14px; + + th, td { + border: 1px solid #e0e0e0; + padding: 8px 12px; + text-align: left; + } + + th { + background: #f5f7fa; + font-weight: 600; + } + + tr:nth-child(even) { + background: #fafbfc; + } + } } +// 참조 문서 섹션 (개선) .message-sources { - margin-top: 12px; + margin-top: 16px; + border-top: 1px solid #eee; padding-top: 12px; - border-top: 1px solid rgba(0, 0, 0, 0.1); +} + +.sources-toggle { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + padding: 8px 12px; + border-radius: 8px; + transition: background 0.2s; - .sources-header { - font-size: 12px; - font-weight: 600; - color: #666; - margin-bottom: 8px; + &:hover { + background: #f5f7fa; } - .source-item { + .toggle-icon { + font-size: 10px; + color: #888; + transition: transform 0.2s; + } + + .toggle-text { + font-size: 13px; + font-weight: 500; + color: #555; + } +} + +.sources-list { + margin-top: 12px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.source-card { + background: linear-gradient(135deg, #f8f9fc 0%, #f0f2f8 100%); + border: 1px solid #e8ebf0; + border-radius: 12px; + padding: 14px; + transition: all 0.2s; + + &:hover { + border-color: #667eea; + box-shadow: 0 2px 8px rgba(102, 126, 234, 0.1); + } +} + +.source-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 10px; +} + +.similarity-badge { + position: relative; + min-width: 60px; + height: 24px; + background: #e8e8e8; + border-radius: 12px; + overflow: hidden; + + .similarity-bar { + position: absolute; + left: 0; + top: 0; + height: 100%; + border-radius: 12px; + transition: width 0.3s ease; + } + + .similarity-text { + position: relative; + z-index: 1; display: flex; - gap: 8px; - font-size: 12px; - padding: 6px 0; - color: #666; - - .source-similarity { - background: #e8f5e9; - color: #2e7d32; - padding: 2px 6px; - border-radius: 4px; - font-weight: 600; - } - - .source-content { - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + align-items: center; + justify-content: center; + height: 100%; + font-size: 11px; + font-weight: 700; + color: white; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); + } + + &.high { + .similarity-bar { + background: linear-gradient(90deg, #11998e 0%, #38ef7d 100%); } } + + &.medium { + .similarity-bar { + background: linear-gradient(90deg, #f093fb 0%, #f5576c 100%); + } + } + + &.low { + .similarity-bar { + background: linear-gradient(90deg, #ffecd2 0%, #fcb69f 100%); + } + .similarity-text { + color: #666; + } + } +} + +.source-doc { + font-size: 12px; + color: #667eea; + font-weight: 500; + flex: 1; + + .chunk-info { + color: #999; + font-weight: 400; + margin-left: 4px; + } +} + +.file-type-badge { + font-size: 10px; + padding: 2px 8px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border-radius: 10px; + font-weight: 600; + text-transform: uppercase; +} + +.source-content { + font-size: 13px; + color: #555; + line-height: 1.6; + word-break: break-word; +} + +.source-full-content { + margin-top: 10px; + padding-top: 10px; + border-top: 1px dashed #ddd; + font-size: 13px; + color: #444; + line-height: 1.6; + white-space: pre-wrap; + word-break: break-word; +} + +.expand-btn { + margin-top: 8px; + padding: 4px 12px; + font-size: 12px; + color: #667eea; + background: rgba(102, 126, 234, 0.1); + border-radius: 12px; + transition: all 0.2s; + + &:hover { + background: rgba(102, 126, 234, 0.2); + } +} + +// 슬라이드 트랜지션 +.slide-enter-active, +.slide-leave-active { + transition: all 0.3s ease; + overflow: hidden; +} + +.slide-enter-from, +.slide-leave-to { + opacity: 0; + max-height: 0; +} + +.slide-enter-to, +.slide-leave-from { + opacity: 1; + max-height: 1000px; } // 로딩 애니메이션 .loading-dots { display: flex; - gap: 4px; + gap: 6px; span { - width: 8px; - height: 8px; - background: #667eea; + width: 10px; + height: 10px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 50%; animation: bounce 1.4s infinite ease-in-out both; @@ -444,57 +740,77 @@ onMounted(() => { } @keyframes bounce { - 0%, 80%, 100% { transform: scale(0); } - 40% { transform: scale(1); } + 0%, 80%, 100% { transform: scale(0.6); opacity: 0.5; } + 40% { transform: scale(1); opacity: 1; } } // 입력 영역 .chat-input-area { - padding: 16px 24px; + padding: 20px 24px; background: white; border-top: 1px solid #e8e8e8; + box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.05); } .input-wrapper { display: flex; gap: 12px; align-items: flex-end; + max-width: 900px; + margin: 0 auto; textarea { flex: 1; - padding: 12px 16px; - border: 1px solid #ddd; - border-radius: 12px; + padding: 14px 20px; + border: 2px solid #e8e8e8; + border-radius: 16px; resize: none; - font-size: 14px; + font-size: 15px; line-height: 1.5; max-height: 120px; + transition: all 0.2s; &:focus { border-color: #667eea; - box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); + box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.1); } &:disabled { background: #f5f5f5; } + + &::placeholder { + color: #aaa; + } } .send-btn { - padding: 12px 24px; + display: flex; + align-items: center; + gap: 8px; + padding: 14px 28px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; - border-radius: 12px; + border-radius: 16px; font-weight: 600; + font-size: 15px; transition: all 0.2s; + .send-icon { + font-size: 16px; + } + &:hover:not(:disabled) { - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4); + } + + &:active:not(:disabled) { + transform: translateY(0); } &:disabled { - opacity: 0.6; + opacity: 0.5; cursor: not-allowed; } }