refactor: 增强消息来源处理,合并相关组件并优化状态管理

This commit is contained in:
Wenjie Zhang 2026-03-02 21:01:37 +08:00
parent 9cbed254bb
commit 3ac6fee7b8
6 changed files with 177 additions and 148 deletions

View File

@ -84,11 +84,8 @@
<RefsComponent
v-if="shouldShowRefs(conv)"
:message="getLastMessage(conv)"
:show-refs="['model', 'copy']"
:show-refs="['model', 'copy', 'sources']"
:is-latest-message="false"
/>
<ConversationSourcesPanel
v-if="shouldShowRefs(conv)"
:sources="getConversationSources(conv)"
/>
</div>
@ -219,7 +216,6 @@ 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({
@ -237,7 +233,9 @@ const {
defaultAgentId,
selectedAgentConfigId,
agentConfig,
configurableItems
configurableItems,
availableKnowledgeBases,
availableMcps
} = storeToRefs(agentStore)
// ==================== LOCAL CHAT & UI STATE ====================
@ -294,10 +292,6 @@ const localUIState = reactive({
isInitialRender: true
})
// Mention resources
const availableKnowledgeBases = ref([])
const availableMcps = ref([])
// Agent Panel State
const isAgentPanelOpen = ref(false)
const isResizing = ref(false)
@ -1135,19 +1129,6 @@ const resumeActiveRunForThread = async (threadId) => {
clearActiveRunSnapshot(threadId)
}
const fetchMentionResources = async () => {
try {
const [dbsRes, mcpsRes] = await Promise.all([
databaseApi.getAccessibleDatabases().catch(() => ({ databases: [] })),
mcpApi.getMcpServers().catch(() => ({ data: [] }))
])
availableKnowledgeBases.value = dbsRes.databases || []
availableMcps.value = mcpsRes.data || []
} catch (e) {
console.warn('Failed to fetch mention resources', e)
}
}
const ensureActiveThread = async (title = '新的对话') => {
if (currentChatId.value) return currentChatId.value
try {
@ -1648,7 +1629,7 @@ const showMsgRefs = (msg) => {
// refs
if (msg.isLast && msg.status === 'finished') {
return ['copy']
return ['copy', 'sources']
}
return false
}
@ -1693,7 +1674,6 @@ const initAll = async () => {
if (!agentStore.isInitialized) {
await agentStore.initialize()
}
await fetchMentionResources()
} catch (error) {
handleChatError(error, 'load')
}

View File

@ -91,6 +91,7 @@
:message="message"
:show-refs="showRefs"
:is-latest-message="isLatestMessage"
:sources="messageSources"
@retry="emit('retry')"
@openRefs="emit('openRefs', $event)"
/>
@ -115,6 +116,7 @@ import { useAgentStore } from '@/stores/agent'
import { useInfoStore } from '@/stores/info'
import { useThemeStore } from '@/stores/theme'
import { storeToRefs } from 'pinia'
import { MessageProcessor } from '@/utils/messageProcessor'
import { MdPreview } from 'md-editor-v3'
import 'md-editor-v3/lib/preview.css'
@ -223,9 +225,18 @@ const getErrorMessage = computed(() => {
// store
const agentStore = useAgentStore()
const { availableKnowledgeBases } = storeToRefs(agentStore)
const infoStore = useInfoStore()
const themeStore = useThemeStore()
//
const messageSources = computed(() => {
if (props.message.type === 'ai') {
return MessageProcessor.extractSourcesFromMessage(props.message, availableKnowledgeBases.value)
}
return { knowledgeChunks: [], webSources: [] }
})
// -
const theme = computed(() => (themeStore.isDark ? 'dark' : 'light'))

View File

@ -1,120 +0,0 @@
<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

@ -41,6 +41,31 @@
title="重新生成"
><RotateCcw size="12" />
</span>
<!-- 来源按钮 - 使用 flex-grow 占据剩余空间并右对齐 -->
<div v-if="hasSources && showKey('sources')" class="sources-spacer"></div>
<span
v-if="hasSources && showKey('sources')"
class="item btn sources-btn"
:class="{ expanded: isSourcesExpanded }"
@click="toggleSources"
:title="isSourcesExpanded ? '收起详情' : '查看来源详情'"
>
<BookOpen size="12" />
<span class="sources-label">
来源
<template v-if="sourceCount > 0">
{{ sourceCount }}
</template>
</span>
<ChevronDown :size="12" class="expand-icon" :class="{ rotated: isSourcesExpanded }" />
</span>
</div>
<!-- 来源详情面板 -->
<div v-if="isSourcesExpanded" class="sources-panel-body">
<KnowledgeSourceSection v-if="knowledgeChunks.length > 0" :chunks="knowledgeChunks" />
<WebSearchSourceSection v-if="webSources.length > 0" :sources="webSources" />
</div>
</div>
@ -68,8 +93,10 @@
import { ref, computed, reactive, watch } from 'vue'
import { useClipboard } from '@vueuse/core'
import { message } from 'ant-design-vue'
import { ThumbsUp, ThumbsDown, Bot, Copy, Check, RotateCcw } from 'lucide-vue-next'
import { ThumbsUp, ThumbsDown, Bot, Copy, Check, RotateCcw, BookOpen, ChevronDown } from 'lucide-vue-next'
import { agentApi } from '@/apis'
import KnowledgeSourceSection from '@/components/KnowledgeSourceSection.vue'
import WebSearchSourceSection from '@/components/WebSearchSourceSection.vue'
const emit = defineEmits(['retry', 'openRefs'])
const props = defineProps({
@ -81,11 +108,39 @@ const props = defineProps({
isLatestMessage: {
type: Boolean,
default: false
},
sources: {
type: Object,
default: () => ({})
}
})
const msg = ref(props.message)
// Sources state
const isSourcesExpanded = ref(false)
const knowledgeChunks = computed(() =>
Array.isArray(props.sources?.knowledgeChunks) ? props.sources.knowledgeChunks : []
)
const webSources = computed(() =>
Array.isArray(props.sources?.webSources) ? props.sources.webSources : []
)
const hasSources = computed(
() =>
knowledgeChunks.value.length > 0 ||
webSources.value.length > 0
)
const sourceCount = computed(
() => knowledgeChunks.value.length + webSources.value.length
)
const toggleSources = () => {
isSourcesExpanded.value = !isSourcesExpanded.value
}
// Feedback state
const feedbackState = reactive({
hasSubmitted: false,
@ -272,11 +327,12 @@ const cancelDislike = () => {
<style lang="less" scoped>
.refs {
display: flex;
flex-direction: column;
margin-bottom: 20px;
margin-top: 10px;
color: var(--gray-500);
font-size: 13px;
gap: 10px;
gap: 12px;
.item {
background: var(--gray-50);
@ -313,7 +369,66 @@ const cancelDislike = () => {
.tags {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
width: 100%;
.sources-spacer {
flex-grow: 1;
}
.sources-btn {
margin-left: auto;
background: var(--gray-50);
border: 1px solid transparent;
padding: 6px 10px;
&:hover {
background: var(--gray-100);
}
&.expanded {
background: var(--main-50);
color: var(--main-700);
border-color: var(--main-100);
}
.sources-label {
font-weight: 500;
margin-left: 2px;
}
.expand-icon {
margin-left: 4px;
transition: transform 0.2s ease;
&.rotated {
transform: rotate(180deg);
}
}
}
}
.sources-panel-body {
background: var(--gray-25);
border: 1px solid var(--gray-150);
border-radius: 8px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
animation: slideDown 0.2s ease-out;
}
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { agentApi } from '@/apis/agent_api'
import { agentApi, databaseApi, mcpApi } from '@/apis'
import { handleChatError } from '@/utils/errorHandler'
import { useUserStore } from '@/stores/user'
@ -14,6 +14,10 @@ export const useAgentStore = defineStore(
const selectedAgentId = ref(null)
const defaultAgentId = ref(null)
// 资源相关状态
const availableKnowledgeBases = ref([])
const availableMcps = ref([])
// 智能体配置相关状态
const agentConfig = ref({})
const originalAgentConfig = ref({})
@ -91,6 +95,22 @@ export const useAgentStore = defineStore(
})
// ==================== 方法 ====================
/**
* 获取可提及的资源知识库MCP等
*/
async function fetchMentionResources() {
try {
const [dbsRes, mcpsRes] = await Promise.all([
databaseApi.getAccessibleDatabases().catch(() => ({ databases: [] })),
mcpApi.getMcpServers().catch(() => ({ data: [] }))
])
availableKnowledgeBases.value = dbsRes.databases || []
availableMcps.value = mcpsRes.data || []
} catch (e) {
console.warn('Failed to fetch mention resources:', e)
}
}
/**
* 初始化 store
*/
@ -104,6 +124,7 @@ export const useAgentStore = defineStore(
try {
await fetchAgents()
await fetchDefaultAgent()
await fetchMentionResources()
if (!selectedAgent.value) {
if (defaultAgent.value) {
@ -449,6 +470,8 @@ export const useAgentStore = defineStore(
agents.value = []
selectedAgentId.value = null
defaultAgentId.value = null
availableKnowledgeBases.value = []
availableMcps.value = []
agentConfig.value = {}
originalAgentConfig.value = {}
agentConfigs.value = {}
@ -468,6 +491,8 @@ export const useAgentStore = defineStore(
agents,
selectedAgentId,
defaultAgentId,
availableKnowledgeBases,
availableMcps,
agentConfig,
originalAgentConfig,
agentDetails,
@ -495,6 +520,7 @@ export const useAgentStore = defineStore(
fetchAgents,
fetchAgentDetail,
fetchDefaultAgent,
fetchMentionResources,
setDefaultAgent,
selectAgent,
selectAgentConfig,

View File

@ -249,6 +249,23 @@ export class MessageProcessor {
return webSources
}
/**
* 提取单个消息中的来源
* @param {Object} message - 消息对象
* @param {Array} databases - 知识库列表
* @returns {{knowledgeChunks: Array, webSources: Array}}
*/
static extractSourcesFromMessage(message, databases = []) {
if (!message || message.type !== 'ai') return { knowledgeChunks: [], webSources: [] }
// 复用提取逻辑,通过构建临时对话对象
const mockConv = { messages: [message] }
return {
knowledgeChunks: MessageProcessor.extractKnowledgeChunksFromConversation(mockConv, databases),
webSources: MessageProcessor.extractWebSourcesFromConversation(mockConv),
}
}
/**
* 提取一轮对话中的全部来源知识库+网络搜索
* @param {Object} conv - 单轮对话