307 lines
7.7 KiB
Vue
307 lines
7.7 KiB
Vue
<template>
|
|
<div class="kb-result-grouped-list">
|
|
<div v-if="showSummary" class="result-summary">
|
|
找到 {{ normalizedChunks.length }} 个相关文档片段,来自 {{ fileGroupList.length }} 个文件
|
|
</div>
|
|
|
|
<div class="kb-results" v-if="normalizedChunks.length > 0">
|
|
<div v-for="fileGroup in fileGroupList" :key="fileGroup.filename" class="file-group">
|
|
<div
|
|
class="file-header"
|
|
:class="{ expanded: expandedFiles.has(fileGroup.filename) }"
|
|
@click="toggleFile(fileGroup.filename)"
|
|
>
|
|
<div class="file-info">
|
|
<FileText :size="14" color="var(--gray-600)" />
|
|
<span class="file-name">{{ fileGroup.filename }}</span>
|
|
<span class="chunk-count">{{ fileGroup.chunks.length }} chunks</span>
|
|
</div>
|
|
<ChevronDown
|
|
:size="14"
|
|
class="expand-icon"
|
|
:class="{ rotated: expandedFiles.has(fileGroup.filename) }"
|
|
/>
|
|
</div>
|
|
|
|
<div v-if="expandedFiles.has(fileGroup.filename)" class="chunks-container">
|
|
<div
|
|
v-for="(chunk, index) in fileGroup.chunks"
|
|
:key="getChunkKey(chunk, index)"
|
|
class="chunk-item"
|
|
:class="{ 'high-relevance': typeof chunk.score === 'number' && chunk.score > 0.5 }"
|
|
@click="openChunkDetail(chunk, index + 1)"
|
|
>
|
|
<div class="chunk-summary">
|
|
<span class="chunk-index">#{{ index + 1 }}</span>
|
|
<div class="chunk-scores">
|
|
<span v-if="typeof chunk.score === 'number'" class="score-item"
|
|
>相似度 {{ (chunk.score * 100).toFixed(0) }}%</span
|
|
>
|
|
<span v-if="typeof chunk.rerank_score === 'number'" class="score-item"
|
|
>重排序 {{ (chunk.rerank_score * 100).toFixed(0) }}%</span
|
|
>
|
|
</div>
|
|
<span class="chunk-preview">{{ getPreviewText(chunk.content) }}</span>
|
|
<Eye :size="14" class="view-icon" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else class="no-results">
|
|
<p>{{ emptyText }}</p>
|
|
</div>
|
|
|
|
<KbChunkDetailModal
|
|
v-model:open="modalVisible"
|
|
:chunk="selectedChunk"
|
|
:title-prefix="`文档片段 #${selectedChunkIndex || '-'} `"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, ref, watch } from 'vue'
|
|
import { FileText, ChevronDown, Eye } from 'lucide-vue-next'
|
|
import KbChunkDetailModal from './KbChunkDetailModal.vue'
|
|
|
|
const props = defineProps({
|
|
chunks: {
|
|
type: [Array, Object],
|
|
default: () => []
|
|
},
|
|
showSummary: {
|
|
type: Boolean,
|
|
default: true
|
|
},
|
|
emptyText: {
|
|
type: String,
|
|
default: '未找到相关知识库内容'
|
|
}
|
|
})
|
|
|
|
const expandedFiles = ref(new Set())
|
|
const modalVisible = ref(false)
|
|
const selectedChunk = ref(null)
|
|
const selectedChunkIndex = ref(null)
|
|
|
|
const resolveChunks = (input) => {
|
|
if (Array.isArray(input)) return input
|
|
if (!input || typeof input !== 'object') return []
|
|
|
|
if (Array.isArray(input.chunks)) return input.chunks
|
|
if (Array.isArray(input.data?.chunks)) return input.data.chunks
|
|
|
|
return []
|
|
}
|
|
|
|
const normalizedChunks = computed(() =>
|
|
resolveChunks(props.chunks).filter((item) => item && typeof item === 'object' && item.content)
|
|
)
|
|
|
|
const fileGroupList = computed(() => {
|
|
const groups = new Map()
|
|
for (const item of normalizedChunks.value) {
|
|
const filename = item?.metadata?.source || '未知来源'
|
|
if (!groups.has(filename)) {
|
|
groups.set(filename, {
|
|
filename,
|
|
chunks: []
|
|
})
|
|
}
|
|
groups.get(filename).chunks.push(item)
|
|
}
|
|
|
|
return Array.from(groups.values()).sort((a, b) => a.filename.localeCompare(b.filename))
|
|
})
|
|
|
|
watch(
|
|
fileGroupList,
|
|
(groups) => {
|
|
// 分组变化时仅清理失效展开项,默认保持折叠状态。
|
|
const validFilenames = new Set(groups.map((item) => item.filename))
|
|
expandedFiles.value = new Set(
|
|
[...expandedFiles.value].filter((filename) => validFilenames.has(filename))
|
|
)
|
|
},
|
|
{ immediate: true }
|
|
)
|
|
|
|
const toggleFile = (filename) => {
|
|
if (expandedFiles.value.has(filename)) {
|
|
expandedFiles.value.delete(filename)
|
|
} else {
|
|
expandedFiles.value.add(filename)
|
|
}
|
|
}
|
|
|
|
const getChunkKey = (chunk, index) => {
|
|
if (chunk?.metadata?.chunk_id) return `${chunk.metadata.chunk_id}-${index}`
|
|
return `${chunk?.metadata?.source || 'chunk'}-${index}`
|
|
}
|
|
|
|
const getPreviewText = (text = '') => {
|
|
const content = String(text)
|
|
return content.length <= 100 ? content : `${content.substring(0, 100)}...`
|
|
}
|
|
|
|
const openChunkDetail = (chunk, index) => {
|
|
selectedChunk.value = chunk
|
|
selectedChunkIndex.value = index
|
|
modalVisible.value = true
|
|
}
|
|
</script>
|
|
|
|
<style scoped lang="less">
|
|
.kb-result-grouped-list {
|
|
padding: 4px;
|
|
.result-summary {
|
|
padding: 10px 12px;
|
|
background: var(--gray-25);
|
|
font-size: 12px;
|
|
color: var(--gray-700);
|
|
border: 1px solid var(--gray-150);
|
|
border-radius: 8px;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.kb-results {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
}
|
|
|
|
.file-group {
|
|
border: 1px solid var(--gray-150);
|
|
border-radius: 8px;
|
|
background: var(--gray-0);
|
|
overflow: hidden;
|
|
|
|
.file-header {
|
|
padding: 8px 12px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
cursor: pointer;
|
|
background: var(--gray-10);
|
|
|
|
&:hover {
|
|
background: var(--gray-25);
|
|
}
|
|
|
|
&.expanded {
|
|
background: var(--gray-25);
|
|
border-bottom: 1px solid var(--gray-100);
|
|
}
|
|
|
|
.file-info {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex: 1;
|
|
min-width: 0;
|
|
|
|
.file-name {
|
|
font-size: 13px;
|
|
color: var(--gray-700);
|
|
flex: 1;
|
|
min-width: 0;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.chunk-count {
|
|
font-size: 11px;
|
|
color: var(--gray-700);
|
|
white-space: nowrap;
|
|
}
|
|
}
|
|
|
|
.expand-icon {
|
|
color: var(--gray-700);
|
|
transition: transform 0.2s ease;
|
|
|
|
&.rotated {
|
|
transform: rotate(180deg);
|
|
}
|
|
}
|
|
}
|
|
|
|
.chunk-item {
|
|
padding: 10px 12px;
|
|
border-bottom: 1px solid var(--gray-100);
|
|
cursor: pointer;
|
|
|
|
&:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
&.high-relevance {
|
|
background: var(--gray-5);
|
|
}
|
|
|
|
&:hover {
|
|
background: var(--gray-25);
|
|
}
|
|
|
|
.chunk-summary {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
|
|
.chunk-index {
|
|
color: var(--gray-700);
|
|
font-size: 11px;
|
|
min-width: 22px;
|
|
text-align: center;
|
|
background: var(--gray-25);
|
|
border-radius: 4px;
|
|
padding: 1px 4px;
|
|
}
|
|
|
|
.chunk-scores {
|
|
display: flex;
|
|
gap: 6px;
|
|
|
|
.score-item {
|
|
font-size: 11px;
|
|
color: var(--gray-700);
|
|
background: var(--gray-25);
|
|
border: 1px solid var(--gray-100);
|
|
border-radius: 4px;
|
|
padding: 1px 5px;
|
|
white-space: nowrap;
|
|
}
|
|
}
|
|
|
|
.chunk-preview {
|
|
flex: 1;
|
|
min-width: 0;
|
|
font-size: 12px;
|
|
color: var(--gray-700);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.view-icon {
|
|
color: var(--gray-700);
|
|
opacity: 0.5;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
.no-results {
|
|
text-align: center;
|
|
color: var(--gray-700);
|
|
padding: 14px;
|
|
font-size: 12px;
|
|
border: 1px dashed var(--gray-200);
|
|
border-radius: 8px;
|
|
}
|
|
}
|
|
</style>
|