feat(ai): 添加 AI 聊天功能

- 新增 AI 聊天对话和消息相关 API
- 实现聊天界面,包括对话列表、消息列表、发送消息等功能
- 添加音乐生成功能的初始框架
This commit is contained in:
gjd
2025-06-12 18:26:10 +08:00
parent d2fbb5a18b
commit 4596cd9fa5
25 changed files with 3109 additions and 94 deletions

View File

@@ -1,28 +1,817 @@
<script lang="ts" setup>
import { Page } from '@vben/common-ui';
import type { AiChatConversationApi } from '#/api/ai/chat/conversation';
import type { AiChatMessageApi } from '#/api/ai/chat/message';
import { Button } from 'ant-design-vue';
import { computed, nextTick, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { alert, confirm, Page, useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { Button, Layout, message, Switch } from 'ant-design-vue';
import { getChatConversationMy } from '#/api/ai/chat/conversation';
import {
deleteByConversationId,
getChatMessageListByConversationId,
sendChatMessageStream,
} from '#/api/ai/chat/message';
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 [FormModal, formModalApi] = useVbenModal({
connectedComponent: ConversationUpdateForm,
destroyOnClose: true,
});
// 聊天对话
const conversationListRef = ref();
const activeConversationId = ref<null | number>(null); // 选中的对话编号
const activeConversation = ref<AiChatConversationApi.ChatConversationVO | null>(
null,
); // 选中的 Conversation
const conversationInProgress = ref(false); // 对话是否正在进行中。目前只有【发送】消息时,会更新为 true避免切换对话、删除对话等操作
// 消息列表
const messageRef = ref();
const activeMessageList = ref<AiChatMessageApi.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: null | number) => {
if (!id) {
return;
}
const conversation: AiChatConversationApi.ChatConversationVO =
await getChatConversationMy(id);
if (!conversation) {
return;
}
activeConversation.value = conversation;
activeConversationId.value = conversation.id;
};
/**
* 点击某个对话
*
* @param conversation 选中的对话
* @return 是否切换成功
*/
const handleConversationClick = async (
conversation: AiChatConversationApi.ChatConversationVO,
) => {
// 对话进行中,不允许切换
if (conversationInProgress.value) {
alert('对话中,不允许切换!');
return false;
}
// 更新选中的对话 id
activeConversationId.value = conversation.id;
activeConversation.value = conversation;
// 刷新 message 列表
await getMessageList();
// 滚动底部
scrollToBottom(true);
// 清空输入框
prompt.value = '';
return true;
};
/** 删除某个对话*/
const handlerConversationDelete = async (
delConversation: AiChatConversationApi.ChatConversationVO,
) => {
// 删除的对话如果是当前选中的,那么就重置
if (activeConversationId.value === delConversation.id) {
await handleConversationClear();
}
};
/** 清空选中的对话 */
const handleConversationClear = async () => {
// 对话进行中,不允许切换
if (conversationInProgress.value) {
alert('对话中,不允许切换!');
return false;
}
activeConversationId.value = null;
activeConversation.value = null;
activeMessageList.value = [];
};
const openChatConversationUpdateForm = async () => {
formModalApi.setData({ id: activeConversationId.value }).open();
};
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 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) {
alert('回答中,不能删除!');
return;
}
// 刷新 message 列表
getMessageList();
};
/** 处理 message 清空 */
const handlerMessageClear = async () => {
if (!activeConversationId.value) {
return;
}
try {
// 确认提示
await confirm('确认清空对话消息?');
// 清空对话
await deleteByConversationId(activeConversationId.value);
// 刷新 message 列表
activeMessageList.value = [];
} catch {}
};
/** 回到 message 列表的顶部 */
const handleGoTopMessage = () => {
messageRef.value.handlerGoTop();
};
// =========== 【发送消息】相关 ===========
/** 处理来自 keydown 的发送消息 */
const handleSendByKeydown = async (event: any) => {
// 判断用户是否在输入
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 || event.data === 'null') {
return;
}
isComposing.value = true;
}
// 清理定时器
if (inputTimeout.value) {
clearTimeout(inputTimeout.value);
}
// 重置定时器
inputTimeout.value = setTimeout(() => {
isComposing.value = false;
}, 400);
};
const onCompositionstart = () => {
isComposing.value = true;
};
const onCompositionend = () => {
// console.log('输入结束...')
setTimeout(() => {
isComposing.value = false;
}, 200);
};
/** 真正执行【发送】消息操作 */
const doSendMessage = async (content: string) => {
// 校验
if (content.length === 0) {
message.error('发送失败,原因:内容为空!');
return;
}
if (activeConversationId.value == null) {
message.error('还没创建对话,不能发送!');
return;
}
// 清空输入框
prompt.value = '';
// 执行发送
await doSendMessageStream({
conversationId: activeConversationId.value,
content,
} as AiChatMessageApi.ChatMessageVO);
};
/** 真正执行【发送】消息操作 */
const doSendMessageStream = async (
userMessage: AiChatMessageApi.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 AiChatMessageApi.ChatMessageVO,
{
id: -2,
conversationId: activeConversationId.value,
type: 'assistant',
content: '思考中...',
createTime: new Date(),
} as AiChatMessageApi.ChatMessageVO,
);
// 1.2 滚动到最下面
await nextTick();
await scrollToBottom(); // 底部
// 1.3 开始滚动
textRoll();
// 2. 发送 event stream
let isFirstChunk = true; // 是否是第一个 chunk 消息段
await sendChatMessageStream(
userMessage.conversationId,
userMessage.content,
conversationInAbortController.value,
enableContext.value,
async (res: any) => {
const { code, data, msg } = JSON.parse(res.data);
if (code !== 0) {
alert(`对话异常! ${msg}`);
return;
}
// 如果内容为空,就不处理。
if (data.receive.content === '') {
return;
}
// 首次返回需要添加一个 message 到页面,后面的都是更新
if (isFirstChunk) {
isFirstChunk = false;
// 弹出两个假数据
activeMessageList.value.pop();
activeMessageList.value.pop();
// 更新返回的数据
activeMessageList.value.push(data.send, data.receive);
}
// debugger
receiveMessageFullText.value =
receiveMessageFullText.value + data.receive.content;
// 滚动到最下面
await scrollToBottom();
},
(error: any) => {
alert(`对话异常! ${error}`);
stopStream();
// 需要抛出异常,禁止重试
throw error;
},
() => {
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: AiChatMessageApi.ChatMessageVO) => {
prompt.value = message.content;
};
/** 刷新 message基于指定消息再次发起对话 */
const handleMessageRefresh = (message: AiChatMessageApi.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];
if (lastMessage)
lastMessage.content = receiveMessageDisplayedText.value;
// 滚动到住下面
await scrollToBottom();
// 重新设置任务
timer = setTimeout(task, textSpeed.value);
} else {
// 不是对话中可以结束
if (conversationInProgress.value) {
// 重新设置任务
timer = setTimeout(task, textSpeed.value);
} else {
textRoleRunning.value = false;
clearTimeout(timer);
}
}
};
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>
<template>
<Page>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/ai/chat/index/index.vue"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/ai/chat/index/index.vue
代码pull request 贡献给我们
</Button>
<Page auto-content-height>
<Layout 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"
/>
<Layout class="detail-container">
<Layout.Header class="header">
<div class="title">
{{ activeConversation?.title ? activeConversation?.title : '对话' }}
<span v-if="activeMessageList.length > 0">
({{ activeMessageList.length }})
</span>
</div>
<div class="btns" v-if="activeConversation">
<Button
type="primary"
ghost
class="mr-[10px]"
size="small"
@click="openChatConversationUpdateForm"
>
<span v-html="activeConversation?.modelName"></span>
<IconifyIcon icon="ep:setting" class="ml-[10px]" />
</Button>
<Button
size="small"
class="btn mr-[10px]"
@click="handlerMessageClear"
>
<IconifyIcon
icon="heroicons-outline:archive-box-x-mark"
color="#787878"
/>
</Button>
<Button size="small" class="btn mr-[10px]">
<IconifyIcon icon="ep:download" color="#787878" />
</Button>
<Button
size="small"
class="btn mr-[10px]"
@click="handleGoTopMessage"
>
<IconifyIcon icon="ep:top" color="#787878" />
</Button>
</div>
</Layout.Header>
<Layout.Content 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>
</Layout.Content>
<Layout.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>
<Switch v-model:checked="enableContext" />
<span class="ml-5px text-14px text-#8f8f8f">上下文</span>
</div>
<Button
type="primary"
@click="handleSendByButton"
:loading="conversationInProgress"
v-if="conversationInProgress === false"
>
{{ conversationInProgress ? '进行中' : '发送' }}
</Button>
<Button
danger
@click="stopStream()"
v-if="conversationInProgress === true"
>
停止
</Button>
</div>
</form>
</Layout.Footer>
</Layout>
</Layout>
<FormModal @success="handleConversationUpdateSuccess" />
</Page>
</template>
<style lang="scss" scoped>
.ai-layout {
position: absolute;
top: 0;
left: 0;
flex: 1;
width: 100%;
height: 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: 1;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 0 5px;
margin-top: 10px;
line-height: 30px;
cursor: pointer;
border-radius: 5px;
&.active {
background-color: #e6e6e6;
.button {
display: inline-block;
}
}
.title-wrapper {
display: flex;
flex-direction: row;
align-items: center;
}
.title {
max-width: 220px;
padding: 5px 10px;
overflow: hidden;
text-overflow: ellipsis;
font-size: 14px;
white-space: nowrap;
}
.avatar {
display: flex;
flex-direction: row;
justify-items: center;
width: 28px;
height: 28px;
}
// 对话编辑、删除
.button-wrapper {
right: 2px;
display: flex;
flex-direction: row;
justify-items: center;
color: #606266;
.el-icon {
margin-right: 5px;
}
}
}
}
// 角色仓库、清空未设置对话
.tool-box {
display: flex;
align-items: center;
justify-content: space-between;
line-height: 35px;
color: var(--el-text-color);
> div {
display: flex;
align-items: center;
padding: 0;
margin: 0;
color: #606266;
cursor: pointer;
> span {
margin-left: 5px;
}
}
}
}
// 头部
.detail-container {
background: #fff;
.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;
flex-direction: row;
justify-content: flex-end;
width: 300px;
//justify-content: space-between;
.btn {
padding: 0 10px;
}
}
}
}
// main 容器
.main-container {
position: relative;
width: 100%;
height: 100%;
padding: 0;
margin: 0;
.message-container {
position: absolute;
inset: 0;
padding: 0;
margin: 0;
overflow-y: hidden;
}
}
// 底部
.footer-container {
display: flex;
flex-direction: column;
height: auto;
padding: 0;
margin: 0;
background-color: white;
.prompt-from {
display: flex;
flex-direction: column;
height: auto;
padding: 9px 10px;
margin: 10px 20px 20px;
border: 1px solid #e3e3e3;
border-radius: 10px;
}
.prompt-input {
box-sizing: border-box;
height: 80px;
padding: 0 2px;
overflow: auto;
resize: none;
//box-shadow: none;
border: none;
}
.prompt-input:focus {
outline: none;
}
.prompt-btns {
display: flex;
justify-content: space-between;
padding-top: 5px;
padding-bottom: 0;
}
}
</style>