【优化】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

@ -0,0 +1,393 @@
<!-- AI 对话 -->
<template>
<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 === activeConversationId ? 'conversation active' : 'conversation'"
>
<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-drawer v-model="drawer" title="角色仓库" size="50%">
<Role/>
</el-drawer>
</el-aside>
</template>
<script setup lang="ts">
import {ChatConversationApi, ChatConversationVO} from '@/api/ai/chat/conversation'
import {ref} from "vue";
import ChatConversationUpdateForm from "@/views/ai/chat/components/ChatConversationUpdateForm.vue";
import Role from "@/views/ai/chat/role/index.vue";
const message = useMessage() //
//
const searchName = ref<string>('') //
const activeConversationId = ref<number | null>(null) // null
const conversationList = ref([] as ChatConversationVO[]) //
const conversationMap = ref<any>({}) // ()
const drawer = ref<boolean>(false) //
// props
const props = defineProps({
activeId: {
type: Number || null,
required: true
}
})
//
const emits = defineEmits(['onConversationClick', 'onConversationClear'])
/**
* 对话 - 搜索
*/
const searchConversation = () => {
// TODO fan
}
/**
* 对话 - 点击
*/
const handleConversationClick = async (id: number) => {
//
activeConversationId.value = id
const filterConversation = conversationList.value.filter(item => {
return item.id !== id
})
// onConversationClick
emits('onConversationClick', filterConversation[0])
}
/**
* 对话 - 获取列表
*/
const getChatConversationList = async () => {
// 1
conversationList.value = await ChatConversationApi.getChatConversationMyList()
// 2
if (conversationList.value.length === 0) {
activeConversationId.value = null
conversationMap.value = {}
return
}
// 3(30)
conversationMap.value = await conversationTimeGroup(conversationList.value)
}
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
}
/**
* 对话 - 新建
*/
const createConversation = async () => {
// 1
const conversationId = await ChatConversationApi.createChatConversationMy(
{} as unknown as ChatConversationVO
)
// 2
await handleConversationClick(conversationId)
// 3
await getChatConversationList()
}
/**
* 对话 - 更新标题
*/
const updateConversationTitle = async (conversation: ChatConversationVO) => {
// 1
const {value} = await ElMessageBox.prompt('修改标题', {
inputPattern: /^[\s\S]*.*\S[\s\S]*$/, //
inputErrorMessage: '标题不能为空',
inputValue: conversation.title
})
// 2
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 {
}
}
// ============
/**
* 角色仓库抽屉
*/
const handleRoleRepository = async () => {
drawer.value = !drawer.value
}
// =============
/**
* 清空对话
*/
const handleClearConversation = async () => {
ElMessageBox.confirm(
'确认后对话会全部清空,置顶的对话除外。',
'确认提示',
{
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning',
})
.then(async () => {
await ChatConversationApi.deleteMyAllExceptPinned()
ElMessage({
message: '操作成功!',
type: 'success'
})
//
activeConversationId.value = null
//
await getChatConversationList()
//
emits('onConversationClear')
})
.catch(() => {
})
}
// ============ onMounted
onMounted(async () => {
//
if (props.activeId != null) {
}
//
await getChatConversationList()
})
</script>
<style scoped lang="scss">
.conversation-container {
position: relative;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 0 10px;
padding-top: 10px;
.btn-new-conversation {
padding: 18px 0;
}
.search-input {
margin-top: 20px;
}
.conversation-list {
margin-top: 20px;
.conversation {
display: flex;
flex-direction: row;
justify-content: space-between;
flex: 1;
padding: 0 5px;
margin-top: 10px;
cursor: pointer;
border-radius: 5px;
align-items: center;
line-height: 30px;
&.active {
background-color: #e6e6e6;
.button {
display: inline-block;
}
}
.title-wrapper {
display: flex;
flex-direction: row;
align-items: center;
}
.title {
padding: 5px 10px;
max-width: 220px;
font-size: 14px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.avatar {
width: 28px;
height: 28px;
display: flex;
flex-direction: row;
justify-items: center;
}
//
.button-wrapper {
right: 2px;
display: flex;
flex-direction: row;
justify-items: center;
color: #606266;
.el-icon {
margin-right: 5px;
}
}
}
}
//
.tool-box {
line-height: 35px;
display: flex;
justify-content: space-between;
align-items: center;
color: var(--el-text-color);
> div {
display: flex;
align-items: center;
color: #606266;
padding: 0;
margin: 0;
cursor: pointer;
> span {
margin-left: 5px;
}
}
}
}
</style>

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>
<!-- ========= 额外组件 ========== -->
<!-- 更新对话 form -->
<ChatConversationUpdateForm
ref="chatConversationUpdateFormRef"
@success="getChatConversationList"
@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()
// ============ ==============
function scrollToBottom() {
nextTick(() => {
//使nexttickdom
console.log('isScrolling.value', isScrolling.value)
if (!isScrolling.value) {
messageContainer.value.scrollTop =
messageContainer.value.scrollHeight - messageContainer.value.offsetHeight
}
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
})
//
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,45 +316,32 @@ 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)
//
await nextTick(() => {
scrollToBottom()
} finally {
}
}
function scrollToBottom() {
nextTick(() => {
//使nexttickdom
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
} finally {
}
}
@ -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)
chatConversationUpdateFormRef.value.open(activeConversationId.value)
}
//
const onCompositionstart = () => {
console.log('onCompositionstart。。。.')
isComposing.value = true
/**
* 对话 - 标题修改成功
*/
const handlerTitleSuccess = async () => {
// TODO
}
const onCompositionend = () => {
// console.log('...')
setTimeout(() => {
console.log('输入结束...')
isComposing.value = false
}, 200)
/**
* 对话 - 点击
*/
const handleConversationClick = async (conversation: ChatConversationVO) => {
// id
activeConversationId.value = conversation.id
// message
await getMessageList()
}
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
/**
* 对话 - 清理全部对话
*/
const handlerConversationClear = async ()=> {
activeConversationId.value = null
activeConversation.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
}
//
const handleConversationClick = async (id: number) => {
//
conversationId.value = id
console.log('conversationId.value', conversationId.value)
//
await messageList()
}
//
const handleRoleRepository = async () => {
drawer.value = !drawer.value
}
//
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(() => {
})
}
/** 初始化 **/
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%