feat(web): add collapsible source panel with kb and web groups
This commit is contained in:
parent
452834d236
commit
df8b97017a
@ -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
|
||||
|
||||
120
web/src/components/ConversationSourcesPanel.vue
Normal file
120
web/src/components/ConversationSourcesPanel.vue
Normal 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>
|
||||
28
web/src/components/KnowledgeSourceSection.vue
Normal file
28
web/src/components/KnowledgeSourceSection.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
28
web/src/components/WebSearchSourceSection.vue
Normal file
28
web/src/components/WebSearchSourceSection.vue
Normal 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>
|
||||
96
web/src/components/sources/KbChunkDetailModal.vue
Normal file
96
web/src/components/sources/KbChunkDetailModal.vue
Normal 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>
|
||||
298
web/src/components/sources/KbResultGroupedList.vue
Normal file
298
web/src/components/sources/KbResultGroupedList.vue
Normal 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>
|
||||
114
web/src/components/sources/WebSearchResultList.vue
Normal file
114
web/src/components/sources/WebSearchResultList.vue
Normal 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>
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user