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

375 lines
11 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,
type ChatMessageId,
type ChatSession,
type ChatSessionId,
llmModels,
type ModelTag
} from "~/typings/llm";
import {useHistory} from "~/composables/useHistory";
import {uuidv4} from "@uniiem/uuid";
import {useLLM} from "~/composables/useLLM";
import {trimObject} from "@uniiem/object-trim";
import ModalAuthentication from "~/components/ModalAuthentication.vue";
useHead({
title: '聊天 | XSH AI'
})
const dayjs = useDayjs()
const toast = useToast()
const modal = useModal()
const loginState = useLoginState()
const historyStore = useHistory()
const {chatSessions} = storeToRefs(historyStore)
const {setChatSessions} = historyStore
const currentSessionId = ref<ChatSessionId | null>(null)
const messagesWrapperRef = ref<HTMLDivElement | null>(null)
const showSidebar = ref(false)
const user_input = ref('')
const responding = ref(false)
const currentModel = ref<ModelTag>('spark3_5')
const modals = reactive({
modelSelect: false
})
/**
* 获取指定 ID 的会话数据
* @param chatSessionId
*/
const getSessionCopyById = (chatSessionId: ChatSessionId): ChatSession | undefined => 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(() => {
showSidebar.value = false
scrollToMessageListBottom()
})
}
/**
* 处理新建会话操作
*/
const handleClickCreateSession = () => {
// 生成一个新的会话 ID
const sessionId = uuidv4()
// 插入新会话数据
setChatSessions([
{
id: sessionId,
subject: '新对话',
messages: [],
create_at: dayjs().unix(),
},
...chatSessions.value,
])
// 切换到新的会话
selectCurrentSessionId(sessionId)
modals.modelSelect = true
nextTick(() => {
insetMessage({
id: uuidv4(),
role: 'assistant',
content: '你好,有什么可以帮助你的吗?',
})
})
}
/**
* 处理发送消息操作
* @param event
*/
const handleClickSend = (event: any) => {
if (!loginState.is_logged_in) {
modal.open(ModalAuthentication)
return;
}
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: currentModel.value
}).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="absolute -translate-x-full md:sticky md:translate-x-0 z-10 flex flex-col h-[calc(100vh-4rem)] bg-neutral-100 dark:bg-neutral-900 p-4 w-full md:w-[300px]
shadow-sidebar border-r border-transparent dark:border-neutral-700 transition-all duration-300 ease-out"
:class="{'translate-x-0': showSidebar}"
>
<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 flex items-center gap-2">
<UButton
class="md:hidden"
color="black"
variant="ghost"
icon="i-tabler-menu-2"
@click="showSidebar = !showSidebar"
>
</UButton>
<h1 class="font-medium">{{ getSessionCopyById(currentSessionId!)?.subject || '新对话' }}</h1>
</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 class="flex items-center gap-2 overflow-auto overflow-y-hidden">
<button class="chat-option-btn" @click="modals.modelSelect = true">
<Icon name="tabler:robot-face"/>
<span class="text-xs">
{{ llmModels.find(m => m.tag === currentModel)?.name.toUpperCase() || '模型' }}
</span>
</button>
</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>
<!-- Modals -->
<UModal prevent-close v-model="modals.modelSelect">
<UCard>
<template #header>
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white">
选择大语言模型
</h3>
</template>
<div class="grid grid-cols-3 gap-4">
<div
v-for="(llm, index) in llmModels"
:key="index"
@click="currentModel = llm.tag"
class="flex flex-col gap-2 justify-center items-center w-full aspect-[1/1] border-2 rounded-xl cursor-pointer transition duration-150 select-none"
:class="llm.tag === currentModel ? 'border-primary shadow-xl bg-primary text-white' : 'dark:border-neutral-800 bg-white dark:bg-black shadow-card'"
>
<Icon :name="llm?.icon" class="text-4xl opacity-80"/>
<div class="flex flex-col gap-0.5 items-center">
<h1 class="font-bold drop-shadow opacity-90">{{ llm.name || 'unknown' }}</h1>
<p class="text-xs opacity-60">{{ llm.description }}</p>
</div>
</div>
</div>
<template #footer>
<div class="flex justify-end items-center" @click="modals.modelSelect = false">
<UButton>
确定
</UButton>
</div>
</template>
</UCard>
</UModal>
</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;
}
.chat-option-btn {
@apply text-lg px-2 py-1.5 flex gap-1 justify-center items-center rounded-lg;
@apply bg-white border border-neutral-300 shadow-sm hover:shadow-card;
@apply dark:bg-neutral-800 dark:border-neutral-600;
}
</style>