效果展示

一、下载依赖

npm i markdown-it dompurify highlight.js

二、创建 MarkdownViewer.vue

用于页面引入进行数据解析回显

注意:此文件下的 style 内的样式 是基础 Markdown 样式,自己按需完善

<!-- src/components/MarkdownViewer.vue -->
<template>
  <div class="markdown-body" v-html="safeHtml"></div>
</template>

<script setup>
import { computed } from 'vue'
import MarkdownIt from 'markdown-it'
import DOMPurify from 'dompurify'
import hljs from 'highlight.js'
import 'highlight.js/styles/github.css' // 可换其它主题

const props = defineProps({
  content: { type: String, default: '' }
})

// 创建 markdown-it 实例,开启常用选项与代码高亮
const md = new MarkdownIt({
  html: true,         // 允许内联 HTML(若不需要可设为 false)
  linkify: true,      // 自动识别链接
  breaks: true,       // 换行转 <br>
  highlight(code, lang) {
    if (lang && hljs.getLanguage(lang)) {
      try {
        return `<pre><code class="hljs language-${lang}">` +
          hljs.highlight(code, { language: lang }).value +
          '</code></pre>'
      } catch (e) {}
    }
    // 无法识别语言时,做一次通用转义
    const escaped = md.utils.escapeHtml(code)
    return `<pre><code class="hljs">${escaped}</code></pre>`
  }
})

const safeHtml = computed(() => {
  const html = md.render(props.content || '')
  // 安全处理,避免 XSS
  return DOMPurify.sanitize(html)
})
</script>

<style>
/* 可选:基础 Markdown 样式,自己按需完善 */
.markdown-body {
  line-height: 1.7;
  word-wrap: break-word;
}
.markdown-body pre {
  background: #f6f8fa;
  padding: 12px;
  overflow: auto;
  border-radius: 6px;
}
.markdown-body code {
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
}
.patient-info {
  font-size: 16px;
  margin-bottom: 20px;
}

.patient-info span {
  font-weight: bold;
}

table {
  width: 100%;
  border-collapse: collapse;
  margin-top: 20px;
}

th,
td {
  border: 1px solid #ddd;
  padding: 8px;
  text-align: left;
}

th {
  background-color: #f4f4f4;
  font-weight: bold;
}

tr:nth-child(even) {
  background-color: #f9f9f9;
}
</style>

二、引入MarkdownViewer.vue 根据需求调整相关逻辑处理

注意:此处绑定的 ref="scrollContainer" 是用于下文中页面渲染时候滚动条滚动到最底部重要参数

<div ref="scrollContainer" @scroll="handleScroll" class="record-list chat-box">
     <div v-for="(item, index) in chatData" :key="index"
        :class="item.type == 1 ? 'right-box' : 'left-box'" class=" jqr-text">
        <div v-if="item.type == 1">{{ item.text }}</div>
         <MarkdownViewer v-else :content="item.text" />
          </div>
          <div v-if="chatData.length == 0"
           style="height:70vh;display: flex;justify-content:center;align-items:center;">
      <a-empty>
         <template #image>
           <!-- <communicateEmpty /> -->
              <div style="font-size: 18px;">
                 {{ "Hi, " + userStore.getActualName }}
                        </div>
                </template>
             <template #description>
           </template>
        </a-empty>
      </div>
 </div>


<div class="input-box">
   <a-textarea :auto-size="{ minRows: 1, maxRows: 3 }" :style="{ resize: 'none' }" class="custom-scroll-textarea"
    v-model:value="inputData" placeholder="请输入内容" @keydown.enter.prevent="handleKeyDown" />
  <div class="send-sty">
   <img style="width: 26px;height: 26px;cursor: pointer" :src="sendSvg" alt="" @click="onSearch">
   </div>
</div>
import MarkdownViewer from './component/MarkdownViewer.vue'

//聊天的集合列表 type:1 发送的内容  2 回复的内容
let chatData = ref([]);
// 获取滚动容器的引用
const scrollContainer = ref(null);
//是否滚动的标识
let isUserScrolling = ref('')
//滚动到底部的方法
const scrollToBottom = () => {
  // 2. 操作前确保 scrollContainer.value 是有效的 DOM 元素
  nextTick(() => {
    if (scrollContainer.value) {
      scrollContainer.value.scrollTop = scrollContainer.value.scrollHeight;
    } else {
      console.error('未找到滚动容器元素');
    }
  })
}

// 滚动事件处理函数
const handleScroll = () => {
  if (!scrollContainer.value) return

  const container = scrollContainer.value
  const scrollTop = container.scrollTop
  const scrollHeight = container.scrollHeight
  const clientHeight = container.clientHeight

  // 计算距离底部的距离
  const distanceToBottom = scrollHeight - (scrollTop + clientHeight)

  // 如果距离底部超过一定阈值(例如 10px),则认为用户在看历史消息
  // 这个阈值可以根据实际情况调整
  if (distanceToBottom > 80) {
    isUserScrolling.value = true
    scrollContainer.value.removeEventListener('scroll', handleScroll)
  } else {
    // 用户滚动到了底部(或非常接近底部),重置状态
    isUserScrolling.value = false
  }
}

//发送的消息内容
const inputData = ref('');
//是否已经输出完标识
const makdowShow = ref(true)
//回车发送消息
function handleKeyDown(event) {
  if (event.key === 'Enter' || event.keyCode === 13) {
    onSearch()
  }
}
//点击发送消息
const onSearch = searchValue => {
  //流式接口调用需要的参数 发送到内容
  setData.query = inputData.value
  if (!inputData.value) {
    message.error('内容不能为空');
    return
  }
  if (makdowShow.value) {
    makdowShow.value = false
  } else {
    message.error('消息回复中,请稍后再试');
    return
  }

  chatData.value.push({
    type: 1,
    text: inputData.value,
  })
  inputData.value = ''

  // ai的对话
  chatData.value.push({
    text: '消息回复中...',
    type: 2,
    isShow: false
  })
  startStream()
};


//流式接口需要的参数
let setData = reactive({
  inputs: '',
  query: "",
  response_mode: "streaming",
  conversation_id: "",
  user: "abc-123",
  files: []
})

const streamData = ref(''); // 存储流式数据
let controller = null; // 用于中止请求

//dify 发送对话消息
const startStream = async () => {
  streamData.value = ''; // 清空旧数据
  controller = new AbortController(); // 创建 AbortController 实例

  try {
    // 1. 发起 Fetch 请求
    const response = await fetch('这里是你的流式接口地址', {
      method: 'POST', // 或 GET,根据接口要求
      headers: {
       //请求头参数根据实际需要配置
        'Content-Type': 'application/json',
        'Authorization': 'Bearer app-VuBKiUeORUG1ychxTIwChey3',
        'Accept': 'text/event-stream', // 根据后端要求设置
        // 其他可能的请求头,如认证信息
        // 'Authorization': 'Bearer your-token'
      },
      body: JSON.stringify(setData),
      signal: controller.signal // 关联中止信号
    });

    // 2. 检查响应是否成功
    if (!response.ok) {
      throw new Error(`网络响应错误: ${response.status}`);
    }

    // 3. 确保响应体存在
    if (!response.body) {
      throw new Error('响应体不存在');
    }
    // console.log(response,'response');
    // 4. 获取可读流的读取器
    const reader = response.body.getReader();
    const decoder = new TextDecoder('utf-8'); // 用于解码二进制数据

    // 5. 循环读取数据流
    try {
      while (true) {
        const { done, value } = await reader.read();

        if (done) {
          //向消息列表中插入数据
          /*          chatData.value.push({
                      type:2,
                      text:streamData.value,
                    })*/
          makdowShow.value = true
          console.log('流式读取完成');
          break;
        }

        // 6. 解码并处理数据块(此处假设为文本)
        const chunk = decoder.decode(value, { stream: true });

        if (chunk.startsWith('data: ')) {
          const jsonString = chunk.substring(6); // 移除 "data: " 前缀
          try {
            const dataMsg = JSON.parse(jsonString);
            // console.log(jsonString,'jsonString');
            //数据的拼接
            if (dataMsg.answer) streamData.value += dataMsg.answer;
          } catch (e) {
            console.error('解析 JSON 失败:', e);
          }
        }
        // console.log(chunk,'chunk');
        // 7. 更新响应式数据,实时展示
        // streamData.value += chunk;

        // console.log(streamData.value,'数据');
        // 如果后端返回的是 NDJSON (每行一个 JSON) 或特定格式,需额外解析
        // 例如:const lines = chunk.split('\n');
        // for (const line of lines) { if (line) { const data = JSON.parse(line); } }
      }
    } finally {
      reader.releaseLock(); // 释放读取器锁
    }
  } catch (error) {
    // 8. 错误处理
    if (error.name === 'AbortError') {
      console.log('请求已被中止');
    } else {
      console.error('流式请求发生错误:', error);
      // 可以设置一个错误状态变量显示给用户
    }
  } finally {
    makdowShow.value = true;
  }
}


//监听ai回复的数据处理
watch(streamData, (newValue, oldValue) => {

  nextTick(() => {
    if (!newValue) {
      chatData.value[chatData.value.length - 1].text = '消息回复中...'
    } else {
      if (!isUserScrolling.value && cardItems.value == 9) {
        scrollToBottom()
      }
      chatData.value[chatData.value.length - 1].text = newValue
    }
  });
})


// 中止请求的函数
const stopStream = () => {
  if (controller) {
    controller.abort();
    controller = null;
  }
};


onUnmounted(() => {
  stopStream()
});
.record-list {
  max-height: 74vh;
  width: 100%;
  overflow-y: auto;

}

.record-list::-webkit-scrollbar {
  display: none;
}

  .left-box {
    padding: 10px 10px 10px 20px;
    border-radius: 0px 16px 16px 16px;
    color: #333333;
    align-self: flex-start;
    /* 对齐到左边 */
    background-color: #FFFFFF;
    /* 背景颜色 */
  }

  .right-box {
    padding: 10px;
    border-radius: 16px 0px 16px 16px;
    color: #FFFFFF;
    align-self: flex-end;
    /* 对齐到右边 */
    background-color: #20C290;
    /* 背景颜色 */
  }
 
.jqr-text {
  max-width: 95%;

  font-size: 16px;
  //letter-spacing: 4rpx;
  margin-bottom: 10px;
  font-weight: normal;
}

.input-box{
    padding: 10px 12px 0 12px;
    width: 100%;
    position: absolute;
    bottom: 15px;
    left: 0;
    background: #F5F7F9;
  }

  .send-sty{
    width: 100%;
    padding: 0 5px 5px 0;
    background: #ffffff;
    text-align: right;
    border-radius: 0 0 8px 8px;
  }

  .custom-scroll-textarea{
    max-height: 96px;
    padding-right: 0;
    border: none;
    border-radius: 0;
    box-shadow: none !important;
    border-radius: 8px 8px 0 0;
  }

.custom-scroll-textarea::-webkit-scrollbar {
 display:  none;
}

Logo

魔乐社区(Modelers.cn) 是一个中立、公益的人工智能社区,提供人工智能工具、模型、数据的托管、展示与应用协同服务,为人工智能开发及爱好者搭建开放的学习交流平台。社区通过理事会方式运作,由全产业链共同建设、共同运营、共同享有,推动国产AI生态繁荣发展。

更多推荐