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>