element-ui + vue开发一个openai的聊天框
效果演示
- 支持流式响应渲染
- 支持渲染思考内容
- 支持渲染工具调用
- 支持markdown内容解析


代码
<template>
<div class="chat-wrapper">
<el-container>
<el-header>
<strong>调试和预览</strong>
<el-tooltip class="item" effect="dark" content="清空对话记录" placement="bottom">
<i class="el-icon-refresh-right"
:class="{ 'rotate-animation': control.clearChatRecord }"
@click="clearChatRecordEvent"/>
</el-tooltip>
</el-header>
<el-main ref="messagesContainer">
<template v-for="(message, index) in messages">
<div v-if="message.role == 'user'" class="user-message">
<el-avatar>
<div class="message-avatar">
<svg-icon icon-class="chat-user"/>
</div>
</el-avatar>
<div class="message-content">
<div class="content">
{{message.content}}
</div>
</div>
</div>
<div v-if="message.role == 'assistant'" class="ai-message">
<el-avatar style="background: white !important;">
<div class="message-avatar">
<svg-icon icon-class="chat-ai"/>
</div>
</el-avatar>
<dot-flashing v-if="!message.content && !message.error && !(message.aiMsgs && message.aiMsgs.length > 0)"
class="chat-load"/>
<div v-if="message.content || (message.aiMsgs && message.aiMsgs.length > 0)" class="message-content">
<div>
<template
v-if="message.aiMsgs && message.aiMsgs.length > 0"
v-for="(aiMsg, index) in message.aiMsgs">
<template v-if="aiMsg.type == 'think'">
<div class="message-title">
<el-button v-if="aiMsg.endTime" @click="clickThinkEvent(aiMsg)">
深度思考(用时
{{ (aiMsg.endTime - aiMsg.startTime) / 1000 }}
秒)
<i class="el-icon-arrow-down arrow-icon" :class="{'up': aiMsg.showThink}"/>
</el-button>
</div>
<div v-if="aiMsg.showThink" class="think-content">
{{aiMsg.think}}
</div>
</template>
<template v-if="aiMsg.type == 'function'">
<div class="message-title">
<el-button @click="clickToolCallEvent(aiMsg)">
工具调用(find_user)
</el-button>
</div>
</template>
</template>
<template v-if="message.content">
<div
class="content markdown-body"
v-html="message.htmlContent">
</div>
</template>
</div>
</div>
<div v-if="message.error" class="message-content error-message-content">
<div class="content">
{{message.error}}
</div>
</div>
</div>
</template>
</el-main>
<el-footer>
<el-input
:rows="1"
v-model="inputMessage"
:disabled="control.waitAiResponseFinish"
@keydown.enter.native.prevent="sendMessage"
type="textarea"
resize="none"/>
<el-button type="primary" size="small" :disabled="control.waitAiResponseFinish" @click="sendMessage">发送</el-button>
</el-footer>
<div v-if="control.waitAiResponseFinish" class="stop-response-container">
<el-button
type="danger"
round
size="small"
class="stop-response-btn"
>
<i class="el-icon-warning-outline"></i> 停止响应
</el-button>
</div>
</el-container>
</div>
</template>
<script>
import MarkdownIt from 'markdown-it'
import hljs from 'highlight.js'
import 'highlight.js/styles/github.css'
import dotFlashing from "./dot_flashing"
import setter from "@/setting/setter";
let that;
export default {
name: "OpenAiChat",
components: {
dotFlashing
},
props: {
agentId: {
type: String,
require: true,
},
},
data() {
return {
messages: [
],
inputMessage: "",
control: {
clearChatRecord: false,
waitAiResponseFinish: false,
},
md: null,
}
},
methods: {
renderMarkdown(text) {
let content = text;
return this.md.render(content || '')
},
sendMessage() {
if (checkSendMessage()) {
let input = this.inputMessage;
this.messages.push({role: "user", content: input});
this.control.waitAiResponseFinish = true;
this.messages.push({role: "assistant", content: "", htmlContent: "", aiMsgs: []});
this.inputMessage = "";
chat(setter.server.request.baseURL + "/chat/completions", input, (data, down) => {
if (down) {
if (this.control.waitAiResponseFinish) {
this.control.waitAiResponseFinish = false;
}
return;
}
let aiMessage = this.messages[this.messages.length - 1];
if (data.content) {
if (aiMessage.aiMsgs.length > 0
&& aiMessage.aiMsgs[aiMessage.aiMsgs.length - 1].type == "think"
&& !aiMessage.aiMsgs[aiMessage.aiMsgs.length - 1].endTime) {
aiMessage.aiMsgs[aiMessage.aiMsgs.length - 1].endTime = new Date().getTime();
aiMessage.aiMsgs[aiMessage.aiMsgs.length - 1].showThink = false;
}
aiMessage.content += data.content;
aiMessage.htmlContent = this.renderMarkdown(aiMessage.content);
}
if (data.think) {
let aiMsgs = aiMessage.aiMsgs;
if (aiMsgs.length == 0 || aiMsgs[aiMsgs.length - 1].type != "think") {
aiMsgs.push(
{
type: "think",
think: data.think,
startTime: new Date().getTime(),
endTime: 0,
showThink: true
}
);
} else {
let aiMsg = aiMsgs[aiMsgs.length - 1];
aiMsg.think += data.think;
}
}
if (data.function) {
if (aiMessage.aiMsgs.length > 0
&& aiMessage.aiMsgs[aiMessage.aiMsgs.length - 1].type == "think"
&& !aiMessage.aiMsgs[aiMessage.aiMsgs.length - 1].endTime) {
aiMessage.aiMsgs[aiMessage.aiMsgs.length - 1].endTime = new Date().getTime();
aiMessage.aiMsgs[aiMessage.aiMsgs.length - 1].showThink = false;
}
aiMessage.aiMsgs.push(
{
type: "function",
function: data.function,
}
);
}
this.messagesContainerScrollToBottom();
}, error => {
let aiMessage = this.messages[this.messages.length - 1];
this.$set(aiMessage, "error", "系统服务异常, 请稍后再试");
if (this.control.waitAiResponseFinish) {
this.control.waitAiResponseFinish = false;
}
});
}
},
clickThinkEvent(aiMsg) {
this.$set(aiMsg, "showThink", aiMsg.showThink ? false : true);
},
clickToolCallEvent(aiMsg) {
this.$alert(JSON.stringify(JSON.stringify(aiMsg.function), null, 2), "工具调用详情", {
showConfirmButton: false,
closeOnPressEscape: true,
callback: action => {}
});
},
clearChatRecordEvent() {
if (this.control.waitAiResponseFinish) {
that.$message.error("当前有未完成的对话, 请稍后再试");
return;
}
this.control.clearChatRecord = true;
this.messages = [];
setTimeout(() => {
this.control.clearChatRecord = false;
}, 3000);
},
messagesContainerScrollToBottom() {
this.$nextTick(() => {
const container = this.$refs.messagesContainer.$el;
container.scrollTop = container.scrollHeight;
})
},
},
created() {
this.md = new MarkdownIt({
html: true,
linkify: true,
typographer: true,
highlight: function (str, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(str, { language: lang }).value
} catch (__) {}
}
return ''
}
})
},
mounted() {
that = this;
this.messagesContainerScrollToBottom();
},
}
function checkSendMessage() {
if (!that.inputMessage) {
that.$message.error("输入消息为空");
return false;
} else if (that.control.waitAiResponseFinish) {
that.$message.error("当前有未完成的对话");
return false;
}
return true;
}
function chat(url, input, callback, errorCallback) {
fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"cookie": "grafana_session=9026800badf33e22d50e868892ff97b0; _bl_uid=aama4bqet6X1nIkRe6jjhI8a213n; locale=zh-Hans; X-XSHELTER-ACCESS-TOKEN=957c62ee-e319-4615-a67b-5234e1aa2a58; sys_token=ea5367a3df814e5899618e5da79706bc; sys_env_id=1022400244723744768; sys_env_code=Init",
},
body: JSON.stringify({
agentId: that.agentId,
username: "admin",
variables: {
"cookie": "sys_token=ea5367a3df814e5899618e5da79706bc; sys_env_id=1022400244723744768; sys_env_code=Init"
},
content: input
})
})
.then(response => {
if (response.ok && response.body) {
return response.body;
} else {
throw new Error('SSE connection failed');
}
})
.then(body => {
const reader = body.getReader();
const decoder = new TextDecoder();
let chunkBuffer = "";
function readStream() {
reader.read().then(({ done, value }) => {
if (done) {
return;
}
let chunk = decoder.decode(value, { stream: true });
let tmpBuf = chunkBuffer + chunk;
let data = "";
for (let i = 0; i < tmpBuf.length; i++) {
if (tmpBuf[i] == "\n" && tmpBuf[i + 1] == "\n") {
if (callback) {
let chunkData = data.replace("data: ", "");
if (chunkData == "[DONE]") {
callback(null, true);
} else {
callback(JSON.parse(chunkData));
}
}
data = "";
i = i + 1;
} else {
data += tmpBuf[i];
}
}
chunkBuffer = data;
readStream();
}).catch(err => {
console.error('Error reading stream:', err);
});
}
readStream();
})
.catch(error => {
if (errorCallback) {
errorCallback(error);
}
console.error('Fetch error:', error);
});
}
</script>
<style scoped>
.chat-wrapper {
display: flex;
height: 100%;
width: 100%;
}
.el-container .el-header {
height: 40px !important;
display: flex;
justify-content: space-between;
align-items: center;
background-color: #ece6e6;
padding: 10px !important;
}
.el-container {
position: relative;
}
.el-container .el-header i {
font-size: 20px;
}
.el-container .el-header i:hover {
color: #ee9090;
cursor: pointer;
}
.el-container .el-main {
background: rgba(231, 230, 230, 0.50);
overflow: auto !important;
overflow-x: hidden !important;
}
.el-container .el-main::-webkit-scrollbar {
display: none !important;
}
.el-container .el-footer {
height: 50px !important;
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
background-color: #f1eeee;
padding: 0 10px 0 10px !important;
}
.stop-response-container {
position: absolute;
bottom: 64px;
left: 50%;
transform: translateX(-50%);
z-index: 1000;
}
.chat-load {
display: flex;
align-items: center;
}
.user-message {
display: flex;
flex-direction: row-reverse;
margin: 20px 10px 20px 10px;
}
.ai-message {
display: flex;
margin: 20px 10px 20px 10px;
}
.message-content {
padding: 0 10px 0 10px;
display: flex;
align-items: center;
line-height: 1.6;
word-break: break-word;
max-width: 70%;
margin: 0 10px 0 10px;
border-radius: 8px;
font-size: 16px;
}
.message-avatar {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.message-avatar svg {
width: 80%;
height: 80%;
}
.user-message .message-content {
background-color: #c4d8f5;
}
.ai-message .message-content {
background-color: white;
}
.error-message-content {
background-color: #bd6079 !important;
color: white;
}
.message-title {
margin-top: 6px;
}
.message-title .el-button {
background: rgba(230, 230, 230, 0.7) !important;
color: rgb(38 38 38) !important;
border: 0px !important;
padding: 8px 6px 8px 6px !important;
font-size: 14px !important;
}
.message-title .el-button:hover {
background: rgba(230, 230, 230, 1) !important;
}
.think-content {
color: #767474;
margin: 10px 0px 10px 0px;
padding: 0 8px 0 8px;
border-left: 1px solid #767474;
font-size: 14px;
}
.content {
margin: 6px 0 6px 0;
}
.arrow-icon {
transition: transform 0.3s ease;
}
.arrow-icon.up {
transform: rotate(180deg);
}
.collapse-content {
max-height: 0;
transition: max-height 0.3s ease, padding 0.3s ease;
}
.rotate-animation {
animation: rotate 1.5s linear infinite;
}
@keyframes rotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.markdown-body {
font-size: 16px;
line-height: 1.5;
word-wrap: break-word;
}
.markdown-body >>> h1,
.markdown-body >>> h2,
.markdown-body >>> h3,
.markdown-body >>> h4,
.markdown-body >>> h5,
.markdown-body >>> h6 {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
}
.markdown-body >>> h1 {
padding-bottom: 0.3em;
font-size: 2em;
border-bottom: 1px solid #eaecef;
}
.markdown-body >>> h2 {
padding-bottom: 0.3em;
font-size: 1.5em;
border-bottom: 1px solid #eaecef;
}
.markdown-body >>> h3 {
font-size: 1.25em;
}
.markdown-body >>> p {
margin-top: 0;
margin-bottom: 16px;
}
.markdown-body >>> ul,
.markdown-body >>> ol {
padding-left: 2em;
margin-top: 0;
margin-bottom: 16px;
}
.markdown-body >>> li {
margin-bottom: 0.25em;
}
.markdown-body >>> blockquote {
padding: 0 1em;
color: #6a737d;
border-left: 0.25em solid #dfe2e5;
margin: 0 0 16px 0;
}
.markdown-body >>> pre {
padding: 16px;
overflow: auto;
font-size: 85%;
line-height: 1.45;
background-color: #f6f8fa;
border-radius: 6px;
margin-bottom: 16px;
}
.markdown-body >>> code {
font-size: 85%;
background-color: rgba(175, 184, 193, 0.2);
border-radius: 3px;
padding: 0.2em 0.4em;
}
.markdown-body >>> pre code {
background-color: transparent;
padding: 0;
}
.markdown-body >>> a {
color: #0366d6;
text-decoration: none;
}
.markdown-body >>> a:hover {
text-decoration: underline;
}
.markdown-body >>> strong {
font-weight: 600;
}
.markdown-body >>> em {
font-style: italic;
}
.markdown-body >>> img {
max-width: 100%;
box-sizing: content-box;
background-color: #fff;
}
.markdown-body >>> table {
border-spacing: 0;
border-collapse: collapse;
display: block;
width: 100%;
overflow: auto;
margin-bottom: 16px;
}
.markdown-body >>> th {
font-weight: 600;
padding: 6px 13px;
border: 1px solid #dfe2e5;
}
.markdown-body >>> td {
padding: 6px 13px;
border: 1px solid #dfe2e5;
}
.markdown-body >>> tr {
background-color: #fff;
border-top: 1px solid #c6cbd1;
}
.markdown-body >>> tr:nth-child(2n) {
background-color: #f6f8fa;
}
</style>
loading 代码
<template>
<div class="dot-flashing">
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
</div>
</template>
<script>
export default {
name: "dot_flashing"
}
</script>
<style>
.dot-flashing {
display: inline-flex;
margin: 0px 10px 0px 10px;
gap: 5px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #3dd91b;
opacity: 0.3;
animation: dot-flash 1s infinite alternate;
}
.dot:nth-child(1) { animation-delay: 0s; }
.dot:nth-child(2) { animation-delay: 0.3s; }
.dot:nth-child(3) { animation-delay: 0.6s; }
@keyframes dot-flash {
to { opacity: 1; }
}
</style>
使用
import openAiChat from "../chat/openai_chat";
<open-ai-cha/>