변경3
This commit is contained in:
@@ -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`)
|
||||
|
||||
@@ -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%);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// 로딩 애니메이션
|
||||
|
||||
Reference in New Issue
Block a user