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

@@ -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;
}
// 로딩 애니메이션