AI对话平台核心技术解析

AI辅助文章编辑平台

AI对话侧边栏模块

AI发送和接收流式消息功能

对话实现技术选择和原因

什么是SSE?

SSE是服务端向客户端单向持续传输信息的HTTP技术,具有单向传输,流式返回的特点。

为什么采用SSE?

在开发中需要一种机制能将大模型生成的Token实时推送到前端,如果等模型生成后再返回,用户可能需要等待10-20秒的时间,这种 体验是不可接受的,因此我们需要一种流式输出方案。

我们有两种选择,SSE和WebSocket:

  1. 对于AI对话来讲,典型的交互是"用户发一个指令,模型回传一段长流",这本质就是HTTP的请求-响应模型,只是响应变成了流式,SSE专门为此设计,比WebSocket更轻量。
  2. SSE基于标准HTTP协议,能够无缝通过各种反向代理,防火墙和负载均衡器,不会像WebSocket那样偶尔遇到协议升级失败或断连的问题。
  3. 更重要的是作为HTTP请求,我可以利用状态码进行错误处理。
  4. 市面上的AI对话产品也是这么做的,借鉴优秀的做法,减少心智负担。
为什么不使用fetch-event-source库而是原生实现?

因为是SSE是AI对话的一个关键实现方式,我之前没有做过类似的项目,是通过敏捷开发边做边学的,想更加了解一下底层的实现,fetch-event-source的底层也是基于fetch+ReaderStreamable+TextDecoder 实现的,两者在功能上是一样的,而且我后续也有重新生成,简单的文本转为多模态的实现,当时这些功能还只是在我的设想中,我不敢保证直接使用库会不会有什么兼容上的问题,所以决定底层实现,而且还有个好处就是你能借助AI一步步完善你的不足。

如何实现SSE?难点在哪里?

因为我的后端返回标准的SSE格式的数据,以data开头,以\n\n分隔,最后以[DONE]结尾。因此整个过程大致分为四步:

  1. 将二进制流转化为文本

  2. 处理网络包的粘包拆包问题

  3. 性能优化(处理大量重复渲染问题)

  4. 可扩展性(重新生成,中断生成)

  5. 使用fetch发送请求 ,通过getReader()获取一个读取器 ,使用while循环开启循环,通过read()逐块获取数据 ,最后使用textDecoder将字节流转化为字符串。TCP是以字节流的形式传输。为什么不使用axios而是使用fetch:fetch原生支持流式获取,axios对流支持很弱,像各大厂商广泛应用的OpenAI接口,本质就是HTTP+Stream,前端必须便接收边解析。

  6. 处理粘包拆包问题:网络传输中TCP可能会把数据拆到两个包里,这会造成数据无法正确解析的问题,在这里我采用的解决方案:

    1. 维护一个变量partialLine。
    2. 这个变量要拼接上一次剩余的碎片
    3. 按照\n来拆分,并且只按照一个而不是跟后端数据一样的检查两个换行符,最开始是完美按照后端返回数据格式拆分,但是发现如果数据刚好在两个换行符之间切开就会出现问题,无法正确识别一个完整的数据段,这里对粘包拆包还是差了点考虑,所以就按照一个换行符来拆分。
    4. 最后获取完整数据会去除和保留有效的数据。
  7. 双缓冲区方案+rAF解决触发大量渲染导致界面卡顿和打字机效果抖动的问题:

    1. 问题出现的原因是最开始我是直接在while循环里直接setState导致的频繁渲染,解决的初步想法就是,既然大量渲染,是因为频繁调用setState,那么我们有没有办法不要频繁setState。
    2. 最直接想到的就是使用setTimeOut实现节流更新,但是我们其实setTimeOut它的更新时间并不准确就是我们指定的delay,这可能导致数据要渲染到页面时不连贯,看瞎眼睛。
    3. 最后调查研究决定可以使用rAF(浏览器提供的一个专门用于动画渲染的API,它可以让代码在浏览器下一次重绘之前执行),我们可以用它来控制渲染频率:
      1. 初步实现:建立一个流式输出的缓冲区streamingBufferRef,先存储已接收的文本;
      2. 引入另一个缓冲区lastRenderBufferRef作为当前屏幕快照;
      3. 使用requestAnimationFrame开启一个高频检查循环,仅当两个缓冲区内容不一致,说明有新的字符存入,才调用setState同步状态。
      4. 将数据更新与UI更新解耦,双缓冲区的设计本质就是削峰填谷:当数据量大的时候保证浏览器不会崩掉,用户不会眼瞎,数据量少的时候又可以减少运行开销。可以说是最佳实践。
  8. 扩展:

    1. 重新生成:删掉最后一条数据,也就是AI那条,重新发送请求即可。
    2. 中断生成:使用AbortController,浏览器提供的一个用于取消异步任务的控制器API,常见用途就是取消fetch.创建一个中断控制器,获得中断信号并传给后端,当用户点击终止生成的时候调用controller.abort()终止fetch请求。

AI对话界面滚动问题

如何处理对话内容过多导致的页面卡顿?

聊天对话项目常见问题:对话内容过多 -> 每一次滚动都要更新比较大量DOM节点 -> 计算量大导致渲染速度慢 -> 页面卡顿用户体验感差

经典解决方案:虚拟滚动

技术选型:

AI 对话跟普通聊天界面还有一个不同点,就是AI是流式输出,高度是动态增加的,加上markdown渲染和代码块出现,考虑到后续我们要增加的用户浏览历史,点击按钮回到底部,滚动跟随等功能,我们选择使用react-virtuoso库来实现。这个库支持动态高度,自带触底检测,自动跟随等API,完美符合我们项目的要求。

操作流程

  1. 基础构建:将消息列表接入 Virtuoso 组件。
  2. 动态测量:配置 itemContent 渲染消息气泡,让组件自动计算每一条消息的真实高度。
  3. 触底检测:增加触底检测的阀值
  4. 自定义触底状态变化:原本会在这里根据原始的计算是否位于底部的公式来设置自动跟随状态,但由于代码块高度突变会导致被误判自动跟随失效,毕竟设定底部阀值这个并不精确,所以直接全权交由滚动容器中的用户交互检测。
如何解决自动跟随和用户观看历史不希望自动跟随的矛盾

矛盾:virtuoso的followOutput可以设置跟随输出强制滑动,这是实现自动滚动的关键条件,但是现在有个情况,如果用户上滑观看历史消息,或者想仔细研读一下已经生成的部分,这个时候用户上滑就会一直被强制拉到最底部。

解决方案

通过与AI交流和阅读virtuoso文档,我们发现virtuoso原本就会内置一个Scroller容器,这里我们可以把它当作virtuoso内部最外层的可滚动div元素,请注意,它就是负责控制滚动的!官方文档有指导说明我们可以自定义Scroller容器,这给了我一点灵感,我们能不能在这个地方做点手脚,让他能够在感知到用户浏览历史的行为时暂停自动滚动。

  1. 自定义Scroller容器

    根据文档指导确认好参数,传进来props和ref,然后先依旧调用原生的onScroll函数,先确保滚动能被正常的监听到;

  2. 我们有设置一个是否开启自动跟随的state来控制开关状态,这个时候我们就监听用户是否存在滚动行为,通过捕捉这个事件立即更新自动跟随的状态,从而解决存在的矛盾

  3. 本质就是在原本Scroller容器的基础上进一步实现用户状态的监听。

  4. 附加在这之后还遇到了一些问题导致自动跟随失效了:

    1. 当代码块生成时会出现由于代码块比较大导致界面变化过于快导致自动跟随判断错误而失效的问题。通过修改Virtuoso的atBottomStateChange,实现只有消息正在流式输出和检测在底部就确保自动跟随的状态为true。不再自己设置根据底部距离判断是否在底部改变自动跟随状态而是依赖 Scroller 中的用户交互检测来关闭,这样可以防止流式输出大段内容(如代码块)导致高度突变时硬编码底部阀值导致的自动跟随失效问题。
    2. 使用useMemo缓存Scroller容器,如果 Scroller 每次父组件渲染都生成一个新的引用, Virtuoso 会认为容器变了,从而卸载并重新挂载整个 DOM 树。这会导致 滚动位置瞬间丢失 (滚回顶部)和明显的界面闪烁。

多模态交互和文件上传

图片获取部分

背景:为用户提供丰富的上传图片的途径,可以使用文件管理器,拖拽上传,截图后复制,同时在上传后还要有本地预览效果,还要有等待图片上传的UI显示,让用户的体验感好一点,不会因为看不见预览而焦躁。

方案

AntDesign的Upload组件,虽然功能齐全,我觉得是可以用来进行二次封装的,但是,我在研究这几个功能的时候有浏览一下博客,发现有个大佬开源了完整的这部分功能的组件,全是使用原生的事件实现的,我觉得可以抄一下学习一下,所以就没有使用组件库的组件。

接下来我就简单讲一讲每个部分的实现:

  1. 打开文件管理器,这个就比较简单,隐藏input元素,使用useImperativeHandle暴露给父组件一个open方法,就是调用input的click();打开文件管理器。

  2. 拖拽功能实现:采用web原生的drag和dropAPI,实现把文件拖到页面上传。

    1. 注意要先阻止默认行为:浏览器默认拖文件到浏览器可能直接打开文件。还要阻止冒泡,拖拽事件会不断触发冒泡,需要阻止避免逻辑重复执行。
    2. 拖拽功能实现一个关键技巧,也是这个开源的组件代码里面我测试之后发现问题的,因为我的设计是拖拽到容器内会有一个ui屏幕遮罩,原先组件的逻辑是进入就显示遮罩,退出就挂壁遮罩,当你在遮罩内移动的时候会不断触发进入和出去导致UI闪烁问题,这里使用一个计数器记录,只有当计数器==0,说明真的离开了才关闭遮罩。
  3. 粘贴功能:调用浏览器提供的剪贴板数据对象,获取数组的第一个元素。

遇到的问题

发现原本的组件是一个功能十分齐全且他的拖拽的组件是一个已经设定好的容器,这并不能满足我想要实现的AI对话界面全屏遮罩的要求,所以修改方案是要从父组件传递ref进来,由父组件接管拖拽的功能操作,这里就设定监听器监听拖拽行为,最开始我一点点尝试我是把遮罩挂到window上,之后我才把遮罩挂到组件的父标签上。然后通过useEffect确保外部条件改变比如说容器变了可以更新方法,防止闭包旧值的问题。然后我在切换路由的时候就发现会报错:removeEventListener的对象为null,研究发现可能因为路由切换导致的组建卸载,父组件的DOM已经被移除,导致为Null;解决方案是闭包存储容器对象,当卸载时调用的是闭包存储的值,这个时候就不会报错了,问题解决。

图片上传部分

思路:要现在输入框下部分有图像占位预览,考虑图像上传的能力

  1. 实现一次可以并发上传多张图片:

    在聊天UI中用户希望看到每一张图的独立进度,如果合在一个接口发送,一张大图会拖慢所有图的进度

  2. 根据每张图片的状态机实现占位和乐观UI渲染

  3. 可扩展性,考虑后面的上传文档功能,希望可以复用,决定实现一个上传文件的组件,功能多一点。

组件实现

第一参考版本

重点关注调度器,和失败重传,先自动重传1次,然后变换状态,用户知道上传失败可以点击重试按钮调用重新上传函数,注意都是加入到任务队列,任务队列存的是一个上传函数,调度器里面直接调用这个函数。

不直接队列存数据而是队列存函数,一方面可以让调度器全身心投入调用的工作,与上传函数解耦;另一方面可以不用去理会上传的类型,如果上传函数在调度器里面实现,还要区分类型,这样就增加了调度器代码的复杂量。

还是那句话,参考别人优秀的做法,别人也是这样做的

为什么设置最大并发量为3个?

分两种情况:

  1. HTTP/1.1 浏览器对同一个域名的最大并发连接数通常是6个。如果设置为6就会占满所有通道,此时如果还有其他的请求,就会被浏览器放入Pending队列,这会影响其他功能的正常进行;同时过多的并发请求也会增加带宽的负担,影响请求的往返速度,对用户的忍耐程度是一个极大的考验
  2. HTTP/2 虽然解决了6条并发连接的限制,但我认为物理限流仍然不可或缺:还是会增加带宽压力;增加服务端的写入,计算压力,对内存和磁盘也有影响;

为什么不使用Promise.all而是自己实现一个任务队列?

Promise.all存在三个问题:

  1. Promise.all只要有一个reject,整个就会失败,而我的要求是每个图片上传都要是独立的,这不符合我的要求,也是最关键的一点!
  2. 并发不可控,他会瞬间把数组里的所有请求全部发出,出现的情况同HTTP/1.1;
  3. Promise.all要求所有Promise在调用的时候就已经创建,而我们的队列需要动态追加,通过队列的push()和shift()可以动态添加新任务;

为什么不使用Promise.allSettle?

  1. 虽然它相比于Promise.all而言不会因为一个reject就全部失败,但是还是存在全量推行的问题,同Promise.all的第1点
  2. 静态性限制,allSettled接受的是一个已创建的Promise数组,问题同Promise.all第3点

为什么要上传oss而不是直接使用base64编码?

方案比对:

base64编码:将图片转为极长的字符串,直接嵌入json请求体和数据库

OSS:云存储,前后端只传递一个轻量的url

很明显最最直接的一个点就是base64编码很大,导致请求payload很大,影响传输效率;对后端兄弟考虑一下,如果将base64直接存入数据库,对数据库的存储压力也很大,传输压力大,存储压力也大;导致传输速度变慢,影响用户体验,用户体验就是天!第二点,通过调查,很多有提供Vision API的AI厂商都比较推荐URL,还是那句话,跟优秀的人学习。

还有一个其实也是未来如果项目上线一个比较好的地方或者一种扩展,就是浏览器可以直接对该URL发起请求,利用HTTP缓存机制,减少资源重复获取,还能配合CDN,让用户从就近的节点获取资源,加载速度更快。而base64编码是把图片转成长字符串嵌入代码 ,每次加载页面或历史记录,浏览器都要重新解析这个长字符串,消耗更多 CPU 和内存。无法利用 CDN 加速,且图片多的对话中,还会拖慢首屏渲染,出现白屏时间更长的问题。

综上,这不失为一种当下好将来也好的实现方式。

ts 复制代码
import { useState, useRef } from "react"
import axios from "axios"

/**
 * 文件状态类型
 */
type UploadStatus =
  | "waiting"     // 等待上传
  | "uploading"   // 上传中
  | "success"     // 上传成功
  | "error"       // 上传失败
  | "cancelled"   // 已取消


/**
 * 每个文件的结构
 */
type UploadFile = {
  id: string
  file: File
  progress: number
  status: UploadStatus
  controller?: AbortController   // 用于取消上传
  retryCount: number             // 重试次数
}


/**
 * Hook 配置项
 */
type Options = {
  concurrency?: number   // 最大并发数
  retry?: number         // 最大重试次数
}


export function useUploadQueue(options: Options = {}) {

  const {
    concurrency = 3,
    retry = 2
  } = options

  /**
   * 文件状态列表
   * React 渲染 UI 依赖这个状态
   */
  const [files, setFiles] = useState<UploadFile[]>([])

  /**
   * 当前正在上传的任务数量
   */
  const activeCount = useRef(0)

  /**
   * 等待执行的任务队列
   */
  const queue = useRef<(() => Promise<void>)[]>([])

  /**
   * 是否暂停队列
   */
  const paused = useRef(false)


  /**
   * 更新某个文件状态
   * 通过 id 精确更新
   */
  const updateFile = (id: string, patch: Partial<UploadFile>) => {

    setFiles(prev =>
      prev.map(file =>
        file.id === id
          ? { ...file, ...patch }
          : file
      )
    )

  }


  /**
   * 添加文件
   * 用户选择文件后调用
   */
  const addFiles = (fileList: FileList | null) => {

    if (!fileList) return

    const list = Array.from(fileList)

    const newFiles: UploadFile[] = list.map(file => ({
      id: crypto.randomUUID(),
      file,
      progress: 0,
      status: "waiting",
      retryCount: 0
    }))

    setFiles(prev => [...prev, ...newFiles])

  }


  /**
   * 上传单个文件
   */
  const uploadFile = async (item: UploadFile) => {

    // 创建取消控制器
    const controller = new AbortController()

    updateFile(item.id, {
      status: "uploading",
      controller
    })

    try {

      const formData = new FormData()
      formData.append("file", item.file)

      await axios.post(
        "/upload",
        formData,
        {

          signal: controller.signal,

          /**
           * 上传进度回调
           */
          onUploadProgress(e) {

            const percent = Math.round(
              (e.loaded * 100) / (e.total || 1)
            )

            updateFile(item.id, {
              progress: percent
            })

          }

        }
      )

      /**
       * 上传成功
       */
      updateFile(item.id, {
        status: "success",
        progress: 100
      })

    } catch (err: any) {

      /**
       * 如果是取消请求
       */
      if (controller.signal.aborted) {

        updateFile(item.id, {
          status: "cancelled"
        })

        return
      }

      /**
       * 失败重试逻辑
       */
      if (item.retryCount < retry) {

        const newRetry = item.retryCount + 1

        updateFile(item.id, {
          retryCount: newRetry,
          status: "waiting"
        })

        // 重新加入队列
        addTask(() => uploadFile({ ...item, retryCount: newRetry }))

        return
      }

      /**
       * 最终失败
       */
      updateFile(item.id, {
        status: "error"
      })

    }

  }


  /**
   * 任务执行器
   * 控制并发数量
   *任务调度器run是实现的重点。先用一个队列存储文件数量,用一个变量记录当前的请求数目,循环调用run函数,当添加任务时调用一次,加入队列时调用一次,任务完成后调用一次,多方面调用调度器,让
 run()
   ↓
执行任务
   ↓
任务完成
   ↓
run()得以持续进行!
   */
  const run = () => {

    // 如果暂停,不执行任务
    if (paused.current) return

    // 并发限制
    if (activeCount.current >= concurrency) return

    // 队列为空
    if (queue.current.length === 0) return

    // 取出一个任务
    const task = queue.current.shift()

    if (!task) return

    activeCount.current++

    task().finally(() => {

      // 任务完成
      activeCount.current--

      // 继续执行队列
      run()

    })

  }


  /**
   * 添加任务到队列
   */
  const addTask = (task: () => Promise<void>) => {

    queue.current.push(task)

    run()

  }


  /**
   * 开始上传
   */
  const startUpload = () => {

    paused.current = false

    files
      .filter(f => f.status === "waiting")
      .forEach(file => {

        addTask(() => uploadFile(file))

      })

  }


  /**
   * 暂停上传
   */
  const pauseUpload = () => {

    paused.current = true

  }


  /**
   * 取消某个文件上传
   */
  const cancelUpload = (id: string) => {

    const file = files.find(f => f.id === id)

    if (!file) return

    file.controller?.abort()

  }


  /**
   * 重试某个文件
   */
  const retryUpload = (id: string) => {

    const file = files.find(f => f.id === id)

    if (!file) return

    updateFile(id, {
      status: "waiting",
      retryCount: 0
    })

    addTask(() => uploadFile(file))

  }


  return {

    files,

    addFiles,

    startUpload,

    pauseUpload,

    cancelUpload,

    retryUpload

  }

}

状态机实现和乐观UI渲染

每个文件都有对应的状态机,根据id进行占位,根据当前的状态进行渲染,利用URL.createObjectURL实现即时预览+毛玻璃+加载图标实现。让用户觉得有在上传。这里我最初是有考虑要监听进度,但我觉得你监听也就只能监听到上传到后端,从后端到oss再返回前端还有一段时间,这段时间是没有办法监听到的,所以我觉得你是没有办法十分精确的监听进度,干脆直接简单一点实现,就搞个加载图标做个样子。失败了就自动重新,重试几次后还不成功就暴露给用户,让用户决定是要点击再次尝试上传还是,实现要求是异步设置一个超时时间作为兜底方案,超时前端自动中断请求,抛出异常

多会话管理

背景

多会话管理+本地化存储,用户可能需要重新开一个会话来记录另一件事情,或者需要避免上下文过长导致的AI效果下降,需要重新开一个会话,另一方面对话历史不能因为刷新就丢失了,那用户之前的活都白干了。

思路

封装一个hook :

  1. 状态内聚:基本会话的增删改查要有
  2. 本地持久化:localStorage或者IndexedDB
  3. 函数式更新,确保基于最新的state进行修改

操作:

  1. 原先的AIChat组件是直接保存message对话的,现在我们要在此基础上扩展,但是还是先不要管它,我们先明确一件事,我们之后是要用会话里的message来替代掉。
  2. 我们先聚焦我们的会话hook,首先新建一个session默认对象存储相关信息,id,title,message,date;
  3. 之后state使用懒初始化,从本地存储中找有没有会话记录吗,没有就调用创建会话函数新建一个;
  4. 同样的做法获取当前会话id,下一次可以直接调出来
  5. 使用useEffect保证会话历史和当前会话id的本地持久化
  6. 剩下的就一样使用函数式更新实现crud;
  7. 使用useCallback稳定了所有的方法引用,避免产生不必要的重复渲染。因为组件内容很多,如果重复渲染压力很大。

接着是修改原先message的逻辑,获取currentSession.messages,剩下的就可以复用之前的逻辑了。

扩展

localStorage有大小限制,如何处理大量对话?

可以转移到IndexedDB,因为

你还了解哪几种本地化存储的方式,分别介绍一下;

存储方式 容量 生命周期 特点 场景
Cookie 4kb 可设置过期时间 每次请求都会携带Header,影响性能 鉴权Token
localStorage ~5MB 永久存储,除非手动清除 简单易用,同步操作,但是大对象会阻塞主线程 用户配置,小量历史记录
SessionStorage ~5MB 页面关闭即失效 仅在当前会话窗口有效 临时表单数据,单次对话状态
IndexedDB 只受到硬盘限制 永久存储 异步操作,支持索引,事务,存结构化大对象 大文件缓存,超长聊天记录

你有什么自定义hook的心得体会吗?

  1. 将非UI逻辑抽离,自定义hook本质就是业务逻辑的独立可复用,它让UI组件变成一个纯粹的皮肤,这极大提高了系统的可维护性
  2. 整个应该是一个黑盒状态,只对外暴露高级方法,而不暴露内部复杂逻辑
  3. 避免闭包陷阱和多次重新渲染问题:
    1. 灵活使用ref保证获取最新值
    2. 使用函数式更新状态
    3. 根据react官方文档的建议,在自定义hook中使用useCallback包裹导出的函数,确保使用者在将他们传递给子组件或者作为依赖时不会引起不必要的重新渲染或者副作用执行

markdown语法渲染组件

这里呢也是拷贝了博客园上面一位大佬的开源代码,但其实也有一些不适配的地方进行了小修改,因为市面上你能找到的相关代码都是直接在整个页面上实现AI对话界面的,但我这个是一个侧边栏的实现,空间上就不一样,导致渲染的效果也会有些问题,最突出的两个问题就是

  1. AI输出有很多空行,而我们的对话界面空间有限,所以有时候用户只能看到一部分内容,然后就面对一大片空行,就得经常下滑,这里的用户体验是不行的,而且频繁空行导致布局发生变化过快导致抖动。
  2. 原先的组件里面并没有对代码块,表格等内容进行宽度限制,导致输出的时候代码块等内容过长引起了整个对话界面出现横向滚动轴
  3. 性能优化:随着AI流式输出,这个组件是用在输出的content上的,一旦变化,整个组件都要卸载重新渲染,这个子组件也会跟着卸载重新渲染,这就造成了一个很严重的大量无效重复渲染问题。

解决方案

  1. 对于大量空行,使用正则表达式对空行进行压缩:

    js 复制代码
     const processedContent = () => {
        if (!content) return '';
        // 正则解释:
        // \n          匹配一个换行
        // (\s*\n){2,} 匹配后面紧跟的 2 个或更多换行(中间允许有空格/制表符)
        // 替换为 \n\n,确保最多只有两个换行(即一个可见空行)
        return content.replace(/\n(\s*\n){2,}/g, '\n\n');
      };

    先匹配一个换行,再匹配后面紧跟的两个或更多换行,替换成两个换行(即一个可见空行)

  2. 出现横向滚动轴解决:

    1. 先严格限制AI对话界面的宽度,并且将横向滚动轴禁掉
    2. 对表格,代码块等内容包装在 <div className="overflow-x-auto my-3">中,防止对话框拉宽
  3. 使用memo包装整个组件,当内容发生改变,只会更新最后一条内容,其他保持不变。最后一条变化 -> state状态改变 -> 整个父组件重新渲染 -> StramingMarkdown重新渲染进行memo浅比较 -> 只有最后一条消息正在渲染

剩下的插件,高亮组件的引用就是参考大哥的写法了嘻嘻嘻

AI对话输出和用户输出内容存在安全风险问题解决

AI和用户直接输入内容,如果你不过滤一下直接渲染,一不小心AI被劫持了还是你这个人有问题,输入内容带有恶意脚本,引发XSS安全问题。react-markdown 默认具有一定的防御性(它会将内容解析为 AST 而不是直接 dangerouslySetInnerHTML)。
解决方案 :虽然react-markdown已经默认做了第一层防护,它会把脚本当普通文本渲染而不是HTML,然而有些其他应用下面是需要解析HTML比如显示图片(后续如果有扩展生图功能的话),为了进一步的安全保障,我会引入 rehype-sanitize 插件。它能根据白名单过滤掉所有危险的 HTML 标签和属性(如 onerror、onload 等),确保输出的内容只包含安全的展示标签。

对于用户输入 :用户输入的内容不仅可能包含 XSS 脚本,还可能包含试图让模型"幻觉"或绕过安全审查的恶意指令。

补充点:

前端过滤:在发送到后端前,对敏感字符进行转义。使用sanitize插件进行过滤,对用户的请求频率进行限制,使用正则表达式对敏感数据进行脱敏,UI显示层对AI返回的链接增加一个中间提示弹窗,自动为所有外部链接添加 target="_blank" rel="noopener noreferrer"(防范 Tabnabbing 攻击)。

这行代码是HTML 超链接 <a> 标签的属性组合,作用是让链接在新标签页打开,同时提升安全性和性能,解析如下:

  1. target="_blank"

    核心作用:点击链接时,会在新的浏览器标签页中打开目标页面,而非替换当前页面。

  2. rel="noopener"

    安全防护:阻止新打开的页面通过 window.opener 访问当前页面的 window 对象,避免新页面恶意篡改原页面(如钓鱼、窃取信息)。

  3. rel="noreferrer"

    隐私+安全:一是不向目标页面传递当前页面的来源信息 (Referer 头),保护原页面隐私;二是和 noopener 一样,能阻止新页面对原页面的恶意操控,兼容性更全面。

简单说,这组属性就是安全、隐私地让链接在新标签页打开的标准写法。

MarkDown编辑器的二次封装和开发

如果用户只能在对话框里看 AI 输出,然后手动复制粘贴到 Word,效率太低。我们需要一个深度集成的编辑器,让用户能将 AI 生成的内容"一键插入"并进行二次润色、图片插入、以及多种格式的导出。这里参照了CSDN的做法来实现,支持丰富的导出模版。

选择使用react-markdown-editor-lite,因为它本身就已经支持很多功能,如编辑区和预览区相结合,支持表格,代码块等语法,而且还提供了插件机制,支持开发者增加或者重构插件。怎么发现的?AI推荐的,AI见多识广。

ImportPlugin (导入)

封装一个异步读取文件的函数,采用的是浏览器原生API FileReader+封装成Promise,把事件回调API变成可await的异步流程,更好控制代码逻辑。导入采用动态创建一个input元素,是由js临时创建,并没有写入页面,编写type,accept,onChange等属性,调用封装的异步读取文件的函数,然后在调用编辑器组件提供的插入文本的API实现功能

ExportPlugin(导出)

这里的导出功能参照一下csdn,提供两种选择,导出markdown或者html,其中html提供了模版选择,我这里提供了有css的修饰过的模版和没有使用css修饰过的模版。

markdown:使用Blob+临时url:Blob是浏览器提供的二进制数据对象,用来表示文件,图片,文本,这里是先把纯文本包装成一个文件对象,浏览器不能直接下载js内存里的文件对象,所以这里生成一个临时url,创建a标签将url作为下载地址,调用click()触发下载。之后释放url内存。createObjetURL 会占用内存,直到页面关闭才释放。所以手动释放一下。

html: 这里和markdown导出的做法基本一致,唯一不同的就是自定义一个html模版函数,先把原来的内容用模版润色一下

为什么前端下载文件必须使用Blob+URL.createObjectURL(),而不能直接下载字符串

浏览器只会下载URL指向的资源,而js字符串不是URL资源,浏览器根本不知道如何把它当做对象下载,Blob是浏览器提供的一种文件对象类型,让浏览器认为这是一个文件资源,但是Blob只是内存对象没有URL地址,所以要搞个临时地址让浏览器能够下载

为什么在导出时不采用tailwindcss而是使用原生的css?

用户在下载这个html文件后,可能会在各种场景下打开:

  • 离线没有网络环境
  • 邮件附件
  • 内外环境

导出的文件需要具备极致的便携性和自包含性。即不依赖外部环境,无依赖运行(不需要本地安装Tailwindcss或者任何构建工具),样式一致性(任何浏览器和预览器看到的样式都应该是一样的)

不选择tailwindcss:

  1. 编译依赖:Tailwind 是基于构建工具(PostCSS)生成的。在导出动态内容时,我无法在浏览器端实时运行一个"Tailwind 编译器"来提取 CSS。如果直接引用全量的 Tailwind CDN 脚本,文件体积会增加 3MB 以上
  2. 如果导出的 HTML 引用了 Tailwind 的 CDN 地址,用户在离线状态下打开文件时,页面将完全失去样式。这对于一个"导出文档"的功能来说是不可接受的。
  3. Tailwind 的做法:需要在每个标签上加 class="text-2xl font-bold..."。这需要对 markdown-it 生成的 HTML 进行二次遍历和注入,非常繁琐。 原生 CSS 的做法:只需定义 .markdown-body h1 { ... }。这种 "标签选择器" 模式天然适配 Markdown 输出,代码极其精简。

一键插入

参考csdn的做法,将AI生成的内容插入到编辑器中,打通两个隔离生态,实现用户与AI的协同作战第一步。组件通信,简单场景,直接状态提升。还可以尝试一下useContext,一个组件读取,另一个组件修改。

出现的一个问题 :使用react-markdown-elite自定义的插件使用的是Editor.use(),而如果你放入到组件中,就会导致多次插入新的插件,控制台报错说有多个相同的key,如果放在组件放在外面,由于是use开头,会被eslint判定为hook而报错,所以解决方案是使用 eslint-disable-next-line 解决误报问题(临时关闭下一行代码的指定 ESLint 规则(或所有规则),常用于代码本身符合业务逻辑,但触发了不必要的 ESLint 警告 / 错误的场景。)

封装Axios拦截器实现双令牌校验和无感刷新

实现这个比较麻烦,考虑的问题也很多,我决定遵循敏捷开发流程

  1. 实现axios拦截器,直接抄袭官方的模版代码

  2. 在发送请求前添加通行令牌

  3. 实现刷新令牌请求

  4. 添加锁和请求队列防止并发刷新请求

  5. 抄,写的挺好

  6. 在请求拦截器中添加刷新令牌

  7. 重点是添加响应拦截器:

    1. 对状态码2XX直接返回结果
    2. 对于非2开头的响应,先获取原来的请求,根据一下状态码和后端传来的错误消息(这里需要和后端协商配合)判断是否要调用刷新接口,是的话就直接调用并且等待返回新的通行令牌。
  8. 解决并发刷新请求问题:

    什么问题:多次刷新请求发送到后端。最先返回的 token 可能被后续返回覆盖。并发请求都在等待不同的刷新结果 → 潜在错误。第一个问题,大量无效刷新;刷新令牌:第一个人请求后更换了刷新令牌,原先刷新令牌失效,他们请求会导致系统误判,报401或者直接跳转回登录页

    解决:使用一个请求队列+锁头

    第一个发送刷新请求的拿到锁去请求刷新接口

    后面的人检测到锁头被人拿了,就进入请求队列进行等待

    等第一个人拿回了新的通行令牌,并把令牌发回给请求队列中的每个人

    大家都拿到后重新发起新的请求

    具体实现

    当一个401和令牌失效消息发过来,判断锁(变量isfreashing===false?)是否有人拿着,没人拿着直接调用刷新接口;然后await等待...

    下一波人来了,判断锁有没有人拿着,这个时候肯定就有了,直接进入请求队列蹲着,请求队列存放的是一个函数,这个函数是接收token然后重新发起请求。

    等待返回了新令牌,调用给队列里的元素分发令牌的函数,触发后面的请求用新的令牌去发起请求。

    问题

    为什么存放进数组的是一个函数,而不是一个原请求对象?

    1. axios要求返回必须是一个Promise,只放一个请求对象,会导致原来的请求中的中的resolve,原来的请求将永远不会结束,导致调用链卡死。
    2. 函数可以保存resolve,这里就是利用了闭包,用函数存放resolve,形成闭包,等到刷新成功就会触发resolve,真正结束掉Promise
    3. 直接存整个函数,到时候可以直接调用,简化了重新发送请求中代码的复杂度

    有什么小细节?在刷新令牌的注意方面

    我是不会出现这种情况的,但是为了增强功能的复用性,这里的刷新令牌可以新开一个干净的axios实例来请求刷新,避免因为后端变化或者前后端没有商量好导致复用原来的实例出现循环请求。毕竟前后端我都把关过

    你还了解过哪些校验方案?

    为什么在请求校验使用axios,而不是使用fetch,xhr?而在流式输出却是用了fetch?

    1. 不选xhr:基于事件监听,代码长,容易陷入回调地狱,不是基于Promise,需要大量包装,我不太会
    2. 不选fetch:没有拦截器模版,不会对状态码有正确的响应,只有收到响应就是resolve,原生fetch不支持timeout,需要手动转化json
    3. 选择axios:拦截器模版,可自定义开发;Axios可以在浏览器跑,也可以在nodejs跑,未来如果把项目迁移到nextjs上实现SSR,网络层逻辑可以无缝迁移
    4. 自动将对象转为json
    5. Axios 的局限:在浏览器端,Axios 对流式响应(Streaming)的支持并不友好,它主要为一次性的 JSON 交换设计。
    6. Fetch 的优势:Fetch 原生支持 ReadableStream。在处理 AI 的 SSE 返回时,我可以利用 reader.read() 逐块读取 Token。

    你还了解哪些校验方案,讲一下

主流登录校验方案对比表

方案名称 核心特点 实现方式 适用场景
Session-Cookie 有状态。服务端存储用户信息,浏览器自动管理 Cookie。 服务端生成 SessionID 存入 Redis/内存,通过 Set-Cookie 发给前端;后续请求自动携带。 中小型项目、传统管理后台、不涉及跨域的单体应用。
单 JWT 方案 无状态。服务端不存数据,由 Token 自带过期时间和载荷。 登录后返回一个长效 JWT;前端存入 LocalStorage/Cookie,请求时手动放入 Header。 移动端 App、简单的 RESTful API 接口、跨域简单的项目。
双 Token 方案 (本项目采用) 兼顾安全与体验。短效 AccessToken + 长效 RefreshToken。 AccessToken 传 Header,RefreshToken 存 HttpOnly Cookie;过期时通过拦截器无感刷新。 中大型互联网应用、对安全性要求高且需要极致用户体验的项目。
OAuth 2.0 / OIDC 第三方授权。标准化的授权协议,解耦身份提供方与应用方。 通过授权码(Code)换取 Token。涉及 ClientID、Secret、RedirectURL 等流程。 第三方登录(微信、Github)、开放平台接口调用、微服务架构。
SSO (单点登录) 一处登录,处处通行。多个子系统共享一个认证中心(CAS)。 在认证中心登录后,通过 Ticket/Token 在多个子域名间同步登录态。 企业内部办公系统 (OA)、阿里/腾讯等拥有庞大子应用矩阵的平台。
WebAuthn / Passkeys 生物识别/硬件鉴权。去中心化,抗钓鱼,安全性极高。 利用浏览器 API 调用系统的指纹、面部识别或硬件 U 盾,生成非对称加密签名。 金融支付类应用、对账号安全要求极高的极客产品。

使用JWT的优势

Session方案:服务器必须存储session数据,内存压力大,集群环境下必须做session共享,每次请求都需要去redis、数据库查询一次sessionID是否有效,跨端配置繁琐且容易被禁用,由于cookie被浏览器自动携带,极其容易受到跨站请求伪造攻击。

JWT:用户信息加密在token中,服务端不存在任何状态,只需验证签名合法性不需要进行额外的I/O查询。而且跨端友好,只是一个字符串,怎么传输都可以,通常将jwt放入Authorization Header 中,浏览器不会自动发送 Header。

综上,考虑到后端架构问题,未来跨端,安全性选择jwt

扩展,JWT劣势在于一旦发放,在过期前很难主动废除。可以在无状态和业务的安全行上寻求平衡点,在redis中使用黑名单。

相关推荐
yuki_uix2 小时前
防抖(Debounce):从用户体验到手写实现
前端·javascript
HelloReader2 小时前
Flutter 进阶 UI搭建 iOS 风格通讯录应用(十一)
前端
张元清2 小时前
每个 React 开发者都需要的 10 个浏览器 API Hooks
前端·javascript·面试
HelloReader2 小时前
Flutter ListenableBuilder让界面自动响应数据变化(十)
前端
yuki_uix2 小时前
深拷贝:JavaScript 引用类型的完全复制之道
前端·javascript
默默学前端2 小时前
JavaScript 中 call、apply、bind 的区别
开发语言·前端·javascript
宁雨桥2 小时前
前端设计模式面试题大全
前端·设计模式
Cg136269159742 小时前
JS函数表示
前端·html
在屏幕前出油2 小时前
02. FastAPI——路由
服务器·前端·后端·python·pycharm·fastapi