Vue2+marked.js實(shí)現(xiàn)AI流式輸出的項(xiàng)目實(shí)踐
1、實(shí)現(xiàn)效果

AI流式輸出
2、實(shí)現(xiàn)流程
1、頁(yè)面內(nèi)容
<template>
<div class="app-container">
<!-- 聊天界面 -->
<div class="chat-container">
<!-- 消息展示區(qū)域 -->
<div class="chat-box" ref="chatBox">
<div
v-for="message in messages"
:key="message.id"
class="message"
:class="message.from === 'user' ? 'user-message' : 'ai-message'"
>
<div v-if="!message.content" class="chat-message waiting">
<!-- 加載動(dòng)畫(huà),例如一個(gè)旋轉(zhuǎn)的圖標(biāo) -->
<div class="loading-spinner"></div>
容我思考片刻 !
</div>
<p v-else v-html="markMessage(message.content)"></p>
</div>
</div>
<!-- 輸入框與發(fā)送按鈕 -->
<div class="input-container">
<el-input
v-model="userInput"
placeholder="請(qǐng)輸入消息..."
clearable
@keyup.enter.native="sendMessage"
class="chat-input"
/>
<el-button type="primary" icon="el-icon-send" @click="sendMessage" class="send-button">發(fā)送</el-button>
</div>
</div>
</div>
</template>循環(huán)展示消息內(nèi)容,根據(jù)是用戶發(fā)送消息和AI返回消息展示不同的樣式
添加了一個(gè)等待動(dòng)畫(huà)
2、js部分與樣式
<script>
// api部分大家根據(jù)自己的前端框架自己封裝即可,分別調(diào)用后端的兩個(gè)controller
import {sendMessage, sendMessage1} from "@/api/ai";
import { Marked } from 'marked'
import { markedHighlight } from "marked-highlight";
import hljs from 'highlight.js';
import 'highlight.js/styles/github.css';
import "highlight.js/styles/paraiso-light.css";
export default {
data() {
return {
messages: [], // 消息記錄
userInput: "你好", // 用戶輸入,默認(rèn)一開(kāi)始發(fā)一個(gè)“你好”
pollingActive: false, // 是否正在長(zhǎng)輪詢
isEnd: false, // 標(biāo)記是否結(jié)束輪詢
currentAiMessageId: null, // 當(dāng)前正在回復(fù)的 AI 消息的 ID
userMsgData: {}, // 用戶消息數(shù)據(jù)
};
},
async mounted() {
this.sendMessage();
},
// computed: {
// newMessages() {
// this.messages.forEach(message=>{
// message.content=this.markMessage(message.content)
// console.log(message.content)
// })
// return this.messages
// }
// },
methods: {
markMessage(message) {
message=message.replaceAll('\\n','\n')
console.log('調(diào)用前'+message)
const marked = new Marked(
markedHighlight({
pedantic: false,
gfm: true, // 開(kāi)啟gfm
breaks: true,
smartLists: true,
xhtml: true,
async: false, // 如果是異步的,可以設(shè)置為 true
langPrefix: 'hljs language-', // 可選:用于給代碼塊添加前綴
emptyLangClass: 'no-lang', // 沒(méi)有指定語(yǔ)言的代碼塊會(huì)添加這個(gè)類名
highlight: (code) => {
return hljs.highlightAuto(code).value
}
})
);
let markedMessage = marked.parse(message)
console.log('調(diào)用了'+markedMessage)
return markedMessage
},
sendMessage() {
if (!this.userInput.trim()) return;
// 添加用戶消息
this.messages.push({
id: Date.now(),
content: this.userInput,
from: "user",
});
this.userMsgData.content = this.markMessage(this.userInput);
// send(this.userMsgData);
// 清空輸入框
this.userInput = "";
// 添加 AI 回復(fù)占位
let newAiMessage = {
id: Date.now() + 1,
content: "",
from: "ai",
};
this.messages.push(newAiMessage);
this.currentAiMessageId = newAiMessage.id;
// 啟動(dòng)輪詢
// if (this.isEnd || !this.pollingActive) {
// this.isEnd = false;
// this.pollingActive = true;
// this.polling();
// }
this.polling()
},
async polling() {
try {
// 給定的字符串
const response = await sendMessage1(this.userMsgData.content);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = ''; // 用于累積部分消息
while (true) {
const {done, value} = await reader.read();
if (done) {
this.isEnd = true;
this.pollingActive = false;
break
}
buffer = decoder.decode(value, {stream: true});
this.processServerSentEvent(buffer);
}
// 流結(jié)束時(shí)處理可能剩余的部分消息
// this.processServerSentEvent(buffer);
} catch (e) {
console.log(e.toString())
}
},
processServerSentEvent(eventData, isFinal = false) {
const lines = eventData.split('\n');
let currentMessage = ''
lines.forEach(line => {
if (line.startsWith('data:')) {
// 提取data字段的值(去掉前面的'data: ')
let a = line.split(':')
currentMessage += a[1];
} else {
currentMessage+=line.trim()
}
})
this.addNewMessage(currentMessage)
},
addNewMessage(data) {
if (data) {
try {
let newMessageContent = data;
// 通過(guò)消息id獲取目前的AI輸入位置
const aiMessage = this.messages.find(
(msg) => msg.id === this.currentAiMessageId
);
// newMessageContent = this.markMessage(newMessageContent)
if (aiMessage) {
aiMessage.content = `${aiMessage.content}${newMessageContent}`;
}
this.scrollToBottom()
} catch (error) {
console.error('Failed to parse JSON:', error);
}
}
},
scrollToBottom() {
const chatBox = this.$refs.chatBox;
chatBox.scrollTop = chatBox.scrollHeight;
}
}
};
</script>
<style scoped>
.app-container {
display: flex;
height: 90vh;
background-color: #f3f4f6;
font-family: "Arial", sans-serif;
}
/* 聊天容器 */
.chat-container {
flex: 1; /* 右側(cè)占比 */
display: flex;
flex-direction: column;
border-left: 1px solid #ddd;
background-color: #fff;
overflow: hidden;
}
.chat-box {
flex: 1;
overflow-y: auto;
padding: 20px;
background-color: #fafafa;
display: flex;
flex-direction: column;
}
/* 通用消息樣式 */
.message {
margin: 10px 0;
padding: 10px;
max-width: 70%;
word-wrap: break-word;
border-radius: 8px;
}
/* 用戶消息:右對(duì)齊 */
.user-message {
align-self: flex-end;
background-color: #e0f7fa;
text-align: left;
}
/* AI 消息:左對(duì)齊 */
.ai-message {
align-self: flex-start;
background-color: #f1f1f1;
text-align: left;
}
/* 輸入框和發(fā)送按鈕 */
.input-container {
display: flex;
padding: 10px;
border-top: 1px solid #e0e0e0;
background-color: #f9f9f9;
}
.chat-input {
flex: 1;
margin-right: 10px;
}
.send-button {
flex-shrink: 0;
}
/* 加載指示器的樣式 */
.loading-spinner {
border: 4px solid rgba(0, 0, 0, 0.1);
border-left-color: #4caf50; /* 可以根據(jù)需要調(diào)整顏色 */
border-radius: 50%;
width: 20px;
height: 20px;
animation: spin 1s linear infinite;
margin-right: 10px; /* 與文本之間留出一些空間 */
}
/* 定義旋轉(zhuǎn)動(dòng)畫(huà) */
@keyframes spin {
to { transform: rotate(360deg); }
}
/* 聊天消息的基本樣式 */
.chat-message {
padding: 10px;
border-radius: 8px;
margin: 5px 0;
position: relative;
display: flex;
align-items: center;
}
/* 正在等待的消息樣式 */
.waiting {
color: #777; /* 設(shè)置文本顏色 */
background-color: #f0f0f0; /* 設(shè)置背景顏色 */
}
</style>發(fā)送消息后,生成用戶消息和AI回復(fù)占位
給AI接口發(fā)送消息,發(fā)送消息后,獲取到響應(yīng),然后使用reader.read方法讀取內(nèi)容。
給AI接口發(fā)送消息
export async function sendMessage1(message) {
console.log(message+"----")
try {
const response = await fetch( '你的接口路徑', {
method: 'POST',
body:message
})
return response
} catch (error) {
console.error('請(qǐng)求失敗:', error);
}
}因?yàn)槭橇魇巾憫?yīng),所以連接不是一次響應(yīng)就直接斷開(kāi)的,使用while(true)循環(huán)不斷從中獲取到響應(yīng)內(nèi)容,并將響應(yīng)的內(nèi)容解碼。
AI接口響應(yīng) content-type: text/event-stream;charset=utf-8是這樣的,不是平常用的application/json,這表明響應(yīng)體是一個(gè)服務(wù)器發(fā)送事件(Server-Sent Events,簡(jiǎn)稱SSE)流。SSE 允許服務(wù)器向客戶端(通常是瀏覽器)推送實(shí)時(shí)更新,而無(wú)需客戶端輪詢服務(wù)器。
async polling() {
try {
// 給定的字符串
const response = await sendMessage1(this.userMsgData.content);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = ''; // 用于累積部分消息
while (true) {
const {done, value} = await reader.read();
if (done) {
this.isEnd = true;
this.pollingActive = false;
break
}
buffer = decoder.decode(value, {stream: true});
this.processServerSentEvent(buffer);
}
// 流結(jié)束時(shí)處理可能剩余的部分消息
// this.processServerSentEvent(buffer);
} catch (e) {
console.log(e.toString())
}將解碼后內(nèi)容經(jīng)過(guò)處理后添加到messages數(shù)組中。
processServerSentEvent(eventData, isFinal = false) {
console.log('收到數(shù)據(jù): '+eventData)
const lines = eventData.split('\n');
let currentMessage = ''
lines.forEach(line => {
if (line.startsWith('data:')) {
// 提取data字段的值(去掉前面的'data: ')
let a = line.split(':')
currentMessage += a[1];
} else {
currentMessage+=line.trim()
}
})
this.addNewMessage(currentMessage)
},3、marked.js和highlight.js
marked.js 是一個(gè)用于將 Markdown 文本轉(zhuǎn)換為 HTML 的 JavaScript 庫(kù),而 highlight.js 是一個(gè)用于語(yǔ)法高亮的庫(kù),它可以與 marked.js 一起使用來(lái)高亮 Markdown 中的代碼塊
安裝marked.js和hightlight.js然后導(dǎo)入
npm install marked npm install highlight.js
import { Marked } from 'marked'
import { markedHighlight } from "marked-highlight";
import hljs from 'highlight.js';
import 'highlight.js/styles/github.css';
import "highlight.js/styles/paraiso-light.css";markMessage(message) {
message=message.replaceAll('\\n','\n')
// console.log('調(diào)用前'+message)
const marked = new Marked(
markedHighlight({
pedantic: false,
gfm: true, // 開(kāi)啟gfm
breaks: true,
smartLists: true,
xhtml: true,
async: false, // 如果是異步的,可以設(shè)置為 true
langPrefix: 'hljs language-', // 可選:用于給代碼塊添加前綴
emptyLangClass: 'no-lang', // 沒(méi)有指定語(yǔ)言的代碼塊會(huì)添加這個(gè)類名
highlight: (code) => {
return hljs.highlightAuto(code).value
}
})
);
let markedMessage = marked.parse(message)
// console.log('調(diào)用了'+markedMessage)
return markedMessage
},message就是markdown格式的文本內(nèi)容
4、添加等待效果
當(dāng)消息內(nèi)容為空時(shí),顯示等待動(dòng)畫(huà),不為空顯示消息內(nèi)容
<div v-if="!message.content" class="chat-message waiting">
<!-- 加載動(dòng)畫(huà),例如一個(gè)旋轉(zhuǎn)的圖標(biāo) -->
<div class="loading-spinner"></div>
容我思考片刻 !
</div>
<p v-else v-html="markMessage(message.content)"></p>等待樣式
/* 加載指示器的樣式 */
.loading-spinner {
border: 4px solid rgba(0, 0, 0, 0.1);
border-left-color: #4caf50; /* 可以根據(jù)需要調(diào)整顏色 */
border-radius: 50%;
width: 20px;
height: 20px;
animation: spin 1s linear infinite;
margin-right: 10px; /* 與文本之間留出一些空間 */
}
/* 定義旋轉(zhuǎn)動(dòng)畫(huà) */
@keyframes spin {
to { transform: rotate(360deg); }
}
/* 聊天消息的基本樣式 */
.chat-message {
padding: 10px;
border-radius: 8px;
margin: 5px 0;
position: relative;
display: flex;
align-items: center;
}
/* 正在等待的消息樣式 */
.waiting {
color: #777; /* 設(shè)置文本顏色 */
background-color: #f0f0f0; /* 設(shè)置背景顏色 */
}5、引入marked.js報(bào)錯(cuò)解決
引入marked.js后,打包工程后,代碼報(bào)錯(cuò)如下
You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. See https://webpack.js.org/concepts#loaders
| cells.shift();
| }
> if (cells.length > 0 && !cells.at(-1)?.trim()) {
| cells.pop();
| }
大概意思就是沒(méi)有l(wèi)oader來(lái)處理新語(yǔ)法.?,
解決方案vue.config.js中configurewebpack中增加如下代碼
module: {
rules: [
{
test: /\.js$/,
use: {
loader: 'babel-loader',
},
},
// 其他rules...
],
},configureWebpack: {
// provide the app's title in webpack's name field, so that
// it can be accessed in index.html to inject the correct title.
name: name,
output: {
chunkFilename: 'static/js/[name].js'
// chunkFilename: 'static/js/[name][contenthash].js'
},
resolve: {
alias: {
'@': resolve('src')
}
},
module: {
rules: [
{
test: /\.js$/,
use: {
loader: 'babel-loader',
},
},
// 其他rules...
],
},
}到此這篇關(guān)于Vue2+marked.js實(shí)現(xiàn)AI流式輸出的項(xiàng)目實(shí)踐的文章就介紹到這了,更多相關(guān)Vue2+marked.js AI流式輸出內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
vue3?hook重構(gòu)DataV的全屏容器組件詳解
這篇文章主要為大家介紹了vue3?hook重構(gòu)DataV的全屏容器組件詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-04-04
el-radio-group中的area-hidden報(bào)錯(cuò)的問(wèn)題解決
本文主要介紹了el-radio-group中的area-hidden報(bào)錯(cuò)的問(wèn)題解決,下面就來(lái)介紹幾種解決方法,具有一定的參考價(jià)值,感興趣的可以了解一下2025-04-04
如何使用Gitee Pages服務(wù) 搭建Vue項(xiàng)目
這篇文章主要介紹了如何使用Gitee Pages服務(wù) 搭建Vue項(xiàng)目,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2022-10-10
vue開(kāi)發(fā)移動(dòng)端h5環(huán)境搭建的全過(guò)程
在正式使用Vue進(jìn)行移動(dòng)端頁(yè)面開(kāi)發(fā)前,需要做一些前置工作,下面這篇文章主要給大家介紹了關(guān)于vue開(kāi)發(fā)移動(dòng)端h5環(huán)境搭建的相關(guān)資料,文中通過(guò)實(shí)例代碼介紹的非常詳細(xì),需要的朋友可以參考下2022-08-08
利用WebStorm創(chuàng)建一個(gè)Vue項(xiàng)目的完整步驟
WebStorm是一個(gè)非常適合學(xué)習(xí)和開(kāi)發(fā)Vue項(xiàng)目的集成開(kāi)發(fā)環(huán)境,下面這篇文章主要給大家介紹了關(guān)于利用WebStorm創(chuàng)建一個(gè)Vue項(xiàng)目的完整步驟,文中通過(guò)圖文介紹的非常詳細(xì),需要的朋友可以參考下2024-06-06
vue中的循環(huán)遍歷對(duì)象、數(shù)組和字符串
這篇文章主要介紹了vue中的循環(huán)遍歷對(duì)象、數(shù)組和字符串,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2022-08-08
antd vue 如何調(diào)整checkbox默認(rèn)樣式
這篇文章主要介紹了antd vue 如何調(diào)整checkbox默認(rèn)樣式,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2022-12-12
vue項(xiàng)目中銷毀window.addEventListener事件監(jiān)聽(tīng)解析
這篇文章主要介紹了vue項(xiàng)目中銷毀window.addEventListener事件監(jiān)聽(tīng),具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2022-07-07

