【增加】增加 chat stream 功能(删除、复制、滚动删除、发送消息自动到最下面)

This commit is contained in:
cherishsince 2024-05-12 19:02:45 +08:00
parent b116d82376
commit 151076a79e
4 changed files with 359 additions and 151 deletions

View File

@ -29,6 +29,7 @@
"@form-create/designer": "^3.1.3", "@form-create/designer": "^3.1.3",
"@form-create/element-ui": "^3.1.24", "@form-create/element-ui": "^3.1.24",
"@iconify/iconify": "^3.1.1", "@iconify/iconify": "^3.1.1",
"@microsoft/fetch-event-source": "^2.0.1",
"@videojs-player/vue": "^1.0.0", "@videojs-player/vue": "^1.0.0",
"@vueuse/core": "^10.9.0", "@vueuse/core": "^10.9.0",
"@wangeditor/editor": "^5.1.23", "@wangeditor/editor": "^5.1.23",

View File

@ -1,15 +1,18 @@
import request from '@/config/axios' import request from '@/config/axios'
import {fetchEventSource} from '@microsoft/fetch-event-source';
import {getAccessToken} from '@/utils/auth'
import {config} from '@/config/axios/config'
// 聊天VO // 聊天VO
export interface ChatMessageVO { export interface ChatMessageVO {
id: number // 编号 id: string // 编号
conversationId: string // 会话编号 conversationId: string // 会话编号
type: string // 消息类型 type: string // 消息类型
userId: string // 用户编号 userId: string // 用户编号
roleId: string // 角色编号 roleId: string // 角色编号
model: number // 模型标志 model: number // 模型标志
modelId: number // 模型编号 modelId: number // 模型编号
content: number // 聊天内容 content: string // 聊天内容
tokens: number // 消耗 Token 数量 tokens: number // 消耗 Token 数量
createTime: Date // 创建时间 createTime: Date // 创建时间
} }
@ -27,11 +30,35 @@ export const ChatMessageApi = {
return await request.get({ url: `/ai/chat/message/list-by-conversation-id?conversationId=${conversationId}`}) return await request.get({ url: `/ai/chat/message/list-by-conversation-id?conversationId=${conversationId}`})
}, },
// 发送 send 消息 // 发送 add 消息
send: async (data: ChatMessageSendVO) => { add: async (data: ChatMessageSendVO) => {
return await request.post({ url: `/ai/chat/message/send`, data }) return await request.post({ url: `/ai/chat/message/add`, data})
}, },
// 发送 send 消息
send: async (data: ChatMessageSendVO) => {
return await request.post({ url: `/ai/chat/message/send`, data})
},
// 发送 send stream 消息
sendStream: async (id: string, ctrl, onMessage, onError, onClose) => {
const token = getAccessToken()
return fetchEventSource(`${ config.base_url}/ai/chat/message/send-stream`, {
method: 'post',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
openWhenHidden: true,
body: JSON.stringify({
id: id,
}),
onmessage: onMessage,
onerror:onError,
onclose: onClose,
signal: ctrl.signal,
});
},
// 发送 send 消息 // 发送 send 消息
delete: async (id: string) => { delete: async (id: string) => {

View File

@ -62,12 +62,11 @@
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useClipboard } from '@vueuse/core' import { useClipboard } from '@vueuse/core'
import {ChatMessageApi, ChatMessageVO, ChatMessageSendVO} from '@/api/ai/chat/message' import { ChatMessageApi, ChatMessageVO, ChatMessageSendVO} from '@/api/ai/chat/message'
import { formatDate } from '@/utils/formatTime' import { formatDate } from '@/utils/formatTime'
import {ApiKeyVO} from "@/api/ai/model/apiKey";
// copy // copy
const { copy, isSupported } = useClipboard(); const { copy, isSupported } = useClipboard();
@ -94,20 +93,6 @@ const messageList = async () => {
const messageContainer: any = ref(null); const messageContainer: any = ref(null);
const isScrolling = ref(false)// const isScrolling = ref(false)//
/** send */
const sendMessage = async () => {
try {
const requestParams = {
conversationId,
content,
}
const messageSendVO = requestParams as unknown as ChatMessageSendVO
const res = await ChatMessageApi.send(messageSendVO) as unknown as ChatMessageVO
console.log('---', res.content)
} finally {
}
}
function scrollToBottom() { function scrollToBottom() {
nextTick(() => { nextTick(() => {
//使nexttickdom //使nexttickdom
@ -167,110 +152,5 @@ onMounted(async () => {
<style scoped lang="scss"> <style scoped lang="scss">
.message-container {
position: absolute;
top: 0;
bottom: 0;
overflow-y: scroll;
padding: 0 15px;
}
//
.chat-list {
display: flex;
flex-direction: column;
overflow-y: hidden;
.message-item {
margin-top: 50px;
}
.left-message {
display: flex;
flex-direction: row;
}
.right-message {
display: flex;
flex-direction: row-reverse;
justify-content: flex-start;
}
.avatar {
//height: 170px;
//width: 170px;
}
.message {
display: flex;
flex-direction: column;
text-align: left;
margin: 0 15px;
.time {
text-align: left;
line-height: 30px;
}
.left-text-container {
display: flex;
flex-direction: column;
overflow-wrap: break-word;
background-color: #e4e4e4;
box-shadow: 0 0 0 1px #e4e4e4;
border-radius: 10px;
padding: 10px 10px 5px 10px;
.left-text {
color: #393939;
}
}
.right-text-container {
display: flex;
flex-direction: column;
overflow-wrap: break-word;
background-color: #267fff;
color: #FFF;
box-shadow: 0 0 0 1px #267fff;
border-radius: 10px;
padding: 10px;
.right-text {
color: #FFF;
}
}
.left-btns, .right-btns {
display: flex;
flex-direction: row;
margin-top: 8px;
}
}
//
.btn-cus {
display: flex;
background-color: transparent;
align-items: center;
.btn-image {
height: 20px;
margin-right: 5px;
}
.btn-cus-text {
color: #757575;
}
}
.btn-cus:hover {
cursor: pointer;
}
.btn-cus:focus {
background-color: #8c939d;
}
}
</style> </style>

View File

@ -84,15 +84,73 @@
<!-- main --> <!-- main -->
<el-main class="main-container"> <el-main class="main-container">
<MessageList /> <div class="message-container" ref="messageContainer">
<div class="chat-list" v-for="(item, index) in list" :key="index">
<!-- 靠左 message -->
<div class="left-message message-item" v-if="item.type === 'system'">
<div class="avatar" >
<el-avatar
src="https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png"
/>
</div>
<div class="message">
<div>
<el-text class="time">{{formatDate(item.createTime)}}</el-text>
</div>
<div class="left-text-container">
<el-text class="left-text">
{{item.content}}
</el-text>
</div>
<div class="left-btns">
<div class="btn-cus" @click="noCopy(item.content)">
<img class="btn-image" src="@/assets/ai/copy.svg"/>
<el-text class="btn-cus-text">复制</el-text>
</div>
<div class="btn-cus" style="margin-left: 20px;" @click="onDelete(item.id)">
<img class="btn-image" src="@/assets/ai/delete.svg" style="height: 17px;"/>
<el-text class="btn-cus-text">删除</el-text>
</div>
</div>
</div>
</div>
<!-- 靠右 message -->
<div class="right-message message-item" v-if="item.type === 'user'">
<div class="avatar">
<el-avatar
src="https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png"
/>
</div>
<div class="message">
<div>
<el-text class="time">{{formatDate(item.createTime)}}</el-text>
</div>
<div class="right-text-container">
<el-text class="right-text">
{{item.content}}
</el-text>
</div>
<div class="right-btns">
<div class="btn-cus" @click="noCopy(item.content)">
<img class="btn-image" src="@/assets/ai/copy.svg"/>
<el-text class="btn-cus-text">复制</el-text>
</div>
<div class="btn-cus" style="margin-left: 20px;" @click="onDelete(item.id)">
<img class="btn-image" src="@/assets/ai/delete.svg" style="height: 17px;"/>
<el-text class="btn-cus-text">删除</el-text>
</div>
</div>
</div>
</div>
</div>
</div>
</el-main> </el-main>
<el-footer class="footer-container"> <el-footer class="footer-container">
<textarea placeholder="问我任何问题...Shift+Enter 换行,按下 Enter 发送)" <textarea class="prompt-input" v-model="prompt" placeholder="问我任何问题...Shift+Enter 换行,按下 Enter 发送)"></textarea>
class="prompt-input">
</textarea>
<div class="prompt-btns"> <div class="prompt-btns">
<el-switch/> <el-switch/>
<el-button type="primary" size="default">发送</el-button> <el-button type="primary" size="default" @click="onSend()">发送</el-button>
</div> </div>
</el-footer> </el-footer>
</el-container> </el-container>
@ -100,25 +158,15 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import MessageList from './components/MessageList.vue' import {ChatMessageApi, ChatMessageSendVO, ChatMessageVO} from "@/api/ai/chat/message";
import {formatDate} from "@/utils/formatTime";
import {useClipboard} from '@vueuse/core'
const conversationList = [ const searchName = ref('') //
{ const conversationId = ref('1781604279872581648') // id
id: 1, const conversationInProgress = ref<Boolean>() //
title: '测试标题', const prompt = ref<string>() // prompt
avatar: const promptRes = ref<string>() // prompt res
'http://test.yudao.iocoder.cn/96c787a2ce88bf6d0ce3cd8b6cf5314e80e7703cd41bf4af8cd2e2909dbd6b6d.png'
},
{
id: 2,
title: '测试对话',
avatar:
'http://test.yudao.iocoder.cn/96c787a2ce88bf6d0ce3cd8b6cf5314e80e7703cd41bf4af8cd2e2909dbd6b6d.png'
}
]
const conversationId = ref(1)
const searchName = ref('')
const leftHeight = window.innerHeight - 240 // TODO
const changeConversation = (conversation) => { const changeConversation = (conversation) => {
console.log(conversation) console.log(conversation)
@ -140,9 +188,154 @@ const searchConversation = () => {
// TODO // TODO
} }
/** send */
const onSend = async () => {
const requestParams = {
conversationId: conversationId.value,
content: prompt.value,
} as unknown as ChatMessageSendVO
// message
const userMessage = await ChatMessageApi.add(requestParams) as unknown as ChatMessageVO;
list.value.push(userMessage)
//
scrollToBottom();
//
await doSendStream(userMessage)
}
const doSendStream = async (userMessage: ChatMessageVO) => {
// AbortController便
const ctrl = new AbortController()
// event stream
let isFirstMessage = true
ChatMessageApi.sendStream(userMessage.id, ctrl,(message) => {
console.log('message', message)
const data = JSON.parse(message.data) as unknown as ChatMessageVO
//
if (data.content === '') {
ctrl.abort()
}
// message
if (isFirstMessage) {
isFirstMessage = false;
list.value.push(data)
} else {
const lastMessage = list.value[list.value.length - 1];
lastMessage.content = lastMessage.content + data.content
list.value[list.value - 1] = lastMessage
}
//
scrollToBottom();
}, (error) => {
console.log('error', error)
}, () => {
console.log('close')
})
// // message
// const chatMessage = {
// id: null, //
// conversationId: conversationId.value, //
// type: 'system', //
// userId: null, //
// roleId: null, //
// model: null, //
// modelId: null, //
// content: '...', //
// tokens: null, // Token
// createTime: new Date(), //
// } as unknown as ChatMessageVO
// list.value.push(chatMessage)
// //
// scrollToBottom();
}
/** Prompt */
const onPromptInput = async (e) => {
console.log(e.data)
// prompt.value = e.data
}
// copy
const { copy, isSupported } = useClipboard();
/** chat message 列表 */
defineOptions({ name: 'chatMessageList' })
const list = ref<ChatMessageVO[]>([]) //
// id TODO @
const content = '苹果是什么颜色?'
/** 查询列表 */
const messageList = async () => {
try {
//
const res = await ChatMessageApi.messageList(conversationId.value)
list.value = res;
//
scrollToBottom();
} finally {
}
}
// ref
const messageContainer: any = ref(null);
const isScrolling = ref(false)//
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
}
}
function noCopy(content) {
copy(content)
ElMessage({
message: '复制成功!',
type: 'success',
})
}
const onDelete = async (id) => {
// message
await ChatMessageApi.delete(id)
ElMessage({
message: '删除成功!',
type: 'success',
})
// message
await messageList();
}
/** 初始化 **/ /** 初始化 **/
onMounted(() => { onMounted(async () => {
//
messageList();
// scrollToBottom();
// await nextTick
//
messageContainer.value.addEventListener('scroll', handleScroll)
}) })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@ -282,6 +475,113 @@ onMounted(() => {
position: relative; position: relative;
} }
.message-container {
position: absolute;
top: 0;
bottom: 0;
overflow-y: scroll;
padding: 0 15px;
}
//
.chat-list {
display: flex;
flex-direction: column;
overflow-y: hidden;
.message-item {
margin-top: 50px;
}
.left-message {
display: flex;
flex-direction: row;
}
.right-message {
display: flex;
flex-direction: row-reverse;
justify-content: flex-start;
}
.avatar {
//height: 170px;
//width: 170px;
}
.message {
display: flex;
flex-direction: column;
text-align: left;
margin: 0 15px;
.time {
text-align: left;
line-height: 30px;
}
.left-text-container {
display: flex;
flex-direction: column;
overflow-wrap: break-word;
background-color: #e4e4e4;
box-shadow: 0 0 0 1px #e4e4e4;
border-radius: 10px;
padding: 10px 10px 5px 10px;
.left-text {
color: #393939;
}
}
.right-text-container {
display: flex;
flex-direction: column;
overflow-wrap: break-word;
background-color: #267fff;
color: #FFF;
box-shadow: 0 0 0 1px #267fff;
border-radius: 10px;
padding: 10px;
.right-text {
color: #FFF;
}
}
.left-btns, .right-btns {
display: flex;
flex-direction: row;
margin-top: 8px;
}
}
//
.btn-cus {
display: flex;
background-color: transparent;
align-items: center;
.btn-image {
height: 20px;
margin-right: 5px;
}
.btn-cus-text {
color: #757575;
}
}
.btn-cus:hover {
cursor: pointer;
}
.btn-cus:focus {
background-color: #8c939d;
}
}
// //
.footer-container { .footer-container {
display: flex; display: flex;