ForcePilot/web/src/components/sources/KbResultGroupedList.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>