1. 效果展示
流式输出
直接输出
2. 核心代码
找了一些示例与AI生成的代码,或多或少有些问题,搞了好久,郁闷~,在此记录下
2.1 依赖安装
npm install vue-sse
2.2 改写main.ts
import VueSSE from 'vue-sse'
const app = Vue.createApp(App)
// Use VueSSE, including a polyfill for older browsers
// @ts-ignore
app.use($).use(ElementPlus).use(store).use(router).use(VueSSE, {
polyfill: true
})
2.3 Chat.vue完整代码
代码尚不完善,最新代码可参考Github, 见文末
<template>
<div class="chat">
<el-form>
<el-row>
<div class="chat-container" style="margin-bottom: 40px">
<!-- <div v-for="message in messages" :key="message.id" class="message">-->
<!-- <el-avatar v-if="!message.isUser" shape="square" size="50" :src="botAvatar"></el-avatar>-->
<!-- <div :class="{'user-message': message.isUser, 'bot-message': !message.isUser}">-->
<!-- <div className="show-html" v-html=message.text></div>-->
<!-- </div>-->
<!-- </div>-->
<div class="messages" v-for="msg in messages" :key="msg.id">
<div :class="msg.from === 'user' ? 'user-message' : 'ai-message'">
<div v-if="msg.type === 'code'" class="code-block">
<pre>
<code class="language-javascript">{{ msg.text }}</code>
</pre>
<button @click="copyToClipboard(msg.text)">复制</button>
</div>
<div v-else v-html="renderMessageContent(msg.text)"></div>
</div>
</div>
</div>
</el-row>
<el-row style="position: fixed; bottom: 45px; left: 5%; right: 5%; width: 90%;">
<el-col :span="21">
<el-input v-model="inputMessage" placeholder="请输入问题..." @keyup.enter="sendMessage" style="width: 100%;"></el-input>
</el-col>
<el-col :span="3">
<el-button type="primary" @click="sendMessage" style="width: 100%;">发送</el-button>
</el-col>
</el-row>
</el-form>
</div>
</template>
<script>
import {marked} from 'marked'
import hljs from 'highlight.js'
import 'highlight.js/styles/atom-one-dark.css'
import store from "@/store";
export default {
name: "sseChat",
data () {
return {
messages: [
{id: 1, text: '我是您的私人智能助理,请问现在能帮您做什么?', isUser: false}
],
inputMessage: '',
botAvatar: require('../../assets/images/robot.png'),
handlers: [
{
event: 'message',
color: '#60778e'
},
{
event: 'time',
color: '#778e60'
}
],
client: null,
// 无需跨域,否则无法接收消息, 这个原因浪费我好多时间
url: 'http://127.0.0.1:8080/sse/subscribe?token=' + store.getters.token,
}
},
mounted() {
this.connect()
},
methods: {
copyToClipboard(text) {
navigator.clipboard.writeText(text).then(() => {
alert('代码已复制到剪贴板!');
});
},
connect () {
// create the client with the user's config
const self = this
let client = this.$sse.create({
url: this.url,
includeCredentials: false
})
// add the user's handlers
this.handlers.forEach((h) => {
client.on(h.event, (data) => { //
if (data === '<SSE_START>') {
self.messages.push( {
text: '',
from: 'ai',
type: 'text',
})
console.log(data)
} else if (data === '<SSE_END>') {
console.log(data)
} else {
const isCode = data.startsWith('```');
console.log(data)
const msg = {
text: data,
from: 'ai',
type: isCode ? 'code' : 'text',
};
self.messages[self.messages.length - 1].text += data;
self.highlightCode();
}
})
})
client.on('error', () => { // eslint-disable-line
console.log('[error] disconnected, automatically re-attempting connection', 'system')
})
// and finally -- try to connect!
client.connect() // eslint-disable-line
.then(() => {
console.log('[info] connected', 'system')
})
.catch(() => {
console.log('[error] failed to connect', 'system')
})
},
highlightCode() {
this.$nextTick(() => {
this.$el.querySelectorAll('pre code').forEach((block) => {
hljs.highlightBlock(block);
});
});
},
// markdown
renderMessageContent(msg) {
if (msg === '') {
return '';
}
marked.setOptions({
renderer: new marked.Renderer(),
highlight: function(code, lang) {
// If lang is provided, use it; otherwise, let hljs guess
return hljs.highlight(code, { language: lang || '' }).value;
},
langPrefix: 'hljs language-',
pedantic: false,
gfm: true, // GitHub Flavored Markdown for better code block support among other things
breaks: false,
sanitize: true, // For security, sanitize the HTML output unless you trust the source
smartypants: false,
xhtml: false
});
let html = marked(msg)
return html
},
sendMessage() {
const self = this
if (self.inputMessage) {
self.messages.push({id: self.messages.length + 1, text: self.inputMessage, isUser: true});
// 一次性输出
// self.$http.post('/chat/chat', {'content': self.inputMessage}, 'apiUrl').then(res => {
// self.messages.push({id: self.messages.length + 1, text: self.renderMessageContent(res), isUser: false});
// self.inputMessage = '';
// })
// 流式输出
self.$http.post('/chat/sseChat', {'content': self.inputMessage}, 'apiUrl').then(res => {
self.inputMessage = '';
})
}
},
}
}
</script>
<style scoped>
.chat{
height: calc(100vh - 120px); /* Adjust based on your header/footer size */
overflow-y: auto;
}
.message {
display: flex;
align-items: flex-start;
margin: 10px;
}
.user-message {
justify-content: flex-end;
text-align: right;
}
.bot-message {
text-align: left;
}
chat-container {
display: flex;
flex-direction: column;
max-width: 600px;
margin: auto;
}
.messages {
flex: 1;
overflow-y: auto;
padding: 10px;
}
.user-message {
text-align: right;
background-color: #d1e7dd;
padding: 8px;
border-radius: 5px;
margin: 5px 0;
}
.ai-message {
text-align: left;
background-color: #f6f8f8;
padding: 8px;
border-radius: 5px;
margin: 5px 0;
}
input {
padding: 10px;
border: 1px solid #ccc;
border-radius: 5px;
}
button {
margin-left: 10px;
padding: 5px 10px;
cursor: pointer;
}
</style>
3. 后端改造
// 1.配置允许跨域与流式响应
@GetMapping(value = "/subscribe", produces = "text/event-stream")
@CrossOrigin
@Operation(summary = "SSE订阅", tags = "AI大模型")
public SseEmitter subscribe(String token, HttpServletResponse response) {
SseEmitter sseEmitter = SseServer.subscribe(token);
response.setHeader("Cache-Control", "no-cache");
response.setHeader("Connection", "keep-alive");
return sseEmitter;
}
// 2.SecurityConfiguration.java中权限控制放开
.requestMatchers("/sse/**").permitAll()
// 3.在订阅式发送了开始<SSE_START>标识,消息结束发送了<SSE_END>标识,其他内容直接返回大模型字符串
4. 开源地址
https://github.com/SJshenjian/cloud-web
https://github.com/SJshenjian/cloud
TODO
- 流式输出Markdown支持
- 代码高亮可复制