diff --git a/src/components/bubble/index.vue b/src/components/bubble/index.vue index 704509fa8189f9c15b5a4b506a8c6731f6c55a71..c238d905bc194bb86b720c384fc62c8874bb6a25 100644 --- a/src/components/bubble/index.vue +++ b/src/components/bubble/index.vue @@ -75,7 +75,6 @@ const props = withDefaults(defineProps(), { padding: 14px; border-radius: 8px; border-top-left-radius: 0px; - white-space: pre-wrap; word-break: break-all; .loading { diff --git a/src/hooks/useScrollBottom.ts b/src/hooks/useScrollBottom.ts new file mode 100644 index 0000000000000000000000000000000000000000..5e65e174de30ac19f2d6387a0c2dfd520bcb0e82 --- /dev/null +++ b/src/hooks/useScrollBottom.ts @@ -0,0 +1,92 @@ +import { nextTick, onBeforeUnmount, onMounted, ref, Ref } from 'vue'; + +interface ScrollOptions { + // 滚动检测阈值(像素) + threshold?: number; +} + +export function useScrollBottom( + containerRef: Ref, + options: ScrollOptions = {}, +) { + const { threshold = 10 } = options; + + const isAutoScrolling = ref(true); + let scrollTimeout: any = undefined; + + const scrollController = { + get isAtBottom() { + if (!containerRef.value) return false; + const { scrollTop, scrollHeight, clientHeight } = containerRef.value; + return scrollTop + clientHeight >= scrollHeight - threshold; + }, + + get shouldAutoScroll() { + return this.isAtBottom && isAutoScrolling.value; + }, + + checkScrollPosition() { + const wasAtBottom = isAutoScrolling.value; + isAutoScrolling.value = this.isAtBottom; + + // 当从底部移出时立即停止自动滚动 + if (wasAtBottom && !this.isAtBottom) { + stopAutoScroll(); + } + }, + }; + + const stopAutoScroll = () => { + isAutoScrolling.value = false; + clearTimeout(scrollTimeout); + }; + + async function scrollToBottom(force: boolean = false) { + if (!containerRef.value) return; + if (force) { + await nextTick(); + containerRef.value.scrollTo({ + top: containerRef.value.scrollHeight, + behavior: 'smooth', + }); + } + + if (!scrollController.shouldAutoScroll) return; + + await nextTick(); + containerRef.value.scrollTo({ + top: containerRef.value.scrollHeight, + behavior: 'smooth', + }); + } + + function onChatContainerScroll() { + if (!containerRef.value) return; + scrollController.checkScrollPosition(); + resetAutoScrollTimer(); + } + + const resetAutoScrollTimer = () => { + clearTimeout(scrollTimeout); + scrollTimeout = setTimeout(() => { + if (scrollController.shouldAutoScroll) { + scrollToBottom(); + } + }, 100); + }; + + onMounted(() => { + containerRef.value?.addEventListener('scroll', onChatContainerScroll, { + passive: true, + }); + }); + + onBeforeUnmount(() => { + containerRef.value?.removeEventListener('scroll', onChatContainerScroll); + clearTimeout(scrollTimeout.value); + }); + + return { + scrollToBottom, + }; +} diff --git a/src/store/conversation.ts b/src/store/conversation.ts index 13ec9d6f00bc3aace4d0804245046f268edec297..e234962c3a2b6118ff95e5749b95239f6b2e61c1 100644 --- a/src/store/conversation.ts +++ b/src/store/conversation.ts @@ -57,29 +57,15 @@ function getCookie(name: string) { return matches ? decodeURIComponent(matches[1]) : undefined; } +import { useScrollBottom } from '@/hooks/useScrollBottom'; export const useSessionStore = defineStore('conversation', () => { // #region ----------------------------------------< scroll >-------------------------------------- // 会话窗口容器 const dialogueRef = ref(null); - /** - * 滚动到底部 - */ - const scrollBottom = (action: 'smooth' | 'auto' = 'smooth'): void => { - nextTick(() => { - if (!dialogueRef.value) { - return; - } - //完成所有渲染再执行 - setTimeout(() => { - if (dialogueRef.value) { - dialogueRef.value.scrollTo({ - top: dialogueRef.value.scrollHeight, - behavior: action, - }); - } - }, 0); - }); - }; + + const { scrollToBottom } = useScrollBottom(dialogueRef, { + threshold: 15, + }); // #endregion // 是否暂停回答 @@ -298,7 +284,7 @@ export const useSessionStore = defineStore('conversation', () => { if ('event' in message) { if (message['event'] === 'text.add') { // conversationItem.message[conversationItem.currentInd] += message.content; - scrollBottom(); + scrollToBottom(); conversationItem.message[conversationItem.currentInd] += message.content.text; } else if (message['event'] === 'heartbeat') { @@ -537,10 +523,10 @@ export const useSessionStore = defineStore('conversation', () => { ] = errorMsg; (conversationList.value[ind] as RobotConversationItem).isFinish = true; isAnswerGenerating.value = false; - scrollBottom(); + scrollToBottom(); return false; } - scrollBottom(); + scrollToBottom(); return true; }; /** @@ -604,7 +590,7 @@ export const useSessionStore = defineStore('conversation', () => { ); } isAnswerGenerating.value = true; - scrollBottom(); + scrollToBottom(true); if (user_selected_flow && user_selected_app) { await getStream( { @@ -801,7 +787,7 @@ export const useSessionStore = defineStore('conversation', () => { : undefined, }, ); - scrollBottom('auto'); + scrollToBottom(); }); } }; diff --git a/src/views/chat/index.vue b/src/views/chat/index.vue index fe241f073125433a36ddedb7fba5201362a688f0..7cf408eb79ca7728405fab01f2e27ef15c0a0f29 100644 --- a/src/views/chat/index.vue +++ b/src/views/chat/index.vue @@ -6,12 +6,21 @@ import Sender from './Sender.vue'; import Welcome from './Welcome.vue'; import userAvatar from '@/assets/svgs/dark_user.svg'; import robotAvatar from '@/assets/svgs/robot.svg'; -import { computed, ref, onBeforeMount, onBeforeUnmount, h } from 'vue'; +import { + computed, + ref, + onBeforeMount, + onBeforeUnmount, + h, + nextTick, +} from 'vue'; import { fetchStream } from '@/utils/fetchStream'; import marked from '@/utils/marked'; import { useHistorySessionStore, useLangStore } from '@/store'; import { storeToRefs } from 'pinia'; +import { useScrollBottom } from '@/hooks/useScrollBottom'; + const headerStyles = computed(() => { if (window.eulercopilot.process.platform === 'win32') { return { paddingRight: '145px' }; @@ -35,6 +44,12 @@ const { conversations, setConversations } = useConversations(); const { isStreaming, queryStream } = useStream(); +const chatContainerRef = ref(null); + +const { scrollToBottom } = useScrollBottom(chatContainerRef, { + threshold: 15, +}); + function useStream() { const isStreaming = ref(false); @@ -75,6 +90,9 @@ function useStream() { } if (chunk.data.trim() === '[DONE]') { isStreaming.value = false; + setTimeout(() => { + scrollToBottom(true); + }, 100); break; } setConversations(chunk.data); @@ -116,7 +134,6 @@ function useConversations() { const conversations = ref([]); const setConversations = (data: string) => { - console.log(JSON.parse(data)); const conversation = conversations.value[conversations.value.length - 1]; const { id, event, content, metadata } = JSON.parse(data) as StreamChunk; if (event === 'init') { @@ -131,6 +148,7 @@ function useConversations() { conversation.content += content.text; conversation.metadata = metadata; } + scrollToBottom(); }; return { conversations, setConversations }; @@ -143,6 +161,7 @@ function onSend(q: string) { role: 'user', }); isStreaming.value = true; + scrollToBottom(true); queryStream(q, currentSelectedSession.value, language.value as 'zh' | 'en'); } @@ -189,7 +208,11 @@ onBeforeUnmount(() => {
-
+
{ flex: 1; .bubble-footer { + margin-top: 20px; .action-toolbar { border-top: 1px dashed var(--o-border-color-light); display: flex;