mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 15:36:49 +08:00
Improve agent assistant message scrolling
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user