mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
refactor: improve chat scroll logic (#263)
This commit is contained in:
@@ -17,48 +17,49 @@
|
|||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- Bot info header -->
|
<!-- Bot info header -->
|
||||||
|
|
||||||
|
|
||||||
<!-- Messages -->
|
<!-- Messages -->
|
||||||
<div
|
<section class="flex-1 relative w-full">
|
||||||
ref="scrollContainer"
|
<section class="absolute inset-0">
|
||||||
class="flex-1 overflow-y-auto"
|
<ScrollArea
|
||||||
role="log"
|
ref="scrollContainer"
|
||||||
aria-live="polite"
|
class="h-full"
|
||||||
aria-relevant="additions text"
|
|
||||||
@scroll="handleScroll"
|
|
||||||
>
|
|
||||||
<div class="max-w-3xl mx-auto px-4 py-6 space-y-6">
|
|
||||||
<!-- Load older indicator -->
|
|
||||||
<div
|
|
||||||
v-if="loadingOlder"
|
|
||||||
class="flex justify-center py-2"
|
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon
|
<div class="max-w-3xl mx-auto px-4 py-6 space-y-6">
|
||||||
:icon="['fas', 'spinner']"
|
<!-- Load older indicator -->
|
||||||
class="size-3.5 animate-spin text-muted-foreground"
|
<div
|
||||||
/>
|
v-if="loadingOlder"
|
||||||
</div>
|
class="flex justify-center py-2"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
:icon="['fas', 'spinner']"
|
||||||
|
class="size-3.5 animate-spin text-muted-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Empty state -->
|
<!-- Empty state -->
|
||||||
<div
|
<div
|
||||||
v-if="messages.length === 0 && !loadingChats"
|
v-if="messages.length === 0 && !loadingChats"
|
||||||
class="flex items-center justify-center min-h-[300px]"
|
class="flex items-center justify-center min-h-[300px]"
|
||||||
>
|
>
|
||||||
<p class="text-muted-foreground text-lg">
|
<p class="text-muted-foreground text-lg">
|
||||||
{{ $t('chat.greeting') }}
|
{{ $t('chat.greeting') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Message list -->
|
||||||
|
<MessageItem
|
||||||
|
v-for="msg in messages"
|
||||||
|
:key="msg.id"
|
||||||
|
:message="msg"
|
||||||
|
:on-open-media="galleryOpenBySrc"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Message list -->
|
|
||||||
<MessageItem
|
|
||||||
v-for="msg in messages"
|
|
||||||
:key="msg.id"
|
|
||||||
:message="msg"
|
|
||||||
:on-open-media="galleryOpenBySrc"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Media gallery lightbox -->
|
<!-- Media gallery lightbox -->
|
||||||
<MediaGalleryLightbox
|
<MediaGalleryLightbox
|
||||||
@@ -115,7 +116,7 @@
|
|||||||
class="justify-end"
|
class="justify-end"
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
v-if="!streaming"
|
v-if="!streaming"
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -200,8 +201,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, nextTick, onMounted, provide } from 'vue'
|
import { ref, computed, nextTick, onMounted, provide, useTemplateRef, watchEffect} from 'vue'
|
||||||
import { Textarea, Button, Avatar, AvatarImage, AvatarFallback, Badge, InputGroup, InputGroupAddon, InputGroupButton, InputGroupText, InputGroupTextarea, Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '@memoh/ui'
|
import { ScrollArea, Button, InputGroup, InputGroupAddon, InputGroupTextarea, Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '@memoh/ui'
|
||||||
import { useChatStore } from '@/store/chat-list'
|
import { useChatStore } from '@/store/chat-list'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import MessageItem from './message-item.vue'
|
import MessageItem from './message-item.vue'
|
||||||
@@ -210,6 +211,7 @@ import FileManager from '@/components/file-manager/index.vue'
|
|||||||
import { useMediaGallery } from '../composables/useMediaGallery'
|
import { useMediaGallery } from '../composables/useMediaGallery'
|
||||||
import { openInFileManagerKey } from '../composables/useFileManagerProvider'
|
import { openInFileManagerKey } from '../composables/useFileManagerProvider'
|
||||||
import type { ChatAttachment } from '@/composables/api/useChat'
|
import type { ChatAttachment } from '@/composables/api/useChat'
|
||||||
|
import { useScroll, useElementBounding } from '@vueuse/core'
|
||||||
|
|
||||||
const chatStore = useChatStore()
|
const chatStore = useChatStore()
|
||||||
const fileInput = ref<HTMLInputElement | null>(null)
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
@@ -231,8 +233,7 @@ provide(openInFileManagerKey, (path: string, isDir = false) => {
|
|||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
streaming,
|
streaming,
|
||||||
currentBotId,
|
currentBotId,
|
||||||
bots,
|
|
||||||
activeChatReadOnly,
|
activeChatReadOnly,
|
||||||
loadingOlder,
|
loadingOlder,
|
||||||
loadingChats,
|
loadingChats,
|
||||||
@@ -247,81 +248,60 @@ const {
|
|||||||
} = useMediaGallery(messages)
|
} = useMediaGallery(messages)
|
||||||
|
|
||||||
const inputText = ref('')
|
const inputText = ref('')
|
||||||
const scrollContainer = ref<HTMLElement>()
|
|
||||||
|
|
||||||
const currentBot = computed(() =>
|
|
||||||
bots.value.find((b) => b.id === currentBotId.value) ?? null,
|
|
||||||
)
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
void chatStore.initialize()
|
try {
|
||||||
})
|
await chatStore.initialize()
|
||||||
|
} finally {
|
||||||
// ---- Auto-scroll ----
|
|
||||||
|
|
||||||
let userScrolledUp = false
|
|
||||||
|
|
||||||
function scrollToBottom(instant = false) {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const el = scrollContainer.value
|
requestAnimationFrame(() => {
|
||||||
if (!el) return
|
isInstant.value = true
|
||||||
el.scrollTo({ top: el.scrollHeight, behavior: instant ? 'instant' : 'smooth' })
|
})
|
||||||
})
|
})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
function handleScroll() {
|
const elNode = useTemplateRef('scrollContainer')
|
||||||
const el = scrollContainer.value
|
const descEl = computed(() => elNode.value?.$el?.children[0]?.children[0])
|
||||||
if (!el) return
|
const scrollEl = computed(() => descEl.value?.parentNode)
|
||||||
const distanceFromBottom = el.scrollHeight - el.clientHeight - el.scrollTop
|
const isAutoScroll = ref(true)
|
||||||
userScrolledUp = distanceFromBottom > 50
|
const isInstant=ref(false)
|
||||||
|
const { y, directions, arrivedState } = useScroll(scrollEl, { behavior: computed(() => isAutoScroll.value&&isInstant.value ? 'smooth' : 'instant') })
|
||||||
|
const { height,bottom } = useElementBounding(descEl)
|
||||||
|
|
||||||
if (el.scrollTop < 200 && hasMoreOlder.value && !loadingOlder.value) {
|
|
||||||
const prevHeight = el.scrollHeight
|
watchEffect(() => {
|
||||||
chatStore.loadOlderMessages().then((count) => {
|
if (directions.top) {
|
||||||
if (count > 0) {
|
isAutoScroll.value = false
|
||||||
nextTick(() => {
|
|
||||||
el.scrollTop = el.scrollHeight - prevHeight
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
if (arrivedState.bottom) {
|
||||||
|
isAutoScroll.value = true
|
||||||
// After full load (initial / chat switch), instantly jump to bottom
|
|
||||||
watch(loadingChats, (cur, prev) => {
|
|
||||||
if (prev && !cur) {
|
|
||||||
userScrolledUp = false
|
|
||||||
scrollToBottom(true)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Stream content auto-scroll
|
watchEffect(() => {
|
||||||
watch(
|
if (isAutoScroll.value) {
|
||||||
() => {
|
y.value = height.value
|
||||||
const last = messages.value[messages.value.length - 1]
|
}
|
||||||
return last?.blocks.reduce((acc, b) => {
|
})
|
||||||
if (b.type === 'text') return acc + b.content.length
|
|
||||||
if (b.type === 'thinking') return acc + b.content.length
|
|
||||||
return acc + 1
|
|
||||||
}, 0) ?? 0
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
if (!userScrolledUp) scrollToBottom()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
// New realtime message auto-scroll
|
let Throttle = true
|
||||||
watch(
|
|
||||||
() => messages.value.length,
|
watchEffect(() => {
|
||||||
() => {
|
if (directions.top && arrivedState.top && Throttle && hasMoreOlder.value && !loadingOlder.value) {
|
||||||
if (loadingChats.value) return
|
const prev=bottom.value
|
||||||
userScrolledUp = false
|
Throttle = false
|
||||||
scrollToBottom()
|
chatStore.loadOlderMessages().then((count) => {
|
||||||
},
|
setTimeout(() => {
|
||||||
)
|
if (count > 0) {
|
||||||
|
y.value = height.value-prev
|
||||||
|
Throttle = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
if (e.isComposing) return
|
if (e.isComposing) return
|
||||||
@@ -329,13 +309,6 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
handleSend()
|
handleSend()
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFileSelect(e: Event) {
|
|
||||||
const input = e.target as HTMLInputElement
|
|
||||||
if (input.files) {
|
|
||||||
pendingFiles.value.push(...Array.from(input.files))
|
|
||||||
}
|
|
||||||
input.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePaste(e: ClipboardEvent) {
|
function handlePaste(e: ClipboardEvent) {
|
||||||
const items = e.clipboardData?.items
|
const items = e.clipboardData?.items
|
||||||
@@ -365,6 +338,7 @@ async function fileToAttachment(file: File): Promise<ChatAttachment> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleSend() {
|
async function handleSend() {
|
||||||
|
isAutoScroll.value=true
|
||||||
const text = inputText.value.trim()
|
const text = inputText.value.trim()
|
||||||
const files = [...pendingFiles.value]
|
const files = [...pendingFiles.value]
|
||||||
if ((!text && !files.length) || streaming.value || activeChatReadOnly.value) return
|
if ((!text && !files.length) || streaming.value || activeChatReadOnly.value) return
|
||||||
|
|||||||
Reference in New Issue
Block a user