From d29b978732c2907abc41a937de33d71f81ca5b51 Mon Sep 17 00:00:00 2001 From: dongyukun <1208714201@qq.com> Date: 星期二, 03 六月 2025 13:14:11 +0800 Subject: [PATCH] Merge remote-tracking branch 'origin/master' --- src/views/ai/chat/index/index.vue | 773 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 files changed, 773 insertions(+), 0 deletions(-) diff --git a/src/views/ai/chat/index/index.vue b/src/views/ai/chat/index/index.vue new file mode 100644 index 0000000..e6efef0 --- /dev/null +++ b/src/views/ai/chat/index/index.vue @@ -0,0 +1,773 @@ +<template> + <el-container class="ai-layout"> + <!-- 左侧:对话列表 --> + <ConversationList + :active-id="activeConversationId" + ref="conversationListRef" + @on-conversation-create="handleConversationCreateSuccess" + @on-conversation-click="handleConversationClick" + @on-conversation-clear="handleConversationClear" + @on-conversation-delete="handlerConversationDelete" + /> + <!-- 右侧:对话详情 --> + <el-container class="detail-container"> + <el-header class="header"> + <div class="title"> + {{ activeConversation?.title ? activeConversation?.title : '对话' }} + <span v-if="activeMessageList.length">({{ activeMessageList.length }})</span> + </div> + <div class="btns" v-if="activeConversation"> + <el-button type="primary" bg plain size="small" @click="openChatConversationUpdateForm"> + <span v-html="activeConversation?.modelName"></span> + <Icon icon="ep:setting" class="ml-10px" /> + </el-button> + <el-button size="small" class="btn" @click="handlerMessageClear"> + <Icon icon="heroicons-outline:archive-box-x-mark" color="#787878" /> + </el-button> + <el-button size="small" class="btn"> + <Icon icon="ep:download" color="#787878" /> + </el-button> + <el-button size="small" class="btn" @click="handleGoTopMessage"> + <Icon icon="ep:top" color="#787878" /> + </el-button> + </div> + </el-header> + + <!-- main:消息列表 --> + <el-main class="main-container"> + <div> + <div class="message-container"> + <!-- 情况一:消息加载中 --> + <MessageLoading v-if="activeMessageListLoading" /> + <!-- 情况二:无聊天对话时 --> + <MessageNewConversation + v-if="!activeConversation" + @on-new-conversation="handleConversationCreate" + /> + <!-- 情况三:消息列表为空 --> + <MessageListEmpty + v-if="!activeMessageListLoading && messageList.length === 0 && activeConversation" + @on-prompt="doSendMessage" + /> + <!-- 情况四:消息列表不为空 --> + <MessageList + v-if="!activeMessageListLoading && messageList.length > 0" + ref="messageRef" + :conversation="activeConversation" + :list="messageList" + @on-delete-success="handleMessageDelete" + @on-edit="handleMessageEdit" + @on-refresh="handleMessageRefresh" + /> + </div> + </div> + </el-main> + + <!-- 底部 --> + <el-footer class="footer-container"> + <form class="prompt-from"> + <textarea + class="prompt-input" + v-model="prompt" + @keydown="handleSendByKeydown" + @input="handlePromptInput" + @compositionstart="onCompositionstart" + @compositionend="onCompositionend" + placeholder="问我任何问题...(Shift+Enter 换行,按下 Enter 发送)" + ></textarea> + <div class="prompt-btns"> + <div> + <el-switch v-model="enableContext" /> + <span class="ml-5px text-14px text-#8f8f8f">上下文</span> + </div> + <el-button + type="primary" + size="default" + @click="handleSendByButton" + :loading="conversationInProgress" + v-if="conversationInProgress == false" + > + {{ conversationInProgress ? '进行中' : '发送' }} + </el-button> + <el-button + type="danger" + size="default" + @click="stopStream()" + v-if="conversationInProgress == true" + > + 停止 + </el-button> + </div> + </form> + </el-footer> + </el-container> + + <!-- 更新对话 Form --> + <ConversationUpdateForm + ref="conversationUpdateFormRef" + @success="handleConversationUpdateSuccess" + /> + </el-container> +</template> + +<script setup lang="ts"> +import { ChatMessageApi, ChatMessageVO } from '@/api/ai/chat/message' +import { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation' +import ConversationList from './components/conversation/ConversationList.vue' +import ConversationUpdateForm from './components/conversation/ConversationUpdateForm.vue' +import MessageList from './components/message/MessageList.vue' +import MessageListEmpty from './components/message/MessageListEmpty.vue' +import MessageLoading from './components/message/MessageLoading.vue' +import MessageNewConversation from './components/message/MessageNewConversation.vue' + +/** AI 聊天对话 列表 */ +defineOptions({ name: 'AiChat' }) + +const route = useRoute() // 路由 +const message = useMessage() // 消息弹窗 + +// 聊天对话 +const conversationListRef = ref() +const activeConversationId = ref<number | null>(null) // 选中的对话编号 +const activeConversation = ref<ChatConversationVO | null>(null) // 选中的 Conversation +const conversationInProgress = ref(false) // 对话是否正在进行中。目前只有【发送】消息时,会更新为 true,避免切换对话、删除对话等操作 + +// 消息列表 +const messageRef = ref() +const activeMessageList = ref<ChatMessageVO[]>([]) // 选中对话的消息列表 +const activeMessageListLoading = ref<boolean>(false) // activeMessageList 是否正在加载中 +const activeMessageListLoadingTimer = ref<any>() // activeMessageListLoading Timer 定时器。如果加载速度很快,就不进入加载中 +// 消息滚动 +const textSpeed = ref<number>(50) // Typing speed in milliseconds +const textRoleRunning = ref<boolean>(false) // Typing speed in milliseconds + +// 发送消息输入框 +const isComposing = ref(false) // 判断用户是否在输入 +const conversationInAbortController = ref<any>() // 对话进行中 abort 控制器(控制 stream 对话) +const inputTimeout = ref<any>() // 处理输入中回车的定时器 +const prompt = ref<string>() // prompt +const enableContext = ref<boolean>(true) // 是否开启上下文 +// 接收 Stream 消息 +const receiveMessageFullText = ref('') +const receiveMessageDisplayedText = ref('') + + +// =========== 【聊天对话】相关 =========== + +/** 获取对话信息 */ +const getConversation = async (id: number | null) => { + if (!id) { + return + } + const conversation: ChatConversationVO = await ChatConversationApi.getChatConversationMy(id) + if (!conversation) { + return + } + activeConversation.value = conversation + activeConversationId.value = conversation.id +} + +/** + * 点击某个对话 + * + * @param conversation 选中的对话 + * @return 是否切换成功 + */ +const handleConversationClick = async (conversation: ChatConversationVO) => { + // 对话进行中,不允许切换 + if (conversationInProgress.value) { + message.alert('对话中,不允许切换!') + return false + } + + // 更新选中的对话 id + activeConversationId.value = conversation.id + activeConversation.value = conversation + // 刷新 message 列表 + await getMessageList() + // 滚动底部 + scrollToBottom(true) + // 清空输入框 + prompt.value = '' + return true +} + +/** 删除某个对话*/ +const handlerConversationDelete = async (delConversation: ChatConversationVO) => { + // 删除的对话如果是当前选中的,那么就重置 + if (activeConversationId.value === delConversation.id) { + await handleConversationClear() + } +} +/** 清空选中的对话 */ +const handleConversationClear = async () => { + // 对话进行中,不允许切换 + if (conversationInProgress.value) { + message.alert('对话中,不允许切换!') + return false + } + activeConversationId.value = null + activeConversation.value = null + activeMessageList.value = [] +} + +/** 修改聊天对话 */ +const conversationUpdateFormRef = ref() +const openChatConversationUpdateForm = async () => { + conversationUpdateFormRef.value.open(activeConversationId.value) +} +const handleConversationUpdateSuccess = async () => { + // 对话更新成功,刷新最新信息 + await getConversation(activeConversationId.value) +} + +/** 处理聊天对话的创建成功 */ +const handleConversationCreate = async () => { + // 创建对话 + await conversationListRef.value.createConversation() +} +/** 处理聊天对话的创建成功 */ +const handleConversationCreateSuccess = async () => { + // 创建新的对话,清空输入框 + prompt.value = '' +} + +// =========== 【消息列表】相关 =========== + +/** 获取消息 message 列表 */ +const getMessageList = async () => { + try { + if (activeConversationId.value === null) { + return + } + // Timer 定时器,如果加载速度很快,就不进入加载中 + activeMessageListLoadingTimer.value = setTimeout(() => { + activeMessageListLoading.value = true + }, 60) + + // 获取消息列表 + activeMessageList.value = await ChatMessageApi.getChatMessageListByConversationId( + activeConversationId.value + ) + + // 滚动到最下面 + await nextTick() + await scrollToBottom() + } finally { + // time 定时器,如果加载速度很快,就不进入加载中 + if (activeMessageListLoadingTimer.value) { + clearTimeout(activeMessageListLoadingTimer.value) + } + // 加载结束 + activeMessageListLoading.value = false + } +} + +/** + * 消息列表 + * + * 和 {@link #getMessageList()} 的差异是,把 systemMessage 考虑进去 + */ +const messageList = computed(() => { + if (activeMessageList.value.length > 0) { + return activeMessageList.value + } + // 没有消息时,如果有 systemMessage 则展示它 + if (activeConversation.value?.systemMessage) { + return [ + { + id: 0, + type: 'system', + content: activeConversation.value.systemMessage + } + ] + } + return [] +}) + +/** 处理删除 message 消息 */ +const handleMessageDelete = () => { + if (conversationInProgress.value) { + message.alert('回答中,不能删除!') + return + } + // 刷新 message 列表 + getMessageList() +} + +/** 处理 message 清空 */ +const handlerMessageClear = async () => { + if (!activeConversationId.value) { + return + } + try { + // 确认提示 + await message.delConfirm('确认清空对话消息?') + // 清空对话 + await ChatMessageApi.deleteByConversationId(activeConversationId.value) + // 刷新 message 列表 + activeMessageList.value = [] + } catch {} +} + +/** 回到 message 列表的顶部 */ +const handleGoTopMessage = () => { + messageRef.value.handlerGoTop() +} + +// =========== 【发送消息】相关 =========== + +/** 处理来自 keydown 的发送消息 */ +const handleSendByKeydown = async (event) => { + // 判断用户是否在输入 + if (isComposing.value) { + return + } + // 进行中不允许发送 + if (conversationInProgress.value) { + return + } + const content = prompt.value?.trim() as string + if (event.key === 'Enter') { + if (event.shiftKey) { + // 插入换行 + prompt.value += '\r\n' + event.preventDefault() // 防止默认的换行行为 + } else { + // 发送消息 + await doSendMessage(content) + event.preventDefault() // 防止默认的提交行为 + } + } +} + +/** 处理来自【发送】按钮的发送消息 */ +const handleSendByButton = () => { + doSendMessage(prompt.value?.trim() as string) +} + +/** 处理 prompt 输入变化 */ +const handlePromptInput = (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) +} +// TODO @芋艿:是不是可以通过 @keydown.enter、@keydown.shift.enter 来实现,回车发送、shift+回车换行;主要看看,是不是可以简化 isComposing 相关的逻辑 +const onCompositionstart = () => { + isComposing.value = true +} +const onCompositionend = () => { + // console.log('输入结束...') + setTimeout(() => { + isComposing.value = false + }, 200) +} + +/** 真正执行【发送】消息操作 */ +const doSendMessage = async (content: string) => { + // 校验 + if (content.length < 1) { + message.error('发送失败,原因:内容为空!') + return + } + if (activeConversationId.value == null) { + message.error('还没创建对话,不能发送!') + return + } + // 清空输入框 + prompt.value = '' + // 执行发送 + await doSendMessageStream({ + conversationId: activeConversationId.value, + content: content + } as ChatMessageVO) +} + +/** 真正执行【发送】消息操作 */ +const doSendMessageStream = async (userMessage: ChatMessageVO) => { + // 创建 AbortController 实例,以便中止请求 + conversationInAbortController.value = new AbortController() + // 标记对话进行中 + conversationInProgress.value = true + // 设置为空 + receiveMessageFullText.value = '' + + try { + // 1.1 先添加两个假数据,等 stream 返回再替换 + activeMessageList.value.push({ + id: -1, + conversationId: activeConversationId.value, + type: 'user', + content: userMessage.content, + createTime: new Date() + } as ChatMessageVO) + activeMessageList.value.push({ + id: -2, + conversationId: activeConversationId.value, + type: 'assistant', + content: '思考中...', + createTime: new Date() + } as ChatMessageVO) + // 1.2 滚动到最下面 + await nextTick() + await scrollToBottom() // 底部 + // 1.3 开始滚动 + textRoll() + + // 2. 发送 event stream + let isFirstChunk = true // 是否是第一个 chunk 消息段 + await ChatMessageApi.sendChatMessageStream( + userMessage.conversationId, + userMessage.content, + conversationInAbortController.value, + enableContext.value, + async (res) => { + const { code, data, msg } = JSON.parse(res.data) + if (code !== 0) { + message.alert(`对话异常! ${msg}`) + return + } + + // 如果内容为空,就不处理。 + if (data.receive.content === '') { + return + } + // 首次返回需要添加一个 message 到页面,后面的都是更新 + if (isFirstChunk) { + isFirstChunk = false + // 弹出两个假数据 + activeMessageList.value.pop() + activeMessageList.value.pop() + // 更新返回的数据 + activeMessageList.value.push(data.send) + activeMessageList.value.push(data.receive) + } + // debugger + receiveMessageFullText.value = receiveMessageFullText.value + data.receive.content + // 滚动到最下面 + await scrollToBottom() + }, + (error) => { + message.alert(`对话异常! ${error}`) + stopStream() + }, + () => { + stopStream() + } + ) + } catch {} +} + +/** 停止 stream 流式调用 */ +const stopStream = async () => { + // tip:如果 stream 进行中的 message,就需要调用 controller 结束 + if (conversationInAbortController.value) { + conversationInAbortController.value.abort() + } + // 设置为 false + conversationInProgress.value = false +} + +/** 编辑 message:设置为 prompt,可以再次编辑 */ +const handleMessageEdit = (message: ChatMessageVO) => { + prompt.value = message.content +} + +/** 刷新 message:基于指定消息,再次发起对话 */ +const handleMessageRefresh = (message: ChatMessageVO) => { + doSendMessage(message.content) +} + +// ============== 【消息滚动】相关 ============= + +/** 滚动到 message 底部 */ +const scrollToBottom = async (isIgnore?: boolean) => { + await nextTick() + if (messageRef.value) { + messageRef.value.scrollToBottom(isIgnore) + } +} + +/** 自提滚动效果 */ +const textRoll = async () => { + let index = 0 + try { + // 只能执行一次 + if (textRoleRunning.value) { + return + } + // 设置状态 + textRoleRunning.value = true + receiveMessageDisplayedText.value = '' + const task = async () => { + // 调整速度 + const diff = + (receiveMessageFullText.value.length - receiveMessageDisplayedText.value.length) / 10 + if (diff > 5) { + textSpeed.value = 10 + } else if (diff > 2) { + textSpeed.value = 30 + } else if (diff > 1.5) { + textSpeed.value = 50 + } else { + textSpeed.value = 100 + } + // 对话结束,就按 30 的速度 + if (!conversationInProgress.value) { + textSpeed.value = 10 + } + + if (index < receiveMessageFullText.value.length) { + receiveMessageDisplayedText.value += receiveMessageFullText.value[index] + index++ + + // 更新 message + const lastMessage = activeMessageList.value[activeMessageList.value.length - 1] + lastMessage.content = receiveMessageDisplayedText.value + // 滚动到住下面 + await scrollToBottom() + // 重新设置任务 + timer = setTimeout(task, textSpeed.value) + } else { + // 不是对话中可以结束 + if (!conversationInProgress.value) { + textRoleRunning.value = false + clearTimeout(timer) + } else { + // 重新设置任务 + timer = setTimeout(task, textSpeed.value) + } + } + } + let timer = setTimeout(task, textSpeed.value) + } catch {} +} + +/** 初始化 **/ +onMounted(async () => { + // 如果有 conversationId 参数,则默认选中 + if (route.query.conversationId) { + const id = route.query.conversationId as unknown as number + activeConversationId.value = id + await getConversation(id) + } + + // 获取列表数据 + activeMessageListLoading.value = true + await getMessageList() +}) +</script> + +<style lang="scss" scoped> +.ai-layout { + position: absolute; + flex: 1; + top: 0; + left: 0; + height: 100%; + width: 100%; +} + +.conversation-container { + position: relative; + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 10px 10px 0; + + .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; + } + } + } +} + +// 头部 +.detail-container { + background: #ffffff; + + .header { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + background: #fbfbfb; + box-shadow: 0 0 0 0 #dcdfe6; + + .title { + font-size: 18px; + font-weight: bold; + } + + .btns { + display: flex; + width: 300px; + flex-direction: row; + justify-content: flex-end; + //justify-content: space-between; + + .btn { + padding: 10px; + } + } + } +} + +// main 容器 +.main-container { + margin: 0; + padding: 0; + position: relative; + height: 100%; + width: 100%; + + .message-container { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + overflow-y: hidden; + padding: 0; + margin: 0; + } +} + +// 底部 +.footer-container { + display: flex; + flex-direction: column; + height: auto; + margin: 0; + padding: 0; + + .prompt-from { + display: flex; + flex-direction: column; + height: auto; + border: 1px solid #e3e3e3; + border-radius: 10px; + margin: 10px 20px 20px 20px; + padding: 9px 10px; + } + + .prompt-input { + height: 80px; + //box-shadow: none; + border: none; + box-sizing: border-box; + resize: none; + padding: 0 2px; + overflow: auto; + } + + .prompt-input:focus { + outline: none; + } + + .prompt-btns { + display: flex; + justify-content: space-between; + padding-bottom: 0; + padding-top: 5px; + } +} +</style> -- Gitblit v1.9.3