AI 应用前端开发技能库 - 基础篇

738 阅读13分钟

最近一直在负责公司 AI 平台的开发,也接触到了很多意想不到的落地应用需求,比如思维导图生成、封面图生成、realtime 实时聊天等等。网上相关的资料又比较少,一开始需求调研实现方案的时候就踩了很多坑,所以说这个系列文章会把这些 AIGC 领域常见的前端开发需求和实现方案整理一下。

如果你刚开始要接触 AI 相关应用的开发,但是对于一些需求不知道怎么下手。又或者你面试的时候被问到了 AI 相关的问题但是不知道怎么回答,相信这些文章会对你有帮助。

本篇就作为一个基础,讲一下无论什么 AI 需求都不可避免会用到的一些技能。

前后端通信:WebSocket & SSE

AI 应用开发中,websocket 和 sse 可谓是前后端通信的首选方案。

websocket 方案

对于 websocket,可以选择 react-use-websocket,我曾经尝试过很多的 websocket 方案,原生实现、Socket.IOSockette,最终用下来体验最好的还是 react-use-websocket,下面是一个简单的聊天 ws 模板 hook:

import useWebSocket from 'react-use-websocket';

type Parameters = {
  /** 连接地址 */
  wsUrl: string;
  /** 是否连接 */
  connect: boolean;
};

export default function useRealTime(props: Parameters) {
  let wsUrl = props.wsUrl;

  if (wsUrl.startsWith('https')) wsUrl = wsUrl.replace('https', 'wss');
  else if (wsUrl.startsWith('http')) wsUrl = wsUrl.replace('http', 'ws');

  const { sendJsonMessage } = useWebSocket(wsUrl, {
    onMessage: (event) => onMessageReceived(event),
    shouldReconnect: () => true,
  }, props.connect);

  const onMessageReceived = (event: MessageEvent<any>) => {
    let message;
    try {
      message = JSON.parse(event.data);
    } catch (e) {
      console.error('Failed to parse JSON message:', e);
      throw e;
    }

    switch (message.type) {
      case 'chat':
        // 收到消息时执行的操作
        break;
      case 'error':
        // 后端报错时执行的操作
        break;
    }
  };

  return { sendJsonMessage };
}

虽然模板很简单,但是在开发的时候会遇到很多的需求,例如:

  • ws 初始化的时候需要传递什么参数?例如使用的模型、面具、当前正在和哪个文件、知识库聊天等
  • 用户切换参数的时候怎么传递?
  • 模型调用工具的时候如何展示?

SSE 方案

SSE 在前端一般都使用 fetch 搭配 TextDecoder 来处理,下面是个简单的封装:

interface RequestSSEConfig extends Partial<RequestInit> {
  /**
   * 请求默认会把 Content-Type 设置为 application/json
   * 如果设置为 true,则会移除 Content-Type
   *
   * 比如发送 FormData 时,就需要把这个参数设置为 true
   */
  removeContentType?: boolean;
}

export const requestSSE = async (url: string, config: RequestSSEConfig, cb: (value) => unknown) => {
  const { removeContentType = false, ...requestConfig } = config;
  const fetchInit: any = {
    method: 'POST',
    responseType: 'stream',
    ...requestConfig,
    headers: {
      // 获取后端接口需要的鉴权等参数
      ...getHttpHeaders(),
      ...(requestConfig.headers || {}),
    },
  };

  if (!removeContentType) {
    fetchInit.headers['Content-Type'] = 'application/json';
  }

  const resp = await fetch(url, fetchInit);
  if (!resp.ok) {
    // 后端接口报错逻辑在这里处理
    throw new Error('Failed to fetch content');
  }

  const reader = resp.body?.getReader();
  const textDecoder = new TextDecoder();
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    const rawText = textDecoder.decode(value);
    // 返回了个 json,说明后端报错了
    if (rawText.startsWith('{') && rawText.endsWith('}')) {
      try {
        const errorInfo = JSON.parse(rawText);
        // 后端接口响应异常逻辑在这里处理
      } catch (e) {
        throw new Error(rawText);
      }
      break;
    }

    rawText.split('\n').forEach((line) => {
      if (!line || !line.startsWith('data: ')) return;
      try {
        const data = JSON.parse(line.replace('data: ', ''));
        cb(data);
      } catch (e) {
        console.error('Failed to parse data', e);
      }
    });
  }
};

这里实际上是有一些坑的:

接口异常处理?

后端接口如果报错的话,响应实际上是个 json 而不是一个 ReadableStream,所以要处理一下。怎么处理的上面代码里有。

不用使用 EventSource 么?

实际上,SSE 默认确实是用 EventSource + GET 请求来实现的,不过我并不推荐这种方案,大家通常还是使用 POST 来发起 SSE。一个比较大的优势就是 POST 请求可以携带 body,这就允许请求带上文件之类杂七杂八的内容了。

但是随之而来的问题就是 EventSource 默认只能从 GET 请求实例化而来,那换用了 POST 请求之后怎么实现呢?微软提供了一个库叫做 @microsoft/fetch-event-source - npm 可以实现这个需求,但是实际上自己简单封装一下就够用了(就是上面给出的这个 requestSSE)。

怎么判断响应是否结束?

如果启用了 SSE 的话,接口的 response 会直接返回回来,所以不能单纯的 await fetch(...),核心是要看 const { done, value } = await reader.read(); 这个 done 的值。

如果你选择用上面这个 requestSSE 的话,直接 await requestSSE(...) 就行了。

语音输入

AI 应用里一个很常见的需求就是实现用户语音输入,具体效果就类似于微信聊天的逻辑。在 web 端实现这个效果的底层 API 是下面这行代码:

const stream = await navigator.mediaDevices.getUserMedia({ audio: true })

这样就可以拿到用户麦克风设备的多媒体流。具体怎么用我后面会写一篇如何实现 realtime 与模型对话的文章来讲。

这里如果你只是简单的想实现用户输入语音消息的话,我推荐使用 recorder-core - npm 这个库。它不仅能录音,还能实现实时音频可视化。

如果你还想实现录制完成后的音频重放的话可以再搭配上 wavesurfer.js - npm 这个库。最终实现的效果就是这样的:

动画.gif

这个具体实现有点复杂,并且和 UI 层绑定的比较深,如果大家感兴趣的话我再单开文章来讲吧。

聊天记录管理

其实在 bot 聊天模块里,对聊天记录的管理是要比 ws 连接模块复杂不少的。这个复杂主要体现在它是一个核心模块,需要对接来自四面八方的需求。

从技术角度来看,聊天记录用一个全局状态管理库,按照会话 id 缓存对应的聊天信息数组,然后把数据用插件持久化到 indexedDB 即可。

而从功能角度来看,需要实现下面这几个比较大的需求:

1、历史记录接口懒加载

在实现聊天模块的时候,实时消息从 ws 里拿到,而历史消息则从 history 接口里懒加载拿到。所以消息模块要提供 api 实现简单的懒加载。

并且这里还有一个坑要注意,这里懒加载不能单纯的用分页去读下一页,因为消息实际上是会不断追加的。

比如一页有十条,你加载完第一页之后,先去聊了十条,然后再去加载第二页,然后就会发现第二页的内容和第一页的十条完全一样。

所以一般都是提供最后一条消息的 ID,然后后端返回“这条 ID 之后的十条”。

2、消息类型区分

在消息类型的设计上要提前考虑到这一点,历史记录里的消息并不一定是单纯的消息气泡,有可能会有语音消息、图片消息、提示消息(展示为浅灰色而不是消息气泡)、提示消息的不同类型,工具调用消息等等。这个有聊天功能开发经验的朋友应该比较熟悉,第一次接触类似功能开发的很容易因为经验不足导致后期多次返工。

3、消息列表性能优化

ai 返回消息一般都是流式输出的,这就导致了消息列表这个状态需要频繁更新,尤其是 react 这种每次更新都需要传递新拷贝的设计,聊个十几次就卡的不行了。想要做优化的话一方面可以从状态存储入手,使用 valtio 或者 mobx 这种代理型状态管理来避免重复创建。另一方面可以给消息气泡组件添加 React.memo 来阻止不必要的渲染,比如:

export const ChatMessageComp = React.memo(_ChatMessageComp, (prev, next) => {
  // 最新一条必定会重新渲染
  if (next.isLatestMessage) return false;
  const needRerender = Object.keys(prev).every((k) => prev[k] === next[k]);
  return needRerender;
});

markdown 展示

markdown 的展示可以说是 AI 应用中绝对绕不开的需求。其实核心倒不是 markdown 实现起来多复杂,而是和 AI 输出的内容相结合时出现的一些坑,我们拿下面这个 markdown 封装举例:

import 'katex/dist/katex.min.css';
import { useMemo } from 'react';
import ReactMarkdown from 'react-markdown';
import RehypeKatex from 'rehype-katex';
import RemarkBreaks from 'remark-breaks';
import RemarkGfm from 'remark-gfm';
import RemarkMath from 'remark-math';
import { AdapterCardProps } from './adapter-card/types';
import { Code } from './code-highlight';

export const Img = (imgProps) => {
  return (
    <img
      {...imgProps}
      data-canpreview={true}
      onError={(e) => {
        (e.target as HTMLImageElement).style.display = 'none';
      }}
    />
  );
};

function escapeBrackets(text: string) {
  const pattern = /(```[\s\S]*?```|`.*?`)|\\\[([\s\S]*?[^\\])\\\]|\\\((.*?)\\\)/g;
  return text.replace(pattern, (match, codeBlock, squareBracket, roundBracket) => {
    if (codeBlock) {
      return codeBlock;
    } else if (squareBracket) {
      return `$$${squareBracket}$$`;
    } else if (roundBracket) {
      return `$${roundBracket}$`;
    }
    return match;
  });
}

function escapeImageGen(text: string) {
  return text.replace(/!\[(.*?)\]\(sandbox:(.*?)\)/g, '![$1]($2)');
}

export function MarkDownContent(props: {
  content: string;
  onAdapterCardSubmit: AdapterCardProps['onSubmit'];
}) {
  const escapedContent = useMemo(() => {
    return escapeImageGen(escapeBrackets(props.content));
  }, [props.content]);

  return (
    <ReactMarkdown
      remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]}
      rehypePlugins={[RehypeKatex]}
      components={{
        img: Img,
        code: Code,
      }}
    >
      {escapedContent}
    </ReactMarkdown>
  );
}

第一个坑是 模型出现幻觉时会生成不可访问的图片,比如让模型去阅读一个图片或者生成一个图片的时候。一方面模型会生成一个完全无法访问的图片链接,这时候就需要用上面的 Img 组件来自动剔除这些照片。另一方面模型生成出来的链接可能会附带一些前缀(比如我们这里的就会自己带上 sandbox: 前缀),这种就通过上面的 escapeImageGen 函数来剔除。

第二个坑是 模型生成 katex 公式时容易用反引号包裹,我们都知道 markdown 中反引号用于声明一个行内代码或者代码块,而 katex 一般使用 &xxx& 来声明行内公式,用 &&xxx&& 来声明多行公式。所以这里就需要通过上面的 escapeBrackets 函数来转换。不然就展示

第三个坑是 RehypeHighlight 偶发的内存溢出问题,具体原因我没有进行排查,不过通过 f12 可以发现罪魁祸首确实是 RehypeHighlight,大概率是模型的流式输出导致 markdown 频繁的重复渲染导致的:

企业微信截图_1709781784865.png

image.png

可以看到内存里都是 RehypeHighlight 写入的字符串。

这个问题是通过直接调用 highlight.js 替换掉 RehypeHighlight 来解决的,既上面 Markdown 中的 Code 组件,具体实现如下:

import hljs from 'highlight.js/lib/core';
import languageJavascript from 'highlight.js/lib/languages/javascript';
import languageJava from 'highlight.js/lib/languages/java';
import languageC from 'highlight.js/lib/languages/c';
import languageYaml from 'highlight.js/lib/languages/yaml';
import languageCsharp from 'highlight.js/lib/languages/csharp';
import languagePython from 'highlight.js/lib/languages/python';
import languageJson from 'highlight.js/lib/languages/json';
import languageSql from 'highlight.js/lib/languages/sql';
import { FC, ReactNode, useMemo } from 'react';

hljs.registerLanguage('javascript', languageJavascript);
hljs.registerLanguage('java', languageJava);
hljs.registerLanguage('json', languageJson);
hljs.registerLanguage('c', languageC);
hljs.registerLanguage('yaml', languageYaml);
hljs.registerLanguage('csharp', languageCsharp);
hljs.registerLanguage('python', languagePython);
hljs.registerLanguage('sql', languageSql);

export { hljs };

interface Props {
  className?: string;
  children: ReactNode | string;
}

export const Code: FC<Props> = (props) => {
  const { className, children } = props;

  if (typeof children !== 'string') {
    return <code className={className}>{children}</code>;
  }

  const isMultiLine = children?.includes('\n');
  if (!className && !isMultiLine) {
    return <code className={className}>{children}</code>;
  }

  const content = useMemo(() => {
    return hljs.highlightAuto(children).value ?? '';
  }, [children]);

  return (
    <code
        className={className}
        dangerouslySetInnerHTML={{ __html: content }}
      ></code>
  );
};

也比较简单,没什么坑,这里就不再赘述了。

PDF 预览

如果你做的 AI 应用中包含和知识库、RAG 相关的功能的话,那么 PDF 预览也是一个很重要的功能。并且不仅仅是 PDF,其他格式的文件预览,例如 word 或者 ppt,也一般都是通过后端转成 pdf 然后交给前端预览的形式呈现的。

预览功能实现

如果你的需求只是展示 pdf 的话,那么可以使用原生的浏览器 pdf 预览,但是要注意移动端可能没办法用或者界面体验不一致

或者你也可以用 pdf.js 来预览,可以参考我之前写的 前端项目中使用 pdf.js 开发指南 - 掘金。这种方案也是使用 iframe 引入到项目中,功能比较全面且支持一些简单的自定义需求。但是要注意这种方案在移动端某些机型上会出现加载缓慢的问题

再或者如果你要做一些比较深入的自定义开发,例如后端按页切成了多个 pdf,你要做懒加载。那么就可以使用 react-pdf - npm 搭配一些懒加载组件来实现。这种方案其实只是提供了 pdf 的预览,一些常用功能比如页码跳转,调整尺寸都需要自己来开发了。

PDF 内容高亮

高亮指定段落的 PDF 内容也是 RAG 很常见的一个需求,一般用于源文定位或者切分方案预览等需求。这个也是有两种方案:

按区域定位

按区域定位要求后端能够返回指定区域的坐标,例如 左上角+右下角,某些模型工具可能会自带这些信息返回。这种方案的好处是前端可以显示任意区域的内容,包括图片和页眉页脚等,但坏处是对后端的要求比较高。

按文字索引定位

第二种方案是后端返回要高亮文本在当前页文本中所处的开始索引和结束索引。然后前端再操作 DOM 进行高亮,这种对于后端的要求比较低,一般都可以实现。但是前端的展示效果会更差一点,比如:

  • 后端提取到的文本可能会包含一些特殊字符,导致给的索引和实际的文本位置有偏移。
  • 没办法高亮图片,但是一些图表横纵坐标的文本会被提取到,高亮的效果就很“零碎”。
  • 操作 DOM 对性能的要求比较高,前端实现起来也比较麻烦。

所以说如果真要用这种方案,你就大大方方提出来这个问题,最终定位到的肯定没办法百分百完全吻合。不过这个的精度要求其实也没那么高,前后偏移几个字甚至十几个字都不会有人关心的。

这里给一个简单的高亮实现:

export interface HighlightConfig {
  page: number;
  startIndex: number;
  endIndex: number;
  highlightClass: string;
}

const runHighlightWork = (config: HighlightConfig) => {
    const highlightClass = config.highlightClass;
    const target: HTMLDivElement = document.querySelector(
      `.react-pdf__Document .pdf-page-${config.page - 1}`,
    );

    if (!target) {
      // 注意,pdfjs 有懒加载,高亮前如果对应页面还不存在,就会导致这个问题
      console.warn('高亮未找到对应页数');
      return false;
    }

    const spanList = target.querySelectorAll('span[role=presentation]');
    if (!target) {
      console.warn('高亮未找到对应内容');
      return false;
    }

    let processCount = 0;
    spanList.forEach((item: HTMLElement) => {
      if (item.nodeType !== Node.ELEMENT_NODE) return;
      const innerText: string = item.innerText;
      if (!innerText) return;

      /** 段落开头在起始索引之前 */
      const a = processCount <= config.startIndex;
      /** 段落开头在起始索引之后 */
      const b = processCount > config.startIndex;
      /** 段落结尾在起始索引之后 */
      const c = processCount + innerText.length > config.startIndex;
      /** 段落结尾在结束索引之后 */
      const d = processCount + innerText.length >= config.endIndex;
      /** 段落结尾在结束索引之前 */
      const e = processCount + innerText.length < config.endIndex;
      /** 段落开始在结束索引之前 */
      const f = processCount < config.endIndex;

      // console.log('metched', a, b, c, d, e, f);

      /**
       * 需要高亮的只有四种情况
       *
       * 1. 段落跨越起始索引
       * 2. 段落位于匹配区间之中
       * 3. 段落跨越结束索引
       * 4. 段落完全包含匹配区间
       *
       * 这四种情况就对应了下面四种判断
       */
      const isMetched = (a && c && e) || (b && e) || (b && f && d) || (a && d);
      if (isMetched) {
        item.classList.add(highlightClass);
      }

      processCount += innerText.length;
    });

    return true;
  };

这种方案其实也有缺陷,因为是把所有存放文本的 span 拿出来然后对 span 标签进行高亮。所以这里也会产生一些高亮效果的偏移。


这里你可能会有一些疑问,比如 我用 Pdf.js 实现的,能用它自带的文本搜索功能来实现高亮么?

答案是不能,一方面,文本搜索功能要求字符串内容可以匹配,但是高亮的内容一般都比较长,后端稍微包含一些特殊字符你就找不到了。我实测下来十个字以上就已经几乎无法高亮了。并且搜索功能只能实现单一颜色的高亮,像切片预览这种要求两种颜色交替高亮的就无法实现了。

还有一个问题,我高亮的时候不去操作 DOM,而是使用浏览器原生的 Highlight API 实现可以么?

答案是可以,但是 Highlight API 兼容性太差了,尤其是移动端,所以并不推荐用。

小结

这篇文章简单讲了下 AI 应用中比较常见的几个功能,后面会陆续分享几个比较大块的需求怎么做: