如何实现AI聊天机器人的打字机效果?

一、背景

类似deepseek,chatgpt的AI聊天机器人层出不穷,有些同学会敏锐地发现:都有酷炫的打字机效果;那么前端开发好奇宝宝,我们发出疑问:为什么要使用打字机?这个效果是怎么实现的呢?

以技术角度,将"打字机"抽象化,其实就是------接口数据的请求、处理与展示

  1. 接口数据的请求、处理

  2. 停止数据生成

  3. 前端数据展示组件

二、概念

观察下这些机器人的发送消息的接口,会发现都用了流式接口,通过SSE(server-sent events),由服务端向客户端实时推送分块数据。

  • 看个例子:

    • 响应标头: Content-Type: text/event-stream; charset=utf-8
    • 响应详情数据:主要内容在"answer"字段中,进行了unicode编码
    swift 复制代码
      data: {"event": "agent_thought", "conversation_id": "5517cc5c-c60f-424e-94a1-8c9a2bf89b9f", "message_id": "03cd14b3-4d23-4cab-9fcd-4265c22efb8a", "created_at": 1760839802, "task_id": "11696524-0719-42a7-9ece-48a0da5ec1d9", "id": "bcd99f3d-e852-4b10-8a83-e14675ef3785", "position": 1, "thought": "", "observation": "", "tool": "", "tool_labels": {}, "tool_input": "", "message_files": []}
    
      data: {"event": "agent_message", "conversation_id": "5517cc5c-c60f-424e-94a1-8c9a2bf89b9f", "message_id": "03cd14b3-4d23-4cab-9fcd-4265c22efb8a", "created_at": 1760839802, "task_id": "11696524-0719-42a7-9ece-48a0da5ec1d9", "id": "03cd14b3-4d23-4cab-9fcd-4265c22efb8a", "answer": "#"}
    
      data: {"event": "agent_message", "conversation_id": "5517cc5c-c60f-424e-94a1-8c9a2bf89b9f", "message_id": "03cd14b3-4d23-4cab-9fcd-4265c22efb8a", "created_at": 1760839802, "task_id": "11696524-0719-42a7-9ece-48a0da5ec1d9", "id": "03cd14b3-4d23-4cab-9fcd-4265c22efb8a", "answer": " Apple"}
    
      data: {"event": "agent_message", "conversation_id": "5517cc5c-c60f-424e-94a1-8c9a2bf89b9f", "message_id": "03cd14b3-4d23-4cab-9fcd-4265c22efb8a", "created_at": 1760839802, "task_id": "11696524-0719-42a7-9ece-48a0da5ec1d9", "id": "03cd14b3-4d23-4cab-9fcd-4265c22efb8a", "answer": " ("}
    
      data: {"event": "agent_message", "conversation_id": "5517cc5c-c60f-424e-94a1-8c9a2bf89b9f", "message_id": "03cd14b3-4d23-4cab-9fcd-4265c22efb8a", "created_at": 1760839802, "task_id": "11696524-0719-42a7-9ece-48a0da5ec1d9", "id": "03cd14b3-4d23-4cab-9fcd-4265c22efb8a", "answer": "AAP"}
    
      ......
    
      data: {"event": "agent_message", "conversation_id": "5517cc5c-c60f-424e-94a1-8c9a2bf89b9f", "message_id": "03cd14b3-4d23-4cab-9fcd-4265c22efb8a", "created_at": 1760839802, "task_id": "11696524-0719-42a7-9ece-48a0da5ec1d9", "id": "03cd14b3-4d23-4cab-9fcd-4265c22efb8a", "answer": ""}
    
      data: {"event": "agent_thought", "conversation_id": "5517cc5c-c60f-424e-94a1-8c9a2bf89b9f", "message_id": "03cd14b3-4d23-4cab-9fcd-4265c22efb8a", "created_at": 1760839802, "task_id": "11696524-0719-42a7-9ece-48a0da5ec1d9", "id": "bcd99f3d-e852-4b10-8a83-e14675ef3785", "position": 1, "thought": "# Apple (AAPL) \u6295\u8d44\u5206\u6790\u62a5\u544a\n\n## \u7b2c\u4e00\u90e8\u5206\uff1a\u57fa\u672c\u9762\u5206\u6790 - \u8d22\u52a1\u62a5\u544a\n\n### [\u8bb0\u5f55 1.1] \u516c\u53f8\u57fa\u672c\u4fe1\u606f\n- **\u516c\u53f8\u540d\u79f0**: Apple Inc.\n- **\u80a1\u7968\u4ee3\u7801**: AAPL\n- **\u884c\u4e1a**: \u6d88\u8d39\u7535\u5b50\u4ea7\u54c1\u3001\u8f6f\u4ef6\u670d\u52a1\n- **\u603b\u90e8**: \u7f8e\u56fd\u52a0\u5229\u798f\u5c3c\u4e9a\u5dde\u5e93\u6bd4\u8482\u8bfa\n- **\u6210\u7acb\u65f6\u95f4**: 1976\u5e74\n- **CEO**: Tim Cook\n- **\u4e3b\u8981\u4ea7\u54c1**: iPhone, iPad, Mac, Apple Watch, AirPods, \u670d\u52a1(Apple Music, iCloud, App Store\u7b49)\n\n### [\u8bb0\u5f55 1.2] \u8d22\u52a1\u6570\u636e (\u622a\u81f3\u6700\u8fd1\u5b63\u5ea6)\n- **\u5e02\u503c**: ~$2.8\u4e07\u4ebf\u7f8e\u5143(\u5168\u7403\u5e02\u503c\u6700\u9ad8\u516c\u53f8)\n- **\u8425\u6536**: \u6700\u8fd1\u5b63\u5ea6$894\u4ebf\u7f8e\u5143\n- **\u51c0\u5229\u6da6**: \u6700\u8fd1\u5b63\u5ea6$236\u4ebf\u7f8e\u5143\n- **\u6bdb\u5229\u7387**: 44.3%\n- **\u8fd0\u8425\u5229\u6da6\u7387**: 30.8%\n- **\u6bcf\u80a1\u6536\u76ca(EPS)**: $1.53\n- **\u73b0\u91d1\u50a8\u5907**: ~$1660\u4ebf\u7f8e\u5143\n- **\u8d1f\u503a\u6743\u76ca\u6bd4**: 1.57\n- **\u80a1\u606f\u6536\u76ca\u7387**: ~0.5%\n- **\u5e02\u76c8\u7387(P/E)**: ~28\n\n*\u6570\u636e\u6765\u6e90: Yahoo Finance\u6700\u65b0\u8d22\u62a5*\n\n### [\u8bb0\u5f55 1.3] \u8d22\u52a1\u5206\u6790\u4e0e\u7ed3\u8bba\n**\u4f18\u52bf**:\n- \u5f3a\u5927\u7684\u76c8\u5229\u80fd\u529b(\u884c\u4e1a\u9886\u5148\u7684\u5229\u6da6\u7387)\n- \u5de8\u989d\u73b0\u91d1\u50a8\u5907\u63d0\u4f9b\u6218\u7565\u7075\u6d3b\u6027\n- \u670d\u52a1\u4e1a\u52a1\u6301\u7eed\u589e\u957f\uff0c\u63d0\u9ad8\u6536\u5165\u591a\u6837\u6027\n- \u9ad8\u6548\u7684\u4f9b\u5e94\u94fe\u7ba1\u7406\n\n**\u98ce\u9669**:\n- \u5bf9iPhone\u9500\u552e\u7684\u4f9d\u8d56(\u4ecd\u5360\u6536\u516550%\u4ee5\u4e0a)\n- \u5168\u7403\u4f9b\u5e94\u94fe\u538b\u529b\n- \u6c47\u7387\u6ce2\u52a8\u5f71\u54cd(60%\u6536\u5165\u6765\u81ea\u56fd\u9645\u5e02\u573a)\n- \u76d1\u7ba1\u538b\u529b\u589e\u52a0(\u7279\u522b\u662f\u6b27\u76df\u6570\u5b57\u5e02\u573a\u6cd5\u6848)\n\n**\u673a\u4f1a**:\n- \u670d\u52a1\u4e1a\u52a1\u589e\u957f\u6f5c\u529b\n- \u65b0\u5174\u5e02\u573a\u6269\u5f20\n- AR/VR\u65b0\u4ea7\u54c1\u7ebf(\u5982Vision Pro)\n- \u4eba\u5de5\u667a\u80fd\u96c6\u6210\u5230\u4ea7\u54c1\u4e2d\n\n## \u7b2c\u4e8c\u90e8\u5206\uff1a\u57fa\u672c\u9762\u5206\u6790 - \u884c\u4e1a\u5730\u4f4d\n\n### [\u8bb0\u5f55 2.1] \u884c\u4e1a\u5206\u7c7b\n- **\u4e3b\u8981\u884c\u4e1a**: \u6d88\u8d39\u7535\u5b50\u4ea7\u54c1\n- **\u7ec6\u5206\u5e02\u573a**: \u9ad8\u7aef\u667a\u80fd\u624b\u673a\u3001\u4e2a\u4eba\u7535\u8111\u3001\u53ef\u7a7f\u6234\u8bbe\u5907\u3001\u6570\u5b57\u670d\u52a1\n- **\u76f8\u5173\u884c\u4e1a**: \u534a\u5bfc\u4f53\u3001\u8f6f\u4ef6\u670d\u52a1\u3001\u4e91\u8ba1\u7b97\u3001\u5a92\u4f53\u5a31\u4e50\n\n### [\u8bb0\u5f55 2.2] \u5e02\u573a\u5b9a\u4f4d\u4e0e\u7ade\u4e89\u5206\u6790\n**\u5e02\u573a\u4efd\u989d**:\n- \u667a\u80fd\u624b\u673a: \u5168\u7403\u7ea618-20%(\u9ad8\u7aef\u5e02\u573a\u4e3b\u5bfc\u5730\u4f4d)\n- \u5e73\u677f\u7535\u8111: iPad\u7ea6\u536030%\u5e02\u573a\u4efd\u989d", "observation": "", "tool": "", "tool_labels": {}, "tool_input": "", "message_files": []}
    
      data: {"event": "message_end", "conversation_id": "5517cc5c-c60f-424e-94a1-8c9a2bf89b9f", "message_id": "03cd14b3-4d23-4cab-9fcd-4265c22efb8a", "created_at": 1760839802, "task_id": "11696524-0719-42a7-9ece-48a0da5ec1d9", "id": "03cd14b3-4d23-4cab-9fcd-4265c22efb8a", "metadata": {"annotation_reply": null, "retriever_resources": [], "usage": {"prompt_tokens": 1682, "prompt_unit_price": "0", "prompt_price_unit": "0", "prompt_price": "0", "completion_tokens": 1294, "completion_unit_price": "0", "completion_price_unit": "0", "completion_price": "0", "total_tokens": 2976, "total_price": "0", "currency": "USD", "latency": 9.213518813252449}}, "files": null}
      ```

流式接口与SSE

  • 流式接口是什么?(Streams API

    • 服务端在准备好一部分数据后,就立即向客户端发送数据,而不是等所有数据准备完成后,一次性发送;向水流一样,持续、分块地"流"向客户端。SSE(server-sent events),一种HTML标准,基于HTTP协议,是实现流式接口的具体、标准化方案。
  • 流式接口的特点?

    • 持续传输

    • 每个数据块包含一个片段(一个词)

    • 严格按顺序,以连续的流的形式发送,每个数据块(chunk)按照他们生成的顺序进行发送,即前端接收顺序与后端生成一致;

    • 可能存在 "event: ping" 消息

      • 是SSE中的心跳机制,服务器发送的ping事件,以保持连接活跃;
  • 为什么使用流式接口?

AI接口响应时间长,若使用传统的接口数据加载完成后再展示,页面延时较长;而通过流式接口的实时更新特点,对数据进行实时展示,进而提升用户体验;

三、方案调研与实现

接口数据的请求与处理

1. 原生方案 fetch

需要reader.read(),decode解码数据,并添加到缓冲区;

javascript 复制代码
       /**
       * 使用 fetch 处理真实的流式请求
       * @param url 请求URL
       * @param options 请求选项
       * @param streamOptions 流处理选项
       */
       export async function fetchStreamData(
         url: string,
         requestOptions: RequestInit = {},
         streamOptions: StreamProcessorOptions = {}
       ): Promise<void> {
         const { onMessage, onError, onComplete, onStart } = streamOptions;
         
         try {
           onStart?.();
           /**
          const requestOptions: RequestInit = {
           method: 'POST',
           headers: {
             'Content-Type': 'application/json',
             'Authorization': `Bearer ${TOKEN}`
           },
           body: JSON.stringify(params),
           signal: abortController?.signal
         };
         */
           
           const response = await fetch(url, {
             ...requestOptions,
             headers: {
               'Accept': 'text/event-stream',
               'Cache-Control': 'no-cache',
               'Content-Type': 'application/json',
               ...requestOptions.headers,
             },
           });
           
           if (!response.ok) {
             throw new Error(`HTTP error! status: ${response.status}`);
           }
           
           const reader = response.body?.getReader();
           if (!reader) {
             throw new Error('无法获取响应流');
           }
           
           const decoder = new TextDecoder();
           let buffer = '';
           
           while (true) {
             const { done, value } = await reader.read();
             
             if (done) {
               break;
             }
             
             // 解码数据并添加到缓冲区
             buffer += decoder.decode(value, { stream: true });
             
             // 处理完整的行
             const lines = buffer.split('\n');
             buffer = lines.pop() || '';  // 保留最后一个不完整的行
             
             // 对lines进行解析
             for (const line of lines) {
               if (line.trim() && line.startsWith('data: ')) {
                 try {
                   const jsonStr = line.substring(6);
                   const event = JSON.parse(jsonStr) as StreamEvent;
                   // 直接对event.answer进行解析
                   onMessage?.(event);
                 } catch (error) {
                   console.warn('解析流式数据失败:', line, error);
                 }
               }
             }
           }
           
           onComplete?.();
           
         } catch (error) {
           onError?.(error as Error);
         }
       }

2. 流式处理库 @microsoft/fetch-event-source

已封装好接口,处理好decode逻辑,只需取消息字段进行追加;

javascript 复制代码
       import {fetchEventSource} from '@microsoft/fetch-event-source'

       // url: 流式接口
        await fetchEventSource(url, {
             method: 'POST'
             headers: {
                 'Content-Type': 'application/json',
                 'Authorization': `Bearer ${TOKEN}`
             },
             body: JSON.stringify(params),
             onopen: async (response) => {
                 console.log('@microsoft/fetch-event-source', '连接打开', response);
             },
             onmessage: (event) => {
                 console.log('@microsoft/fetch-event-source', '接收到事件:', event);
                 // 对流式数据进行处理,并追加
                 // eg.
                 // currentStreamTextRef.current += event.answer;
                 // dispatch({ type: 'SET_STREAM_TEXT', payload: currentStreamTextRef.current });
             },
             onerror: (error) => {
                 console.log('@microsoft/fetch-event-source', '连接错误:', error);
             },
             signal: options.signal,
       }

3. 流式处理库 eventsource-parser

需要reader.read(),decode解码数据,底层也使用了fetch接口;

javascript 复制代码
    import { createParser } from 'eventsource-parser';

        export const EventSourceParser = async (params: any, callback: any) => {
            const parser = createParser({
                // 对流式数据进行处理,并追加
                // eg.
                // currentStreamTextRef.current += event.answer;
                // dispatch({ type: 'SET_STREAM_TEXT', payload: currentStreamTextRef.current });
                onEvent: callback.onEvent,
                onError: callback.onError,
            });
            
            const url = `...`;
            const response = await fetch(url, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                    'Authorization': `Bearer ${TOKEN}`
                },
                body: JSON.stringify(params),
                // 停止生成
                signal: callback.signal,
            })
            const reader = response.body?.getReader();
            if (!reader) {
                throw new Error('无法获取响应流');
            }
            const decoder = new TextDecoder();
             // @ts-ignore
            return reader.read().then(function process({ done, value }) {
                if (done) return;
                
                const chunk = decoder.decode(value);
                parser.feed(chunk);
                return reader.read().then(process);
            });
        }

停止数据生成

向服务器发送一个明确的停止信号:signal: abortController?.signal

前端数据展示组件

先抛出疑问: 需不需要前端代码写个定时器(setInterval),控制展示?

🤔ing

答案: 不需要,只需要利用流式接口的特点:分块、连续,实时更新渲染文本,进而实现组件的实时渲染;

那么我们只需要解决一个问题:渲染Markdown格式的文本。

已有现成库 react-markdown,react-syntax-highlighter (代码展示)等;

javascript 复制代码
import ReactMarkdown from 'react-markdown';

// Markdown渲染组件
  const MarkdownRenderer = ({ content }: { content: string }) => {
    const components: Components = {
      code({ className, children, ...props }) {
        const match = /language-(\w+)/.exec(className || '');
        const isInline = !match;
        
        return isInline ? (
          <code className={className} {...props}>
            {children}
          </code>
        ) : (
          <SyntaxHighlighter
            style={tomorrow as any}
            language={match[1]}
            PreTag="div"
          >
            {String(children).replace(/\n$/, '')}
          </SyntaxHighlighter>
        );
      },
    };

    return (
      <ReactMarkdown className={markdownClassName} components={components}>
        {content}
      </ReactMarkdown>
    );
  };

附:成熟的社区方案

ai-sdk.dev/docs/founda...

Vercel出品了一套方案,可快速实现AI聊天机器人的搭建;

主要功能:

  • AI SDK Core:封装通用的调用AI模型api
  • AI SDK UI:封装一系列hook,解析接口数据,以直接进行UI的展示

四、总结

回到标题的问题,我们怎么实现AI机器人的打字机效果呢?

具体可分为以下几个步骤:

  1. 前端接收流式数据
  2. 流式数据的处理(包括解码,拼接等)
  3. onData/onmessage 回调函数中,实时拼接新文本内容
  4. 前端组件之间渲染文本内容:内容更新->触发重新渲染,则流式接口的特点,天然可实现打字机的效果 🎉
  5. 若要停止生成打字,那么给流式接口传递abort的信号即可。
相关推荐
IT_陈寒3 小时前
Vite 5个隐藏技巧让你的项目构建速度提升50%,第3个太香了!
前端·人工智能·后端
詩句☾⋆᭄南笙3 小时前
HTML的盒子模型
前端·html·盒子模型
落言3 小时前
AI 时代的工程师:懂,却非懂的时代
前端·程序员·架构
一枚攻城狮3 小时前
前端知识点大汇总
前端
余道各努力,千里自同风4 小时前
el-input 输入框宽度自适应宽度
javascript·vue.js·elementui
Mike_jia4 小时前
DumbAssets:开源资产管理神器,家庭与企业的高效管家
前端
Southern Wind5 小时前
Vue 3 多实例 + 缓存复用:理念及实践
前端·javascript·vue.js·缓存·html
HuangYongbiao5 小时前
Rspack 原理:webpack,我为什么不要你
前端
yinuo5 小时前
前端项目开发阶段崩溃?试试这招“Node 内存扩容术”,立马复活!
前端