【优化】采用 marked 重写 md 渲染,增加 code 复制功能
This commit is contained in:
parent
ffc134d26c
commit
6c1298f7ef
@ -29,7 +29,6 @@
|
||||
"@form-create/designer": "^3.1.3",
|
||||
"@form-create/element-ui": "^3.1.24",
|
||||
"@iconify/iconify": "^3.1.1",
|
||||
"@iktakahiro/markdown-it-katex": "^4.0.1",
|
||||
"@microsoft/fetch-event-source": "^2.0.1",
|
||||
"@videojs-player/vue": "^1.0.0",
|
||||
"@vueuse/core": "^10.9.0",
|
||||
@ -53,7 +52,7 @@
|
||||
"highlight.js": "^11.9.0",
|
||||
"jsencrypt": "^3.3.2",
|
||||
"lodash-es": "^4.17.21",
|
||||
"markdown-it": "^14.1.0",
|
||||
"marked": "^12.0.2",
|
||||
"min-dash": "^4.1.1",
|
||||
"mitt": "^3.0.1",
|
||||
"nprogress": "^0.2.0",
|
||||
|
@ -1,22 +0,0 @@
|
||||
export const copyText = (content: string) => {//复制
|
||||
// content = content.replace(/^\s/,'')
|
||||
navigator.clipboard.writeText(content).then(function () {
|
||||
ElMessage({
|
||||
message: '复制成功!',
|
||||
type: 'success',
|
||||
})
|
||||
}).catch(function () {
|
||||
(function (content) {
|
||||
document.oncopy = function (e) {
|
||||
e.clipboardData?.setData('text', content);
|
||||
e.preventDefault();
|
||||
document.oncopy = null;
|
||||
ElMessage({
|
||||
message: '复制成功!',
|
||||
type: 'success',
|
||||
})
|
||||
};
|
||||
})(content);
|
||||
document.execCommand('copy');
|
||||
});
|
||||
};
|
@ -1,225 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
defineOptions({ name: "md-preview" });
|
||||
import { copyText } from './copy';
|
||||
import { onMounted, ref, watch, watchEffect, type Ref } from 'vue';
|
||||
import 'highlight.js/styles/vs2015.min.css';
|
||||
import md from "./md";
|
||||
const markdown:Ref<any> = ref(null);
|
||||
const sleep = (during:number) => {
|
||||
return new Promise(function(rs,rj){setTimeout(rs,during);})
|
||||
};
|
||||
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
content: string; // md内容
|
||||
delay: boolean; // 延迟渲染
|
||||
}>(), {
|
||||
content: "",
|
||||
delay: true,
|
||||
});
|
||||
|
||||
const runing = ref(false);
|
||||
const mdDelay = ref('');//延迟渲染的md内容
|
||||
const mdContent = ref('');//延迟渲染的md html
|
||||
const WORDS = 1;//打印字数
|
||||
const interval = ref(Math.floor(1000 / 60));//最小间隔时长
|
||||
const preTime = ref(0);
|
||||
|
||||
const render = async () => {
|
||||
if (props.content.length - mdDelay.value.length <= WORDS) {
|
||||
runing.value = false;
|
||||
mdDelay.value = props.content;
|
||||
mdContent.value = md.render(props.content);
|
||||
} else {
|
||||
runing.value = true;
|
||||
mdDelay.value = props.content.substring(0, mdDelay.value.length + WORDS);
|
||||
mdContent.value = md.render(mdDelay.value);
|
||||
await sleep(interval.value);
|
||||
await render();
|
||||
}
|
||||
mdContent.value = md.render(props.content);
|
||||
};
|
||||
|
||||
watchEffect(() => {
|
||||
if (props.delay) {
|
||||
if (!runing.value) render();
|
||||
} else {
|
||||
// if (runing.value) return;
|
||||
mdDelay.value = props.content;
|
||||
mdContent.value = md.render(props.content);
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => props.content, (newVal, oldVal) => {
|
||||
const now = Date.now();
|
||||
if (preTime.value) {
|
||||
interval.value = Math.floor((now - preTime.value) / (newVal.length - oldVal.length));
|
||||
// console.log('间隔:', Math.floor((now - preTime.value)), 'ms', ' 每字间隔:', interval.value, 'ms', ' 变化字符:', newVal.replace(oldVal, ''));
|
||||
}
|
||||
preTime.value = now;
|
||||
});
|
||||
function addMarkdownEvent() {
|
||||
markdown.value.addEventListener('click', (e:any) => {
|
||||
if (e.target.id === 'copy') {
|
||||
copyText(e.target?.dataset?.copy);
|
||||
}
|
||||
})
|
||||
}
|
||||
onMounted(()=> {
|
||||
addMarkdownEvent();
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-html="mdContent" ref="markdown" class="md-preview"></div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.md-preview {
|
||||
font-family: PingFang SC;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.6rem;
|
||||
letter-spacing: 0em;
|
||||
text-align: left;
|
||||
color: #3B3E55;
|
||||
max-width: 100%;
|
||||
|
||||
pre {
|
||||
position: relative;
|
||||
}
|
||||
pre code.hljs {
|
||||
width: auto;
|
||||
}
|
||||
code.hljs {
|
||||
border-radius: 6px;
|
||||
padding-top: 20px;
|
||||
width: auto;
|
||||
@media screen and (min-width:1536px) {
|
||||
width: 960px;
|
||||
}
|
||||
|
||||
@media screen and (max-width:1536px) and (min-width:1024px) {
|
||||
width: calc(100vw - 400px - 64px - 32px * 2);
|
||||
}
|
||||
|
||||
@media screen and (max-width:1024px) and (min-width:768px) {
|
||||
width: calc(100vw - 32px * 2);
|
||||
}
|
||||
|
||||
@media screen and (max-width:768px) {
|
||||
width: calc(100vw - 16px * 2);
|
||||
}
|
||||
}
|
||||
|
||||
p,
|
||||
code.hljs {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 1rem !important;
|
||||
}
|
||||
|
||||
/* 标题通用格式 */
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
color: var(--color-G900);
|
||||
margin: 24px 0 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 22px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 16px;
|
||||
line-height: 26px;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
/* 列表(有序,无序) */
|
||||
ul,
|
||||
ol {
|
||||
margin: 0 0 8px 0;
|
||||
padding: 0;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
color: #3b3e55; // var(--color-CG600);
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 4px 0 0 20px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
ol>li {
|
||||
list-style-type: decimal;
|
||||
margin-bottom: 1rem;
|
||||
// 表达式,修复有序列表序号展示不全的问题
|
||||
// &:nth-child(n + 10) {
|
||||
// margin-left: 30px;
|
||||
// }
|
||||
|
||||
// &:nth-child(n + 100) {
|
||||
// margin-left: 30px;
|
||||
// }
|
||||
}
|
||||
|
||||
ul>li {
|
||||
list-style-type: disc;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
margin-right: 11px;
|
||||
margin-bottom: 1rem;
|
||||
color: #3b3e55; // var(--color-G900);
|
||||
}
|
||||
|
||||
ol ul,
|
||||
ol ul>li,
|
||||
ul ul,
|
||||
ul ul li {
|
||||
// list-style: circle;
|
||||
font-size: 16px;
|
||||
list-style: none;
|
||||
margin-left: 6px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
ul ul ul,
|
||||
ul ul ul li,
|
||||
ol ol,
|
||||
ol ol>li,
|
||||
ol ul ul,
|
||||
ol ul ul>li,
|
||||
ul ol,
|
||||
ul ol>li {
|
||||
list-style: square;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -1,30 +0,0 @@
|
||||
|
||||
// @ts-ignore
|
||||
import markdownit from 'markdown-it';
|
||||
import hljs from 'highlight.js'; // https://highlightjs.org
|
||||
import katexPlugin from '@iktakahiro/markdown-it-katex';
|
||||
const codeTool = (text: string) => `<svg id="copy" class="icon" aria-hidden="true"
|
||||
style="font-size:16px;display: inline-block;color:#fff;position:absolute;right:8px;top:6px;cursor:pointer;"
|
||||
data-copy="${text}">
|
||||
<use xlink:href="#gt-line-copy"></use>
|
||||
</svg>`;
|
||||
|
||||
const md = markdownit({
|
||||
html: true,
|
||||
linkfy: true,
|
||||
highlight: function (str: string, lang: string) {
|
||||
const baseText = str
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
try {
|
||||
return '<pre><code class="hljs">' +
|
||||
hljs.highlight(str, { language: lang, ignoreIllegals: true }).value +
|
||||
'</code>' + codeTool(baseText) + '</pre>';
|
||||
} catch (__) { }
|
||||
}
|
||||
return '<pre><code class="hljs">' + md.utils.escapeHtml(str) + '</code>' + codeTool(baseText) + '</pre>';
|
||||
}
|
||||
});
|
||||
|
||||
md.use(katexPlugin);
|
||||
|
||||
export default md;
|
@ -88,18 +88,18 @@
|
||||
<div class="chat-list" v-for="(item, index) in list" :key="index">
|
||||
<!-- 靠左 message -->
|
||||
<div class="left-message message-item" v-if="item.type === 'system'">
|
||||
<div class="avatar" >
|
||||
<div class="avatar">
|
||||
<el-avatar
|
||||
src="https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png"
|
||||
/>
|
||||
</div>
|
||||
<div class="message">
|
||||
<div>
|
||||
<el-text class="time">{{formatDate(item.createTime)}}</el-text>
|
||||
<el-text class="time">{{ formatDate(item.createTime) }}</el-text>
|
||||
</div>
|
||||
<div class="left-text-container">
|
||||
<!-- <div class="left-text md-preview" v-html="item.content"></div>-->
|
||||
<mdPreview :content="item.content" :delay="false" />
|
||||
<div class="left-text-container" ref="markdownViewRef">
|
||||
<div class="left-text markdown-view" v-html="item.content"></div>
|
||||
<!-- <mdPreview :content="item.content" :delay="false" />-->
|
||||
</div>
|
||||
<div class="left-btns">
|
||||
<div class="btn-cus" @click="noCopy(item.content)">
|
||||
@ -122,13 +122,13 @@
|
||||
</div>
|
||||
<div class="message">
|
||||
<div>
|
||||
<el-text class="time">{{formatDate(item.createTime)}}</el-text>
|
||||
<el-text class="time">{{ formatDate(item.createTime) }}</el-text>
|
||||
</div>
|
||||
<div class="right-text-container">
|
||||
<div class="right-text">{{item.content}}</div>
|
||||
<div class="right-text">{{ item.content }}</div>
|
||||
</div>
|
||||
<div class="right-btns">
|
||||
<div class="btn-cus" @click="noCopy(item.content)">
|
||||
<div class="btn-cus" @click="noCopy(item.content)">
|
||||
<img class="btn-image" src="@/assets/ai/copy.svg"/>
|
||||
<el-text class="btn-cus-text">复制</el-text>
|
||||
</div>
|
||||
@ -145,13 +145,16 @@
|
||||
</el-main>
|
||||
<el-footer class="footer-container">
|
||||
<form @submit.prevent="onSend" class="prompt-from">
|
||||
<textarea class="prompt-input" v-model="prompt" @keyup.enter="onSend" placeholder="问我任何问题...(Shift+Enter 换行,按下 Enter 发送)"></textarea>
|
||||
<textarea class="prompt-input" v-model="prompt" @keyup.enter="onSend"
|
||||
placeholder="问我任何问题...(Shift+Enter 换行,按下 Enter 发送)"></textarea>
|
||||
<div class="prompt-btns">
|
||||
<el-switch/>
|
||||
<el-button type="primary" size="default" @click="onSend()" :loading="conversationInProgress" v-if="conversationInProgress == false">
|
||||
{{ conversationInProgress ? '进行中' : '发送'}}
|
||||
<el-button type="primary" size="default" @click="onSend()"
|
||||
:loading="conversationInProgress" v-if="conversationInProgress == false">
|
||||
{{ conversationInProgress ? '进行中' : '发送' }}
|
||||
</el-button>
|
||||
<el-button type="danger" size="default" @click="stopStream()" v-if="conversationInProgress == true">
|
||||
<el-button type="danger" size="default" @click="stopStream()"
|
||||
v-if="conversationInProgress == true">
|
||||
停止
|
||||
</el-button>
|
||||
</div>
|
||||
@ -164,7 +167,25 @@
|
||||
<script setup lang="ts">
|
||||
import {ChatMessageApi, ChatMessageSendVO, ChatMessageVO} from "@/api/ai/chat/message"
|
||||
import {formatDate} from "@/utils/formatTime"
|
||||
import {useClipboard} from '@vueuse/core'
|
||||
import {useClipboard} from "@vueuse/core";
|
||||
// 转换 markdown
|
||||
import {marked} from 'marked';
|
||||
// 代码高亮 https://highlightjs.org/
|
||||
import 'highlight.js/styles/vs2015.min.css';
|
||||
import hljs from 'highlight.js';
|
||||
|
||||
|
||||
// 自定义渲染器
|
||||
const renderer = {
|
||||
code(code, language, c) {
|
||||
const highlightHtml = hljs.highlight(code, {language: language, ignoreIllegals: true}).value
|
||||
const copyHtml = `<div id="copy" data-copy='${code}' style="position: absolute; right: 10px; top: 5px; color: #fff;cursor: pointer;">复制</div>`
|
||||
return `<pre>${copyHtml}<code class="hljs">${highlightHtml}</code></pre>`
|
||||
},
|
||||
};
|
||||
marked.use({
|
||||
renderer: renderer,
|
||||
})
|
||||
|
||||
const conversationList = [
|
||||
{
|
||||
@ -181,11 +202,12 @@ const conversationList = [
|
||||
}
|
||||
]
|
||||
// 初始化 copy 到粘贴板
|
||||
const { copy } = useClipboard();
|
||||
const {copy} = useClipboard();
|
||||
|
||||
const searchName = ref('') // 查询的内容
|
||||
const conversationId = ref('1781604279872581648') // 对话id
|
||||
const conversationInProgress = ref<false>() // 对话进行中
|
||||
const conversationInProgress = ref<Boolean>() // 对话进行中
|
||||
conversationInProgress.value = false
|
||||
const conversationInAbortController = ref<any>() // 对话进行中 abort 控制器(控制 stream 对话)
|
||||
|
||||
const prompt = ref<string>() // prompt
|
||||
@ -249,7 +271,8 @@ const doSendStream = async (userMessage: ChatMessageVO) => {
|
||||
try {
|
||||
// 发送 event stream
|
||||
let isFirstMessage = true
|
||||
ChatMessageApi.sendStream(userMessage.id, conversationInAbortController.value,(message) => {
|
||||
let content = ''
|
||||
ChatMessageApi.sendStream(userMessage.id, conversationInAbortController.value, (message) => {
|
||||
console.log('message', message)
|
||||
const data = JSON.parse(message.data) as unknown as ChatMessageVO
|
||||
// 如果没有内容结束链接
|
||||
@ -264,10 +287,9 @@ const doSendStream = async (userMessage: ChatMessageVO) => {
|
||||
isFirstMessage = false;
|
||||
list.value.push(data)
|
||||
} else {
|
||||
content = content + data.content
|
||||
const lastMessage = list.value[list.value.length - 1];
|
||||
lastMessage.content = lastMessage.content + data.content
|
||||
// markdown
|
||||
// lastMessage.content = marked(lastMessage.content)
|
||||
lastMessage.content = marked(content) as unknown as string
|
||||
list.value[list.value - 1] = lastMessage
|
||||
}
|
||||
// 滚动到最下面
|
||||
@ -301,7 +323,9 @@ const messageList = async () => {
|
||||
// marked(this.markdownText)
|
||||
res.map(item => {
|
||||
// item.content = marked(item.content)
|
||||
// item.content = md.render(item.content)
|
||||
if (item.type !== 'user') {
|
||||
item.content = marked(item.content)
|
||||
}
|
||||
})
|
||||
|
||||
list.value = res;
|
||||
@ -376,7 +400,17 @@ onMounted(async () => {
|
||||
// await nextTick
|
||||
// 监听滚动事件,判断用户滚动状态
|
||||
messageContainer.value.addEventListener('scroll', handleScroll)
|
||||
//
|
||||
// 添加 copy 监听
|
||||
messageContainer.value.addEventListener('click', (e: any) => {
|
||||
console.log(e)
|
||||
if (e.target.id === 'copy') {
|
||||
copy(e.target?.dataset?.copy)
|
||||
ElMessage({
|
||||
message: '复制成功!',
|
||||
type: 'success',
|
||||
})
|
||||
}
|
||||
})
|
||||
// marked.use({
|
||||
// async: false,
|
||||
// pedantic: false,
|
||||
@ -682,3 +716,155 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.markdown-view {
|
||||
font-family: PingFang SC;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.6rem;
|
||||
letter-spacing: 0em;
|
||||
text-align: left;
|
||||
color: #3B3E55;
|
||||
max-width: 100%;
|
||||
|
||||
pre {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
pre code.hljs {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
code.hljs {
|
||||
border-radius: 6px;
|
||||
padding-top: 20px;
|
||||
width: auto;
|
||||
@media screen and (min-width: 1536px) {
|
||||
width: 960px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1536px) and (min-width: 1024px) {
|
||||
width: calc(100vw - 400px - 64px - 32px * 2);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1024px) and (min-width: 768px) {
|
||||
width: calc(100vw - 32px * 2);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
width: calc(100vw - 16px * 2);
|
||||
}
|
||||
}
|
||||
|
||||
p,
|
||||
code.hljs {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 1rem !important;
|
||||
}
|
||||
|
||||
/* 标题通用格式 */
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
color: var(--color-G900);
|
||||
margin: 24px 0 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 22px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 16px;
|
||||
line-height: 26px;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
/* 列表(有序,无序) */
|
||||
ul,
|
||||
ol {
|
||||
margin: 0 0 8px 0;
|
||||
padding: 0;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
color: #3b3e55; // var(--color-CG600);
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 4px 0 0 20px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
ol > li {
|
||||
list-style-type: decimal;
|
||||
margin-bottom: 1rem;
|
||||
// 表达式,修复有序列表序号展示不全的问题
|
||||
// &:nth-child(n + 10) {
|
||||
// margin-left: 30px;
|
||||
// }
|
||||
|
||||
// &:nth-child(n + 100) {
|
||||
// margin-left: 30px;
|
||||
// }
|
||||
}
|
||||
|
||||
ul > li {
|
||||
list-style-type: disc;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
margin-right: 11px;
|
||||
margin-bottom: 1rem;
|
||||
color: #3b3e55; // var(--color-G900);
|
||||
}
|
||||
|
||||
ol ul,
|
||||
ol ul > li,
|
||||
ul ul,
|
||||
ul ul li {
|
||||
// list-style: circle;
|
||||
font-size: 16px;
|
||||
list-style: none;
|
||||
margin-left: 6px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
ul ul ul,
|
||||
ul ul ul li,
|
||||
ol ol,
|
||||
ol ol > li,
|
||||
ol ul ul,
|
||||
ol ul ul > li,
|
||||
ul ol,
|
||||
ul ol > li {
|
||||
list-style: square;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
Loading…
Reference in New Issue
Block a user