Files
xsh-assistant-next/pages/aigc/chat/index.vue

296 lines
8.2 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts" setup>
import ChatItem from "~/components/aigc/chat/ChatItem.vue";
import Message from "~/components/aigc/chat/Message.vue";
import type {ChatMessage, ChatMessageId, ChatSessionId} from "~/typings/llm";
import {useHistory} from "~/composables/useHistory";
import {uuidv4} from "@uniiem/uuid";
import {useLLM} from "~/composables/useLLM";
import {trimObject} from "@uniiem/object-trim";
useHead({
title: '聊天 | XSH AI'
})
const dayjs = useDayjs()
const toast = useToast()
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)
/**
* 获取指定 ID 的会话数据
* @param chatSessionId
*/
const getSessionCopyById = (chatSessionId: ChatSessionId) => chatSessions.value.find(s => s.id === chatSessionId);
/**
* 切换当前会话
* @param chatSessionId 指定会话 ID不传则切换到列表中第一个会话
*/
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()
}
nextTick(() => {
scrollToMessageListBottom()
})
}
/**
* 处理新建会话操作
*/
const handleClickCreateSession = () => {
// 生成一个新的会话 ID
const sessionId = uuidv4()
// 插入新会话数据
setChatSessions([
{
id: sessionId,
subject: '新对话',
messages: [],
create_at: dayjs().unix(),
},
...chatSessions.value,
])
// 切换到新的会话
selectCurrentSessionId(sessionId)
nextTick(() => {
insetMessage({
id: uuidv4(),
role: 'assistant',
content: '你好,有什么可以帮助你的吗?',
})
})
}
/**
* 处理发送消息操作
* @param event
*/
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: uuidv4(),
role: 'user',
content: user_input.value,
create_at: dayjs().unix(),
})
user_input.value = ''
// 进入响应中状态
responding.value = true
// 插入空助手消息(加载状态)
const assistantReplyId = insetMessage({
id: uuidv4(),
role: 'assistant',
content: '',
})
// 请求模型回复
const trimmedMessages = trimObject(getMessages(), 2000, {
keys: ['content']
})
useLLM(trimmedMessages, {
modelTag: 'spark3_5'
}).then(reply => {
modifyMessageContent(assistantReplyId, reply)
}).catch(err => {
modifyMessageContent(assistantReplyId, err, true)
}).finally(() => {
responding.value = false
})
}
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 getMessages = () => getSessionCopyById(currentSessionId.value!)?.messages || []
const modifyMessageContent = (
messageId: ChatMessageId,
content: string,
interrupted: boolean = false,
updateTime: boolean = true
) => {
setChatSessions(chatSessions.value.map(s => s.id === currentSessionId.value ? {
...s,
messages: s.messages.map(m => m.id === messageId ? {
...m,
content,
interrupted,
create_at: updateTime ? dayjs().unix() : m.create_at,
} : 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 relative">
<!-- ClientOnly avoids hydrate exception -->
<ClientOnly>
<TransitionGroup name="chat-item">
<ChatItem
v-for="session in chatSessions"
:chat-session="session" :key="session.id"
:active="session.id === currentSessionId"
@click="selectCurrentSessionId(session.id)"
@remove="() => {
chatSessions.splice(chatSessions.findIndex(s => s.id === session.id), 1)
session.id === currentSessionId && selectCurrentSessionId()
}"
/>
</TransitionGroup>
</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 getMessages() || []"
: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;
}
.chat-item-move,
.chat-item-enter-active,
.chat-item-leave-active {
@apply transition-all duration-300;
}
.chat-item-enter-from,
.chat-item-leave-to {
@apply opacity-0 scale-90;
}
.chat-item-leave-active {
@apply absolute inset-x-0;
}
</style>