当前位置: 首页 > article >正文

基于 Vue 的Deepseek流式加载对话Demo

目录

    • 引言
    • 组件概述
    • 核心组件与功能实现
      • 1. 消息显示组件(Message.vue)
      • 2. 输入组件(Input.vue)
      • 3. 流式请求处理(useDeepseek.ts)
      • 4. 语音处理模块(Voice.vue)
    • 总结
    • Demo Github 地址

引言

在当今数字化时代,智能对话系统的应用越来越广泛,如客服聊天机器人、智能助手等。本文将详细介绍一个基于 Vue 框架开发的智能对话系统的实现过程,该系统支持文本输入、语音输入、流式响应等功能,让我们一步步揭开它的神秘面纱。

在这里插入图片描述

组件概述

整个组件主要由以下几个核心部分组成:

  1. 用户界面组件:负责与用户进行交互,包括文本输入框、语音输入按钮、消息显示区域等。
  2. 消息处理组件:将用户输入的消息进行处理,并显示在界面上。
  3. 流式请求处理:与后端进行流式通信,实时获取响应内容。
  4. 语音处理模块:支持语音输入功能,将语音转换为文本。

核心组件与功能实现

1. 消息显示组件(Message.vue)

该组件用于显示用户和系统的消息。在这个组件中,我们使用了 Markdown 解析器来处理消息内容,支持代码高亮和自定义标签。

<template>
  <div
    class="msg-item"
    :class="{
      'msg-item-system': role === 'system'
    }"
  >
    <div
      class="msg-content"
      :class="{
        'msg-content-user': role === 'user'
      }"
    >
      <span class="msg-pop-container">
        <span
          class="msg-pop-default"
          v-html="mkHtml"
          ref="popRef"
          :class="{
            'msg-pop-primary': role === 'user'
          }"
        >
        </span>
      </span>
    </div>
  </div>
</template>

<script lang="ts" setup>
import MarkdownIt from "markdown-it";
import mk from "markdown-it-katex";
import hljs from "highlight.js";
import "highlight.js/styles/atom-one-dark-reasonable.css";
import { computed, nextTick, ref } from "vue";

interface Props {
  role: string;
  content: string;
  streaming?: boolean;
}

const props = withDefaults(defineProps<Props>(), {
  content: "",
  streaming: false
});

const md: MarkdownIt = MarkdownIt({
  highlight: function (str: string, lang: string) {
    if (lang && hljs.getLanguage(lang)) {
      try {
        return `<div class="hl-code"><div class="hl-code-header"><span>${lang}</span></div><div class="hljs"><code>${
          hljs.highlight(str, { language: lang, ignoreIllegals: true }).value
        }</code></div></div>`;
      } catch (__) {
        console.log(__, "error");
      }
    }
    return `<div class="hl-code"><div class="hl-code-header"><span>${lang}</span></div><div class="hljs"><code>${md.utils.escapeHtml(
      str
    )}</code></div></div>`;
  }
});

md.use(mk, {
  throwOnError: false,
  errorColor: " #cc0000"
});

// 自定义规则函数,用于解析 <think> 标签
function thinkTagRule(state, startLine, endLine, silent) {
  let pos,
    max,
    nextLine,
    token,
    autoClosed = false;
  let start = state.bMarks[startLine] + state.tShift[startLine];
  let end = state.eMarks[startLine];

  // 检查是否以 <think> 开头
  if (start + 7 > end || state.src.slice(start, start + 7) !== "<think>") {
    return false;
  }

  // 跳过 <think>
  pos = start + 7;

  // 查找 </think> 结束标签
  for (nextLine = startLine; ; nextLine++) {
    if (nextLine >= endLine) {
      // 未找到结束标签
      break;
    }

    max = state.bMarks[nextLine] + state.tShift[nextLine];
    if (max < state.eMarks[nextLine] && state.src.slice(max, max + 8) === "</think>") {
      // 找到结束标签
      autoClosed = true;
      break;
    }
  }

  // 如果处于静默模式,只验证标签,不生成 token
  if (silent) {
    return autoClosed;
  }

  // 创建一个新的 token 表示开始标签
  token = state.push("think_tag_open", "think", 1);
  token.markup = "<think>";
  token.map = [startLine, nextLine];

  // 处理标签内部的内容
  state.md.block.tokenize(state, startLine + 1, nextLine);

  // 创建结束 token
  token = state.push("think_tag_close", "think", -1);
  token.markup = "</think>";

  // 更新状态,跳过已处理的行
  state.line = nextLine + 1;

  return true;
}

// 将自定义规则添加到 MarkdownIt 实例中
md.block.ruler.before("paragraph", "think_tag", thinkTagRule);

// 自定义渲染规则,将 <think> 标签渲染为 <span class="think">
md.renderer.rules.think_tag_open = function () {
  return '<span class="think">';
};

md.renderer.rules.think_tag_close = function () {
  return "</span>";
};

function findLastElement(element: HTMLElement): HTMLElement {
  if (!element.children.length) {
    return element;
  }
  const lastChild = element.children[element.children.length - 1];
  if (lastChild.nodeType === Node.ELEMENT_NODE) {
    return findLastElement(lastChild as HTMLElement);
  }
  return element;
}

const popRef = ref();
const mkHtml = computed(() => {
  if (props.role === "user") {
    return props.content;
  }
  let html = md.render(props.content);
  console.log(html); // 调试信息
  nextTick(() => {
    if (props.streaming) {
      const parent = popRef.value;
      if (!parent) return;
      let lastChild = parent.lastElementChild || parent;
      console.log(lastChild.tagName);
      if (lastChild.tagName === "PRE") {
        lastChild = lastChild.getElementsByClassName("hljs")[0] || lastChild;
      }
      if (lastChild.tagName === "OL") {
        lastChild = findLastElement(lastChild as HTMLElement);
      }
      lastChild?.insertAdjacentHTML("beforeend", '<span class="input-cursor"></span>');
    }
  });
  return html;
});
</script>

<style lang="scss" scoped>
.msg-item {
  width: 100%;
  display: flex;
  margin-bottom: 10px;
  padding: 0 10px;
  border-radius: 4px;

  .msg-content {
    position: relative;
    width: 100%;
    flex: 1 1 auto;

    .msg-pop-container {
      position: relative;
      display: inline-block;
      max-width: 95%;

      .msg-pop-default {
        width: 100%;
        display: inline-block;
        padding: 8px;
        background: #f5f5f5;
        border-radius: 4px;
        color: #252724;

        :deep(p) {
          margin-bottom: 0;
          white-space: pre-line;
        }
      }

      .msg-pop-primary {
        background: #95ec69;
        // white-space: pre-line;
      }
    }
  }
}

.msg-content-user {
  text-align: end;
}
.msg-item-system {
  justify-content: flex-end;
}
</style>

<style lang="scss">
.think {
  color: blue;
  font-style: italic;
}
.hl-code {
  margin-top: 1em;
}

.hl-code-header {
  padding: 0 10px;
  color: #abb2bf;
  background: #1d2635;
  border-radius: 4px 4px 0 0;
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.hljs {
  padding: 10px;
  overflow-x: auto;
  border-radius: 0 0 4px 4px;

  .input-cursor {
    background: #fff;
    /* fallback for old browsers */
  }
}

.input-cursor {
  position: relative;
  display: inline-flex;
  align-items: center;
  width: 1px;
  height: 1em;
  background: #3b414b;
  /* fallback for old browsers */
  padding-left: 0.05em;
  top: 0.1em;
  animation: blink 1s steps(1) infinite;
}

@keyframes blink {
  0% {
    visibility: visible;
  }

  50% {
    visibility: hidden;
  }

  100% {
    visibility: visible;
  }
}
</style>

2. 输入组件(Input.vue)

该组件提供了文本输入框和语音输入按钮,支持用户输入问题并发送,同时可以开启新的对话。

<template>
  <div class="msg-editor-container">
    <!-- 文本输入框 -->
    <div class="flex items-center">
      <el-input
        class="flex-1"
        ref="inputDiv"
        v-model="inputValue"
        type="textarea"
        :autosize="{ minRows: 2, maxRows: 4 }"
        placeholder="请输入你的问题"
        @keydown.enter.exact="handleKeydown"
      ></el-input>
      <!-- 语音 -->
      <DeepseekVoiceVue @voiceTextChange="voiceTextChange"></DeepseekVoiceVue>
      <!-- 新对话 -->
      <el-tooltip :z-index="100000" effect="dark" content="新对话" placement="top">
        <el-icon class="mr-1 focus:border-blue-400 hover:bg-[#f5f5f5] bg-white cursor-pointer" size="22px" @click="newSessionBtn">
          <Plus />
        </el-icon>
      </el-tooltip>
      <!-- 操作按钮 -->
      <el-tooltip :z-index="100000" effect="dark" content="发送" placement="top">
        <el-button type="primary" icon="Top" circle @click="handleKeydown"></el-button>
      </el-tooltip>
    </div>
  </div>
</template>

<script lang="ts" setup>
import { ref } from "vue";
import DeepseekVoiceVue from "./Voice.vue";

const emits = defineEmits(["submit", "newSession"]);
const inputValue = ref("");

const voiceTextChange = (text: string) => {
  console.log(text);
  inputValue.value = inputValue.value + text;
};

const handleKeydown = (e: Event) => {
  e.stopPropagation();
  e.returnValue = false;
  if (inputValue.value === "") return;

  emits("submit", inputValue.value);
  inputValue.value = "";
};

const newSessionBtn = () => {
  emits("newSession");
};
</script>

<style lang="scss" scoped>
.msg-editor-container {
  border: 1px solid #dee0e3;
  border-radius: 4px;
  padding: 5px;
}

.operationBtn {
  display: flex;
}
</style>

3. 流式请求处理(useDeepseek.ts)

通过useGpt钩子函数处理 GPT 流式请求,使用Typewriter类模拟打字效果,让响应内容逐字显示。

import { ref } from "vue";
import { StreamGpt, Typewriter, GptMsgs, RequestData } from "./fetchApi";

// useGpt 钩子函数,用于处理 GPT 流式请求
export const useGpt = (key: string) => {
  const streamingText = ref("");
  const streaming = ref(false);
  const msgList = ref<GptMsgs>([]);
  const sessionId = ref("");

  // 初始化 Typewriter 实例
  const typewriter = new Typewriter((str: string) => {
    streamingText.value += str || "";
    // console.log("str", str);
  });

  // 初始化 StreamGpt 实例
  const gpt = new StreamGpt(key, {
    onStart: (prompt: string) => {
      streaming.value = true;
      msgList.value.push({
        role: "user",
        content: prompt
      });
    },
    onPatch: (text: string) => {
      // console.log("onPatch", text);
      typewriter.add(text);
    },
    onCreated: () => {
      typewriter.start();
    },
    onDone: () => {
      typewriter.done();
      streaming.value = false;
      msgList.value.push({
        role: "system",
        content: streamingText.value
      });
      streamingText.value = "";
    }
  });

  // 发送流式请求
  const stream = (requestData: RequestData) => {
    if (sessionId.value === "") {
      sessionId.value = generateUUID();
    }
    gpt.stream({ ...requestData, sessionId: sessionId.value });
  };

  // 新会话
  const newSession = () => {
    msgList.value = [];
    streamingText.value = "";
    sessionId.value = generateUUID();
  };

  // 生成 UUID
  const generateUUID = () => {
    let uuid = "";
    for (let i = 0; i < 32; i++) {
      const random = (Math.random() * 16) | 0;
      if (i === 8 || i === 12 || i === 16 || i === 20) uuid += "-";
      uuid += (i === 12 ? 4 : i === 16 ? (random & 3) | 8 : random).toString(16);
    }
    return uuid;
  };

  return {
    streamingText,
    streaming,
    msgList,
    stream,
    newSession
  };
};

4. 语音处理模块(Voice.vue)

该模块支持语音录制和识别,将录制的语音转换为 WAV 文件或Base64,并发送到后端进行识别。

<template>
  <!-- 语音输入按钮 -->
  <button @click="toggleRecording">{{ isRecording ? '停止录音' : '开始录音' }}</button>
</template>

<script lang="ts" setup>
import { ref } from "vue";

const isRecording = ref(false);
const mediaRecorder: any = ref(null);
const chunks: any = ref([]);

const emits = defineEmits(["voiceTextChange"]);

// 开始录音
const startRecording = async () => {
  try {
    const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
    mediaRecorder.value = new MediaRecorder(stream);
    mediaRecorder.value.ondataavailable = (event: any) => {
      if (event.data.size > 0) {
        chunks.value.push(event.data);
      }
    };
    mediaRecorder.value.onstop = () => {
      const wavBlob = encodeWAV(chunks.value);
      wavTransformBase64(wavBlob);
      chunks.value = [];
    };
    mediaRecorder.value.start();
    isRecording.value = true;
  } catch (error) {
    console.error("录音失败:", error);
  }
};

// 停止录音
const stopRecording = () => {
  if (mediaRecorder.value) {
    mediaRecorder.value.stop();
    isRecording.value = false;
  }
};

// 生成WAV文件
function encodeWAV(chunks) {
  const totalLength = chunks.reduce((acc, chunk) => acc + chunk.byteLength, 0);
  const header = generateWavHeader(totalLength);
  const wavBuffer = new Uint8Array(header.byteLength + totalLength);

  // 合并头和数据
  wavBuffer.set(new Uint8Array(header.buffer), 0);
  let offset = header.byteLength;
  chunks.forEach(chunk => {
    wavBuffer.set(new Uint8Array(chunk.buffer), offset);
    offset += chunk.byteLength;
  });

  return new Blob([wavBuffer], { type: "audio/wav" });
}

// 生成WAV文件头
function generateWavHeader(dataLength) {
  const header = new ArrayBuffer(44);
  const view = new DataView(header);

  // RIFF标识
  writeString(view, 0, "RIFF");
  // 文件长度(数据长度 + 36)
  view.setUint32(4, dataLength + 36, true);
  // WAVE格式
  writeString(view, 8, "WAVE");
  // fmt子块
  writeString(view, 12, "fmt ");
  // fmt块长度(16字节)
  view.setUint32(16, 16, true);
  // 格式类型(1=PCM)
  view.setUint16(20, 1, true);
  // 声道数
  view.setUint16(22, WAV_CONFIG.channelCount, true);
  // 采样率
  view.setUint32(24, WAV_CONFIG.sampleRate, true);
  // 字节率
  view.setUint32(28, WAV_CONFIG.sampleRate * WAV_CONFIG.channelCount * (WAV_CONFIG.bitDepth / 8), true);
  // 块对齐
  view.setUint16(32, WAV_CONFIG.channelCount * (WAV_CONFIG.bitDepth / 8), true);
  // 位深度
  view.setUint16(34, WAV_CONFIG.bitDepth, true);
  // data标识
  writeString(view, 36, "data");
  // 数据长度
  view.setUint32(40, dataLength, true);

  return view;
}

// 写入字符串到DataView
function writeString(view, offset, string) {
  for (let i = 0; i < string.length; i++) {
    view.setUint8(offset + i, string.charCodeAt(i));
  }
}

// 将WAV文件转换为Base64编码
function wavTransformBase64(wavBlob) {
  const reader = new FileReader();
  reader.onload = function (e) {
    console.log("wav base64:", e.target.result);
    getRecognition(e.target.result);
  };
  reader.readAsDataURL(wavBlob);
}

// 切换录音状态
const toggleRecording = () => {
  if (isRecording.value) {
    stopRecording();
  } else {
    startRecording();
  }
};

const getRecognition = base64Str => {
  isLoading.value = true;
  Recognition(base64Str)
    .then(res => {
      emits("voiceTextChange", res.data);
    })
    .finally(() => {
      console.log("voiceTextChange");
      isLoading.value = false;
    });
};
</script>

总结

通过以上步骤,我们实现了一个基于 Vue 的智能对话系统,支持文本输入、语音输入和流式响应。在开发过程中,我们使用了 Vue 的响应式原理和组件化开发思想,结合 Markdown 解析器、语音处理 API 和流式请求技术,为用户提供了一个流畅、智能的对话体验。未来,我们可以进一步优化系统性能,增加更多的功能,如多语言支持、情感分析等。

Demo Github 地址

stream-deepseek


http://www.kler.cn/a/581334.html

相关文章:

  • 基于python下载ERA5小时尺度和月尺度的数据
  • 【反无人机数据集】【目标检测】基于深度学习和距离分析的无人机检测图像处理技术应用
  • 基于MATLAB的冰块变化仿真
  • XTDrone调试报错问题集锦
  • 动态规划详解(二):从暴力递归到动态规划的完整优化之路
  • NLP常见任务专题介绍(2)-多项选择任务(MultipleChoice)训练与推理模板
  • SpringBoot开发——整合SpringReport开源报表工具
  • Git的命令学习——适用小白版
  • Android实现Socket通信
  • Chrome 扩展开发 API实战:Bookmarks(二)
  • Python高级之操作Mysql
  • 华为OD机试 - 平均像素值-贪心算法(Java 2024 E卷 100分)
  • 【区块链+ 医疗健康】基于区块链和AI 技术的儿童近视防控大数据平台 | FISCO BCOS 应用案例
  • iTextSharp-PDF批量导出
  • 3.3.2 用仿真图实现点灯效果
  • 软考高级信息系统项目管理师笔记-第22章组织通用治理
  • nginx的使用
  • Ubuntu22.04修改root用户并安装cuda
  • 网络安全之命令
  • 发展史 | 深度学习 / 云计算