feat(web): add collapsible source panel with kb and web groups

This commit is contained in:
肖泽涛 2026-02-24 14:22:11 +08:00
parent 452834d236
commit df8b97017a
10 changed files with 900 additions and 603 deletions

View File

@ -87,6 +87,10 @@
:show-refs="['model', 'copy']"
:is-latest-message="false"
/>
<ConversationSourcesPanel
v-if="shouldShowRefs(conv)"
:sources="getConversationSources(conv)"
/>
</div>
<!-- 生成中的加载状态 - 增强条件支持主聊天和resume流程 -->
@ -215,6 +219,7 @@ import HumanApprovalModal from '@/components/HumanApprovalModal.vue'
import { useApproval } from '@/composables/useApproval'
import { useAgentStreamHandler } from '@/composables/useAgentStreamHandler'
import AgentPanel from '@/components/AgentPanel.vue'
import ConversationSourcesPanel from '@/components/ConversationSourcesPanel.vue'
// ==================== PROPS & EMITS ====================
const props = defineProps({
@ -692,7 +697,9 @@ const fetchAgentState = async (agentId, threadId) => {
const newTs = getThreadState(threadId)
if (newTs) newTs.agentState = res.agent_state || null
}
} catch (error) {}
} catch {
//
}
}
const fetchMentionResources = async () => {
@ -716,7 +723,7 @@ const ensureActiveThread = async (title = '新的对话') => {
chatState.currentThreadId = newThread.id
return newThread.id
}
} catch (error) {
} catch {
// createThread
}
return null
@ -1187,6 +1194,10 @@ const showMsgRefs = (msg) => {
return false
}
const getConversationSources = (conv) => {
return MessageProcessor.extractSourcesFromConversation(conv, availableKnowledgeBases.value)
}
// ==================== LIFECYCLE & WATCHERS ====================
const loadChatsList = async () => {
const agentId = currentAgentId.value

View File

@ -0,0 +1,120 @@
<template>
<div v-if="hasAny" class="conversation-sources-panel">
<button class="panel-header" type="button" @click="toggleExpanded">
<div class="header-left">
<div class="title">来源</div>
<div class="summary">
<span v-if="knowledgeChunks.length > 0">知识库 {{ knowledgeChunks.length }}</span>
<span v-if="webSources.length > 0">网络 {{ webSources.length }}</span>
</div>
</div>
<ChevronDown :size="14" class="expand-icon" :class="{ rotated: !expanded }" />
</button>
<div v-if="expanded" class="panel-body">
<KnowledgeSourceSection v-if="knowledgeChunks.length > 0" :chunks="knowledgeChunks" />
<WebSearchSourceSection v-if="webSources.length > 0" :sources="webSources" />
</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { ChevronDown } from 'lucide-vue-next'
import KnowledgeSourceSection from '@/components/KnowledgeSourceSection.vue'
import WebSearchSourceSection from '@/components/WebSearchSourceSection.vue'
const props = defineProps({
sources: {
type: Object,
default: () => ({})
},
knowledgeChunks: {
type: Array,
default: () => []
},
webSources: {
type: Array,
default: () => []
}
})
const expanded = ref(false)
const knowledgeChunks = computed(() =>
Array.isArray(props.sources?.knowledgeChunks) ? props.sources.knowledgeChunks : props.knowledgeChunks
)
const webSources = computed(() =>
Array.isArray(props.sources?.webSources) ? props.sources.webSources : props.webSources
)
const hasAny = computed(
() =>
(Array.isArray(knowledgeChunks.value) && knowledgeChunks.value.length > 0) ||
(Array.isArray(webSources.value) && webSources.value.length > 0)
)
const toggleExpanded = () => {
expanded.value = !expanded.value
}
</script>
<style scoped lang="less">
.conversation-sources-panel {
margin: 8px 0 14px 0;
border: 1px solid var(--gray-200);
border-radius: 8px;
background: var(--gray-25);
overflow: hidden;
.panel-header {
width: 100%;
border: none;
background: transparent;
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
cursor: pointer;
.header-left {
display: flex;
align-items: baseline;
gap: 10px;
min-width: 0;
.title {
font-size: 13px;
font-weight: 600;
color: var(--gray-700);
line-height: 20px;
}
.summary {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 12px;
color: var(--gray-600);
line-height: 20px;
}
}
.expand-icon {
color: var(--gray-600);
transition: transform 0.2s ease;
flex-shrink: 0;
&.rotated {
transform: rotate(-90deg);
}
}
}
.panel-body {
display: flex;
flex-direction: column;
gap: 10px;
padding: 0 10px 10px;
}
}
</style>

View File

@ -0,0 +1,28 @@
<template>
<div class="source-section">
<div class="section-title">知识库来源 ({{ chunks.length }})</div>
<KbResultGroupedList :chunks="chunks" :show-summary="false" />
</div>
</template>
<script setup>
import KbResultGroupedList from '@/components/sources/KbResultGroupedList.vue'
defineProps({
chunks: {
type: Array,
default: () => []
}
})
</script>
<style scoped lang="less">
.source-section {
.section-title {
font-size: 12px;
color: var(--gray-700);
margin-bottom: 8px;
font-weight: 600;
}
}
</style>

View File

@ -10,129 +10,16 @@
</div>
</template>
<template #result="{ resultContent }">
<!-- get_mindmap 操作纯文本显示 -->
<div v-if="operation === 'get_mindmap'" class="knowledge-base-result">
<div class="mindmap-result">
<pre class="mindmap-content">{{ formatMindmapResult(resultContent) }}</pre>
</div>
</div>
<!-- search 操作原有的文件分组显示 -->
<div v-else-if="isArrayResult(resultContent)" class="knowledge-base-result">
<div class="result-summary">
找到 {{ parsedData(resultContent).length }} 个相关文档片段来自
{{ fileGroups(parsedData(resultContent)).length }} 个文件
</div>
<div class="kb-results">
<div
v-for="fileGroup in fileGroups(parsedData(resultContent))"
: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" />
<span class="file-name">{{ fileGroup.filename }}</span>
<span class="chunk-count">{{ fileGroup.chunks.length }} chunks</span>
</div>
<div class="expand-icon">
<ChevronDown
:size="14"
:class="{ rotated: expandedFiles.has(fileGroup.filename) }"
/>
</div>
</div>
<!-- 展开的chunks列表 -->
<div v-if="expandedFiles.has(fileGroup.filename)" class="chunks-container">
<div
v-for="(chunk, index) in fileGroup.chunks"
:key="chunk.id"
class="chunk-item"
:class="{ 'high-relevance': chunk.score > 0.5 }"
@click="showChunkDetail(chunk, index + 1)"
>
<div class="chunk-summary">
<span class="chunk-index">#{{ index + 1 }}</span>
<div class="chunk-scores">
<span class="score-item">相似度 {{ (chunk.score * 100).toFixed(0) }}%</span>
<span v-if="chunk.rerank_score" 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-if="parsedData(resultContent).length === 0" class="no-results">
<p>未找到相关知识库内容</p>
</div>
<!-- 弹窗展示chunk详细信息 -->
<a-modal
v-model:open="modalVisible"
:title="`文档片段 #${selectedChunk?.index} - ${selectedChunk?.data?.metadata?.source}`"
width="800px"
:footer="null"
class="chunk-detail-modal"
>
<div v-if="selectedChunk" class="chunk-detail">
<div class="detail-header">
<div class="detail-scores">
<div class="score-card">
<div class="score-label">相似度分数</div>
<div class="score-value-large">
{{ (selectedChunk.data.score * 100).toFixed(1) }}%
</div>
<a-progress
:percent="getPercent(selectedChunk.data.score)"
:stroke-color="getScoreColor(selectedChunk.data.score)"
:show-info="false"
:stroke-width="6"
/>
</div>
<div v-if="selectedChunk.data.rerank_score" class="score-card">
<div class="score-label">重排序分数</div>
<div class="score-value-large">
{{ (selectedChunk.data.rerank_score * 100).toFixed(1) }}%
</div>
<a-progress
:percent="getPercent(selectedChunk.data.rerank_score)"
:stroke-color="getScoreColor(selectedChunk.data.rerank_score)"
:show-info="false"
:stroke-width="6"
/>
</div>
</div>
<div class="detail-meta">
<span class="meta-item"
><Database :size="12" /> ID:
{{
selectedChunk.data.metadata.chunk_id || selectedChunk.data.metadata.file_id
}}</span
>
</div>
</div>
<div class="detail-content">
<h5>文档内容</h5>
<div class="content-text">{{ selectedChunk.data.content }}</div>
</div>
</div>
</a-modal>
<KbResultGroupedList :chunks="parsedData(resultContent)" />
</div>
<!-- 纯文本结果显示 -->
<div v-else class="knowledge-base-result">
<div class="plain-text-result">
<pre class="plain-text-content">{{ resultContent }}</pre>
@ -143,9 +30,9 @@
</template>
<script setup>
import { computed } from 'vue'
import BaseToolCall from '../BaseToolCall.vue'
import { ref, computed } from 'vue'
import { FileText, ChevronDown, Eye, Database } from 'lucide-vue-next'
import KbResultGroupedList from '@/components/sources/KbResultGroupedList.vue'
const props = defineProps({
toolCall: {
@ -154,29 +41,20 @@ const props = defineProps({
}
})
//
const args = computed(() => {
const args = props.toolCall.args || props.toolCall.function?.arguments
if (!args) return {}
if (typeof args === 'object') return args
const value = props.toolCall.args || props.toolCall.function?.arguments
if (!value) return {}
if (typeof value === 'object') return value
try {
return JSON.parse(args)
} catch (e) {
return JSON.parse(value)
} catch {
return {}
}
})
const toolName = computed(() => {
return props.toolCall.name || props.toolCall.function?.name || '知识库'
})
const toolName = computed(() => props.toolCall.name || props.toolCall.function?.name || '知识库')
const operation = computed(() => args.value.operation || 'search')
//
const operation = computed(() => {
return args.value.operation || 'search'
})
//
const operationLabel = computed(() => {
const labels = {
search: `${toolName.value} 搜索`,
@ -185,20 +63,14 @@ const operationLabel = computed(() => {
return labels[operation.value] || operation.value
})
//
const queryText = computed(() => {
return args.value.query_text || ''
})
const fileName = computed(() => {
return args.value.file_name || ''
})
const queryText = computed(() => args.value.query_text || '')
const fileName = computed(() => args.value.file_name || '')
const parseData = (content) => {
if (typeof content === 'string') {
try {
return JSON.parse(content)
} catch (error) {
} catch {
return []
}
}
@ -207,13 +79,11 @@ const parseData = (content) => {
const parsedData = (content) => parseData(content)
//
const isArrayResult = (content) => {
if (Array.isArray(content)) return true
if (typeof content === 'string') {
try {
const parsed = JSON.parse(content)
return Array.isArray(parsed)
return Array.isArray(JSON.parse(content))
} catch {
return false
}
@ -221,364 +91,37 @@ const isArrayResult = (content) => {
return false
}
//
const expandedFiles = ref(new Set())
//
const modalVisible = ref(false)
const selectedChunk = ref(null)
//
const fileGroups = (data) => {
const groups = new Map()
data.forEach((item) => {
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))
}
// /
const toggleFile = (filename) => {
if (expandedFiles.value.has(filename)) {
expandedFiles.value.delete(filename)
} else {
expandedFiles.value.add(filename)
}
}
// chunk
const showChunkDetail = (chunk, index) => {
selectedChunk.value = {
data: chunk,
index: index
}
modalVisible.value = true
}
//
const getPreviewText = (text) => {
if (text.length <= 100) return text
return text.substring(0, 100) + '...'
}
const getPercent = (score) => {
if (score <= 1) {
return Math.round(score * 100)
}
return Math.min(Math.round(score * 100), 100)
}
const getScoreColor = (score) => {
if (score >= 0.7) return '#52c41a' // 绿 -
if (score >= 0.5) return '#faad14' // -
return '#ff4d4f' // -
}
//
const formatMindmapResult = (content) => {
if (typeof content === 'string') {
return content
}
if (typeof content === 'object') {
return JSON.stringify(content, null, 2)
}
if (typeof content === 'string') return content
if (typeof content === 'object') return JSON.stringify(content, null, 2)
return String(content)
}
</script>
<style lang="less" scoped>
<style scoped lang="less">
.knowledge-base-result {
background: var(--gray-0);
border-radius: 8px;
// border: 1px solid var(--gray-200);
.mindmap-result {
padding: 12px 16px;
max-height: 300px;
overflow-y: auto;
.mindmap-content {
margin: 0;
font-size: 13px;
line-height: 1.6;
color: var(--gray-700);
white-space: pre-wrap;
word-break: break-word;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
}
.mindmap-result,
.plain-text-result {
padding: 12px 16px;
max-height: 300px;
overflow-y: auto;
.plain-text-content {
margin: 0;
font-size: 13px;
line-height: 1.6;
color: var(--gray-700);
white-space: pre-wrap;
word-break: break-word;
}
}
.result-summary {
padding: 12px 16px;
background: var(--gray-25);
font-size: 12px;
.mindmap-content,
.plain-text-content {
margin: 0;
font-size: 13px;
line-height: 1.6;
color: var(--gray-700);
border-bottom: 1px solid var(--gray-100);
white-space: pre-wrap;
word-break: break-word;
}
.kb-results {
padding: 8px;
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 14px;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
transition: all 0.2s ease;
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: 10px;
flex: 1;
min-width: 0;
svg {
color: var(--gray-700);
}
.file-name {
font-size: 13px;
font-weight: 500;
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;
margin-right: 4px;
}
}
.expand-icon {
color: var(--gray-700);
transition: transform 0.2s ease;
.rotated {
transform: rotate(180deg);
}
}
}
.chunks-container {
background: var(--gray-0);
}
.chunk-item {
padding: 10px 14px;
border-bottom: 1px solid var(--gray-100);
cursor: pointer;
transition: all 0.2s ease;
&:last-child {
border-bottom: none;
}
&.high-relevance {
background: var(--main-5);
}
&:hover {
background: var(--gray-25);
.view-icon {
opacity: 1;
}
}
.chunk-summary {
display: flex;
align-items: center;
gap: 10px;
.chunk-index {
color: var(--gray-700);
font-size: 11px;
font-weight: 500;
min-width: 20px;
text-align: center;
background: var(--gray-25);
padding: 1px 4px;
border-radius: 4px;
}
.chunk-scores {
display: flex;
gap: 6px;
.score-item {
font-size: 11px;
color: var(--gray-700);
background: var(--gray-25);
padding: 1px 5px;
border-radius: 4px;
border: 1px solid var(--gray-100);
white-space: nowrap;
}
}
.chunk-preview {
flex: 1;
font-size: 12px;
color: var(--gray-700);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.view-icon {
color: var(--gray-700);
opacity: 0.5;
transition: opacity 0.2s ease;
}
}
}
}
.no-results {
text-align: center;
color: var(--gray-700);
padding: 20px;
font-size: 12px;
}
}
:deep(.chunk-detail-modal) {
.ant-modal-header {
border-bottom: 1px solid var(--gray-200);
margin-bottom: 0;
}
.ant-modal-title {
color: var(--main-color);
font-weight: 500;
}
}
.chunk-detail {
.detail-header {
margin-bottom: 16px;
.detail-scores {
display: flex;
gap: 16px;
margin-bottom: 12px;
.score-card {
flex: 1;
padding: 12px 14px;
background: var(--gray-25);
border-radius: 8px;
border: 1px solid var(--gray-150);
.score-label {
font-size: 12px;
color: var(--gray-700);
margin-bottom: 6px;
font-weight: 500;
}
.score-value-large {
font-size: 18px;
font-weight: 600;
color: var(--gray-800);
margin-bottom: 8px;
}
}
}
.detail-meta {
display: flex;
gap: 12px;
.meta-item {
font-size: 11px;
color: var(--gray-700);
display: flex;
align-items: center;
gap: 4px;
svg {
color: var(--gray-700);
}
}
}
}
.detail-content {
h5 {
margin: 0 0 8px 0;
color: var(--gray-800);
font-size: 14px;
font-weight: 500;
}
.content-text {
font-size: 13px;
line-height: 1.6;
color: var(--gray-700);
white-space: pre-wrap;
word-break: break-word;
background: var(--gray-25);
padding: 16px;
border-radius: 8px;
border: 1px solid var(--gray-150);
max-height: 400px;
overflow-y: auto;
}
.mindmap-content {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
}
</style>

View File

@ -9,36 +9,10 @@
</template>
<template #result="{ resultContent }">
<div class="web-search-result">
<div
class="search-results"
<WebSearchResultList
v-if="parsedData(resultContent).results && parsedData(resultContent).results.length > 0"
>
<div
v-for="(result, index) in parsedData(resultContent).results"
:key="index"
class="search-result-item"
>
<div class="result-header">
<h5 class="result-title">
<a :href="result.url" target="_blank" rel="noopener noreferrer">
{{ result.title }}
</a>
</h5>
<span class="result-score">相关度: {{ (result.score * 100).toFixed(1) }}%</span>
</div>
<div class="result-meta">
<!-- <span class="result-url">{{ result.url }}</span> -->
<span v-if="result.published_date" class="result-date">
{{ formatDate(result.published_date) }}
</span>
</div>
<div class="result-content">
{{ result.content }}
</div>
</div>
</div>
:results="parsedData(resultContent).results"
/>
<div v-else-if="parsedData(resultContent).rawText" class="raw-content">
{{ parsedData(resultContent).rawText }}
@ -54,7 +28,7 @@
<script setup>
import BaseToolCall from '../BaseToolCall.vue'
import { parseToShanghai } from '@/utils/time'
import WebSearchResultList from '@/components/sources/WebSearchResultList.vue'
import { computed } from 'vue'
const props = defineProps({
@ -68,7 +42,7 @@ const parseData = (content) => {
if (typeof content === 'string') {
try {
return JSON.parse(content)
} catch (error) {
} catch {
return { query: '', results: [], response_time: 0, rawText: content }
}
}
@ -89,17 +63,11 @@ const query = computed(() => {
try {
const parsed = JSON.parse(args)
return parsed.query || parsed.q || ''
} catch (e) {
} catch {
return ''
}
})
const formatDate = (dateString) => {
if (!dateString) return ''
const parsed = parseToShanghai(dateString)
if (!parsed) return ''
return parsed.format('YYYY年MM月DD日')
}
</script>
<style lang="less" scoped>
@ -122,84 +90,6 @@ const formatDate = (dateString) => {
}
}
.search-results {
padding: 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.search-result-item {
padding: 12px;
border-bottom: 1px solid var(--gray-200);
transition: all 0.2s ease;
&:last-child {
border-bottom: none;
}
.result-header {
display: flex;
justify-content: space-between;
align-items: center;
.result-title {
margin: 0;
font-size: 14px;
line-height: 1.4;
flex: 1;
a {
color: var(--main-color);
text-decoration: none;
font-weight: 500;
&:hover {
color: var(--main-color);
text-decoration: underline;
}
}
}
.result-score {
font-size: 11px;
color: var(--gray-600);
background: var(--gray-50);
padding: 0px 6px;
border-radius: 10px;
margin-left: 8px;
}
}
.result-meta {
display: flex;
gap: 12px;
margin-bottom: 8px;
font-size: 11px;
color: var(--gray-500);
.result-url {
color: var(--main-400);
word-break: break-all;
}
.result-date {
color: var(--gray-500);
}
}
.result-content {
font-size: 13px;
line-height: 1.5;
color: var(--gray-700);
overflow: hidden;
display: -webkit-box;
line-clamp: 2;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
}
.raw-content {
padding: 12px;
font-size: 13px;

View File

@ -0,0 +1,28 @@
<template>
<div class="source-section">
<div class="section-title">网络搜索来源 ({{ sources.length }})</div>
<WebSearchResultList :results="sources" empty-text="未找到网络搜索来源" />
</div>
</template>
<script setup>
import WebSearchResultList from '@/components/sources/WebSearchResultList.vue'
defineProps({
sources: {
type: Array,
default: () => []
}
})
</script>
<style scoped lang="less">
.source-section {
.section-title {
font-size: 12px;
color: var(--gray-700);
margin-bottom: 8px;
font-weight: 600;
}
}
</style>

View File

@ -0,0 +1,96 @@
<template>
<a-modal
v-model:open="visible"
:title="modalTitle"
width="960px"
:footer="null"
:destroyOnClose="true"
wrap-class-name="chunk-detail-modal"
:bodyStyle="{ maxHeight: '72vh', overflowY: 'auto', padding: '12px 16px' }"
>
<div v-if="chunk" class="detail-meta">
<span v-if="typeof chunk.score === 'number'" class="score"
>相似度 {{ (chunk.score * 100).toFixed(1) }}%</span
>
<span v-if="chunk.metadata?.chunk_id" class="meta-item">chunk_id: {{ chunk.metadata.chunk_id }}</span>
</div>
<MdPreview
v-if="chunk?.content"
:modelValue="chunk.content"
:theme="theme"
previewTheme="github"
class="chunk-markdown-content"
/>
<div v-else class="empty-text">暂无内容</div>
</a-modal>
</template>
<script setup>
import { computed } from 'vue'
import { MdPreview } from 'md-editor-v3'
import 'md-editor-v3/lib/preview.css'
import { useThemeStore } from '@/stores/theme'
const props = defineProps({
open: {
type: Boolean,
default: false
},
chunk: {
type: Object,
default: null
},
titlePrefix: {
type: String,
default: '文档片段详情'
}
})
const emit = defineEmits(['update:open'])
const themeStore = useThemeStore()
const theme = computed(() => (themeStore.isDark ? 'dark' : 'light'))
const visible = computed({
get: () => props.open,
set: (value) => emit('update:open', value)
})
const modalTitle = computed(() => {
const source = props.chunk?.metadata?.source
return source ? `${props.titlePrefix} - ${source}` : props.titlePrefix
})
</script>
<style scoped lang="less">
.detail-meta {
margin-bottom: 8px;
font-size: 12px;
color: var(--gray-600);
display: flex;
gap: 10px;
.score {
color: var(--gray-700);
font-weight: 600;
}
.meta-item {
color: var(--gray-600);
}
}
.chunk-markdown-content :deep(.md-editor) {
background: transparent;
}
.chunk-markdown-content :deep(.md-editor-preview-wrapper) {
padding: 0;
}
.empty-text {
color: var(--gray-500);
font-size: 13px;
}
</style>

View File

@ -0,0 +1,298 @@
<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" />
<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,
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 normalizedChunks = computed(() =>
(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))
const nextExpanded = new Set(
[...expandedFiles.value].filter((filename) => validFilenames.has(filename))
)
for (const group of groups) {
if (group.chunks.length === 1) {
nextExpanded.add(group.filename)
}
}
expandedFiles.value = nextExpanded
},
{ 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 {
.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;
font-weight: 500;
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(--main-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>

View File

@ -0,0 +1,114 @@
<template>
<div class="web-search-result-list">
<div v-if="results.length > 0" class="search-results">
<div v-for="(result, index) in results" :key="getItemKey(result, index)" class="search-result-item">
<div class="result-header">
<h5 class="result-title">
<a :href="result.url" target="_blank" rel="noopener noreferrer">
{{ result.title }}
</a>
</h5>
<span v-if="typeof result.score === 'number'" class="result-score">
相关度: {{ (result.score * 100).toFixed(1) }}%
</span>
</div>
<div v-if="result.content" class="result-content">
{{ result.content }}
</div>
</div>
</div>
<div v-else class="no-results">
<p>{{ emptyText }}</p>
</div>
</div>
</template>
<script setup>
defineProps({
results: {
type: Array,
default: () => []
},
emptyText: {
type: String,
default: '未找到相关搜索结果'
}
})
const getItemKey = (item, index) => {
if (item?.url) return item.url
if (item?.title) return `${item.title}-${index}`
return `${index}`
}
</script>
<style scoped lang="less">
.web-search-result-list {
.search-results {
display: flex;
flex-direction: column;
gap: 8px;
}
.search-result-item {
padding: 10px;
border: 1px solid var(--gray-150);
border-radius: 8px;
background: var(--gray-0);
.result-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
margin-bottom: 4px;
.result-title {
margin: 0;
font-size: 14px;
line-height: 1.4;
flex: 1;
a {
color: var(--main-color);
text-decoration: none;
font-weight: 500;
&:hover {
text-decoration: underline;
}
}
}
.result-score {
font-size: 11px;
color: var(--gray-600);
background: var(--gray-50);
padding: 0 6px;
border-radius: 10px;
white-space: nowrap;
}
}
.result-content {
font-size: 12px;
line-height: 1.5;
color: var(--gray-700);
overflow: hidden;
display: -webkit-box;
line-clamp: 2;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
}
.no-results {
text-align: center;
color: var(--gray-500);
padding: 12px;
font-size: 12px;
}
}
</style>

View File

@ -93,6 +93,175 @@ export class MessageProcessor {
return conversations
}
/**
* 提取一轮对话中所有知识库检索块
* @param {Object} conv - 单轮对话
* @param {Array} databases - 知识库列表
* @returns {Array} 归一化后的检索块
*/
static extractKnowledgeChunksFromConversation(conv, databases = []) {
if (!conv || !Array.isArray(conv.messages) || conv.messages.length === 0) return []
const databaseNames = new Set(
(databases || []).map((db) => db?.name).filter((name) => typeof name === 'string' && name.trim())
)
if (databaseNames.size === 0) return []
const normalizedChunks = []
const dedupSet = new Set()
const appendChunk = (chunk, kbName) => {
if (!chunk || typeof chunk !== 'object') return
const content = typeof chunk.content === 'string' ? chunk.content.trim() : ''
if (!content) return
const metadata = chunk.metadata && typeof chunk.metadata === 'object' ? chunk.metadata : {}
const dedupKey =
metadata.chunk_id && typeof metadata.chunk_id === 'string'
? `${kbName}::${metadata.chunk_id}`
: `${kbName}::${content}`
if (dedupSet.has(dedupKey)) return
dedupSet.add(dedupKey)
const score = typeof chunk.score === 'number' ? chunk.score : null
normalizedChunks.push({
kb_name: kbName,
content,
score,
metadata: {
source: metadata.source || '',
file_id: metadata.file_id || '',
chunk_id: metadata.chunk_id || '',
chunk_index: metadata.chunk_index,
},
})
}
const parseToolResultContent = (content) => {
if (Array.isArray(content)) return content
if (content && typeof content === 'object') return content
if (typeof content === 'string') {
try {
return JSON.parse(content)
} catch {
return null
}
}
return null
}
for (const msg of conv.messages) {
if (!msg || msg.type !== 'ai' || !Array.isArray(msg.tool_calls)) continue
for (const toolCall of msg.tool_calls) {
const kbName = toolCall?.name || toolCall?.function?.name
if (!databaseNames.has(kbName)) continue
const content = toolCall?.tool_call_result?.content
const parsed = parseToolResultContent(content)
if (!parsed) continue
// Milvus / Dify: 直接是 chunks 数组
if (Array.isArray(parsed)) {
for (const chunk of parsed) appendChunk(chunk, kbName)
continue
}
// LightRAG: 结果为对象chunks 在 data.chunks 下
const lightragChunks = parsed?.data?.chunks
if (Array.isArray(lightragChunks)) {
for (const chunk of lightragChunks) appendChunk(chunk, kbName)
}
}
}
normalizedChunks.sort((a, b) => {
const scoreA = typeof a.score === 'number' ? a.score : Number.NEGATIVE_INFINITY
const scoreB = typeof b.score === 'number' ? b.score : Number.NEGATIVE_INFINITY
return scoreB - scoreA
})
return normalizedChunks
}
/**
* 提取一轮对话中的网络搜索来源
* @param {Object} conv - 单轮对话
* @returns {Array} 归一化后的网络来源
*/
static extractWebSourcesFromConversation(conv) {
if (!conv || !Array.isArray(conv.messages) || conv.messages.length === 0) return []
const webSources = []
const dedupSet = new Set()
const parseToolResultContent = (content) => {
if (Array.isArray(content)) return content
if (content && typeof content === 'object') return content
if (typeof content === 'string') {
try {
return JSON.parse(content)
} catch {
return null
}
}
return null
}
for (const msg of conv.messages) {
if (!msg || msg.type !== 'ai' || !Array.isArray(msg.tool_calls)) continue
for (const toolCall of msg.tool_calls) {
const toolName = (toolCall?.name || toolCall?.function?.name || '').toLowerCase()
if (!toolName.includes('tavily_search')) continue
const content = toolCall?.tool_call_result?.content
const parsed = parseToolResultContent(content)
const results = Array.isArray(parsed?.results) ? parsed.results : []
if (results.length === 0) continue
for (const item of results) {
const title = typeof item?.title === 'string' ? item.title.trim() : ''
const url = typeof item?.url === 'string' ? item.url.trim() : ''
if (!title || !url) continue
if (dedupSet.has(url)) continue
dedupSet.add(url)
webSources.push({
tool_name: toolCall?.name || toolCall?.function?.name || '网络搜索',
title,
url,
score: typeof item?.score === 'number' ? item.score : null,
content: typeof item?.content === 'string' ? item.content : '',
published_date:
typeof item?.published_date === 'string' ? item.published_date : '',
})
}
}
}
webSources.sort((a, b) => {
const scoreA = typeof a.score === 'number' ? a.score : Number.NEGATIVE_INFINITY
const scoreB = typeof b.score === 'number' ? b.score : Number.NEGATIVE_INFINITY
return scoreB - scoreA
})
return webSources
}
/**
* 提取一轮对话中的全部来源知识库+网络搜索
* @param {Object} conv - 单轮对话
* @param {Array} databases - 知识库列表
* @returns {{knowledgeChunks: Array, webSources: Array}}
*/
static extractSourcesFromConversation(conv, databases = []) {
return {
knowledgeChunks: MessageProcessor.extractKnowledgeChunksFromConversation(conv, databases),
webSources: MessageProcessor.extractWebSourcesFromConversation(conv),
}
}
/**
* 合并消息块
* @param {Array} chunks - 消息块数组
@ -290,7 +459,7 @@ export class MessageProcessor {
try {
const data = JSON.parse(buffer.trim())
await processChunk(data)
} catch (e) {
} catch {
console.warn('最终缓冲区内容无法解析:', buffer)
}
}