From 1b053f3cd4b2f349f7098a806d1dc495bb7d427c Mon Sep 17 00:00:00 2001 From: Wenjie Zhang Date: Sun, 19 Apr 2026 18:13:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=E5=A2=9E=E5=8A=A0=E5=AF=B9?= =?UTF-8?q?=E8=AF=9D=E9=85=8D=E7=BD=AE=E5=8F=98=E6=9B=B4=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=EF=BC=8C=E4=BC=98=E5=8C=96=E7=94=A8=E6=88=B7=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/develop-guides/roadmap.md | 2 + web/src/components/AgentChatComponent.vue | 302 +++++++++++++++++++--- 2 files changed, 266 insertions(+), 38 deletions(-) diff --git a/docs/develop-guides/roadmap.md b/docs/develop-guides/roadmap.md index aa458a16..73ba92c4 100644 --- a/docs/develop-guides/roadmap.md +++ b/docs/develop-guides/roadmap.md @@ -22,6 +22,7 @@ ### BREAKING CHANGE(不兼容变更,0.7 版本再实现) - 将自定义provider 的实现逻辑,从文件移动到数据库中,并将相关处理代码,移出 config 文件,放到 provider 模块中 +- 已补充方案文档:`docs/vibe/2026-04-18-custom-provider-db-refactor-plan.md`,明确采用“provider 一行、models 放 JSON、移除 provider 默认模型”的落地方案 - 优化知识库的 API 接口设计,使用 /{db_id}/xxx 的形式,整合 mindmap / eval 接口 @@ -54,6 +55,7 @@ - 调整智能体对话中的工具调用展示:连续工具调用默认折叠为“调用了 N 个工具”的轻量摘要,展开后改为弱化时间线样式,减少工具结果卡片对正文阅读节奏的干扰。 - 调整聊天首页的智能体切换入口:在无历史对话时,智能体数量 `<= 3` 且 `chat-main` 宽度不小于 `380px` 时继续使用横向 segmented;当智能体数量 `>= 4` 或内容区宽度小于 `380px` 时自动收敛为“当前智能体 + 下拉按钮”形式。 - 调整对话配置入口与侧边栏头尾交互:输入区配置按钮改为轻量 dropdown 触发器,默认保持透明、hover 才出现背景;下拉中直接提供配置切换、新建配置,以及查看/编辑当前配置的入口。配置侧边栏则移除头部配置切换和新建入口,改为仅显示当前配置名称、可点击星标和关闭图标,并将删除操作下沉到底部保存按钮右侧。 +- 为历史线程补充前端本地配置变更提示:当已有历史消息的对话中切换 Agent、切换配置或编辑任意配置项时,在聊天消息流中插入一条非持久化的信息提示,提醒继续在当前线程运行可能影响最终效果,建议新建对话;提示仅保留在前端当前线程状态中,不写入后端历史消息。后续补齐了 3 个关键边界:提示插入时主动滚动到底部确保可见、历史消息尚未加载完成时先挂起提示并在加载完成后落地、流式回复过程中切换配置时将提示锚定在当时完整对话流之后,避免把正在生成的回复错误归因到新配置。 --- diff --git a/web/src/components/AgentChatComponent.vue b/web/src/components/AgentChatComponent.vue index 847829f5..78dc3533 100644 --- a/web/src/components/AgentChatComponent.vue +++ b/web/src/components/AgentChatComponent.vue @@ -85,32 +85,42 @@
-
- - - -
+
+
+ {{ row.notice.message }} +
+
@@ -394,6 +404,11 @@ const hasMoreChats = ref(true) // 是否还有更多对话可加载 const isLoadingMoreChats = ref(false) // 加载更多对话中 const threadFilesMap = ref({}) const threadAttachmentsMap = ref({}) +const threadConfigNoticeMap = ref({}) +const threadPendingConfigNoticeMap = ref({}) +const threadConfigSnapshotMap = ref({}) +const configNoticeSyncDepth = ref(0) +const configNoticeScrollVersion = ref(0) // 本地 UI 状态(仅在本组件使用) const localUIState = reactive({ @@ -508,6 +523,11 @@ const { mentionConfig } = useAgentMentionConfig({ }) const currentThreadMessages = computed(() => threadMessages.value[currentChatId.value] || []) +const currentThreadHasHistory = computed(() => currentThreadMessages.value.length > 0) +const currentThreadConfigNotice = computed(() => { + if (!currentChatId.value) return null + return threadConfigNoticeMap.value[currentChatId.value] || null +}) // 计算是否显示Refs组件的条件 const shouldShowRefs = computed(() => { @@ -560,6 +580,28 @@ const conversations = computed(() => { return historyConvs }) +const conversationRows = computed(() => { + const rows = conversations.value.map((conv, index) => ({ + type: 'conversation', + key: conv.status === 'streaming' ? 'ongoing-conversation' : `history-${index}`, + conv + })) + + if (currentThreadConfigNotice.value) { + const insertAfterCount = Math.max( + 0, + Math.min(Number(currentThreadConfigNotice.value.insertAfterConversationCount) || 0, rows.length) + ) + rows.splice(insertAfterCount, 0, { + type: 'notice', + key: currentThreadConfigNotice.value.id, + notice: currentThreadConfigNotice.value + }) + } + + return rows +}) + // 智能体图标映射 const agentIconMap = { ChatbotAgent: Bot, @@ -629,6 +671,143 @@ const startSendCooldown = () => { }, 2000) } +const CONFIG_CHANGE_NOTICE_MESSAGE = + '在运行过程中切换或修改配置可能会影响最终效果,建议新建一个对话。' + +const withConfigNoticeSync = async (task) => { + configNoticeSyncDepth.value += 1 + try { + return await task() + } finally { + configNoticeSyncDepth.value = Math.max(0, configNoticeSyncDepth.value - 1) + } +} + +const buildThreadConfigSnapshot = () => { + return { + agentId: currentAgentId.value || '', + agentConfigId: selectedAgentConfigId.value ?? null, + configJson: JSON.stringify(agentConfig.value || {}) + } +} + +const clearThreadConfigTracking = (threadId) => { + if (!threadId) return + + const nextNotices = { ...threadConfigNoticeMap.value } + delete nextNotices[threadId] + threadConfigNoticeMap.value = nextNotices + + const nextPendingNotices = { ...threadPendingConfigNoticeMap.value } + delete nextPendingNotices[threadId] + threadPendingConfigNoticeMap.value = nextPendingNotices + + const nextSnapshots = { ...threadConfigSnapshotMap.value } + delete nextSnapshots[threadId] + threadConfigSnapshotMap.value = nextSnapshots +} + +const syncThreadConfigSnapshot = (threadId, options = {}) => { + if (!threadId) return + + const { overwrite = true } = options + if (!overwrite && threadConfigSnapshotMap.value[threadId]) return + if (threadPendingConfigNoticeMap.value[threadId]) return + + // 线程切换时先记录当前 UI 的配置快照,避免同步 thread 绑定配置时误报。 + threadConfigSnapshotMap.value = { + ...threadConfigSnapshotMap.value, + [threadId]: buildThreadConfigSnapshot() + } +} + +const upsertThreadConfigNotice = (threadId, insertAfterConversationCount) => { + if (!threadId) return + + const existingNotice = threadConfigNoticeMap.value[threadId] + const nextNotice = { + id: existingNotice?.id || `config-change-notice-${threadId}`, + message: existingNotice?.message || CONFIG_CHANGE_NOTICE_MESSAGE, + insertAfterConversationCount + } + const shouldScroll = + !existingNotice || existingNotice.insertAfterConversationCount !== insertAfterConversationCount + + threadConfigNoticeMap.value = { + ...threadConfigNoticeMap.value, + [threadId]: nextNotice + } + + if (threadPendingConfigNoticeMap.value[threadId]) { + const nextPendingNotices = { ...threadPendingConfigNoticeMap.value } + delete nextPendingNotices[threadId] + threadPendingConfigNoticeMap.value = nextPendingNotices + } + + if (shouldScroll) { + configNoticeScrollVersion.value += 1 + } +} + +const queuePendingThreadConfigNotice = (threadId) => { + if (!threadId) return + threadPendingConfigNoticeMap.value = { + ...threadPendingConfigNoticeMap.value, + [threadId]: { + id: `config-change-notice-${threadId}`, + message: CONFIG_CHANGE_NOTICE_MESSAGE + } + } +} + +const flushPendingThreadConfigNotice = (threadId) => { + if (!threadId || !currentThreadHasHistory.value || !threadPendingConfigNoticeMap.value[threadId]) { + return + } + + upsertThreadConfigNotice(threadId, conversations.value.length) +} + +const maybeInsertThreadConfigNotice = () => { + const threadId = currentChatId.value + if (!threadId || configNoticeSyncDepth.value > 0) { + return + } + + const previousSnapshot = threadConfigSnapshotMap.value[threadId] + const currentSnapshot = buildThreadConfigSnapshot() + + if (!previousSnapshot) { + threadConfigSnapshotMap.value = { + ...threadConfigSnapshotMap.value, + [threadId]: currentSnapshot + } + return + } + + if ( + previousSnapshot.agentId === currentSnapshot.agentId && + previousSnapshot.agentConfigId === currentSnapshot.agentConfigId && + previousSnapshot.configJson === currentSnapshot.configJson + ) { + return + } + + if (currentThreadHasHistory.value) { + upsertThreadConfigNotice(threadId, conversations.value.length) + } else if (chatUIStore.isLoadingMessages) { + // 历史线程仍在加载时先挂起提示,避免消息返回后把变更误当成新的基线。 + queuePendingThreadConfigNotice(threadId) + } else { + return + } + + threadConfigSnapshotMap.value = { + ...threadConfigSnapshotMap.value, + [threadId]: currentSnapshot + } +} + // ==================== SCROLL & RESIZE HANDLING ==================== const scrollController = new ScrollController('.chat-main') const chatMainRef = ref(null) @@ -798,6 +977,7 @@ const deleteThread = async (threadId) => { delete threadMessages.value[threadId] delete threadFilesMap.value[threadId] delete threadAttachmentsMap.value[threadId] + clearThreadConfigTracking(threadId) if (chatState.currentThreadId === threadId) { chatState.currentThreadId = null @@ -1087,21 +1267,18 @@ const selectChat = async (chatId) => { isAgentPanelOpen.value = false } - // 先更新当前线程,确保底部智能体名称与选中项即时同步 - chatState.currentThreadId = chatId - - if (!props.singleMode && targetChat?.agent_id && targetChat.agent_id !== currentAgentId.value) { - try { - await agentStore.selectAgent(targetChat.agent_id) - } catch (error) { - chatState.currentThreadId = previousThreadId - handleChatError(error, 'load') - return - } - } - try { - await syncSelectedConfigForThread(targetChat) + await withConfigNoticeSync(async () => { + // 先更新当前线程,确保底部智能体名称与选中项即时同步。 + chatState.currentThreadId = chatId + + if (!props.singleMode && targetChat?.agent_id && targetChat.agent_id !== currentAgentId.value) { + await agentStore.selectAgent(targetChat.agent_id) + } + + await syncSelectedConfigForThread(targetChat) + syncThreadConfigSnapshot(chatId) + }) } catch (error) { chatState.currentThreadId = previousThreadId handleChatError(error, 'load') @@ -1121,6 +1298,7 @@ const selectChat = async (chatId) => { scrollController.scrollToBottomStaticForce() // await fetchAgentState(targetAgentId, chatId) await handleAgentStateRefresh(chatId) + syncThreadConfigSnapshot(chatId, { overwrite: false }) await resumeActiveRunForThread(chatId) } @@ -1778,6 +1956,35 @@ watch( { immediate: true } ) +watch( + currentThreadMessages, + () => { + if (currentThreadHasHistory.value) { + flushPendingThreadConfigNotice(currentChatId.value) + syncThreadConfigSnapshot(currentChatId.value, { overwrite: false }) + } + }, + { deep: false } +) + +watch(currentAgentId, (newAgentId, oldAgentId) => { + if (oldAgentId === undefined || newAgentId === oldAgentId) return + maybeInsertThreadConfigNotice() +}) + +watch(selectedAgentConfigId, (newConfigId, oldConfigId) => { + if (oldConfigId === undefined || newConfigId === oldConfigId) return + maybeInsertThreadConfigNotice() +}) + +watch( + () => JSON.stringify(agentConfig.value || {}), + (newConfigJson, oldConfigJson) => { + if (oldConfigJson === undefined || newConfigJson === oldConfigJson) return + maybeInsertThreadConfigNotice() + } +) + watch( conversations, () => { @@ -1788,6 +1995,15 @@ watch( { deep: true, flush: 'post' } ) +watch( + configNoticeScrollVersion, + () => { + if (!currentChatId.value) return + scrollController.scrollToBottom(true) + }, + { flush: 'post' } +) + watch(currentChatId, (threadId, oldThreadId) => { if (threadId === oldThreadId) return emit('thread-change', threadId || '') @@ -2176,6 +2392,16 @@ watch(currentChatId, (threadId, oldThreadId) => { flex-direction: column; } +.chat-inline-notice { + display: flex; + justify-content: center; + padding: 6px 16px 12px; + color: var(--gray-500); + font-size: 12px; + line-height: 1.6; + text-align: center; +} + .bottom { position: sticky; bottom: 0;