大模型代码对接(fetchEventSource、hightlight.js

<template>
	<a-modal
		class="modal-container"
		style="min-width: 1180px;"
		:visible="modalState.visible"
		:footer="null"
		:bodyStyle="{padding: 0, borderRadius: '8px'}"
		:loading="chatState.spinning"
		@cancel="() => {modalState.visible = false}"
	>
		<template #closeIcon>
			<CloseOutlined style="position: relative; top: -10px; right: -10px" />
		</template>
		<a-spin :spinning="chatState.spinning">
			<main class="chat-box">
				<section class="left-chat-title">
					<div class="new-chat-box">
						<div class="new-chat-btn" @click="onNewChat">
					<span>
						新建对话
					</span>
						</div>
					</div>
					<div class="left-title-box">
							<li v-for="(item) in menuState.menuData" :key="item.id" :class="[menuState.activeKey === item.id ? 'chat-title chat-title-item-active' : 'chat-title' ]"
									 @click="onTitleClick(item)">
								<i class="chat-title-logo"></i>
								<span class="chat-title-desc">{{ item.title }}</span>
								<span class="operate-btn-space" style="flex: 1">
									<div style="display: flex; justify-content: flex-end; width: 100%">
										<a-popconfirm
											title="删除后无法恢复,是否继续删除?"
											ok-text="确认"
											cancel-text="取消"
											@confirm="onDeleteMainChat(item)"
										>
											<delete-outlined
												class="icon"
												@click.stop="(e) => {e.preventDefault()}"
											/>
									</a-popconfirm>
									</div>
								</span>
						</li>
					</div>
				</section>
				<section class="right-chat-space">
					<section class="list-space">
						<div class="question-answer-item" v-if="!chatState.chatData.length">
							<div class="answer-item">
								<i class="chat-avatar"></i>
								<div class="answer-content-info">
									<p>
										<h3 style="color: #fff; font-weight: 600; font-size: 20px;margin-bottom: 16px;">你好,我是锐智大模型</h3>
										<p style="color: #E0E0E0; font-weight: 600">作为您的智能伙伴,非常高兴与您合作!</p>
									</p>
								</div>
							</div>
						</div>
						<div class="question-answer-item" v-for="(qa, index) in chatState.chatData" :key="qa.id">
							<div class="question-item">
								<i class="question-avatar"></i>
								<div class="question-content">
									{{ qa.answerData.desc }}
								</div>
							</div>
							<div class="answer-item">
								<div style="height: 100%; display: flex; flex-direction: column; justify-content: flex-start">

									<img
										v-if="chatState.isChatting && index === chatState.chatData.length-1"
										class="robot" style="height: 32px;width: 32px;" src="@/assets/bigmodel/loading.gif" alt="" />
									<i v-else class="chat-avatar"></i>
								</div>
								<div class="answer-content-space">
									<div class="answer-content">
										<div :id="`type-id-${index}`" style="padding: 0">
											<MarkdownIt :source="qa.questionData.desc" />
										</div>
									</div>
									<div>
										<a-button type="link" style="font-size: 12px; font-weight: 400"
															@click="onStopChat"
															v-if="typeInstanceStatus === 'start'
																&& index === chatState.chatData.length - 1
															"
										>停止生成
										</a-button>
										<!--									<a-button type="link" style="font-size: 12px; font-weight: 400"-->
										<!--														@click="onStopChat"-->
										<!--														v-if="-->
										<!--													typeInstanceStatus === 'end' && index === chatState.chatData.length - 1"-->
										<!--									>重新生成-->
										<!--									</a-button>-->
									</div>
								</div>
								<div
									style="display: flex; flex-direction: column; justify-content: flex-start; height: 100%; position: relative; top: 10px;">
									<a-popconfirm
										title="确认删除该对话?"
										ok-text="确认"
										cancel-text="取消"
										@confirm="onDeleteSubChat(qa)"
									>
										<delete-outlined
											class="icon"
											@click.stop="(e) => {e.preventDefault()}"
										/>
									</a-popconfirm>
								</div>
							</div>

						</div>
					</section>
					<section class="foot-space" style="margin-top: 20px">
						<div class="input-wrapper">
							<a-textarea
								class="chat-input-box"
								placeholder="在此输入您想了解的内容"
								:autosize="{ minRows: 6, maxRows: 5 }"
								v-model:value="chatState.chatContent"
								@pressEnter="onAnswerSend"
							>
							</a-textarea>
							<a-button :loading="chatState.isChatting" class="enter-btn" type="primary" @click.stop="onAnswerSend">发送
							</a-button>
						</div>
					</section>
				</section>

			</main>
		</a-spin>
	</a-modal>

</template>

<script setup>
import { onMounted, onUnmounted, reactive, ref } from 'vue'
import TypeIt from 'typeit'
import { message } from 'ant-design-vue'
import { isEmpty } from 'lodash'
import { deleteMainChat, deleteSubChat, getCurChatData, listChatData, sendQuestion } from '@/api/bigmodel'
import { useResHandle } from '@/hooks/useResHandle'
import { CloseOutlined, DeleteOutlined } from '@ant-design/icons-vue'
import MarkdownIt from 'vue3-markdown-it'
import { fetchEventSource } from '@microsoft/fetch-event-source'

onUnmounted(() => {
});
// 页面加载时
onMounted(() => {
	loadLeftData()
});

// Load left main chat data
const loadLeftData = async () => {
	chatState.spinning = true
	const response = await listChatData()
	let param = {
		response,
		successInfo: '',
		failInfo: '数据请求失败',
		callback: () => {
			chatState.spinning = false
			menuState.menuData = response.data.data
		}
	}
	useResHandle(param)
}

const menuState = reactive({
	activeKey: undefined,
	menuData: []
})
const onNewChat = () => {
	menuState.activeKey = undefined
	chatState.chatData = []
	chatState.chatId = undefined
	chatState.isChatting = false
}

const modalState = reactive({
	visible: false
})

const chatState = reactive({
	isChatting: false, // the big model is chatting or not
	chatContent: undefined,
	chatId: undefined,
	chatData: [],
	spinning: false
})

let typeInstance = ref(null)
let typeInstanceStatus = ref('')
const onTitleClick = async ({ id }) => {
	menuState.activeKey = id
	chatState.chatId = id
	chatState.spinning = true
	// load right data by main id
	const response = await getCurChatData({ chatId: id })
	let param = {
		response,
		successInfo: '',
		failInfo: '',
		callback: () => {
			chatState.spinning = false
			chatState.isChatting = false
			chatState.chatData = response.data.data.map(res => {
				return {
					subChatId: res.id,
					createTime: res.createTime,
					questionData: {
						desc: res.chatResponse
					},
					answerData: {
						desc: res.chatRequest
					}
				}
			})
		}
	}
	useResHandle(param)
}
const controller = new AbortController();
const signal = controller.signal;
const onAnswerSend = () => {
	// 聊天框内容列表
	let chatData = []
	let flag = checkChatContent()
	if (!flag) return
	chatState.isChatting = true
	console.log('output-> chatState.chatContent::: ', chatState.chatContent)
	let curChatContent = chatState.chatContent
	chatState.chatData.push({
		answerData: {
			desc: curChatContent
		},
		questionData: {
			desc: ''
		}
	})
	if (!chatState.chatId) {
		menuState.menuData.unshift({ title: curChatContent })
	}
	chatState.chatContent = undefined
	// ======================对话请求===================
	let reqUrl = '/streamChat/v2/chat/completions'
	// let reqUrl = 'http://10.2.164.106:8085/v2/chat/completions'
	let reqData = {
		messages: [
			{
				role: 'user',
				content: curChatContent
			}
		],
		model: "ayenaspring-advanced-001",
		stream: true,
	}
	let eventSource = fetchEventSource(reqUrl, {
		signal,
		openWhenHidden: true,
		method: 'POST',
		headers: {
			'Content-Type': 'application/json',
			'Authorization': 'Bearer D3A93A0F076AAE0A9548BFED152CD4BF',
			'Accept': '*/*'
		},
		body: JSON.stringify(reqData),
		async onmessage(evt) {
			console.log('output-> evt::: ', evt)
			let resData = JSON.parse(evt.data)
			console.log('output-> resData', resData)
			let resChoices = resData.choices[0]
			console.log('output-> resChoices?.finish_reason::: ', resChoices?.finish_reason)
			if (resChoices?.finish_reason === 'stop' || resChoices.delta?.content === '[]') {
				chatState.isChatting = false
				let payload = {
					chatRequest: curChatContent,
					chatId: chatState.chatId,
					chatResponse: chatData.join()
				}
				const response = await sendQuestion(payload)
				let resData = response.data.data
				chatState.chatData.map(item => {
					if (item.answerData.desc === curChatContent) {
						item.subChatId = resData.chatInfoId
						item.chatId = resData.chatId
						if(isEmpty(menuState.menuData[0]?.id)) {
							menuState.menuData[0].id = resData.chatId
							chatState.chatId = resData.chatId
							menuState.activeKey = resData.chatId
						}
					}
					return item
				})
				return
			}
			let streamContent = resChoices.delta.content
			console.log('output-> streamContent::: ', streamContent)
			let perStreamContents = streamContent.split('')
			if(!isEmpty(perStreamContents)) {
				perStreamContents.forEach(char => {
					setTimeout(() => {
						chatData = [
							...chatData,
							char,
						]
						chatState.chatData = chatState.chatData.map(item => {
							if (item.answerData.desc === curChatContent) {
								item.questionData.desc = chatData.join('')
							}
							return item
						})
					}, 200)
				})
			}

			setTimeout(() => {
				let content = document.querySelector('.list-space')
				// scroll to bottom
				content.scrollTop = content.scrollHeight;
			}, 0)
			// setTimeout(() => {
			// 	typeInstance.value = new TypeIt(`#type-id-${chatState.chatData.length - 1}`, {
			// 		strings: streamContent.split(''),
			// 		speed: .5,
			// 		lifeLike: true,
			// 		breakLines: true, // 控制是将多个字符串打印在彼此之上,还是删除这些字符串并相互替换
			// 		loop: false,
			// 		html: true,
			// 		beforeStep: async (instance) => {
			// 			typeInstanceStatus.value = 'start'
			// 		},
			// 		beforeString: async (characters, instance) => {
			// 			let content = document.querySelector('.list-space')
			// 			content.scrollTop = content.scrollHeight;
			// 		},
			// 		afterStep: async (instance) => {
			// 			let content = document.querySelector('.list-space')
			// 			content.scrollTop = content.scrollHeight;
			// 		},
			// 		afterString: async (characters, instance) => {
			// 			let content = document.querySelector('.list-space')
			// 			content.scrollTop = content.scrollHeight;
			// 		},
			// 		afterComplete: async (instance) => {
			// 			typeInstanceStatus.value = 'end'
			// 			instance.cursor.style = 'font-size: 0'
			// 			let content = document.querySelector('.list-space')
			// 			content.scrollTop = content.scrollHeight;
			// 		}
			// 	}).go()
			// 	console.log('output-> typeInstance::: ', typeInstance.value)
			// }, 0)
		},
		onerror() {
			controller.abort()
		}
	})
}
const checkChatContent = () => {
	console.log('output-> chatState.chatContent::: ', chatState.chatContent)
	if (!chatState.chatContent || isEmpty(chatState.chatContent.replace(/^\s*|\s*$/g, ''))) {
		message.warn('请输入对话内容哦~')
		chatState.chatContent = ''
		return false
	}
	if (!chatState.isChatting) {
		chatState.isChatting = true
		return true
	} else {
		message.warn('请等机器人回复后再发送哦~')
		return false
	}
}
const onStopChat = () => {
	controller.abort();
	if (typeInstance.value) {
		chatState.isChatting = false
		typeInstance.value.freeze()
	}
}

const onDeleteMainChat = async (item) => {
	if(!item.id) {
		return;
	}

	chatState.spinning = true
	const response = await deleteMainChat({ id: item.id })
	let param = {
		response,
		successInfo: '删除成功',
		failInfo: '删除失败',
		callback: () => {
			chatState.spinning = false
			if(item.id === chatState.chatId) {
				chatState.chatData = []
			}
		}
	}
	useResHandle(param)
	if (response.data.status === 200) {
		await loadLeftData()
	}
}

const onDeleteSubChat = async (qa) => {
	console.log('output-> qa', qa)
	const response = await deleteSubChat({ id: qa.subChatId })
	let param = {
		response,
		successInfo: '删除成功',
		failInfo: '删除失败',
		callback: () => {
			onTitleClick({ id: chatState.chatId })
		}
	}
	useResHandle(param)
}

defineExpose({
	modalState,
	loadLeftData
})

</script>

<style lang="scss" scoped>

.chat-box {
	display: flex;
	height: 800px;
	min-height: calc(100vh - 500px);

	.left-chat-title {
		background-color: #374A60;
		padding: 16px 16px 16px;
		width: 20%;
		overflow: hidden;

		.new-chat-box {
			width: 100%;
			display: flex;
			justify-content: flex-start;
			margin-bottom: 20px;

			.new-chat-btn {
				background-image: linear-gradient(-20deg, #b721ff 0%, #21d4fd 100%);
				position: relative;
				padding: 1px;
				display: inline-block;
				border-radius: 7px;

				span {
					display: inline-block;
					background: #efecf7;
					color: #5964f5;
					text-transform: uppercase;
					padding: 12px 50px;
					border-radius: 5px;
					font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
					font-size: 16px;
				}
			}

			.new-chat-btn:hover {
				cursor: pointer;

				span {
					color: white;
					background-image: linear-gradient(-20deg, #b721ff 0%, #21d4fd 100%);
				}

				background: transparent;
			}

		}

		.chat-title {
			display: flex;
			align-items: center;
			padding: 16px 16px 16px;
			border-radius: 8px;

			.operate-btn-space {
				display: none;
			}

			.chat-title-logo {
				margin-right: 6px;
				height: 18px;
				width: 18px;
				display: inline-block;
				background-image: url('@/assets/bigmodel/chat-title.svg');
				background-repeat: no-repeat;
				background-size: 100%;
			}

			.chat-title-desc {
				color: #E0E0E0;
				max-width: 150px;
				white-space: nowrap; /* 防止文本换行 */
				overflow: hidden; /* 隐藏超出部分 */
				text-overflow: ellipsis; /* 显示省略号 */
			}

			&:hover {
				cursor: pointer;
				background-color:  #3B5468;

				.operate-btn-space {
					display: inline;
				}
			}
		}

		.chat-title-item-active {
			background-color: #3B5468;
		}
	}

	.right-chat-space {
		overflow: auto;
		background-color: #293246;
		flex: 1;
		display: flex;
		flex-direction: column;
		justify-content: space-between;

		.list-space {
			overflow: auto;
			flex: 1;
			width: 100%;
			display: flex;
			flex-direction: column;
			align-items: center;

			.question-answer-item {
				padding: 8px 2px 24px;
				width: 68%;
				display: flex;
				flex-direction: column;

				.question-item {
					width: 100%;
					display: flex;
					align-items: center;

					.question-avatar {
						height: 32px;
						width: 32px;
						display: inline-block;
						background-image: url('@/assets/bigmodel/user.svg');
						background-repeat: no-repeat;
						background-size: 100%;
					}

					.question-content {
						flex: 1;
						padding: 0 16px 0px;
						max-width: 600px;
						white-space: nowrap; /* 防止文本换行 */
						overflow: hidden; /* 隐藏超出部分 */
						text-overflow: ellipsis; /* 显示省略号 */
					}

					margin-bottom: 12px;
				}

				.answer-content-info {
					margin-top: 8px;
					margin-left: 8px;
					width: 100%;
					padding: 16px 16px 24px;
					background-color: #374A60;
					border-radius: 8px 8px;
				}

				.answer-item {
					display: flex;
					align-items: center;

					.chat-avatar {
						height: 32px;
						width: 32px;
						display: inline-block;
						background-image: url('@/assets/bigmodel/robot.png');
						background-repeat: no-repeat;
						background-size: 100%;
					}

					.answer-content-space {
						flex: 1;
						display: flex;
						flex-direction: column;

						.answer-content {
							margin-top: 8px;
							margin-left: 8px;
							width: 98%;
							padding: 16px 16px 24px;
							background-color: #374A60;
							border-radius: 8px 8px;
							line-height: 25px;
						}
					}
				}
			}
		}

		.foot-space {
			width: 100%;
			height: 210px;
			display: flex;
			justify-content: center;

			.input-wrapper {
				width: 61%;
				height: 80%;
				position: relative;
				left: 2px;

				.chat-input-box {
					border: 1px solid #575e73;
					border-radius: 8px;
					font-weight: 600;
					font-size: 14px;
					opacity: .8;
					width: 100%;
					height: 100%;
				}

				.enter-btn {
					position: absolute;
					bottom: 35px;
					right: 10px;
				}
			}

		}
	}
}

:deep(.ant-menu:not(.ant-menu-horizontal) .ant-menu-item-selected) {
	background-color: white;
	color: #4361ee;
}

:deep(.ant-menu.ant-menu-inline .ant-menu-item, .ant-menu.ant-menu-inline .ant-menu-submenu-title) {
	width: 98%;
}

.chat-box {
	max-height: 800px;
	overflow: auto;
}

.left-title-box {
	overflow: auto;
	height: 100%;

	&::-webkit-scrollbar {
		width: 6px; /*高宽分别对应横竖滚动条的尺寸*/
		height: 1px;
	}

	&::-webkit-scrollbar-thumb {
		border-radius: 6px;
		// -webkit-box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.2);
		background: rgba(144, 147, 153, 0.5);
	}

	&::-webkit-scrollbar-track {
		// -webkit-box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.2);
		border-radius: 5px;
		background: transparent;
	}
}

.list-space {
	&::-webkit-scrollbar {
		width: 6px; /*高宽分别对应横竖滚动条的尺寸*/
		height: 1px;
	}

	&::-webkit-scrollbar-thumb {
		border-radius: 6px;
		// -webkit-box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.2);
		background: rgba(144, 147, 153, 0.5);
	}

	&::-webkit-scrollbar-track {
		// -webkit-box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.2);
		border-radius: 5px;
		background: transparent;
	}
}
</style>

posted @ 2024-09-12 20:15  Felix_Openmind  阅读(26)  评论(0编辑  收藏  举报