This commit is contained in:
2025-12-12 03:30:14 +09:00
parent dc9e031d93
commit 1b2430bb7a
3 changed files with 386 additions and 184 deletions

View File

@@ -20,11 +20,13 @@ 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' },
timeout: 600000 // 10분 (600초)
timeout: 60000 // 1분 (파일 업로드만)
}),
// 문서 처리 상태 조회 (폴링용)
getStatus: (docId) => api.get(`/documents/${docId}/status`),
delete: (docId) => api.delete(`/documents/${docId}`),
deleteAll: (topicId) => api.delete(`/topics/${topicId}/documents`),
getChunks: (docId) => api.get(`/documents/${docId}/chunks`)

View File

@@ -82,30 +82,39 @@
<div class="doc-meta">
<span>{{ formatFileSize(doc.fileSize) }}</span>
<span></span>
<span>청크 {{ doc.chunkCount }}</span>
<span>청크 {{ doc.chunkCount || 0 }}</span>
<span></span>
<span>{{ formatDate(doc.createdAt) }}</span>
</div>
<!-- 처리 중일 프로그레스 표시 -->
<div v-if="doc.docStatus === 'PROCESSING' || doc.docStatus === 'PENDING'" class="progress-section">
<div class="progress-bar">
<div
class="progress-fill"
:style="{ width: (doc.processProgress || 0) + '%' }"
></div>
</div>
<span class="progress-text">
{{ doc.processMessage || '처리 대기중' }} ({{ doc.processProgress || 0 }}%)
</span>
</div>
</div>
<div :class="['doc-status', doc.docStatus.toLowerCase()]">
<div :class="['doc-status', doc.docStatus?.toLowerCase()]">
{{ getStatusLabel(doc.docStatus) }}
</div>
<button class="chunk-btn" @click="openChunkModal(doc)" title="청크 보기">📝</button>
<button
class="chunk-btn"
@click="openChunkModal(doc)"
title="청크 보기"
:disabled="doc.docStatus !== 'INDEXED'"
>📝</button>
<button class="delete-btn" @click="deleteDocument(doc.docId)" title="삭제">🗑</button>
</div>
</div>
</template>
</div>
<!-- 업로드 진행 상태 -->
<div v-if="uploading" class="upload-overlay">
<div class="upload-modal">
<div class="upload-spinner"></div>
<p>문서 업로드 ...</p>
<p class="upload-hint">문서 파싱 임베딩 생성에 시간이 소요됩니다.</p>
</div>
</div>
<!-- 주제 추가/수정 모달 -->
<div v-if="showTopicModal" class="modal-overlay" @click.self="closeTopicModal">
<div class="topic-modal">
@@ -229,14 +238,17 @@
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { topicApi, docApi } from '@/api'
// 상태
const topics = ref([])
const selectedTopic = ref(null)
const documents = ref([])
const uploading = ref(false)
// 폴링 관련
const pollingInterval = ref(null)
const processingDocIds = ref(new Set())
// 청크 모달 상태
const showChunkModal = ref(false)
@@ -282,23 +294,102 @@ const loadDocuments = async (topicId) => {
try {
const { data } = await docApi.getList(topicId)
documents.value = data
// 처리 중인 문서가 있으면 폴링 시작
checkAndStartPolling()
} catch (error) {
console.error('문서 로드 실패:', error)
documents.value = []
}
}
// 처리 중인 문서 확인 및 폴링 시작
const checkAndStartPolling = () => {
const processingDocs = documents.value.filter(
doc => doc.docStatus === 'PENDING' || doc.docStatus === 'PROCESSING'
)
if (processingDocs.length > 0) {
processingDocIds.value = new Set(processingDocs.map(d => d.docId))
startPolling()
} else {
stopPolling()
}
}
// 폴링 시작
const startPolling = () => {
if (pollingInterval.value) return // 이미 폴링 중
console.log('[Polling] 시작 - 처리 중인 문서:', [...processingDocIds.value])
pollingInterval.value = setInterval(async () => {
await pollDocumentStatuses()
}, 3000) // 3초마다
}
// 폴링 중지
const stopPolling = () => {
if (pollingInterval.value) {
console.log('[Polling] 중지')
clearInterval(pollingInterval.value)
pollingInterval.value = null
}
processingDocIds.value.clear()
}
// 문서 상태 폴링
const pollDocumentStatuses = async () => {
if (processingDocIds.value.size === 0) {
stopPolling()
return
}
const idsToRemove = []
for (const docId of processingDocIds.value) {
try {
const { data } = await docApi.getStatus(docId)
// 문서 목록에서 해당 문서 찾아서 업데이트
const doc = documents.value.find(d => d.docId === docId)
if (doc) {
doc.docStatus = data.docStatus
doc.processProgress = data.processProgress
doc.processMessage = data.processMessage
doc.chunkCount = data.chunkCount
doc.errorMsg = data.errorMsg
}
// 완료/실패 시 폴링 목록에서 제거
if (data.docStatus === 'INDEXED' || data.docStatus === 'FAILED') {
idsToRemove.push(docId)
console.log(`[Polling] 문서 ${docId} 처리 완료:`, data.docStatus)
}
} catch (error) {
console.error(`[Polling] 문서 ${docId} 상태 조회 실패:`, error)
}
}
// 완료된 문서 제거
idsToRemove.forEach(id => processingDocIds.value.delete(id))
// 모든 문서 처리 완료 시 폴링 중지
if (processingDocIds.value.size === 0) {
stopPolling()
}
}
// 주제 모달 열기
const openTopicModal = (topic = null) => {
editingTopic.value = topic
if (topic) {
// 수정 모드
topicForm.topicIcon = topic.topicIcon || '📁'
topicForm.topicName = topic.topicName
topicForm.topicCode = topic.topicCode
topicForm.topicDesc = topic.topicDesc || ''
} else {
// 추가 모드
topicForm.topicIcon = '📁'
topicForm.topicName = ''
topicForm.topicCode = ''
@@ -322,7 +413,6 @@ const saveTopic = async () => {
try {
if (editingTopic.value) {
// 수정
await topicApi.update(editingTopic.value.topicId, {
topicIcon: topicForm.topicIcon,
topicName: topicForm.topicName,
@@ -332,7 +422,6 @@ const saveTopic = async () => {
})
alert('주제가 수정되었습니다.')
} else {
// 추가
await topicApi.create({
topicIcon: topicForm.topicIcon,
topicName: topicForm.topicName,
@@ -361,6 +450,7 @@ const deleteTopic = async (topicId) => {
await loadTopics()
selectedTopic.value = null
documents.value = []
stopPolling()
alert('주제가 삭제되었습니다.')
} catch (error) {
console.error('주제 삭제 실패:', error)
@@ -373,23 +463,28 @@ const handleFileUpload = async (event) => {
const files = event.target.files
if (!files.length || !selectedTopic.value) return
uploading.value = true
try {
for (const file of files) {
const formData = new FormData()
formData.append('file', file)
await docApi.upload(selectedTopic.value.topicId, formData)
// 업로드 (즉시 응답 받음)
const { data } = await docApi.upload(selectedTopic.value.topicId, formData)
// 목록에 즉시 추가
documents.value.unshift(data)
// 폴링 목록에 추가
processingDocIds.value.add(data.docId)
}
await loadDocuments(selectedTopic.value.topicId)
alert('업로드 완료!')
// 폴링 시작
startPolling()
} catch (error) {
console.error('업로드 실패:', error)
alert('업로드 중 오류가 발생했습니다.')
} finally {
uploading.value = false
event.target.value = ''
}
}
@@ -400,6 +495,8 @@ const deleteDocument = async (docId) => {
try {
await docApi.delete(docId)
// 폴링 목록에서 제거
processingDocIds.value.delete(docId)
await loadDocuments(selectedTopic.value.topicId)
} catch (error) {
console.error('삭제 실패:', error)
@@ -414,6 +511,7 @@ const deleteAllDocuments = async () => {
try {
await docApi.deleteAll(selectedTopic.value.topicId)
stopPolling()
await loadDocuments(selectedTopic.value.topicId)
alert('전체 삭제 완료!')
} catch (error) {
@@ -481,17 +579,23 @@ const formatDate = (dateStr) => {
const getStatusLabel = (status) => {
const labels = {
'PENDING': '대기중',
'PROCESSING': '처리중',
'INDEXED': '완료',
'FAILED': '실패'
'PENDING': '대기중',
'PROCESSING': '⚙️ 처리중',
'INDEXED': '완료',
'FAILED': '실패'
}
return labels[status] || status
}
// 컴포넌트 마운트 시
onMounted(() => {
loadTopics()
})
// 컴포넌트 언마운트 시 폴링 정리
onUnmounted(() => {
stopPolling()
})
</script>
<style lang="scss" scoped>
@@ -746,6 +850,7 @@ onMounted(() => {
border-radius: 12px;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
&.pending { background: #fff3e0; color: #e65100; }
&.processing { background: #e3f2fd; color: #1565c0; }
@@ -761,47 +866,50 @@ onMounted(() => {
opacity: 0.5;
transition: opacity 0.2s;
&:hover {
&:hover:not(:disabled) {
opacity: 1;
}
&:disabled {
opacity: 0.3;
cursor: not-allowed;
}
}
.chunk-btn:hover {
.chunk-btn:hover:not(:disabled) {
color: #667eea;
}
}
// 업로드 오버레이
.upload-overlay {
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;
}
.upload-modal {
background: white;
padding: 40px 60px;
border-radius: 16px;
text-align: center;
// 프로그레스 바
.progress-section {
margin-top: 8px;
p {
margin-top: 16px;
font-size: 16px;
.progress-bar {
width: 100%;
height: 6px;
background: #e8e8e8;
border-radius: 3px;
overflow: hidden;
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
border-radius: 3px;
transition: width 0.3s ease;
}
}
.upload-hint {
font-size: 13px;
color: #888;
.progress-text {
display: block;
margin-top: 4px;
font-size: 11px;
color: #667eea;
font-weight: 500;
}
}
// 업로드 스피너
.upload-spinner {
width: 48px;
height: 48px;
@@ -817,7 +925,7 @@ onMounted(() => {
100% { transform: rotate(360deg); }
}
// 주제 모달
// 모달
.modal-overlay {
position: fixed;
top: 0;
@@ -1068,7 +1176,6 @@ onMounted(() => {
}
}
// 스마트 청킹 메타데이터
.chunk-metadata {
padding: 12px;
background: linear-gradient(135deg, #f8f9ff 0%, #f0f4ff 100%);

View File

@@ -53,54 +53,75 @@
<div class="message-content">
<div class="message-text" v-html="renderMarkdown(msg.content)"></div>
<!-- 참조 문서 (개선된 버전) -->
<!-- 참조 문서 (문서별 그룹핑) -->
<div v-if="msg.sources && msg.sources.length > 0" class="message-sources">
<div
class="sources-toggle"
@click="msg.sourcesExpanded = !msg.sourcesExpanded"
>
<span class="toggle-icon">{{ msg.sourcesExpanded ? '▼' : '▶' }}</span>
<span class="toggle-text">📎 참조 문서 ({{ msg.sources.length }})</span>
<span class="toggle-text">
📎 참조 문서 ({{ getUniqueDocCount(msg.sources) }} 문서, {{ msg.sources.length }} 구간)
</span>
</div>
<transition name="slide">
<div v-if="msg.sourcesExpanded" class="sources-list">
<!-- 문서별로 그룹핑 -->
<div
v-for="(source, idx) in msg.sources"
:key="idx"
class="source-card"
v-for="(group, docId) in groupSourcesByDoc(msg.sources)"
:key="docId"
class="doc-group"
>
<div class="source-header">
<div
class="similarity-badge"
:class="getSimilarityClass(source.similarity)"
>
<div class="similarity-bar" :style="{ width: (source.similarity * 100) + '%' }"></div>
<span class="similarity-text">{{ (source.similarity * 100).toFixed(0) }}%</span>
</div>
<span class="source-doc" v-if="source.docName">
📄 {{ source.docName }}
<span class="chunk-info" v-if="source.totalChunks">
({{ source.chunkIndex + 1 }}/{{ source.totalChunks }})
</span>
</span>
<span class="file-type-badge" v-if="source.fileType">
{{ source.fileType.toUpperCase() }}
</span>
</div>
<div class="source-content">
{{ truncateText(source.content, 200) }}
</div>
<button
v-if="source.content && source.content.length > 200"
class="expand-btn"
@click="source.expanded = !source.expanded"
<!-- 문서 헤더 -->
<div
class="doc-header"
@click="group.expanded = !group.expanded"
>
{{ source.expanded ? '접기' : '더보기' }}
</button>
<div v-if="source.expanded" class="source-full-content">
{{ source.content }}
<span class="doc-toggle">{{ group.expanded ? '' : '' }}</span>
<span class="file-type-badge" v-if="group.fileType">
{{ group.fileType.toUpperCase() }}
</span>
<span class="doc-name">📄 {{ group.docName || '문서 ' + docId }}</span>
<span class="doc-chunk-count">({{ group.chunks.length }} 구간)</span>
<span class="doc-max-similarity">
최대 {{ (group.maxSimilarity * 100).toFixed(0) }}%
</span>
</div>
<!-- 청크 목록 -->
<transition name="slide">
<div v-if="group.expanded" class="chunk-list">
<div
v-for="(chunk, idx) in group.chunks"
:key="idx"
class="chunk-card"
>
<div class="chunk-header">
<div
class="similarity-badge"
:class="getSimilarityClass(chunk.similarity)"
>
<div class="similarity-bar" :style="{ width: (chunk.similarity * 100) + '%' }"></div>
<span class="similarity-text">{{ (chunk.similarity * 100).toFixed(0) }}%</span>
</div>
<span class="chunk-position">
구간 {{ chunk.chunkIndex + 1 }} / {{ chunk.totalChunks }}
</span>
</div>
<div class="chunk-content">
{{ chunk.expanded ? chunk.content : truncateText(chunk.content, 150) }}
</div>
<button
v-if="chunk.content && chunk.content.length > 150"
class="expand-btn"
@click="chunk.expanded = !chunk.expanded"
>
{{ chunk.expanded ? '접기' : '더보기' }}
</button>
</div>
</div>
</transition>
</div>
</div>
</transition>
@@ -187,6 +208,43 @@ const startNewChat = () => {
sessionKey.value = null
}
// 고유 문서 수 계산
const getUniqueDocCount = (sources) => {
const uniqueDocs = new Set(sources.map(s => s.docId))
return uniqueDocs.size
}
// 문서별로 소스 그룹핑
const groupSourcesByDoc = (sources) => {
const groups = {}
sources.forEach(source => {
const docId = source.docId
if (!groups[docId]) {
groups[docId] = {
docId: docId,
docName: source.docName,
fileType: source.fileType,
totalChunks: source.totalChunks,
chunks: [],
maxSimilarity: 0,
expanded: true // 기본 펼침
}
}
groups[docId].chunks.push({ ...source, expanded: false })
if (source.similarity > groups[docId].maxSimilarity) {
groups[docId].maxSimilarity = source.similarity
}
})
// 청크 정렬 (chunkIndex 순)
Object.values(groups).forEach(group => {
group.chunks.sort((a, b) => a.chunkIndex - b.chunkIndex)
})
return groups
}
// 메시지 전송
const sendMessage = async () => {
const question = inputText.value.trim()
@@ -213,11 +271,11 @@ const sendMessage = async () => {
sessionKey.value = data.sessionKey
}
// AI 응답 추가 (sourcesExpanded 기본값 false)
// AI 응답 추가
messages.value.push({
role: 'assistant',
content: data.answer,
sources: data.sources?.map(s => ({ ...s, expanded: false })) || [],
sources: data.sources || [],
sourcesExpanded: false
})
} catch (error) {
@@ -536,7 +594,7 @@ onMounted(() => {
}
}
// 참조 문서 섹션 (개선)
// 참조 문서 섹션
.message-sources {
margin-top: 16px;
border-top: 1px solid #eee;
@@ -559,7 +617,6 @@ onMounted(() => {
.toggle-icon {
font-size: 10px;
color: #888;
transition: transform 0.2s;
}
.toggle-text {
@@ -573,91 +630,54 @@ onMounted(() => {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 10px;
gap: 12px;
}
.source-card {
background: linear-gradient(135deg, #f8f9fc 0%, #f0f2f8 100%);
// 문서 그룹
.doc-group {
background: #f8f9fc;
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);
}
overflow: hidden;
}
.source-header {
.doc-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;
gap: 10px;
padding: 12px 16px;
background: linear-gradient(135deg, #f0f2f8 0%, #e8ebf2 100%);
cursor: pointer;
transition: background 0.2s;
.similarity-bar {
position: absolute;
left: 0;
top: 0;
height: 100%;
border-radius: 12px;
transition: width 0.3s ease;
&:hover {
background: linear-gradient(135deg, #e8ebf2 0%, #dfe3ed 100%);
}
.similarity-text {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
.doc-toggle {
font-size: 10px;
color: #888;
}
.doc-name {
flex: 1;
font-size: 13px;
font-weight: 600;
color: #333;
}
.doc-chunk-count {
font-size: 12px;
color: #888;
}
.doc-max-similarity {
font-size: 11px;
font-weight: 700;
padding: 2px 8px;
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
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;
border-radius: 10px;
font-weight: 600;
}
}
@@ -671,31 +691,104 @@ onMounted(() => {
text-transform: uppercase;
}
.source-content {
// 청크 목록
.chunk-list {
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.chunk-card {
background: white;
border: 1px solid #e8e8e8;
border-radius: 10px;
padding: 12px;
transition: all 0.2s;
&:hover {
border-color: #667eea;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.1);
}
}
.chunk-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.chunk-position {
font-size: 11px;
color: #888;
background: #f0f0f0;
padding: 2px 8px;
border-radius: 8px;
}
.similarity-badge {
position: relative;
min-width: 55px;
height: 22px;
background: #e8e8e8;
border-radius: 11px;
overflow: hidden;
.similarity-bar {
position: absolute;
left: 0;
top: 0;
height: 100%;
border-radius: 11px;
transition: width 0.3s ease;
}
.similarity-text {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 10px;
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;
}
}
}
.chunk-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;
font-size: 11px;
color: #667eea;
background: rgba(102, 126, 234, 0.1);
border-radius: 12px;
border-radius: 10px;
transition: all 0.2s;
&:hover {
@@ -719,7 +812,7 @@ onMounted(() => {
.slide-enter-to,
.slide-leave-from {
opacity: 1;
max-height: 1000px;
max-height: 2000px;
}
// 로딩 애니메이션