【优化】Ai 对话解耦

This commit is contained in:
cherishsince
2024-05-16 18:29:42 +08:00
parent 91593d5d40
commit 482ce62370
2 changed files with 522 additions and 341 deletions

View File

@ -1,81 +1,19 @@
<template>
<el-container class="ai-layout">
<!-- 左侧会话列表 -->
<el-aside width="260px" class="conversation-container">
<div>
<!-- 左顶部新建对话 -->
<el-button class="w-1/1 btn-new-conversation" type="primary" @click="createConversation">
<Icon icon="ep:plus" class="mr-5px"/>
新建对话
</el-button>
<!-- 左顶部搜索对话 -->
<el-input
v-model="searchName"
size="large"
class="mt-10px search-input"
placeholder="搜索历史记录"
@keyup="searchConversation"
>
<template #prefix>
<Icon icon="ep:search"/>
</template>
</el-input>
<!-- 左中间对话列表 -->
<div class="conversation-list">
<!-- TODO @fain置顶聊天记录一星期钱30天前前端对数据重新做一下分组或者后端接口改一下 -->
<div v-for="conversationKey in Object.keys(conversationMap)" :key="conversationKey" >
<div v-if="conversationMap[conversationKey].length">
<el-text class="mx-1" size="small" tag="b">{{conversationKey}}</el-text>
</div>
<el-row
v-for="conversation in conversationMap[conversationKey]"
:key="conversation.id"
@click="handleConversationClick(conversation.id)">
<div
:class="conversation.id === conversationId ? 'conversation active' : 'conversation'"
@click="changeConversation(conversation.id)"
>
<div class="title-wrapper">
<img class="avatar" :src="conversation.roleAvatar"/>
<span class="title">{{ conversation.title }}</span>
</div>
<!-- TODO @fan缺一个置顶按钮效果改成 hover 上去展示 -->
<div class="button-wrapper">
<el-icon title="编辑" @click="updateConversationTitle(conversation)">
<Icon icon="ep:edit"/>
</el-icon>
<el-icon title="删除会话" @click="deleteChatConversation(conversation)">
<Icon icon="ep:delete"/>
</el-icon>
</div>
</div>
</el-row>
</div>
</div>
</div>
<!-- 左底部工具栏 -->
<div class="tool-box">
<div @click="handleRoleRepository">
<Icon icon="ep:user"/>
<el-text size="small">角色仓库</el-text>
</div>
<div @click="handleClearConversation">
<Icon icon="ep:delete"/>
<el-text size="small">清空未置顶对话</el-text>
</div>
</div>
</el-aside>
<Conversation @onConversationClick="handleConversationClick"
@onConversationClear="handlerConversationClear" />
<!-- 右侧会话详情 -->
<el-container class="detail-container">
<!-- 右顶部 TODO 芋艿右对齐 -->
<el-header class="header">
<div class="title">
{{ useConversation?.title }}
{{ activeConversation?.title }}
</div>
<div>
<!-- TODO @fan样式改下这里我已经改成点击后弹出了 -->
<el-button type="primary" @click="openChatConversationUpdateForm">
<span v-html="useConversation?.modelName"></span>
<span v-html="activeConversation?.modelName"></span>
<Icon icon="ep:setting" style="margin-left: 10px"/>
</el-button>
<el-button>
@ -107,8 +45,6 @@
<el-text class="time">{{ formatDate(item.createTime) }}</el-text>
</div>
<div class="left-text-container" ref="markdownViewRef">
<!-- <div class="left-text markdown-view" v-html="item.content"></div>-->
<!-- <mdPreview :content="item.content" :delay="false" />-->
<MarkdownView class="left-text" :content="item.content" />
</div>
<div class="left-btns">
@ -136,7 +72,6 @@
</div>
<div class="right-text-container">
<div class="right-text">{{ item.content }}</div>
<!-- <MarkdownView class="right-text" :content="item.content" />-->
</div>
<div class="right-btns">
<div class="btn-cus" @click="noCopy(item.content)">
@ -152,10 +87,6 @@
</div>
</div>
</div>
<!-- 角色仓库抽屉 -->
<el-drawer v-model="drawer" title="角色仓库" size="50%">
<Role/>
</el-drawer>
</el-main>
<el-footer class="footer-container">
<form @submit.prevent="onSend" class="prompt-from">
@ -191,38 +122,35 @@
</form>
</el-footer>
</el-container>
</el-container>
<ChatConversationUpdateForm
ref="chatConversationUpdateFormRef"
@success="getChatConversationList"
/>
<!-- ========= 额外组件 ========== -->
<!-- 更新对话 form -->
<ChatConversationUpdateForm
ref="chatConversationUpdateFormRef"
@success="handlerTitleSuccess"
/>
</el-container>
</template>
<script setup lang="ts">
import MarkdownView from '@/components/MarkdownView/index.vue'
import Conversation from './Conversation.vue'
import {ChatMessageApi, ChatMessageVO} from '@/api/ai/chat/message'
import {ChatConversationApi, ChatConversationVO} from '@/api/ai/chat/conversation'
import ChatConversationUpdateForm from './components/ChatConversationUpdateForm.vue'
import Role from '@/views/ai/chat/role/index.vue'
import {ChatConversationVO} from '@/api/ai/chat/conversation'
import {formatDate} from '@/utils/formatTime'
import {useClipboard} from '@vueuse/core'
import ChatConversationUpdateForm from "@/views/ai/chat/components/ChatConversationUpdateForm.vue";
const route = useRoute() // 路由
const message = useMessage() // 消息弹窗
const {copy} = useClipboard() // 初始化 copy 到粘贴板
const conversationList = ref([] as ChatConversationVO[])
const conversationMap = ref<any>({})
// 初始化 copy 到粘贴板
const {copy} = useClipboard()
const drawer = ref<boolean>(false) // 角色仓库抽屉
const searchName = ref('') // 查询的内容
const inputTimeout = ref<any>() // 处理输入中回车的定时器
const conversationId = ref<number | null>(null) // 选中的对话编号
// ref 属性定义
const activeConversationId = ref<number | null>(null) // 选中的对话编号
const activeConversation = ref<ChatConversationVO | null>(null) // 选中的 Conversation
const conversationInProgress = ref(false) // 对话进行中
const conversationInAbortController = ref<any>() // 对话进行中 abort 控制器(控制 stream 对话)
const inputTimeout = ref<any>() // 处理输入中回车的定时器
const prompt = ref<string>() // prompt
// 判断 消息列表 滚动的位置(用于判断是否需要滚动到消息最下方)
@ -231,66 +159,73 @@ const isScrolling = ref(false) //用于判断用户是否在滚动
const isComposing = ref(false) // 判断用户是否在输入
/** chat message 列表 */
// defineOptions({ name: 'chatMessageList' })
const list = ref<ChatMessageVO[]>([]) // 列表的数据
const useConversation = ref<ChatConversationVO | null>(null) // 使用的 Conversation
/** 新建对话 */
const createConversation = async () => {
// 新建对话
const conversationId = await ChatConversationApi.createChatConversationMy(
{} as unknown as ChatConversationVO
)
changeConversation(conversationId)
// 刷新对话列表
await getChatConversationList()
}
// ============ 处理对话滚动 ==============
const changeConversation = (id: number) => {
// 切换对话
conversationId.value = id
// TODO 芋艿:待实现
// 刷新 message 列表
messageList()
}
/** 更新聊天会话的标题 */
const updateConversationTitle = async (conversation: ChatConversationVO) => {
// 二次确认
const {value} = await ElMessageBox.prompt('修改标题', {
inputPattern: /^[\s\S]*.*\S[\s\S]*$/, // 判断非空,且非空格
inputErrorMessage: '标题不能为空',
inputValue: conversation.title
function scrollToBottom() {
nextTick(() => {
//注意要使用nexttick以免获取不到dom
console.log('isScrolling.value', isScrolling.value)
if (!isScrolling.value) {
messageContainer.value.scrollTop =
messageContainer.value.scrollHeight - messageContainer.value.offsetHeight
}
})
// 发起修改
await ChatConversationApi.updateChatConversationMy({
id: conversation.id,
title: value
} as ChatConversationVO)
message.success('重命名成功')
// 刷新列表
await getChatConversationList()
}
/** 删除聊天会话 */
const deleteChatConversation = async (conversation: ChatConversationVO) => {
try {
// 删除的二次确认
await message.delConfirm(`是否确认删除会话 - ${conversation.title}?`)
// 发起删除
await ChatConversationApi.deleteChatConversationMy(conversation.id)
message.success('会话已删除')
// 刷新列表
await getChatConversationList()
} catch {
function handleScroll() {
const scrollContainer = messageContainer.value
const scrollTop = scrollContainer.scrollTop
const scrollHeight = scrollContainer.scrollHeight
const offsetHeight = scrollContainer.offsetHeight
if (scrollTop + offsetHeight < scrollHeight) {
// 用户开始滚动并在最底部之上,取消保持在最底部的效果
isScrolling.value = true
} else {
// 用户停止滚动并滚动到最底部,开启保持到最底部的效果
isScrolling.value = false
}
}
const searchConversation = () => {
// TODO fan待实现
// ============= 处理聊天输入回车发送 =============
const onCompositionstart = () => {
isComposing.value = true
}
/** send */
const onCompositionend = () => {
// console.log('输入结束...')
setTimeout(() => {
isComposing.value = false
}, 200)
}
const onPromptInput = (event) => {
// 非输入法 输入设置为 true
if (!isComposing.value) {
// 回车 event data 是 null
if (event.data == null) {
return
}
isComposing.value = true
}
// 清理定时器
if (inputTimeout.value) {
clearTimeout(inputTimeout.value)
}
// 重置定时器
inputTimeout.value = setTimeout(() => {
isComposing.value = false
}, 400)
}
// ============== 对话消息相关 =================
/**
* 发送消息
*/
const onSend = async () => {
// 判断用户是否在输入
if (isComposing.value) {
@ -311,21 +246,15 @@ const onSend = async () => {
// TODO 芋艿:这块交互要在优化;应该是先插入到 UI 界面,里面会有当前的消息,和正在思考中;之后发起请求;
// 清空输入框
prompt.value = ''
// const requestParams = {
// conversationId: conversationId.value,
// content: content
// } as unknown as ChatMessageSendVO
// // 添加 message
const userMessage = {
conversationId: conversationId.value,
conversationId: activeConversationId.value,
content: content
} as ChatMessageVO
// list.value.push(userMessage)
// // 滚动到住下面
// scrollToBottom()
// 滚动到住下面
scrollToBottom()
// stream
await doSendStream(userMessage)
//
}
const doSendStream = async (userMessage: ChatMessageVO) => {
@ -387,48 +316,35 @@ const doSendStream = async (userMessage: ChatMessageVO) => {
}
}
/** 查询列表 */
const messageList = async () => {
const stopStream = async () => {
// tip如果 stream 进行中的 message就需要调用 controller 结束
if (conversationInAbortController.value) {
conversationInAbortController.value.abort()
}
// 设置为 false
conversationInProgress.value = false
}
// ============== message 数据 =================
/**
* 获取 - message 列表
*/
const getMessageList = async () => {
try {
if (conversationId.value === null) {
if (activeConversationId.value === null) {
return
}
// 获取列表数据
const res = await ChatMessageApi.messageList(conversationId.value)
list.value = res
list.value = await ChatMessageApi.messageList(activeConversationId.value)
// 滚动到最下面
scrollToBottom()
await nextTick(() => {
scrollToBottom()
})
} finally {
}
}
function scrollToBottom() {
nextTick(() => {
//注意要使用nexttick以免获取不到dom
console.log('isScrolling.value', isScrolling.value)
if (!isScrolling.value) {
messageContainer.value.scrollTop =
messageContainer.value.scrollHeight - messageContainer.value.offsetHeight
}
})
}
function handleScroll() {
const scrollContainer = messageContainer.value
const scrollTop = scrollContainer.scrollTop
const scrollHeight = scrollContainer.scrollHeight
const offsetHeight = scrollContainer.offsetHeight
if (scrollTop + offsetHeight < scrollHeight) {
// 用户开始滚动并在最底部之上,取消保持在最底部的效果
isScrolling.value = true
} else {
// 用户停止滚动并滚动到最底部,开启保持到最底部的效果
isScrolling.value = false
}
}
function noCopy(content) {
copy(content)
ElMessage({
@ -445,186 +361,57 @@ const onDelete = async (id) => {
type: 'success'
})
// tip如果 stream 进行中的 message就需要调用 controller 结束
stopStream()
await stopStream()
// 重新获取 message 列表
await messageList()
await getMessageList()
}
const stopStream = async () => {
// tip如果 stream 进行中的 message就需要调用 controller 结束
if (conversationInAbortController.value) {
conversationInAbortController.value.abort()
}
// 设置为 false
conversationInProgress.value = false
}
/** 修改聊天会话 */
const chatConversationUpdateFormRef = ref()
const openChatConversationUpdateForm = async () => {
chatConversationUpdateFormRef.value.open(conversationId.value)
}
// 输入
const onCompositionstart = () => {
console.log('onCompositionstart。。。.')
isComposing.value = true
}
const onCompositionend = () => {
// console.log('输入结束...')
setTimeout(() => {
console.log('输入结束...')
isComposing.value = false
}, 200)
}
const onPromptInput = (event) => {
// 非输入法 输入设置为 true
if (!isComposing.value) {
// 回车 event data 是 null
if (event.data == null) {
return
}
console.log('setTimeout 输入开始...')
isComposing.value = true
}
// 清理定时器
if (inputTimeout.value) {
clearTimeout(inputTimeout.value)
}
// 重置定时器
inputTimeout.value = setTimeout(() => {
console.log('setTimeout 输入结束...')
isComposing.value = false
}, 400)
}
const getConversation = async (conversationId: number | null) => {
if (!conversationId) {
return
}
// 获取对话信息
useConversation.value = await ChatConversationApi.getChatConversationMy(conversationId)
console.log('useConversation.value', useConversation.value)
}
/** 获得聊天会话列表 */
const getChatConversationList = async () => {
conversationList.value = await ChatConversationApi.getChatConversationMyList()
// 默认选中第一条
if (conversationList.value.length === 0) {
conversationId.value = null
list.value = []
} else {
if (conversationId.value === null) {
conversationId.value = conversationList.value[0].id
changeConversation(conversationList.value[0].id)
}
}
// map
const groupRes = await conversationTimeGroup(conversationList.value)
conversationMap.value = groupRes
}
const conversationTimeGroup = async (list: ChatConversationVO[]) => {
// 排序、指定、时间分组(今天、一天前、三天前、七天前、30天前)
const groupMap = {
'置顶': [],
'今天': [],
'一天前': [],
'三天前': [],
'七天前': [],
'三十天前': []
}
// 当前时间的时间戳
const now = Date.now();
// 定义时间间隔常量(单位:毫秒)
const oneDay = 24 * 60 * 60 * 1000;
const threeDays = 3 * oneDay;
const sevenDays = 7 * oneDay;
const thirtyDays = 30 * oneDay;
console.log('listlistlist', list)
for (const conversation: ChatConversationVO of list) {
// 置顶
if (conversation.pinned) {
groupMap['置顶'].push(conversation)
continue
}
// 计算时间差(单位:毫秒)
const diff = now - conversation.updateTime;
// 根据时间间隔判断
if (diff < oneDay) {
groupMap['今天'].push(conversation)
} else if (diff < threeDays) {
groupMap['一天前'].push(conversation)
} else if (diff < sevenDays) {
groupMap['三天前'].push(conversation)
} else if (diff < thirtyDays) {
groupMap['七天前'].push(conversation)
} else {
groupMap['三十天前'].push(conversation)
}
}
return groupMap
chatConversationUpdateFormRef.value.open(activeConversationId.value)
}
// 对话点击
const handleConversationClick = async (id: number) => {
// 切换对话
conversationId.value = id
console.log('conversationId.value', conversationId.value)
// 获取列表数据
await messageList()
/**
* 对话 - 标题修改成功
*/
const handlerTitleSuccess = async () => {
// TODO 需要刷新 对话列表
}
// 角色仓库
const handleRoleRepository = async () => {
drawer.value = !drawer.value
/**
* 对话 - 点击
*/
const handleConversationClick = async (conversation: ChatConversationVO) => {
// 更新选中的对话 id
activeConversationId.value = conversation.id
// 刷新 message 列表
await getMessageList()
}
// 清空对话
const handleClearConversation = async () => {
ElMessageBox.confirm(
'确认后对话会全部清空,置顶的对话除外。',
'确认提示',
{
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning',
}
)
.then(async () => {
await ChatConversationApi.deleteMyAllExceptPinned()
ElMessage({
message: '操作成功!',
type: 'success'
})
// 清空选中的对话
useConversation.value = null
conversationId.value = null
list.value = []
// 获得聊天会话列表
await getChatConversationList()
})
.catch(() => {
})
/**
* 对话 - 清理全部对话
*/
const handlerConversationClear = async ()=> {
activeConversationId.value = null
activeConversation.value = null
list.value = []
}
/** 初始化 **/
onMounted(async () => {
// 设置当前对话
if (route.query.conversationId) {
conversationId.value = route.query.conversationId as number
}
// 设置当前对话 TODO 角色仓库过来的,自带 conversationId 需要选中
// if (route.query.conversationId) {
// conversationId.value = route.query.conversationId as number
// }
// 获得聊天会话列表
await getChatConversationList()
// await getChatConversationList()
// 获取对话信息
await getConversation(conversationId.value)
// await getConversation(conversationId.value)
// 获取列表数据
await messageList()
await getMessageList()
// scrollToBottom();
// await nextTick
// 监听滚动事件,判断用户滚动状态
@ -642,6 +429,7 @@ onMounted(async () => {
})
})
</script>
<style lang="scss" scoped>
.ai-layout {
// TODO @范 这里height不能 100% 先这样临时处理