Files
xsh-assistant-next/pages/aigc/chat/index.vue
2024-03-29 18:12:44 +08:00

241 lines
7.0 KiB
Vue

<script lang="ts" setup>
import ChatItem from "~/components/aigc/chat/ChatItem.vue";
import type {ChatMessage, ChatMessageId, ChatSessionId} from "~/components/aigc/chat";
import Message from "~/components/aigc/chat/Message.vue";
import {useIdGenerator} from "~/composables/useIdGenerator";
import {useHistory} from "~/composables/useHistory";
useHead({
title: '聊天 | XSH AI'
})
const dayjs = useDayjs()
const toast = useToast()
const {generateUUID} = useIdGenerator()
const historyStore = useHistory()
const {chatSessions} = storeToRefs(historyStore)
const {setChatSessions} = historyStore
// const chatSessions = ref<ChatSession[]>([])
const currentSessionId = ref<ChatSessionId | null>(null)
const messagesWrapperRef = ref<HTMLDivElement | null>(null)
const user_input = ref('')
const responding = ref(false)
const getSessionCopyById = (chatSessionId: ChatSessionId) => chatSessions.value.find(s => s.id === chatSessionId);
const selectCurrentSessionId = (chatSessionId?: ChatSessionId) => {
if (chatSessions.value.length > 0) {
if (chatSessionId) { // 切换到指定 ID
// 保存当前输入并清空输入框
setChatSessions(chatSessions.value.map(s => s.id === currentSessionId.value ? {
...s,
last_input: user_input.value,
} : s))
user_input.value = ''
// 切换到指定 ID 会话
currentSessionId.value = chatSessionId
// 恢复输入
user_input.value = getSessionCopyById(chatSessionId)?.last_input || ''
// 清除已恢复的输入
setChatSessions(chatSessions.value.map(s => s.id === chatSessionId ? {
...s,
last_input: '',
} : s))
} else { // 切换到第一个会话
currentSessionId.value = chatSessions.value[0].id
}
} else {
handleClickCreateSession()
}
}
const handleClickCreateSession = () => {
// 生成一个新的会话 ID
const sessionId = generateUUID()
// 插入新会话数据
setChatSessions([
{
id: sessionId,
subject: '新对话',
messages: [],
create_at: dayjs().unix(),
},
...chatSessions.value,
])
// 切换到新的会话
selectCurrentSessionId(sessionId)
}
const handleClickSend = (event: any) => {
if (event.ctrlKey) {
return;
}
if (responding.value) return;
if (!user_input.value) return
if (!currentSessionId.value) {
toast.add({
title: '发送失败',
description: '请先选择一个会话',
color: 'red',
icon: 'i-tabler-circle-x',
})
return;
}
// 插入用户消息
insetMessage({
id: generateUUID(),
role: 'user',
content: user_input.value,
create_at: dayjs().unix(),
})
user_input.value = ''
// 进入响应中状态
responding.value = true
// 插入空助手消息(加载状态)
const assistantReplyId = insetMessage({
id: generateUUID(),
role: 'assistant',
content: '',
create_at: dayjs().unix(),
})
// mock
setTimeout(() => {
modifyMessageContent(assistantReplyId, '草,走,忽略!ጿ ኈ ቼ ዽ ጿ')
responding.value = false
}, 1000)
}
const scrollToMessageListBottom = () => {
nextTick(() => {
messagesWrapperRef.value?.scrollTo({
top: messagesWrapperRef.value.scrollHeight,
behavior: 'smooth',
})
})
}
const insetMessage = (message: ChatMessage): ChatMessageId => {
setChatSessions(chatSessions.value.map(s => s.id === currentSessionId.value ? {
...s,
messages: [
...s.messages,
message
]
} : s))
scrollToMessageListBottom()
return message.id
}
const modifyMessageContent = (messageId: ChatMessageId, content: string) => {
setChatSessions(chatSessions.value.map(s => s.id === currentSessionId.value ? {
...s,
messages: s.messages.map(m => m.id === messageId ? {
...m,
content,
} : m)
} : s))
scrollToMessageListBottom()
}
onMounted(() => {
// 切换到第一个会话, 没有会话会自动创建
selectCurrentSessionId()
})
</script>
<template>
<div class="w-full flex relative">
<div class="h-[calc(100vh-4rem)] bg-neutral-100 dark:bg-neutral-900 p-4 flex flex-col w-[300px]
shadow-sidebar border-r border-transparent dark:border-neutral-700">
<div class="flex-1 flex flex-col overflow-auto overflow-x-hidden">
<!-- list -->
<div class="flex flex-col gap-3">
<!-- ClientOnly avoids hydrate exception -->
<ClientOnly>
<ChatItem
v-for="(session, i) in chatSessions"
:chat-session="session" :key="i"
:active="session.id === currentSessionId"
@click="selectCurrentSessionId(session.id)"
@remove="() => {
chatSessions.splice(chatSessions.findIndex(s => s.id === session.id), 1)
session.id === currentSessionId && selectCurrentSessionId()
}"
/>
</ClientOnly>
</div>
</div>
<div class="pt-4 flex justify-between items-center">
<div></div>
<div>
<UButton
color="white"
variant="solid"
icon="i-tabler-message-circle-plus"
@click="handleClickCreateSession"
>
新建聊天
</UButton>
</div>
</div>
</div>
<div class="h-[calc(100vh-4rem)] bg-white dark:bg-neutral-900 flex-1 flex flex-col">
<div class="w-full p-4 bg-neutral-50 dark:bg-neutral-800/50 border-b dark:border-neutral-700/50">
{{ getSessionCopyById(currentSessionId!)?.subject || '新对话' }}
</div>
<div ref="messagesWrapperRef" class="flex-1 flex flex-col overflow-auto overflow-x-hidden">
<div class="flex flex-col gap-8 px-4 py-8">
<TransitionGroup name="message">
<Message
v-for="message in getSessionCopyById(currentSessionId!)?.messages || []"
:message="message"
:key="message.id"
/>
</TransitionGroup>
</div>
</div>
<div
class="w-full p-4 pt-2 flex flex-col gap-2 bg-neutral-50 dark:bg-neutral-800/50 border-t dark:border-neutral-700/50">
<div>action bar</div>
<div class="relative">
<UTextarea
v-model="user_input"
size="lg"
autoresize
:rows="5"
:maxrows="12"
class="font-sans"
placeholder="Enter 发送, Ctrl + Enter 换行"
@keydown.ctrl.enter="user_input += '\n'"
@keydown.enter.prevent="handleClickSend"
/>
<UButton
color="black"
variant="solid"
icon="i-tabler-send-2"
class="absolute bottom-2.5 right-3"
@click.stop="handleClickSend"
>
发送
</UButton>
</div>
</div>
</div>
</div>
</template>
<!--suppress CssUnusedSymbol -->
<style scoped>
.message-enter-active {
@apply transition duration-300;
}
.message-enter-from {
@apply translate-y-4 opacity-0;
}
.message-leave-to {
@apply -translate-y-4 opacity-0;
}
</style>