Files
Memoh/apps/web/src/pages/home/components/chat-area.vue
T
Acbox 7825b49ff3 feat(web): add resizable session sidebar and inline file manager panel
Replace fixed-width session sidebar with a draggable resize handle on
its right edge (180–480px, persisted to localStorage). Convert the file
manager from a Sheet overlay to an embedded right-side panel with a
left-edge resize handle (320–800px, also persisted).
2026-03-29 19:23:10 +08:00

465 lines
14 KiB
Vue

<template>
<div class="flex-1 flex h-full min-w-0">
<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-xs font-medium text-foreground">
{{ $t('chat.selectBot') }}
</p>
<p class="mt-1 text-xs text-muted-foreground">
{{ $t('chat.selectBotHint') }}
</p>
</div>
</div>
<template v-else>
<!-- Session header -->
<!-- <div class="border-b px-4 py-2 flex items-center justify-between min-h-12">
<div class="flex items-center gap-2 min-w-0">
<h2 class="text-xs font-medium truncate">
{{ activeSession?.title || $t('chat.untitledSession') }}
</h2>
</div>
<div class="flex items-center gap-1 shrink-0">
<Button
type="button"
size="sm"
variant="ghost"
:aria-label="$t('chat.newSession')"
@click="chatStore.createNewSession()"
>
<FontAwesomeIcon
:icon="['fas', 'plus']"
class="size-3.5"
/>
</Button>
</div>
</div> -->
<!-- Messages -->
<section class="flex-1 relative w-full px-3 sm:px-5 lg:px-8">
<section class="absolute inset-0">
<ScrollArea
ref="scrollContainer"
class="h-full"
>
<div class="w-full max-w-4xl mx-auto px-10 py-6 space-y-6">
<!-- Load older indicator -->
<div
v-if="loadingOlder"
class="flex justify-center py-2"
>
<LoaderCircle
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-xs">
{{ $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="px-3 sm:px-5 lg:px-8 py-2.5">
<div class="w-full max-w-4xl 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"
>
<component
:is="file.type.startsWith('image/') ? ImageIcon : FileIcon"
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)"
>
<X
class="size-3"
/>
</button>
</div>
</div>
<input
ref="fileInput"
type="file"
multiple
class="hidden"
@change="handleFileInputChange"
>
<section>
<InputGroup class="bg-transparent overflow-hidden shadow-none! ring-0! border-border!">
<InputGroupTextarea
v-model="inputText"
class="min-h-14 max-h-14 text-xs 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="items-center py-1.5"
>
<Button
type="button"
size="sm"
variant="ghost"
:disabled="!currentBotId || activeChatReadOnly || streaming"
aria-label="Attach files"
@click="fileInput?.click()"
>
<Paperclip
class="size-3.5"
/>
</Button>
<Button
type="button"
size="sm"
variant="ghost"
:disabled="!currentBotId"
:aria-label="$t('chat.files')"
@click="fileManagerOpen = true"
>
<FolderOpen
class="size-3.5"
/>
</Button>
<Button
v-if="!streaming"
type="button"
size="icon"
:disabled="(!inputText.trim() && !pendingFiles.length) || !currentBotId || activeChatReadOnly"
aria-label="Send message"
class="ml-auto size-7 rounded-full bg-[#8B56E3] text-white"
@click="handleSend"
>
<Send
class="size-3"
/>
</Button>
<Button
v-else
type="button"
size="icon"
variant="destructive"
class="ml-auto size-7 rounded-full"
aria-label="Stop generating response"
@click="chatStore.abort()"
>
<LoaderCircle
class="size-3.5 animate-spin"
/>
</Button>
</InputGroupAddon>
</InputGroup>
</section>
</div>
</div>
</template>
</div>
<!-- File manager panel -->
<div
v-if="fileManagerOpen"
class="flex shrink-0 h-full relative"
:style="{ width: `${fileManagerWidth}px` }"
>
<div
class="absolute top-0 left-0 w-1 h-full cursor-col-resize z-10 group"
@mousedown="onFmResizeStart"
>
<div
class="w-full h-full transition-colors group-hover:bg-primary/20"
:class="{ 'bg-primary/30': isFmResizing }"
/>
</div>
<div class="flex flex-col h-full flex-1 min-w-0 border-l border-border bg-sidebar">
<div class="flex items-center justify-between px-4 h-12 shrink-0">
<span class="text-sm font-medium text-foreground">{{ $t('chat.files') }}</span>
<Button
type="button"
size="icon"
variant="ghost"
class="size-6"
@click="fileManagerOpen = false"
>
<X class="size-3.5" />
</Button>
</div>
<div class="flex-1 min-h-0 relative">
<FileManager
v-if="currentBotId"
ref="fileManagerRef"
:bot-id="currentBotId"
:sync-url="false"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick, onMounted, onBeforeUnmount, provide, useTemplateRef, watchEffect } from 'vue'
import { useLocalStorage } from '@vueuse/core'
import { LoaderCircle, Image as ImageIcon, File as FileIcon, X, Paperclip, FolderOpen, Send } from 'lucide-vue-next'
import { ScrollArea, Button, InputGroup, InputGroupAddon, InputGroupTextarea } from '@memohai/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)
const FM_MIN_WIDTH = 320
const FM_MAX_WIDTH = 800
const FM_DEFAULT_WIDTH = 520
const fileManagerWidth = useLocalStorage('file-manager-panel-width', FM_DEFAULT_WIDTH)
const isFmResizing = ref(false)
function onFmResizeStart(e: MouseEvent) {
e.preventDefault()
isFmResizing.value = true
const startX = e.clientX
const startWidth = fileManagerWidth.value
function onMouseMove(ev: MouseEvent) {
const delta = startX - ev.clientX
fileManagerWidth.value = Math.min(FM_MAX_WIDTH, Math.max(FM_MIN_WIDTH, startWidth + delta))
}
function onMouseUp() {
isFmResizing.value = false
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
onBeforeUnmount(() => {
document.body.style.cursor = ''
document.body.style.userSelect = ''
})
const FILE_MANAGER_ROOT = '/data'
function normalizeFileManagerPath(path: string): string {
const trimmedPath = path.trim()
if (!trimmedPath) return FILE_MANAGER_ROOT
if (trimmedPath === FILE_MANAGER_ROOT || trimmedPath.startsWith(`${FILE_MANAGER_ROOT}/`)) {
return trimmedPath
}
if (trimmedPath === '/') return FILE_MANAGER_ROOT
if (trimmedPath.startsWith('/')) {
return `${FILE_MANAGER_ROOT}${trimmedPath}`
}
return `${FILE_MANAGER_ROOT}/${trimmedPath}`
}
provide(openInFileManagerKey, (path: string, isDir = false) => {
const normalizedPath = normalizeFileManagerPath(path)
fileManagerOpen.value = true
nextTick(() => {
if (!fileManagerRef.value) return
if (isDir) {
fileManagerRef.value.navigateTo(normalizedPath)
} else {
fileManagerRef.value.openFileByPath(normalizedPath)
}
})
})
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 {
if (chatStore.currentBotId || chatStore.sessionId) {
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 || e.keyCode === 229) return
e.preventDefault()
handleSend()
}
function handleFileInputChange(e: Event) {
const input = e.target as HTMLInputElement
if (input.files) {
for (const file of Array.from(input.files)) {
pendingFiles.value.push(file)
}
}
input.value = ''
}
function handlePaste(e: ClipboardEvent) {
const items = e.clipboardData?.items
if (!items) return
for (const item of Array.from(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>