375 lines
11 KiB
Vue
375 lines
11 KiB
Vue
<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> |