Files
Memoh/apps/web/src/pages/chat/components/chat-area.vue
T
2026-03-18 15:19:32 +08:00

357 lines
11 KiB
Vue

<template>
<div class="flex-1 flex flex-col h-full min-w-0">
<!-- No bot selected -->
<div
v-if="!currentBotId"
class="flex-1 flex items-center justify-center"
>
<div class="text-center">
<p class="text-xl font-semibold tracking-tight text-foreground">
{{ $t('chat.selectBot') }}
</p>
<p class="mt-1 text-sm text-muted-foreground">
{{ $t('chat.selectBotHint') }}
</p>
</div>
</div>
<template v-else>
<!-- Bot info header -->
<!-- Messages -->
<section class="flex-1 relative w-full">
<section class="absolute inset-0">
<ScrollArea
ref="scrollContainer"
class="h-full"
>
<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
:icon="['fas', 'spinner']"
class="size-3.5 animate-spin text-muted-foreground"
/>
</div>
<!-- Empty state -->
<div
v-if="messages.length === 0 && !loadingChats"
class="flex items-center justify-center min-h-[300px]"
>
<p class="text-muted-foreground text-lg">
{{ $t('chat.greeting') }}
</p>
</div>
<!-- Message list -->
<MessageItem
v-for="msg in messages"
:key="msg.id"
:message="msg"
:on-open-media="galleryOpenBySrc"
/>
</div>
</ScrollArea>
</section>
</section>
<!-- Media gallery lightbox -->
<MediaGalleryLightbox
:items="galleryItems"
:open-index="galleryOpenIndex"
@update:open-index="gallerySetOpenIndex"
/>
<!-- Input -->
<div class="border-t p-4">
<div class="max-w-3xl mx-auto">
<!-- Pending attachment previews -->
<div
v-if="pendingFiles.length"
class="flex flex-wrap gap-2 mb-2"
>
<div
v-for="(file, i) in pendingFiles"
:key="i"
class="relative group flex items-center gap-1.5 px-2 py-1 rounded-md border bg-muted/40 text-xs"
>
<FontAwesomeIcon
:icon="['fas', file.type.startsWith('image/') ? 'image' : 'file']"
class="size-3 text-muted-foreground"
/>
<span class="truncate max-w-30">{{ file.name }}</span>
<button
type="button"
class="ml-1 text-muted-foreground hover:text-foreground"
:aria-label="`${$t('common.delete')}: ${file.name}`"
@click="pendingFiles.splice(i, 1)"
>
<FontAwesomeIcon
:icon="['fas', 'xmark']"
class="size-3"
/>
</button>
</div>
</div>
<section>
<InputGroup>
<InputGroupTextarea
v-model="inputText"
class=" max-h-15 resize-none break-all!"
:placeholder="activeChatReadOnly ? $t('chat.readonlyHint') : $t('chat.inputPlaceholder')"
:disabled="!currentBotId || activeChatReadOnly"
style="scrollbar-width: none;"
@keydown.enter.exact="handleKeydown"
@paste="handlePaste"
/>
<InputGroupAddon
align="block-end"
class="justify-end"
>
<Button
v-if="!streaming"
type="button"
size="sm"
variant="ghost"
:disabled="!currentBotId || activeChatReadOnly"
aria-label="Attach files"
@click="fileInput?.click()"
>
<FontAwesomeIcon
:icon="['fas', 'paperclip']"
class="size-3.5"
/>
</Button>
<Button
type="button"
size="sm"
variant="ghost"
:disabled="!currentBotId"
:aria-label="$t('chat.files')"
@click="fileManagerOpen = true"
>
<FontAwesomeIcon
:icon="['fas', 'folder-open']"
class="size-3.5"
/>
</Button>
<Button
v-if="!streaming"
type="button"
size="sm"
:disabled="(!inputText.trim() && !pendingFiles.length) || !currentBotId || activeChatReadOnly"
aria-label="Send message"
@click="handleSend"
>
<FontAwesomeIcon
:icon="['fas', 'paper-plane']"
class="size-3.5"
/>
</Button>
<Button
v-else
type="button"
size="sm"
variant="destructive"
aria-label="Stop generating response"
@click="chatStore.abort()"
>
<FontAwesomeIcon
:icon="['fas', 'spinner']"
class="size-3.5 animate-spin"
/>
</Button>
</InputGroupAddon>
</InputGroup>
</section>
</div>
</div>
</template>
<!-- File manager sheet -->
<Sheet v-model:open="fileManagerOpen">
<SheetContent
side="right"
class="sm:max-w-2xl w-full p-0 flex flex-col"
>
<SheetHeader class="px-4 pt-4 pb-0">
<SheetTitle>{{ $t('chat.files') }}</SheetTitle>
<SheetDescription class="sr-only">
{{ $t('chat.files') }}
</SheetDescription>
</SheetHeader>
<div class="flex-1 min-h-0 relative">
<FileManager
v-if="currentBotId"
ref="fileManagerRef"
:bot-id="currentBotId"
:sync-url="false"
/>
</div>
</SheetContent>
</Sheet>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick, onMounted, provide, useTemplateRef, watchEffect} from 'vue'
import { ScrollArea, Button, InputGroup, InputGroupAddon, InputGroupTextarea, Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '@memoh/ui'
import { useChatStore } from '@/store/chat-list'
import { storeToRefs } from 'pinia'
import MessageItem from './message-item.vue'
import MediaGalleryLightbox from './media-gallery-lightbox.vue'
import FileManager from '@/components/file-manager/index.vue'
import { useMediaGallery } from '../composables/useMediaGallery'
import { openInFileManagerKey } from '../composables/useFileManagerProvider'
import type { ChatAttachment } from '@/composables/api/useChat'
import { useScroll, useElementBounding } from '@vueuse/core'
const chatStore = useChatStore()
const fileInput = ref<HTMLInputElement | null>(null)
const pendingFiles = ref<File[]>([])
const fileManagerOpen = ref(false)
const fileManagerRef = ref<InstanceType<typeof FileManager> | null>(null)
provide(openInFileManagerKey, (path: string, isDir = false) => {
fileManagerOpen.value = true
nextTick(() => {
if (!fileManagerRef.value) return
if (isDir) {
fileManagerRef.value.navigateTo(path)
} else {
fileManagerRef.value.openFileByPath(path)
}
})
})
const {
messages,
streaming,
currentBotId,
activeChatReadOnly,
loadingOlder,
loadingChats,
hasMoreOlder,
} = storeToRefs(chatStore)
const {
items: galleryItems,
openIndex: galleryOpenIndex,
setOpenIndex: gallerySetOpenIndex,
openBySrc: galleryOpenBySrc,
} = useMediaGallery(messages)
const inputText = ref('')
onMounted(async () => {
try {
await chatStore.initialize()
} finally {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
isInstant.value = true
})
})
}
})
const elNode = useTemplateRef('scrollContainer')
const descEl = computed(() => elNode.value?.$el?.children[0]?.children[0])
const scrollEl = computed(() => descEl.value?.parentNode)
const isAutoScroll = ref(true)
const isInstant=ref(false)
const { y, directions, arrivedState } = useScroll(scrollEl, { behavior: computed(() => isAutoScroll.value&&isInstant.value ? 'smooth' : 'instant') })
const { height,bottom } = useElementBounding(descEl)
watchEffect(() => {
if (directions.top) {
isAutoScroll.value = false
}
if (arrivedState.bottom) {
isAutoScroll.value = true
}
})
watchEffect(() => {
if (isAutoScroll.value) {
y.value = height.value
}
})
let Throttle = true
watchEffect(() => {
if (directions.top && arrivedState.top && Throttle && hasMoreOlder.value && !loadingOlder.value) {
const prev=bottom.value
Throttle = false
chatStore.loadOlderMessages().then((count) => {
setTimeout(() => {
if (count > 0) {
y.value = height.value-prev
Throttle = true
}
})
})
}
})
function handleKeydown(e: KeyboardEvent) {
if (e.isComposing) return
e.preventDefault()
handleSend()
}
function handlePaste(e: ClipboardEvent) {
const items = e.clipboardData?.items
if (!items) return
for (const item of items) {
if (item.kind === 'file') {
const file = item.getAsFile()
if (file) pendingFiles.value.push(file)
}
}
}
async function fileToAttachment(file: File): Promise<ChatAttachment> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
resolve({
type: file.type.startsWith('image/') ? 'image' : 'file',
base64: reader.result as string,
mime: file.type || 'application/octet-stream',
name: file.name,
})
}
reader.onerror = () => reject(new Error('Failed to read file'))
reader.readAsDataURL(file)
})
}
async function handleSend() {
isAutoScroll.value=true
const text = inputText.value.trim()
const files = [...pendingFiles.value]
if ((!text && !files.length) || streaming.value || activeChatReadOnly.value) return
inputText.value = ''
pendingFiles.value = []
let attachments: ChatAttachment[] | undefined
if (files.length) {
attachments = await Promise.all(files.map(fileToAttachment))
}
chatStore.sendMessage(text, attachments)
}
</script>