mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
357 lines
11 KiB
Vue
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>
|