555 lines
16 KiB
Vue
555 lines
16 KiB
Vue
<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'
|
||
|
||
useSeoMeta({
|
||
title: '聊天',
|
||
})
|
||
|
||
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"
|
||
>
|
||
<div
|
||
v-if="!loginState.is_logged_in"
|
||
class="w-full h-full"
|
||
>
|
||
<div
|
||
class="w-full h-full flex flex-col justify-center items-center gap-2 bg-neutral-100 dark:bg-neutral-900"
|
||
>
|
||
<Icon
|
||
name="i-tabler-user-circle"
|
||
class="text-7xl text-neutral-300 dark:text-neutral-700"
|
||
/>
|
||
<p class="text-sm text-neutral-500 dark:text-neutral-400">
|
||
请登录后使用
|
||
</p>
|
||
<UButton
|
||
class="mt-2 font-bold"
|
||
color="black"
|
||
variant="solid"
|
||
size="xs"
|
||
@click="modal.open(ModalAuthentication)"
|
||
>
|
||
登录
|
||
</UButton>
|
||
</div>
|
||
</div>
|
||
<NewSessionScreen
|
||
v-else-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>
|