Vue2+marked.js实现AI流式输出

1、实现效果

AI流式输出

2、实现流程

1、页面内容

<template>
  <div class="app-container">
    <!-- 聊天界面 -->
    <div class="chat-container">
      <!-- 消息展示区域 -->
      <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">

            <!-- 加载动画,例如一个旋转的图标 -->
            <div class="loading-spinner"></div>
            容我思考片刻 !
          </div>
          <p v-else v-html="markMessage(message.content)"></p>
        </div>
      </div>
      <!-- 输入框与发送按钮 -->
      <div class="input-container">
        <el-input
          v-model="userInput"
          placeholder="请输入消息..."
          clearable
          @keyup.enter.native="sendMessage"
          class="chat-input"
        />
        <el-button type="primary" icon="el-icon-send" @click="sendMessage" class="send-button">发送</el-button>
      </div>
    </div>
  </div>
</template>

循环展示消息内容,根据是用户发送消息和AI返回消息展示不同的样式

添加了一个等待动画

2、js部分与样式

<script>
// api部分大家根据自己的前端框架自己封装即可,分别调用后端的两个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: "你好", // 用户输入,默认一开始发一个“你好”
      pollingActive: false, // 是否正在长轮询
      isEnd: false, // 标记是否结束轮询
      currentAiMessageId: null, // 当前正在回复的 AI 消息的 ID
      userMsgData: {}, // 用户消息数据
    };
  },
  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('调用前'+message)
      const marked = new Marked(
        markedHighlight({
          pedantic: false,
          gfm: true, // 开启gfm
          breaks: true,
          smartLists: true,
          xhtml: true,
          async: false, // 如果是异步的,可以设置为 true
          langPrefix: 'hljs language-', // 可选:用于给代码块添加前缀
          emptyLangClass: 'no-lang', // 没有指定语言的代码块会添加这个类名
          highlight: (code) => {
                 return hljs.highlightAuto(code).value
          }
        })
      );
     let  markedMessage = marked.parse(message)
      console.log('调用了'+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 回复占位
      let newAiMessage = {
        id: Date.now() + 1,
        content: "",
        from: "ai",
      };
      this.messages.push(newAiMessage);

      this.currentAiMessageId = newAiMessage.id;

      // 启动轮询
      // 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);

        }
        // 流结束时处理可能剩余的部分消息
        // 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;
          // 通过消息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; /* 右侧占比 */
  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;
}

/* 用户消息:右对齐 */
.user-message {
  align-self: flex-end;
  background-color: #e0f7fa;
  text-align: left;
}

/* AI 消息:左对齐 */
.ai-message {
  align-self: flex-start;
  background-color: #f1f1f1;
  text-align: left;
}

/* 输入框和发送按钮 */
.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; /* 可以根据需要调整颜色 */
  border-radius: 50%;
  width: 20px;
  height: 20px;
  animation: spin 1s linear infinite;
  margin-right: 10px; /* 与文本之间留出一些空间 */
}

/* 定义旋转动画 */
@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; /* 设置文本颜色 */
  background-color: #f0f0f0; /* 设置背景颜色 */
}
</style>

发送消息后,生成用户消息和AI回复占位

给AI接口发送消息,发送消息后,获取到响应,然后使用reader.read方法读取内容。

给AI接口发送消息

export async function sendMessage1(message) {
  console.log(message+"----")
  try {
    const response = await fetch( '你的接口路径', {
      method: 'POST',
      body:message
    })
    return response
  } catch (error) {
    console.error('请求失败:', error);
  }
}

因为是流式响应,所以连接不是一次响应就直接断开的,使用while(true)循环不断从中获取到响应内容,并将响应的内容解码。

AI接口响应 content-type: text/event-stream;charset=utf-8是这样的,不是平常用的application/json,这表明响应体是一个服务器发送事件(Server-Sent Events,简称SSE)流。SSE 允许服务器向客户端(通常是浏览器)推送实时更新,而无需客户端轮询服务器。

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);

        }
        // 流结束时处理可能剩余的部分消息
        // this.processServerSentEvent(buffer);
      } catch (e) {
        console.log(e.toString())
      }

将解码后内容经过处理后添加到messages数组中。

 processServerSentEvent(eventData, isFinal = false) {
      console.log('收到数据:  '+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 是一个用于将 Markdown 文本转换为 HTML 的 JavaScript 库,而 highlight.js 是一个用于语法高亮的库,它可以与 marked.js 一起使用来高亮 Markdown 中的代码块

安装marked.js和hightlight.js然后导入

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('调用前'+message)
      const marked = new Marked(
        markedHighlight({
          pedantic: false,
          gfm: true, // 开启gfm
          breaks: true,
          smartLists: true,
          xhtml: true,
          async: false, // 如果是异步的,可以设置为 true
          langPrefix: 'hljs language-', // 可选:用于给代码块添加前缀
          emptyLangClass: 'no-lang', // 没有指定语言的代码块会添加这个类名
          highlight: (code) => {
                 return hljs.highlightAuto(code).value
          }
        })
      );
     let  markedMessage = marked.parse(message)
      // console.log('调用了'+markedMessage)
      return markedMessage
    },
message就是markdown格式的文本内容

4、添加等待效果

当消息内容为空时,显示等待动画,不为空显示消息内容

<div v-if="!message.content" class="chat-message waiting">

            <!-- 加载动画,例如一个旋转的图标 -->
            <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; /* 可以根据需要调整颜色 */
  border-radius: 50%;
  width: 20px;
  height: 20px;
  animation: spin 1s linear infinite;
  margin-right: 10px; /* 与文本之间留出一些空间 */
}

/* 定义旋转动画 */
@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; /* 设置文本颜色 */
  background-color: #f0f0f0; /* 设置背景颜色 */
}

5、引入marked.js报错解决

引入marked.js后,打包工程后,代码报错如下

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();
|         }

大概意思就是没有loader来处理新语法.?,

解决方案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...
      ],

    },
  }

猜你喜欢

转载自blog.csdn.net/lingxiyizhi_ljx/article/details/145543192
今日推荐