feat(chat): 增加对话配置变更提示,优化用户体验

This commit is contained in:
Wenjie Zhang 2026-04-19 18:13:18 +08:00
parent 4035f3c02d
commit 1b053f3cd4
2 changed files with 266 additions and 38 deletions

View File

@ -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 个关键边界:提示插入时主动滚动到底部确保可见、历史消息尚未加载完成时先挂起提示并在加载完成后落地、流式回复过程中切换配置时将提示锚定在当时完整对话流之后,避免把正在生成的回复错误归因到新配置。
---

View File

@ -85,32 +85,42 @@
<!-- Main Chat Area -->
<div class="chat-main" ref="chatMainRef">
<div class="chat-box">
<div class="conv-box" v-for="(conv, index) in conversations" :key="index">
<template v-for="(displayItem, itemIndex) in getConversationDisplayItems(conv)" :key="displayItem.key">
<AgentMessageComponent
v-if="displayItem.type === 'message'"
:message="displayItem.message"
:is-processing="isDisplayMessageProcessing(conv, displayItem)"
:show-refs="showMsgRefs(displayItem.message)"
:hide-tool-calls="true"
@retry="retryMessage(displayItem.message)"
<template v-for="row in conversationRows" :key="row.key">
<div v-if="row.type === 'conversation'" class="conv-box">
<template
v-for="(displayItem, itemIndex) in getConversationDisplayItems(row.conv)"
:key="displayItem.key"
>
</AgentMessageComponent>
<ToolCallsGroupComponent
v-else
:tool-calls="displayItem.toolCalls"
:is-active="isToolGroupActive(conv, itemIndex, getConversationDisplayItems(conv))"
<AgentMessageComponent
v-if="displayItem.type === 'message'"
:message="displayItem.message"
:is-processing="isDisplayMessageProcessing(row.conv, displayItem)"
:show-refs="showMsgRefs(displayItem.message)"
:hide-tool-calls="true"
@retry="retryMessage(displayItem.message)"
>
</AgentMessageComponent>
<ToolCallsGroupComponent
v-else
:tool-calls="displayItem.toolCalls"
:is-active="
isToolGroupActive(row.conv, itemIndex, getConversationDisplayItems(row.conv))
"
/>
</template>
<!-- 显示对话最后一个消息使用的模型 -->
<RefsComponent
v-if="shouldShowRefs(row.conv)"
:message="getLastMessage(row.conv)"
:show-refs="['model', 'copy', 'sources']"
:is-latest-message="false"
:sources="getConversationSources(row.conv)"
/>
</template>
<!-- 显示对话最后一个消息使用的模型 -->
<RefsComponent
v-if="shouldShowRefs(conv)"
:message="getLastMessage(conv)"
:show-refs="['model', 'copy', 'sources']"
:is-latest-message="false"
:sources="getConversationSources(conv)"
/>
</div>
</div>
<div v-else class="chat-inline-notice">
<span>{{ row.notice.message }}</span>
</div>
</template>
<!-- 生成中的加载状态 - 增强条件支持主聊天和resume流程 -->
<div class="generating-status" v-if="isProcessing && conversations.length > 0">
@ -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;