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>

浙公网安备 33010602011771号