Improve agent assistant message scrolling

This commit is contained in:
jxxghp
2026-06-23 16:24:23 +08:00
parent bf0b17c314
commit 8d9c622dc5
+211 -146
View File
@@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import MarkdownIt from 'markdown-it' import MarkdownIt from 'markdown-it'
import mdLinkAttributes from 'markdown-it-link-attributes' import mdLinkAttributes from 'markdown-it-link-attributes'
import type { PerfectScrollbarExpose } from 'vue3-perfect-scrollbar'
import { useDisplay } from 'vuetify' import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAuthStore, useUserStore } from '@/stores' import { useAuthStore, useUserStore } from '@/stores'
@@ -129,6 +128,8 @@ const MAX_LOCAL_HISTORY_SESSIONS = 120
const MAX_PERSISTED_MESSAGES = 30 const MAX_PERSISTED_MESSAGES = 30
const HISTORY_TITLE_LENGTH = 36 const HISTORY_TITLE_LENGTH = 36
const HISTORY_ITEM_HEIGHT = 76 const HISTORY_ITEM_HEIGHT = 76
const MESSAGE_SCROLL_FOLLOW_THRESHOLD = 96
const STREAM_STATE_PERSIST_DELAY = 1000
const drawer = ref(false) const drawer = ref(false)
const inputText = ref('') const inputText = ref('')
@@ -138,7 +139,7 @@ const sessionId = ref('')
const sending = ref(false) const sending = ref(false)
const streamError = ref('') const streamError = ref('')
const historyMenuOpen = ref(false) const historyMenuOpen = ref(false)
const messageListRef = ref<PerfectScrollbarExpose | null>(null) const messageListRef = ref<HTMLElement | null>(null)
const inputRef = ref<HTMLTextAreaElement | null>(null) const inputRef = ref<HTMLTextAreaElement | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null) const fileInputRef = ref<HTMLInputElement | null>(null)
const pendingAttachments = ref<AgentPendingAttachment[]>([]) const pendingAttachments = ref<AgentPendingAttachment[]>([])
@@ -167,6 +168,9 @@ let recordingChunks: BlobPart[] = []
let fabIdleTimer: number | null = null let fabIdleTimer: number | null = null
let fabDragStart: { pointerId: number; x: number; y: number } | null = null let fabDragStart: { pointerId: number; x: number; y: number } | null = null
let fabSuppressNextClick = false let fabSuppressNextClick = false
let messageScrollFrame: number | null = null
let pendingMessageScrollToBottom = false
let streamPersistTimer: number | null = null
const md = new MarkdownIt({ const md = new MarkdownIt({
html: true, html: true,
@@ -570,21 +574,46 @@ function resolveApiUrl(path: string) {
return `${baseUrl.replace(/\/?$/, '/')}${path.replace(/^\//, '')}` return `${baseUrl.replace(/\/?$/, '/')}${path.replace(/^\//, '')}`
} }
// 获取 PerfectScrollbar 内部滚动元素,供自动滚动和滚动条刷新使用 // 消息主列表使用原生滚动,避免流式回复时 JS 滚动库频繁测量影响手感
function getMessageScrollerElement() { function getMessageScrollerElement() {
return messageListRef.value?.ps?.element || null return messageListRef.value
} }
function scrollToBottom() { function isMessageScrollerNearBottom() {
nextTick(() => { const scroller = getMessageScrollerElement()
requestAnimationFrame(() => { if (!scroller) return true
const scroller = getMessageScrollerElement()
if (!scroller) return
messageListRef.value?.ps?.update() return scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight <= MESSAGE_SCROLL_FOLLOW_THRESHOLD
scroller.scrollTop = scroller.scrollHeight }
messageListRef.value?.ps?.update()
}) function scheduleMessageScrollerUpdate(options: { toBottom?: boolean } = {}) {
const { toBottom = false } = options
pendingMessageScrollToBottom ||= toBottom
if (messageScrollFrame !== null) return
messageScrollFrame = window.requestAnimationFrame(() => {
messageScrollFrame = null
const scroller = getMessageScrollerElement()
if (!scroller) return
if (pendingMessageScrollToBottom) scroller.scrollTop = scroller.scrollHeight
pendingMessageScrollToBottom = false
})
}
function scrollToBottom(options: { smooth?: boolean } = {}) {
const { smooth = false } = options
nextTick(() => {
const scroller = getMessageScrollerElement()
if (!scroller) return
if (smooth) {
scroller.scrollTo({ top: scroller.scrollHeight, behavior: 'smooth' })
scheduleMessageScrollerUpdate()
return
}
scheduleMessageScrollerUpdate({ toBottom: true })
}) })
} }
@@ -595,13 +624,34 @@ function scrollToTop() {
const scroller = getMessageScrollerElement() const scroller = getMessageScrollerElement()
if (!scroller) return if (!scroller) return
messageListRef.value?.ps?.update()
scroller.scrollTop = 0 scroller.scrollTop = 0
messageListRef.value?.ps?.update()
}) })
}) })
} }
function clearStreamPersistTimer() {
if (streamPersistTimer === null) return
window.clearTimeout(streamPersistTimer)
streamPersistTimer = null
}
function clearMessageScrollFrame() {
if (messageScrollFrame === null) return
window.cancelAnimationFrame(messageScrollFrame)
messageScrollFrame = null
pendingMessageScrollToBottom = false
}
function scheduleStreamPersist() {
clearStreamPersistTimer()
streamPersistTimer = window.setTimeout(() => {
persistState()
streamPersistTimer = null
}, STREAM_STATE_PERSIST_DELAY)
}
function syncInputHeight() { function syncInputHeight() {
nextTick(() => { nextTick(() => {
const input = inputRef.value const input = inputRef.value
@@ -653,6 +703,8 @@ function markToolsDone(message: AgentChatMessage) {
} }
function applyStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMessage) { function applyStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMessage) {
const shouldFollowBottom = isMessageScrollerNearBottom()
switch (event.type) { switch (event.type) {
case 'delta': case 'delta':
assistantMessage.content += event.content || '' assistantMessage.content += event.content || ''
@@ -699,9 +751,10 @@ function applyStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMe
break break
} }
refreshMessageList() scheduleStreamPersist()
persistState() nextTick(() => {
scrollToBottom() scheduleMessageScrollerUpdate({ toBottom: shouldFollowBottom })
})
} }
function parseSseBlock(block: string) { function parseSseBlock(block: string) {
@@ -902,6 +955,7 @@ async function streamAgentMessage(
const assistantMessage = addMessage('assistant', '', 'streaming') const assistantMessage = addMessage('assistant', '', 'streaming')
abortController = new AbortController() abortController = new AbortController()
let shouldFollowBottomAfterStream = true
try { try {
const response = await fetch(resolveApiUrl('message/agent/stream'), { const response = await fetch(resolveApiUrl('message/agent/stream'), {
@@ -927,6 +981,7 @@ async function streamAgentMessage(
} }
await readAgentStream(response, assistantMessage) await readAgentStream(response, assistantMessage)
shouldFollowBottomAfterStream = isMessageScrollerNearBottom()
if (assistantMessage.status === 'streaming') { if (assistantMessage.status === 'streaming') {
assistantMessage.status = 'done' assistantMessage.status = 'done'
markToolsDone(assistantMessage) markToolsDone(assistantMessage)
@@ -946,6 +1001,7 @@ async function streamAgentMessage(
refreshMessageList() refreshMessageList()
} finally { } finally {
abortController = null abortController = null
clearStreamPersistTimer()
persistState() persistState()
try { try {
await saveCurrentSessionToServer() await saveCurrentSessionToServer()
@@ -953,7 +1009,7 @@ async function streamAgentMessage(
} catch (error) { } catch (error) {
// 服务端历史保存失败时保留本地兜底历史,不影响当前会话继续交互。 // 服务端历史保存失败时保留本地兜底历史,不影响当前会话继续交互。
} }
scrollToBottom() if (shouldFollowBottomAfterStream) scrollToBottom()
} }
} }
@@ -1427,6 +1483,8 @@ onScopeDispose(clearAgentAssistantOpenState)
onScopeDispose(clearPendingAttachments) onScopeDispose(clearPendingAttachments)
onScopeDispose(cancelVoiceRecording) onScopeDispose(cancelVoiceRecording)
onScopeDispose(clearFabIdleTimer) onScopeDispose(clearFabIdleTimer)
onScopeDispose(clearMessageScrollFrame)
onScopeDispose(clearStreamPersistTimer)
onScopeDispose(() => { onScopeDispose(() => {
if (typeof window === 'undefined') return if (typeof window === 'undefined') return
@@ -1617,153 +1675,157 @@ onScopeDispose(() => {
</div> </div>
</header> </header>
<PerfectScrollbar <main
ref="messageListRef" ref="messageListRef"
tag="main"
class="agent-assistant-messages" class="agent-assistant-messages"
:class="{ 'agent-assistant-messages--has-content': hasMessages }" :class="{ 'agent-assistant-messages--has-content': hasMessages }"
:options="{ wheelPropagation: false }"
> >
<div v-if="!hasMessages" class="agent-assistant-empty"> <div class="agent-assistant-messages__content">
<div class="agent-assistant-empty__mark"> <div v-if="!hasMessages" class="agent-assistant-empty">
<VIcon icon="lucide:sparkles" size="28" /> <div class="agent-assistant-empty__mark">
</div> <VIcon icon="lucide:sparkles" size="28" />
<div class="agent-assistant-empty__title">{{ t('agentAssistant.emptyTitle') }}</div>
<div class="agent-assistant-empty__subtitle">{{ t('agentAssistant.emptySubtitle') }}</div>
</div>
<div
v-for="message in messages"
:key="message.id"
class="agent-assistant-message"
:class="`agent-assistant-message--${message.role}`"
>
<div class="agent-assistant-message__meta">
<VIcon :icon="message.role === 'user' ? 'mdi-account-circle-outline' : 'lucide:bot'" size="16" />
<span>{{ message.role === 'user' ? currentUserName : t('agentAssistant.assistant') }}</span>
</div>
<div v-if="message.tools.length" class="agent-assistant-tools">
<div v-for="tool in message.tools" :key="tool.id" class="agent-assistant-tool">
<VIcon
:icon="
tool.status === 'running' && message.status === 'streaming'
? 'line-md:loading-twotone-loop'
: 'mdi-check-circle-outline'
"
size="16"
/>
<span>{{ tool.message }}</span>
</div> </div>
<div class="agent-assistant-empty__title">{{ t('agentAssistant.emptyTitle') }}</div>
<div class="agent-assistant-empty__subtitle">{{ t('agentAssistant.emptySubtitle') }}</div>
</div> </div>
<div <div
v-if="message.content" v-for="message in messages"
class="agent-assistant-message__bubble markdown-body" :key="message.id"
v-html="renderMarkdown(message.content)" class="agent-assistant-message"
/> :class="`agent-assistant-message--${message.role}`"
>
<div class="agent-assistant-message__meta">
<VIcon :icon="message.role === 'user' ? 'mdi-account-circle-outline' : 'lucide:bot'" size="16" />
<span>{{ message.role === 'user' ? currentUserName : t('agentAssistant.assistant') }}</span>
</div>
<div v-if="message.choices.length" class="agent-assistant-choices"> <div v-if="message.tools.length" class="agent-assistant-tools">
<div v-for="choice in message.choices" :key="choice.id" class="agent-assistant-choice"> <div v-for="tool in message.tools" :key="tool.id" class="agent-assistant-tool">
<div v-if="choice.title" class="agent-assistant-choice__title">{{ choice.title }}</div> <VIcon
<div class="agent-assistant-choice__prompt">{{ choice.prompt }}</div> :icon="
<div v-if="choice.status === 'selected'" class="agent-assistant-choice__selected"> tool.status === 'running' && message.status === 'streaming'
<VIcon icon="mdi-check-circle-outline" size="16" /> ? 'line-md:loading-twotone-loop'
<span>{{ t('agentAssistant.choiceSelected', { option: choice.selected_label }) }}</span> : 'mdi-check-circle-outline'
</div> "
<div v-else-if="choice.status === 'expired'" class="agent-assistant-choice__selected is-expired"> size="16"
<VIcon icon="mdi-alert-circle-outline" size="16" /> />
<span>{{ t('agentAssistant.choiceExpired') }}</span> <span>{{ tool.message }}</span>
</div>
<div class="agent-assistant-choice__buttons">
<VBtn
v-for="button in choice.buttons"
:key="button.callback_data"
size="small"
rounded="lg"
variant="tonal"
color="primary"
:disabled="sending || choice.status !== 'pending'"
@click="handleChoiceClick(choice, button)"
>
{{ button.label }}
</VBtn>
</div> </div>
</div> </div>
</div>
<div v-if="message.attachments.length" class="agent-assistant-attachments">
<div <div
v-for="attachment in message.attachments" v-if="message.content"
:key="`${message.id}-${attachment.url}`" class="agent-assistant-message__bubble markdown-body"
class="agent-assistant-attachment" v-html="renderMarkdown(message.content)"
:class="`agent-assistant-attachment--${attachment.kind}`" />
>
<img
v-if="attachment.kind === 'image'"
class="agent-assistant-attachment__image"
:src="resolveAttachmentUrl(attachment.url)"
:alt="getAttachmentName(attachment)"
loading="lazy"
/>
<template v-else-if="attachment.kind === 'audio'"> <div v-if="message.choices.length" class="agent-assistant-choices">
<div class="agent-assistant-attachment__meta"> <div v-for="choice in message.choices" :key="choice.id" class="agent-assistant-choice">
<VIcon :icon="getAttachmentIcon(attachment)" size="18" /> <div v-if="choice.title" class="agent-assistant-choice__title">{{ choice.title }}</div>
<span>{{ getAttachmentName(attachment) }}</span> <div class="agent-assistant-choice__prompt">{{ choice.prompt }}</div>
<div v-if="choice.status === 'selected'" class="agent-assistant-choice__selected">
<VIcon icon="mdi-check-circle-outline" size="16" />
<span>{{ t('agentAssistant.choiceSelected', { option: choice.selected_label }) }}</span>
</div> </div>
<audio class="agent-assistant-attachment__audio" controls :src="resolveAttachmentUrl(attachment.url)" /> <div v-else-if="choice.status === 'expired'" class="agent-assistant-choice__selected is-expired">
<VBtn <VIcon icon="mdi-alert-circle-outline" size="16" />
class="agent-assistant-surface-btn" <span>{{ t('agentAssistant.choiceExpired') }}</span>
:href="getAttachmentDownloadUrl(attachment)" </div>
:download="getAttachmentName(attachment)" <div class="agent-assistant-choice__buttons">
size="small" <VBtn
variant="tonal" v-for="button in choice.buttons"
color="primary" :key="button.callback_data"
prepend-icon="mdi-download" size="small"
> rounded="lg"
{{ t('agentAssistant.download') }} variant="tonal"
</VBtn> color="primary"
</template> :disabled="sending || choice.status !== 'pending'"
@click="handleChoiceClick(choice, button)"
>
{{ button.label }}
</VBtn>
</div>
</div>
</div>
<template v-else> <div v-if="message.attachments.length" class="agent-assistant-attachments">
<div class="agent-assistant-attachment__file"> <div
<VIcon :icon="getAttachmentIcon(attachment)" size="22" /> v-for="attachment in message.attachments"
<div class="agent-assistant-attachment__file-text"> :key="`${message.id}-${attachment.url}`"
class="agent-assistant-attachment"
:class="`agent-assistant-attachment--${attachment.kind}`"
>
<img
v-if="attachment.kind === 'image'"
class="agent-assistant-attachment__image"
:src="resolveAttachmentUrl(attachment.url)"
:alt="getAttachmentName(attachment)"
loading="lazy"
/>
<template v-else-if="attachment.kind === 'audio'">
<div class="agent-assistant-attachment__meta">
<VIcon :icon="getAttachmentIcon(attachment)" size="18" />
<span>{{ getAttachmentName(attachment) }}</span> <span>{{ getAttachmentName(attachment) }}</span>
<small>{{ attachment.mime_type || formatAttachmentSize(attachment.size) }}</small>
</div> </div>
<audio
class="agent-assistant-attachment__audio"
controls
:src="resolveAttachmentUrl(attachment.url)"
/>
<VBtn <VBtn
class="agent-assistant-surface-btn" class="agent-assistant-surface-btn"
:href="getAttachmentDownloadUrl(attachment)" :href="getAttachmentDownloadUrl(attachment)"
:download="getAttachmentName(attachment)" :download="getAttachmentName(attachment)"
icon size="small"
variant="text" variant="tonal"
color="primary" color="primary"
:aria-label="t('agentAssistant.download')" prepend-icon="mdi-download"
> >
<VIcon icon="mdi-download" /> {{ t('agentAssistant.download') }}
</VBtn> </VBtn>
</div> </template>
</template>
<template v-else>
<div class="agent-assistant-attachment__file">
<VIcon :icon="getAttachmentIcon(attachment)" size="22" />
<div class="agent-assistant-attachment__file-text">
<span>{{ getAttachmentName(attachment) }}</span>
<small>{{ attachment.mime_type || formatAttachmentSize(attachment.size) }}</small>
</div>
<VBtn
class="agent-assistant-surface-btn"
:href="getAttachmentDownloadUrl(attachment)"
:download="getAttachmentName(attachment)"
icon
variant="text"
color="primary"
:aria-label="t('agentAssistant.download')"
>
<VIcon icon="mdi-download" />
</VBtn>
</div>
</template>
</div>
</div>
<div
v-if="
!message.content &&
!message.attachments.length &&
!message.choices.length &&
message.status === 'streaming'
"
class="agent-assistant-typing"
>
<span />
<span />
<span />
</div> </div>
</div> </div>
<div
v-if="
!message.content &&
!message.attachments.length &&
!message.choices.length &&
message.status === 'streaming'
"
class="agent-assistant-typing"
>
<span />
<span />
<span />
</div>
</div> </div>
</PerfectScrollbar> </main>
<footer class="agent-assistant-composer"> <footer class="agent-assistant-composer">
<VAlert v-if="streamError" type="error" variant="tonal" density="compact" class="mb-3"> <VAlert v-if="streamError" type="error" variant="tonal" density="compact" class="mb-3">
@@ -2317,7 +2379,7 @@ onScopeDispose(() => {
position: relative; position: relative;
display: grid; display: grid;
block-size: 100%; block-size: 100%;
grid-template-rows: auto 1fr; grid-template-rows: auto minmax(0, 1fr);
min-block-size: 0; min-block-size: 0;
--agent-assistant-assistant-bg: rgba(var(--v-theme-surface), 0.92); --agent-assistant-assistant-bg: rgba(var(--v-theme-surface), 0.92);
@@ -2601,25 +2663,28 @@ onScopeDispose(() => {
} }
.agent-assistant-messages { .agent-assistant-messages {
display: flex;
box-sizing: border-box; box-sizing: border-box;
flex-direction: column; block-size: 100%;
min-block-size: 0; min-block-size: 0;
overflow-y: auto; overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain; overscroll-behavior: contain;
padding-block: 1rem; padding-block: 1rem;
padding-inline: 1rem; padding-inline: 1rem;
scroll-behavior: auto;
scrollbar-gutter: stable;
scrollbar-width: thin; scrollbar-width: thin;
}
:deep(.ps__rail-x), .agent-assistant-messages__content {
:deep(.ps__rail-y) { display: flex;
display: none !important; flex-direction: column;
} min-block-size: 100%;
} }
/* 只有消息态预留输入框空间,避免 iOS 空态被 padding 撑出不可滚动的滚动条。 */ /* 只有消息态预留输入框空间,避免 iOS 空态被 padding 撑出不可滚动的滚动条。 */
.agent-assistant-messages--has-content { .agent-assistant-messages--has-content {
padding-block-end: calc(env(safe-area-inset-bottom, 0px) + 6rem); padding-block-end: calc(env(safe-area-inset-bottom, 0px) + 8.75rem);
} }
.agent-assistant-empty { .agent-assistant-empty {