feat: 增强聊天组件功能,优化消息展示和滚动逻辑

- 在 AgentChatComponent.vue 中新增对话最后一个消息使用的模型显示。
- 添加生成中的加载状态指示,提升用户体验。
- 优化滚动控制逻辑,确保在消息更新时自动滚动到底部。
- 在 AgentMessageComponent.vue 中更新消息解析逻辑,处理推理内容。
- 在 RefsComponent.vue 中增强模型信息的显示和交互功能。
This commit is contained in:
Wenjie Zhang 2025-06-24 00:15:51 +08:00
parent 17ec5c5f77
commit bb4793e785
3 changed files with 181 additions and 17 deletions

View File

@ -66,6 +66,13 @@
@retry="retryMessage(message)"
>
</AgentMessageComponent>
<!-- 显示对话最后一个消息使用的模型 -->
<RefsComponent
v-if="getLastMessage(conv)"
:message="getLastMessage(conv)"
:show-refs="['model', 'copy']"
:is-latest-message="false"
/>
</div>
<div class="conv-box" v-if="onGoingConv.messages.length > 0">
<AgentMessageComponent
@ -79,6 +86,18 @@
>
</AgentMessageComponent>
</div>
<!-- 生成中的加载状态 -->
<div class="generating-status" v-if="state.isProcessingRequest && !state.waitingServerResponse">
<div class="generating-indicator">
<div class="loading-dots">
<div></div>
<div></div>
<div></div>
</div>
<span class="generating-text">正在生成回复...</span>
</div>
</div>
</div>
<div class="bottom">
<div class="message-input-wrapper">
@ -111,6 +130,7 @@ import { message } from 'ant-design-vue';
import MessageInputComponent from '@/components/MessageInputComponent.vue'
import AgentMessageComponent from '@/components/AgentMessageComponent.vue'
import ChatSidebarComponent from '@/components/ChatSidebarComponent.vue'
import RefsComponent from '@/components/RefsComponent.vue'
import { chatApi, threadApi } from '@/apis/auth_api'
import { PanelLeftOpen, MessageSquarePlus } from 'lucide-vue-next';
@ -150,7 +170,12 @@ const isSmallContainer = computed(() => containerWidth.value <= 520);
const isMediumContainer = computed(() => containerWidth.value <= 768);
let resizeObserver = null;
//
//
const shouldAutoScroll = ref(true); //
const isUserScrolling = ref(false); //
let scrollTimer = null;
//
onMounted(() => {
//
nextTick(() => {
@ -166,6 +191,12 @@ onMounted(() => {
resizeObserver.observe(chatContainerRef.value);
}
//
const chatContainer = document.querySelector('.chat');
if (chatContainer) {
chatContainer.addEventListener('scroll', handleScroll, { passive: true });
}
});
//
@ -178,6 +209,16 @@ onUnmounted(() => {
if (resizeObserver) {
resizeObserver.disconnect();
}
//
const chatContainer = document.querySelector('.chat');
if (chatContainer) {
chatContainer.removeEventListener('scroll', handleScroll);
}
if (scrollTimer) {
clearTimeout(scrollTimer);
}
});
const showMsgRefs = (msg) => {
@ -187,6 +228,21 @@ const showMsgRefs = (msg) => {
return false
}
//
const getLastMessage = (conv) => {
if (!conv || !conv.messages || conv.messages.length === 0) {
return null;
}
// AI
for (let i = conv.messages.length - 1; i >= 0; i--) {
const message = conv.messages[i];
if (message.type === 'ai') {
return message;
}
}
return null;
}
// DOM
const messagesContainer = ref(null);
@ -375,6 +431,8 @@ const sendMessageToServer = async (text) => {
renameChat({'chatId': currentChatId.value, 'title': text})
}
//
shouldAutoScroll.value = true;
state.isProcessingRequest = true;
await scrollToBottom();
@ -480,8 +538,11 @@ const processResponseChunk = async (data) => {
}
onGoingConv.msgChunks[data.msg.id].push(data.msg)
}
} else if (data.status === 'error') {
console.error("流式处理出错:", data.message);
message.error(data.message);
} else if (data.status === 'finished') {
// await getAgentHistory();
await getAgentHistory();
}
// await scrollToBottom();
};
@ -561,6 +622,11 @@ const getAgentHistory = async () => {
//
onGoingConv.msgChunks = {};
convs.value = convertServerHistoryToMessages(response.history);
//
shouldAutoScroll.value = true;
await nextTick();
await scrollToBottom();
} else {
message.warning('未找到历史记录或格式不正确');
}
@ -652,20 +718,49 @@ onMounted(() => {
});
//
// watch(convs, () => {
// scrollToBottom();
// }, { deep: true });
watch([convs, () => onGoingConv.messages], () => {
scrollToBottom();
}, { deep: true });
// ==================== ====================
// TODO:
//
const isAtBottom = () => {
const container = document.querySelector('.chat');
if (!container) return false;
const threshold = 100; // 100px
const isBottom = container.scrollHeight - container.scrollTop - container.clientHeight <= threshold;
return isBottom;
};
//
const handleScroll = () => {
if (scrollTimer) {
clearTimeout(scrollTimer);
}
//
isUserScrolling.value = true;
//
const atBottom = isAtBottom();
shouldAutoScroll.value = atBottom;
//
scrollTimer = setTimeout(() => {
isUserScrolling.value = false;
}, 150);
};
//
const scrollToBottom = async () => {
await nextTick();
if (!messagesContainer.value) return;
//
const containerBox = messagesContainer.value;
//
if (!shouldAutoScroll.value) return;
const container = document.querySelector('.chat');
if (!container) return;
@ -673,9 +768,21 @@ const scrollToBottom = async () => {
//
container.scrollTo(scrollOptions);
setTimeout(() => container.scrollTo(scrollOptions), 50);
setTimeout(() => container.scrollTo(scrollOptions), 150);
setTimeout(() => container.scrollTo({ top: container.scrollHeight, behavior: 'auto' }), 300);
setTimeout(() => {
if (shouldAutoScroll.value) {
container.scrollTo(scrollOptions);
}
}, 50);
setTimeout(() => {
if (shouldAutoScroll.value) {
container.scrollTo(scrollOptions);
}
}, 150);
setTimeout(() => {
if (shouldAutoScroll.value) {
container.scrollTo({ top: container.scrollHeight, behavior: 'auto' });
}
}, 300);
};
@ -713,8 +820,11 @@ const mergeMessageChunk = (chunks) => {
}
// chunk
const result = JSON.parse(JSON.stringify(chunks[0]));
console.debug("result", toRaw(result))
result.content = result.content || '';
result.additional_kwargs = result.additional_kwargs || {};
result.additional_kwargs.reasoning_content = result.additional_kwargs?.reasoning_content || '';
// chunks
for (let i = 1; i < chunks.length; i++) {
@ -722,6 +832,7 @@ const mergeMessageChunk = (chunks) => {
// content
result.content += chunk.content || '';
result.additional_kwargs.reasoning_content += chunk.additional_kwargs?.reasoning_content || '';
// chunk key, value, result[key] result
for (const key in chunk) {
@ -1029,6 +1140,28 @@ const mergeMessageChunk = (chunks) => {
animation-delay: -0.16s;
}
.generating-status {
display: flex;
justify-content: flex-start;
padding: 0.8rem 0;
animation: fadeInUp 0.3s ease-out;
}
.generating-indicator {
display: flex;
align-items: center;
padding: 0.75rem 1rem;
background: var(--gray-100);
border-radius: 12px;
border: 1px solid var(--gray-200);
.generating-text {
margin-left: 12px;
color: var(--gray-700);
font-size: 14px;
}
}
.toggle-sidebar {
cursor: pointer;

View File

@ -154,6 +154,7 @@ const parsedMessage = computed(() => {
}
}
}
message.reasoning_content = message.reasoning_content || message.additional_kwargs?.reasoning_content || '';
return message;
});

View File

@ -1,10 +1,10 @@
<template>
<div class="refs" v-if="showRefs">
<div class="tags">
<!-- <span class="item btn" @click="likeThisResponse(msg)"><LikeOutlined /></span> -->
<!-- <span class="item btn" @click="dislikeThisResponse(msg)"><DislikeOutlined /></span> -->
<span v-if="msg.meta?.server_model_name" class="item" @click="console.log(msg)">
<BulbOutlined /> {{ msg.meta.server_model_name }}
<span class="item btn" @click="likeThisResponse(msg)"><LikeOutlined /></span>
<span class="item btn" @click="dislikeThisResponse(msg)"><DislikeOutlined /></span>
<span v-if="showKey('model') && getModelName(msg)" class="item" @click="console.log(msg)">
<BulbOutlined /> {{ getModelName(msg) }}
</span>
<span
v-if="showKey('copy')"
@ -48,6 +48,8 @@ import {
DeploymentUnitOutlined,
BulbOutlined,
ReloadOutlined,
LikeOutlined,
DislikeOutlined,
} from '@ant-design/icons-vue'
const emit = defineEmits(['retry', 'openRefs']);
@ -91,7 +93,14 @@ const copyText = async (text) => {
}
}
const showRefs = computed(() => (msg.value.role=='received' || msg.value.role=='assistant') && msg.value.status=='finished')
const showRefs = computed(() => {
//
if (props.showRefs && Array.isArray(props.showRefs) && props.showRefs.includes('model')) {
return true;
}
//
return (msg.value.role=='received' || msg.value.role=='assistant') && msg.value.status=='finished';
})
// refs
const openGlobalRefs = (type) => {
@ -117,6 +126,27 @@ const hasKnowledgeBaseData = (msg) => {
const regenerateMessage = () => {
emit('retry')
}
//
const getModelName = (msg) => {
// response_metadata.model_name
if (msg.response_metadata?.model_name) {
return msg.response_metadata.model_name;
}
// meta.server_model_name
if (msg.meta?.server_model_name) {
return msg.meta.server_model_name;
}
return null;
}
const likeThisResponse = (msg) => {
console.log(msg)
}
const dislikeThisResponse = (msg) => {
console.log(msg)
}
</script>
<style lang="less" scoped>