markdown——大模型返回结果展示以及打字机效果

1、背景

Markdown 是一种轻量级的标记语言,可以用一些简单语法来表达一些富文本内容。大部分大模型返回的格式是markdown格式,本文介绍一下大模型返回结果的展示

2、v-md-editor

本文中使用的是v-md-editor插件展示,大模型返回结果展示、包含打字机竖线展示,创建MdPreview.vue

<template>
  <div :class="[randomClass, 'machine-text-contain']">
    <v-md-preview
      :text="defaultVal"
      @copy-code-success="() => emit('copyCodeSuccess')"
      :height="height"></v-md-preview>
    <!--  打印机的竖线效果 -->
    <div class="cursor" v-show="defaultVal && cursorShow"></div>
  </div>
</template>
<script lang="ts" setup name="t-md-preview">
import VMdPreview from '@kangc/v-md-editor/lib/preview';
import '@kangc/v-md-editor/lib/style/preview.css';

// vuepress主题
import vuepressTheme from '@kangc/v-md-editor/lib/theme/vuepress.js';
import '@kangc/v-md-editor/lib/theme/style/vuepress.css';

/** 提示信息 */
import createTipPlugin from '@kangc/v-md-editor/lib/plugins/tip/index';
import '@kangc/v-md-editor/lib/plugins/tip/tip.css';

/** Emoji标签包 */
import createEmojiPlugin from '@kangc/v-md-editor/lib/plugins/emoji/index';
import '@kangc/v-md-editor/lib/plugins/emoji/emoji.css';

/** katex */
import createKatexPlugin from '@kangc/v-md-editor/lib/plugins/katex/npm';
import 'katex/dist/katex.min.css';

/** todo-list */
import createTodoListPlugin from '@kangc/v-md-editor/lib/plugins/todo-list/index';
import '@kangc/v-md-editor/lib/plugins/todo-list/todo-list.css';

/** 代码拷贝 */
import createCopyCodePlugin from '@kangc/v-md-editor/lib/plugins/copy-code/index';
import '@kangc/v-md-editor/lib/plugins/copy-code/copy-code.css';

import Prism from 'prismjs';

import { watch, nextTick } from 'vue';
import { generateUUID } from '@/utils/common';


VMdPreview.use(vuepressTheme, {
  Prism,
  extend(md: any) {
    md.set({ quotes: '""\'\'' });
    // md.set({
    //   throwOnError: false,
    //   errorColor: ' #cc0000',
    // }).use(markdownItKatex);
  }
});

VMdPreview.use(createTipPlugin());
VMdPreview.use(createEmojiPlugin());
VMdPreview.use(createKatexPlugin());
VMdPreview.use(createTodoListPlugin());
VMdPreview.use(createCopyCodePlugin());

const props = defineProps({
  defaultVal: {
    type: String,
    default: ''
  },
  height: {
    type: String,
    default: 'fit-content'
  },
  cursorShow: {
    type: Boolean,
    default: false
  }
});

const emit = defineEmits(['copyCodeSuccess']);

const randomClass = `random-${generateUUID()}`;

/** 更新光标的位置 */
const updateCursor = () => {
  const containDom: any = document.querySelector(
    `.${randomClass}.machine-text-contain`
  );
  const cursorDom: any = document.querySelector(`.${randomClass} .cursor`);
  const mdTextDom: any = document.querySelector(
    `.${randomClass} .v-md-editor-preview .vuepress-markdown-body`
  );
  // 使用的vuepress主题,渲染有点差异
  // console.log('mdTextDom', mdTextDom);
  const lastTextNode = getLastTextNode(mdTextDom.lastElementChild);
  const textNode = document.createTextNode('张');
  if (lastTextNode) {
    lastTextNode.parentNode.appendChild(textNode);
  } else {
    mdTextDom?.appendChild(textNode);
  }

  const range = document.createRange();
  range.setStart(textNode, 0);
  range.setEnd(textNode, 0);
  // console.log('==>', lastTextNode, range);
  const rect = range.getBoundingClientRect();
  const containRect = containDom.getBoundingClientRect();
  const x = rect.x - containRect.x;
  const y = rect.y - containRect.y;
  cursorDom.style.transform = `translate(${x}px, ${y}px)`;
  textNode.remove();
};

/** 获取节点的最后一个文本节点 */
const getLastTextNode = (node: any) => {
  if (!node) return null;
  if (node.nodeType === Node.TEXT_NODE) {
    return node;
  }
  const children: any = [];
  for (let i = 0, len = node.childNodes.length; i < len; i++) {
    if (node.childNodes[i].textContent !== '\n') {
      children.push(node.childNodes[i]);
    }
  }
  for (let i = children.length - 1; i >= 0; i--) {
    const child = children[i];
    const result: any = getLastTextNode(child);
    if (result) return result;
  }
  return null;
};

watch(
  () => props.defaultVal,
  val => {
    if (val && props.cursorShow) {
      nextTick(() => {
        updateCursor();
      });
    }
  },
  {
    deep: true
  }
);
</script>

<style lang="scss" scoped>
.machine-text-contain {
  position: relative;
  z-index: 22;

  .cursor {
    position: absolute;
    top: 2px;
    left: 4px;
    width: 3px;
    height: 16px;
    background-color: #374151;
    animation: animationCursor 0.6s ease infinite;
  }

  @keyframes animationCursor {
    0% {
      opacity: 0;
    }

    50% {
      opacity: 1;
    }

    100% {
      opacity: 0;
    }
  }
}
</style>

3、打字机效果

处理大模型返回的信息、代码如下

<template>
  <div v-for="(item, index) in converSissionList">
    <div v-if="item.role === 'user'">用户提问内容</div>
    <div v-else>
      <!-- 大模型思考内容 -->
      <MdPreview v-if="item.reasoningContent"
      :defaultVal="item.reasoningContent"
      :cursorShow="cursorShow && !item.thinkingElapsedSecs" /> 
      <!-- 大模型回答内容 -->
      <MdPreview v-if="item.content"
      :defaultVal="item.content"
      :cursorShow="cursorShow" /> 
    </div>
  </div>
</template>

<script setup lang='ts'>
import { ref } from 'vue';
import MdPreview from '@/views/components/MdPreview.vue';
interface ConverSission {
  content: string; // 问题和答案
  id?: string; // id
  role: string; // 角色
  reasoningContent?: string; // deepseek思考过程的内容
  thinkingElapsedSecs?: number; // deepseek思考过程的时长
}
  // ws数据缓冲区
const cacheConetnt = ref<any[]>([]);
// 是否去处理数据,只有等当前currentText的数据处理完毕后才取下一个
const cacheTag = ref(true);
/** 处理缓存数据cacheConetnt中最后一个的标志 */
const cacheLastEnd = ref(false);
// 是否展示打印的光标
const cursorShow = ref(false);
// 缓冲区处理中
const isCacheHandling = ref(false);
/** 问答接口是否已经停止输出内容【包含正向返回status为2,和接口异常情况】 */
const isWsStop = ref(true);
// 答案是否在加载中
const isLoading = ref(false);
// 会话列表
const converSissionList = ref<ConverSission[]>([]);
// 打字机效果处理模块---开始
/** 定时处理缓冲区内的数据,直到cacheConetnt为空且isLoading为false */
const dowithCache = () => {
  if (cacheTag.value && cacheConetnt.value.length) {
    const { text: currentText, type: currentType } = cacheConetnt.value.shift();
    if (currentText) {
      cacheTag.value = false;
      const lastMessageObj =
        converSissionList.value[converSissionList.value.length - 1]; // 问答的最后一句话
      printerEffect(lastMessageObj, currentText, currentType);
    }
  }
  if (!isWsStop.value || cacheConetnt.value.length) {
    requestAnimationFrame(dowithCache);
  } else {
    cacheLastEnd.value = true;
    if (cacheTag.value && !cacheConetnt.value.length) {
      // 当前已打印结束
      console.log('当前已打印结束');
      cursorShow.value = false;
      isCacheHandling.value = false;
      isLoading.value = false; // 打印结束,回答结束
    }
  }
};

/** 3、执行打印机效果吐字 */
const printerEffect = (
  lastText: any,
  text: string = '',
  currentType: string = 'text'
) => {
  let index = 0;
  cursorShow.value = true;
  const printText = () => {
    // if (canotGoonStatus.value || lastText.stopSession) {
    //   // 处理敏感信息或停止会话
    //   cursorShow.value = false;
    //   isCacheHandling.value = false;
    //   console.log('处理敏感信息或停止会话');
    //   return;
    // }
    if (index < text.length) {
      if (currentType === 'reasoning') {
        lastText.reasoningContent =
          lastText.reasoningContent + (text[index] || '');
      } else {
        lastText.content = lastText.content + (text[index] || '');
      }
      index++;
      requestAnimationFrame(printText);
    } else {
      cacheTag.value = true;
      // setPosition(); // 滚动到最底下
      if (cacheLastEnd.value) {
        console.log('打印结束');
        cursorShow.value = false;
        isCacheHandling.value = false;
        isLoading.value = false; // 打印结束,回答结束
      }
    }
  };
  printText();
};
// 打字机效果处理模块---结束

// 处理大模型返回的信息
// websocket 实例
let nowSorcket: any = null;
// 处理大模型返回的信息
const handleMessage = () => {
  const lastMessageObj =
    converSissionList.value[converSissionList.value.length - 1]; // 最后一次对话
  nowSorcket.onMessage = (received_msg: any) => { 
    const { header, payload } = received_msg;
    const choices = payload?.choices; // 返回的文字内容
    const text = choices?.text;
    let currentContent = '';
    let currentType = 'text';
    if (!isEmpty(text)) { 
      text.forEach((m: any) => {
          if (
            m.reasoning_content ||
            (m.hasOwnProperty('thinking_elapsed_secs') &&
              m.thinking_elapsed_secs != 0)
          ) {
            currentType = 'reasoning';
            currentContent = m.reasoning_content || '';
            lastMessageObj.thinkingElapsedSecs = m.thinking_elapsed_secs; // 大模型深度思考的内容
          } else {
            currentType = 'text';
            currentContent = m.content || ''; //大模型正文回答的内容
          }
        });
    }
    if (choices && choices.status === 2) {
        // 最后一句
        // isLoading.value = false;
        isWsStop.value = true;
        nowSorcket.closeWs();
      }
    // 将数据推入缓存区
    cacheConetnt.value.push({
        text: currentContent,
        type: currentType
      });
  }
}
</script>
posted @ 2025-09-09 16:06  webHYT  阅读(64)  评论(0)    收藏  举报