Files
xsh-assistant-next/pages/aigc/chat/index.vue
2024-04-08 17:18:20 +08:00

453 lines
14 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 Assistant,
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'
import NewSessionScreen from '~/components/aigc/chat/NewSessionScreen.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 currentAssistant = computed<Assistant | null>(() => getSessionCopyById(currentSessionId.value || '')?.assistant || null)
const modals = reactive({
modelSelect: false,
assistantSelect: false,
newSessionScreen: 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
modals.newSessionScreen = false
scrollToMessageListBottom()
})
}
/**
* 创建新会话处理函数
* @param assistant 指定助手,不传或空值则不指定助手
*/
const createSession = (assistant: Assistant | null) => {
// 生成一个新的会话 ID
const sessionId = uuidv4()
// 新会话数据
const newChat = !!assistant ? {
id: sessionId,
subject: '新对话',
messages: [],
create_at: dayjs().unix(),
assistant,
} : {
id: sessionId,
subject: '新对话',
messages: [],
create_at: dayjs().unix(),
}
// 插入新会话数据
setChatSessions([
newChat,
...chatSessions.value,
])
// 切换到新的会话
selectCurrentSessionId(sessionId)
// 关闭新建会话屏幕
modals.newSessionScreen = false
nextTick(() => {
if (!!currentAssistant.value) {
insetMessage({
id: uuidv4(),
role: 'system',
content: currentAssistant.value?.role || '',
preset: true,
})
insetMessage({
id: uuidv4(),
role: 'user',
content: `${currentAssistant.value?.target}${currentAssistant.value?.demand}`,
preset: true,
})
insetMessage({
id: uuidv4(),
role: 'assistant',
content: currentAssistant.value?.input_tpl || '',
preset: true,
})
} else {
insetMessage({
id: uuidv4(),
role: 'assistant',
content: '你好,有什么可以帮助你的吗?',
preset: true,
})
}
})
}
/**
* 处理点击新建会话按钮事件
*/
const handleClickCreateSession = () => {
showSidebar.value = false
modals.newSessionScreen = true
}
/**
* 处理发送消息操作
* @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">
<div v-if="chatSessions.length === 0">
<div class="text-center text-neutral-400 dark:text-neutral-500 py-4 flex flex-col items-center gap-2">
<Icon name="i-tabler-messages" class="text-2xl" />
<span>没有会话</span>
</div>
</div>
<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)] flex-1 bg-white dark:bg-neutral-900">
<Transition name="message" mode="out-in">
<NewSessionScreen
v-if="modals.newSessionScreen || getSessionCopyById(currentSessionId!) === undefined"
:non-back="!getSessionCopyById(currentSessionId!)"
@select="createSession"
@cancel="modals.newSessionScreen = false"
/>
<div
v-else
class="w-full h-full 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>
<ClientOnly>
<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:box"/>
<span class="text-xs">
{{ llmModels.find(m => m.tag === currentModel)?.name.toUpperCase() || '模型' }}
</span>
</button>
<button
v-if="currentAssistant?.tpl_name"
class="chat-option-btn"
>
<Icon name="tabler:robot-face"/>
<span class="text-xs">
{{ currentAssistant.tpl_name }}
</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>
</ClientOnly>
</div>
</Transition>
</div>
<!-- Modals -->
<UModal 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 v-if="llm?.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>