模拟Ai缓慢内容逐字输出打字功能效果

博客介绍模拟AI缓慢内容逐字输出打字功能效果,涉及markdown数据、Json文件,给出两种写法。还提到用EventSource实现SSE流式输出,使用@microsoft/fetch-event-source插件发送SSE请求达成此功能。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

markdown数据

类似于ai回复效果 内容一字一字缓慢加载效果
结合marked插件分析类似markdowm数据
数据结果如下:

在这里插入图片描述

npm i marked --save

Json文件

{
    "code": 1,
    "message": "",
    "success": true,
    "data": "### 数据分析\n\n1. **通话时长分布概览**:\n   - **0-5分钟**:9次\n   - **5-10分钟**:6次\n   - **10-15分钟**:6次\n   - **15-20分钟**:8次\n   - **20-25分钟**:5次\n   - **25-30分钟**:1次\n   - **30-35分钟**:4次\n   - **40-45分钟**:1次\n   - **45-50分钟**:1次\n\n2. **主要发现**:\n   - **短时长通话(0-5分钟)**:占比最高,达到9次,占总通话量的约25.7%。这表明大部分通话是简短的,可能是快速查询或简单问题解决。\n   - **中等时长通话(5-20分钟)**:占比也较高,合计20次,占总通话量的约57.1%。这些通话可能是较为复杂的问题解决或详细咨询。\n   - **长时长通话(20分钟以上)**:占比相对较低,合计12次,占总通话量的约34.3%。这些通话可能是复杂问题或需要深入讨论的情况。\n\n3. **异常值**:\n   - **40-45分钟**和**45-50分钟**:各1次,可能是特殊情况或异常通话。\n\n### 建议\n\n1. **优化短时长通话服务**:\n   - **自助服务**:增加自助服务选项,如IVR(交互式语音应答)系统,帮助客户快速解决简单问题,减少人工通话量。\n   - **知识库**:建立完善的知识库,让客户可以通过网站或APP自助查询常见问题。\n\n2. **提升中等时长通话效率**:\n   - **培训**:加强客服人员的培训,提高问题解决效率,减少通话时长。\n   - **工具支持**:提供更好的工具支持,如CRM系统,帮助客服快速获取客户信息和历史记录,提高服务效率。\n\n3. **处理长时长通话**:\n   - **问题分类**:对长时长通话进行详细分类,找出常见问题,优化解决方案。\n   - **专家支持**:对于复杂问题,提供专家支持或转接服务,确保问题得到有效解决。\n\n4. **监控异常通话**:\n   - **异常检测**:建立异常通话检测机制,及时发现和处理异常通话,防止资源浪费。\n   - **回访机制**:对异常通话进行回访,了解具体情况,优化服务流程。\n\n### 总结\n\n通过对通话时长分布的分析,可以看出大部分通话集中在短时长和中等时长区间。建议通过优化自助服务、加强培训和工具支持,提升服务效率,减少通话时长。同时,关注和处理长时长通话和异常通话,确保服务质量。"
}

请添加图片描述

写法1

<template>
  <div>
    <!-- 用于打字效果和最终显示带格式的 Markdown 内容的容器 -->
    <div 
      ref="typingContainer" 
      class="typing-container"
      :class="{ 'result-streaming': isStreaming }"
    ></div>
  </div>
</template>

<script lang='ts'>
import { defineComponent, ref, onMounted, watch, nextTick } from 'vue';
import { marked } from 'marked';
import model from '../db.json';

export default defineComponent({
  name: 'MarkdownDisplay',
  setup() {
    const typingContainer = ref<HTMLElement | null>(null);
    const markdownContent = ref(model.data || '');
    const isStreaming = ref(false);

    // 渲染Markdown到HTML
    const renderMarkdown = () => {
      return marked(markdownContent.value, { sanitize: true });
    };

    // 打字效果实现
    const typeText = async (container: HTMLElement, node: Node, delay: number = 50) => {
      if (node.nodeType === Node.TEXT_NODE) {
        const text = (node as Text).data;
        for (let char of text) {
          await new Promise(resolve => setTimeout(resolve, delay));
          container.appendChild(document.createTextNode(char));
        }
      } else if (node.nodeType === Node.ELEMENT_NODE) {
        const element = node.cloneNode(false) as HTMLElement;
        container.appendChild(element);
        
        // 处理子节点
        for (const childNode of Array.from((node as HTMLElement).childNodes)) {
          await typeText(element, childNode, delay);
        }
      }
    };

    // 更新内容并触发打字效果
    const updateMarkdownContent = async () => {
      if (!typingContainer.value) return;

      isStreaming.value = true;
      typingContainer.value.innerHTML = '';

      const tempDiv = document.createElement('div');
      tempDiv.innerHTML = renderMarkdown();

      try {
        for (const node of Array.from(tempDiv.childNodes)) {
          await typeText(typingContainer.value, node);
        }
      } finally {
        isStreaming.value = false;
      }
    };

    // 生命周期和监听
    onMounted(async () => {
      await nextTick();
      await updateMarkdownContent();
    });

    watch(markdownContent, async () => {
      await updateMarkdownContent();
    });

    return {
      typingContainer,
      isStreaming
    };
  },
});
</script>

<style scoped>
/* 容器基础样式 */
.typing-container {
  width: 800px;
  height: 500px;
  overflow-y: auto;
  font-family: 'Consolas', monospace;
  line-height: 1.6;
  padding: 20px;
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  background: #f8f9fa;
}

/* 光标闪烁动画 */
@keyframes blink-caret {
  from, to { opacity: 0; }
  50% { opacity: 1; }
}

/* 流式输出时的光标效果 */
.typing-container.result-streaming::after {
  content: "▋";
  display: inline-block;
  margin-left: 2px;
  animation: blink-caret 1s step-end infinite;
  vertical-align: baseline;
}

/* 优化Markdown元素间距 */
:deep(.typing-container) p {
  margin: 0.8em 0;
}

:deep(.typing-container) h1,
:deep(.typing-container) h2,
:deep(.typing-container) h3 {
  margin: 1.2em 0 0.8em;
}

:deep(.typing-container) ul,
:deep(.typing-container) ol {
  padding-left: 1.5em;
  margin: 0.8em 0;
}

:deep(.typing-container) li {
  margin: 0.4em 0;
}

/* 代码块样式 */
:deep(.typing-container) pre {
  background: #f3f4f6;
  padding: 1em;
  border-radius: 4px;
  overflow-x: auto;
  margin: 1em 0;
}

:deep(.typing-container) code {
  font-family: 'Consolas', monospace;
  font-size: 0.9em;
}
</style>

写法2

请添加图片描述

<template>
  <div class="sse-container">
    <div ref="contentBox" class="markdown-content">
      <div v-if="loading" class="loading">加载中...</div>
      <div v-if="error" class="error">加载失败: {{ error }}</div>
      <div v-html="processedContent" :class="{ streaming }"></div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed,nextTick} from 'vue'
import { marked } from 'marked'

// 配置Markdown解析
marked.setOptions({
  breaks: true,    // 将\n转换为<br>
  gfm: true        // 支持GitHub风格Markdown
})

// 响应式数据
const contentBox = ref<HTMLElement>()
const rawContent = ref('')
const loading = ref(true)
const error = ref('')
const streaming = ref(true)

// 处理后的Markdown内容
const processedContent = computed(() => {
  return marked(rawContent.value)
})

// 完整模拟数据(保留原有数据)
const mockData = { data: `### 数据分析\n\n1. **通话时长分布概览**:\n   - **0-5分钟**:9次\n   - **5-10分钟**:6次\n   - **10-15分钟**:6次\n   - **15-20分钟**:8次\n   - **20-25分钟**:5次\n   - **25-30分钟**:1次\n   - **30-35分钟**:4次\n   - **40-45分钟**:1次\n   - **45-50分钟**:1次\n\n2. **主要发现**:\n   - **短时长通话(0-5分钟)**:占比最高,达到9次,占总通话量的约25.7%。这表明大部分通话是简短的,可能是快速查询或简单问题解决。\n   - **中等时长通话(5-20分钟)**:占比也较高,合计20次,占总通话量的约57.1%。这些通话可能是较为复杂的问题解决或详细咨询。\n   - **长时长通话(20分钟以上)**:占比相对较低,合计12次,占总通话量的约34.3%。这些通话可能是复杂问题或需要深入讨论的情况。\n\n3. **异常值**:\n   - **40-45分钟**和**45-50分钟**:各1次,可能是特殊情况或异常通话。\n\n### 建议\n\n1. **优化短时长通话服务**:\n   - **自助服务**:增加自助服务选项,如IVR(交互式语音应答)系统,帮助客户快速解决简单问题,减少人工通话量。\n   - **知识库**:建立完善的知识库,让客户可以通过网站或APP自助查询常见问题。\n\n2. **提升中等时长通话效率**:\n   - **培训**:加强客服人员的培训,提高问题解决效率,减少通话时长。\n   - **工具支持**:提供更好的工具支持,如CRM系统,帮助客服快速获取客户信息和历史记录,提高服务效率。\n\n3. **处理长时长通话**:\n   - **问题分类**:对长时长通话进行详细分类,找出常见问题,优化解决方案。\n   - **专家支持**:对于复杂问题,提供专家支持或转接服务,确保问题得到有效解决。\n\n4. **监控异常通话**:\n   - **异常检测**:建立异常通话检测机制,及时发现和处理异常通话,防止资源浪费。\n   - **回访机制**:对异常通话进行回访,了解具体情况,优化服务流程。\n\n### 总结\n\n通过对通话时长分布的分析,可以看出大部分通话集中在短时长和中等时长区间。建议通过优化自助服务、加强培训和工具支持,提升服务效率,减少通话时长。同时,关注和处理长时长通话和异常通话,确保服务质量。` } // 保持原数据不变

// 拆分数据为字符块(优化换行处理)
const chunkData = (data: string) => {
  // 按行拆分后再按字符拆分
  return data.split('\n').flatMap(line => {
    const chunks = []
    for (let i = 0; i < line.length; i += 5) {
      chunks.push(line.slice(i, i + 5))
    }
    chunks.push('\n') // 保留换行符
    return chunks
  })
}

onMounted(async () => {
  const chunks = chunkData(mockData.data)
  let currentChunk = 0
  
  // 优化后的流式处理
  const simulateStream = async () => {
    try {
      loading.value = false
      streaming.value = true
      
      while (currentChunk < chunks.length) {
        await new Promise(resolve => setTimeout(resolve, 50))
        
        // 添加内容并保留换行符
        rawContent.value += chunks[currentChunk]
        currentChunk++
        
        // 立即触发解析
        forceRender()
        autoScroll()
      }
      
      streaming.value = false
    } catch (err) {
      error.value = (err as Error).message
      loading.value = false
    }
  }

  simulateStream()
})

// 强制渲染处理
let renderTimer: number
const forceRender = () => {
  clearTimeout(renderTimer)
  renderTimer = setTimeout(() => {
    // 通过重新赋值触发计算属性更新
    const temp = rawContent.value
    rawContent.value = ''
    rawContent.value = temp
  }, 50)
}

// 优化自动滚动
const autoScroll = () => {
  nextTick(() => {
    if (contentBox.value) {
      const isScrolledToBottom = 
        contentBox.value.scrollHeight - contentBox.value.clientHeight <=
        contentBox.value.scrollTop + 50
      
      if (isScrolledToBottom) {
        contentBox.value.scrollTop = contentBox.value.scrollHeight
      }
    }
  })
}

// 清理
onUnmounted(() => {
  clearTimeout(renderTimer)
})
</script>

<style scoped>
/* 新增样式优化 */
.markdown-content ::v-deep(p) {
  margin: 0.8em 0;
  line-height: 1.7;
}

.markdown-content ::v-deep(br) {
  content: "";
  display: block;
  margin-bottom: 0.5em;
}

.markdown-content ::v-deep(ul) {
  margin-left: 1.2em;
}

.markdown-content ::v-deep(li) {
  margin: 0.5em 0;
  position: relative;
}

/* 优化光标动画 */
.streaming::after {
  animation: blink 1s step-end infinite, pulse 1.5s ease-in-out infinite;
}

@keyframes pulse {
  0%, 100% { transform: scale(1); }
  50% { transform: scale(1.1); }
}

</style>

EventSource实现SSE流式输出

请添加图片描述

数据格式为如下

[
  '# 实时 Markdown 流式演示\n\n',
  '**加粗文本** 和 _斜体文本_\n\n',
  '```javascript\nconsole.log("代码块");\n```\n\n',
  '列表示例:\n',
  '- 第一项\n',
  '- 第二项\n',
  '- 第三项\n\n',
  '[链接示例](https://example.com)',
  
]
<template>
  <div class="stream-container">
    <div ref="streamContent" class="markdown-content" :class="{ streaming }"></div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { marked } from 'marked'

// 模拟 SSE 数据流
const mockStreamData = [
  '# 实时 Markdown 流式演示\n\n',
  '**加粗文本** 和 _斜体文本_\n\n',
  '```javascript\nconsole.log("代码块");\n```\n\n',
  '列表示例:\n',
  '- 第一项\n',
  '- 第二项\n',
  '- 第三项\n\n',
  '[链接示例](https://example.com)',
]

// 组件逻辑
const streamContent = ref<HTMLElement>()
const streaming = ref(true)
let currentIndex = 0

// 创建模拟的 EventSource
const createMockStream = () => {
  return {
    onmessage: (callback: (event: MessageEvent) => void) => {
      const sendChunk = () => {
        if (currentIndex < mockStreamData.length) {
          const event = new MessageEvent('message', {
            data: mockStreamData[currentIndex]
          })
          callback(event)
          currentIndex++
          setTimeout(sendChunk, 500)//可以改变输出的速度
        } else {
          streaming.value = false
        }
      }
      sendChunk()
    },
    close: () => {
      currentIndex = mockStreamData.length
    }
  }
}

// 渲染流式内容
const renderStream = async (chunk: string) => {
  if (!streamContent.value) return

  const cursor = document.createElement('span')
  cursor.className = 'typing-cursor'
  
  // 保留当前滚动位置
  const shouldScroll = 
    streamContent.value.scrollTop + streamContent.value.clientHeight >= 
    streamContent.value.scrollHeight - 50

  // 创建临时容器
  const tempDiv = document.createElement('div')
  tempDiv.innerHTML = await marked.parse(chunk)
  
  // 逐字输出
  for (const node of tempDiv.childNodes) {
    await typeEffect(node as HTMLElement)
  }

  // 保持滚动位置
  if (shouldScroll) {
    streamContent.value.scrollTop = streamContent.value.scrollHeight
  }
}

// 逐字输出效果
const typeEffect = async (element: HTMLElement) => {
  if (!streamContent.value) return

  const walker = document.createTreeWalker(
    element,
    NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT
  )

  while (walker.nextNode()) {
    const node = walker.currentNode
    if (node.nodeType === Node.TEXT_NODE) {
      const text = node.textContent || ''
      let displayText = ''
      for (const char of text) {
        displayText += char
        node.textContent = displayText
        await new Promise(resolve => setTimeout(resolve, 20))
      }
    } else if (node instanceof HTMLElement) {
      const clone = node.cloneNode(true) as HTMLElement
      streamContent.value.appendChild(clone)
      await typeEffect(clone)
    }
  }
}

// 组件生命周期
onMounted(() => {
  const eventSource = createMockStream()
  eventSource.onmessage((event) => renderStream(event.data))

  onUnmounted(() => {
    eventSource.close()
  })
})
</script>

<style scoped>
.stream-container {
  max-width: 800px;
  margin: 2rem auto;
  padding: 1rem;
  background: #f8fafc;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}

.markdown-content {
  min-height: 400px;
  max-height: 600px;
  overflow-y: auto;
  padding: 1rem;
  background: white;
  border-radius: 4px;
  line-height: 1.6;
  font-family: 'Segoe UI', system-ui;
}

.markdown-content.streaming::after {
  content: "▋";
  animation: blink 1s step-end infinite;
  color: #3b82f6;
  margin-left: 2px;
}

@keyframes blink {
  0%, 100% { opacity: 1; }
  50% { opacity: 0; }
}

/* Markdown 样式增强 */
:deep(h1) {
  color: #1e293b;
  border-bottom: 2px solid #e2e8f0;
  padding-bottom: 0.3em;
}

:deep(strong) {
  color: #3b82f6;
  font-weight: 600;
}

:deep(em) {
  color: #10b981;
  font-style: italic;
}

:deep(pre) {
  background: #1e293b;
  color: #f8fafc;
  padding: 1rem;
  border-radius: 6px;
  overflow-x: auto;
  margin: 1rem 0;
}

:deep(code) {
  font-family: 'Fira Code', monospace;
  font-size: 0.9em;
}

:deep(ul) {
  padding-left: 1.5em;
  list-style-type: '→ ';
}

:deep(a) {
  color: #3b82f6;
  text-decoration: underline;
  transition: opacity 0.2s;
}

:deep(a:hover) {
  opacity: 0.8;
}
</style>

使用@microsoft/fetch-event-source插件发送SSE请求实现

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值