🚀🚀【封闭结束5】Agent 中的 SSE 流式推送与 HITL 暂停恢复

前言 :封闭告一段落,开始系统复盘:一个生产 Agent 到底怎么跑起来。踩坑、经验和落地,最主要的是用户认可产品。尽量用几篇文章讲清,这篇文章是系列第5篇:多步、变慢、要确认时,前端不能靠猜。把 Run、中断、恢复和流式事件定成前后端都能认的协议。

用户说:"重启这个异常实例。" 或中间补一句:"继续查它。"

这篇只讲:一次请求怎么流式推给前端,以及人确认之后怎么从断点接着跑。

单轮聊天接口不难。业务一接进来,通常马上会碰到这三件事:

  1. 用户说「继续查它」,系统不知道「它」是谁;
  2. Agent 跑很久,前端长时间没反馈;
  3. 删除、发布、转账必须让人确认,确认后还要从中断处继续。

这三件事分别对应:多轮状态、SSE 流式通信、HITL(Human in the Loop,人在回路)。继续用案例 OpsPilot(企业运维助手,虚构案例),技术栈仍是 TypeScript;前端侧可按 Vue 3 + fetch 读流来接。

一次运行要有自己的状态

不要只存一串聊天文本。至少分成三张表(或三份记录)来存:

  • 会话(Conversation):左边那一列聊天列表里的一项,比如「排障 - payment-api」。一个人可以有很多个会话。
  • 消息(Message):会话里的每一句:用户说的、助手回的、工具结果,各算一条。
  • 运行(Run):用户点一次发送,后台为这句话开的一次任务(跑模型、调工具、等人确认)。同一会话里可以有很多次 Run。
ts 复制代码
type Conversation = { // 一次聊天会话(聊天列表里的一项)
  id: string                 // 会话 id
  userId: string             // 属于哪个用户
  createdAt: string          // 创建时间
}

type Message = { // 会话里的一条消息
  id: string                 // 消息 id
  conversationId: string     // 属于哪个会话
  role: 'user' | 'assistant' | 'tool' // 谁说的
  content: string            // 正文(展示 / 写进数据库)
  createdAt: string          // 创建时间
}

type Run = { // 用户点一次发送对应的一次后台运行
  id: string                 // Run id
  conversationId: string     // 落在哪个会话
  userMessageId: string      // 由哪条用户消息触发
  status:                    // 这次运行进行到哪
    | 'running'              // 执行中
    | 'interrupted'          // 等人确认 / 补参
    | 'completed'            // 正常结束
    | 'failed'               // 失败
    | 'cancelled'            // 取消
  checkpoint?: unknown       // 中断后接着跑所需的最小状态
  createdAt: string          // 创建时间
  updatedAt: string          // 状态最后变更时间
}

一次用户请求对应一个 Run。用户确认后恢复的是这次运行,不用重新猜整段对话发生了什么。

三张表管「存哪」;下面三层管「每次喂模型什么」。

先能听懂「它」,别整段塞历史。 用户常说「继续查它」「刚才那个」。别把整段聊天记录都扔进模型;拼上下文时用三层就够:

  1. 已确认的事实 → 结构化 state(挂在会话上:Conversation 的字段或关联表);只在工具成功或用户明确确认后写入,查找失败不要把猜的写进来。
  2. 更早的对话 → 压成一段摘要,控制长度。
  3. 最近几句原文 → 谁说了什么原样保留,别先压成摘要,不然模型不知道「它」是谁。

对应到一次模型调用:第 1、2 层放进 system;第 3 层作为最近消息列表接在后面(不是把 messages 全表倒进去):

ts 复制代码
// 1) 已确认事实(挂在会话上)
const state = { service: 'payment-api', env: 'prod' as const }

// 2) 更早对话的摘要
const earlierSummary = '用户在排查生产环境报错;服务名与环境已确认。'

// 3) 最近几句原文(含当前这句「继续查它...」)
const recentMessages = [
  { role: 'user', content: '看下 payment-api 最近的错误' },
  { role: 'assistant', content: '生产环境过去 15 分钟有 12 次 5xx。' },
  { role: 'user', content: '继续查它最近的错误日志' },
]

// 拼装:1+2 → system;3 → 消息列表
const modelInput = [
  {
    role: 'system',
    content:
      `已知事实:${JSON.stringify(state)}\n` +
      `更早摘要:${earlierSummary}\n` +
      `用最近原文理解「它 / 刚才那个」是谁;不要靠关键词硬猜。`,
  },
  ...recentMessages,
]

先看一条完整链路(SSE + HITL)

用 OpsPilot 举例:用户说「重启这个异常实例」。整件事分两段------先查清、等人点确认 ;点完再真重启。

第一段:发消息、看进度、弹出确认

  1. 前端把这句话发给会话服务,同时打开一条 SSE,用来收进度(开始了、正在查、查完了......)。
  2. 后台先跑只读步骤(比如定位是哪个实例),这些进度通过 SSE 推到页面。
  3. 模型提出要调「重启」工具。这时还不执行重启 ,而是发一条 interrupt 事件:页面弹出确认(风险说明、要重启谁)。
  4. 这次 Run 记成「等人」;这一轮推流可以结束。页面上是确认,不是红字报错。

第二段:用户点确认,从断点接着跑

  1. 用户点「确认」。前端调用恢复接口(带上这次中断的 id、同意/拒绝、以及防双击用的同一把请求键),不是再发一条全新聊天。
  2. 服务端先确认:这个中断还有效、当前用户仍有权限、实例现在还是那个状态;通过后才真正重启。
  3. 执行过程再次用 SSE 推进度(工具开始/结束、最终回答),直到结束。
sequenceDiagram participant U as 用户 participant V as 前端 participant S as 会话服务 participant R as Runtime U->>V: 重启这个异常实例 V->>S: 发消息并订 SSE S->>R: 开始本次 Run S-->>V: 进度:开始 / 正在查询... R-->>S: 需要确认(先别重启) S-->>V: 弹出确认 U->>V: 点确认 V->>S: 调用恢复接口 S->>R: 再验权限后执行重启 S-->>V: 进度:重启中 / 最终回答

就记两句:

  • 等人确认用 interrupt,这是正常流程,不是失败。
  • 点确认后走恢复接口接着这次 Run 跑,不要当成新开一轮对话。

SSE 流式推送

SSE(Server-Sent Events)基于普通 HTTP:前端发一次请求,服务端持续推文本事件。「用户发一句,服务端持续回进度」时通常比 WebSocket 简单;要高频双向或二进制时再评估 WebSocket。

有的编排框架每次推过来的不是「刚发生的那一步」,而是「到目前为止的全部状态」(例如步骤列表又完整发一遍)。这时前端或网关要自己对比上次看过的内容,只把新多出来的那几步 转成 tool_started 这类事件;否则页面上同一步会显示两遍。

下面先定业务事件,再套 SSE 外壳;长连接上的注释行防超时、断开后重新连接,放在本节末。

事件协议

  • 流必须是 UTF-8 ;MIME 为 text/event-stream(可写成 text/event-stream; charset=utf-8)。
  • 建议每条业务事件里带一个本 Run 内越来越大的序号,比如 sequence: 1, 2, 3...。前端拿它判断「这条是不是已经渲染过」;以后若要「断开后把没收到的进度再拉回来」,也靠这个号。注意:字段先留着,不代表第一版就要做这件事;另外,这是你们自己 JSON 里的字段,和 SSE 协议自带的 id: 不是一回事。
ts 复制代码
import type { ServerResponse } from 'node:http'

type StreamEvent =
  | {
      type: 'run_started'
      runId: string
      sequence: number // 本 Run 内 1,2,3...;防重复渲染,以后也可按号把没收到的进度拉回来
    }
  | {
      type: 'text_delta'
      runId: string
      sequence: number
      delta: string // 增量字,不是全量覆盖
    }
  | {
      type: 'tool_started'
      runId: string
      sequence: number
      callId: string // 与 tool_finished 配对
      tool: string // 工具名,步骤条展示
    }
  | {
      type: 'tool_finished'
      runId: string
      sequence: number
      callId: string
      tool: string
      status: 'success' | 'error'
      summary?: string // 给前端的短摘要,不是原始大结果
    }
  | {
      type: 'interrupt'
      runId: string
      sequence: number
      interruptId: string // resume 时原样带回
      payload: unknown // 确认:风险说明、参数摘要等(已脱敏)
    }
  | {
      type: 'done'
      runId: string
      sequence: number
      messageId: string // 已写入数据库的助手消息 id
    }
  | {
      type: 'error'
      runId: string
      sequence: number
      code: string
      message: string // 可展示给用户;不要堆栈
    }

// 片段:在已创建 Run、准备推流的路由里调用(res 是 Node/Express 响应,不是 fetch Response)
function openSse(res: ServerResponse) {
  res.setHeader('Content-Type', 'text/event-stream; charset=utf-8')
  res.setHeader('Cache-Control', 'no-cache, no-transform')
  res.setHeader('X-Accel-Buffering', 'no') // 仅 nginx;不用可省略
  res.flushHeaders()
}

function sendEvent(res: ServerResponse, event: StreamEvent) {
  // 一条 SSE 消息:event 行 + data 行 + 空行结束
  res.write(`event: ${event.type}\n`)
  res.write(`data: ${JSON.stringify(event)}\n\n`)
}

// 需要等人时:先把 interrupt 和 Run=interrupted 写进数据库,再推事件(示意)
function emitInterrupt(
  res: ServerResponse,
  runId: string,
  sequence: number,
  interrupt: { id: string; payload: unknown }
) {
  sendEvent(res, {
    type: 'interrupt',
    runId,
    sequence,
    interruptId: interrupt.id,
    payload: interrupt.payload
  })
  // 随后可以 res.end() 结束本轮流;用户确认走 POST resume,不必占用同一条长连接
}

事件类型应带业务含义。不要让前端解析模型输出文本去猜「现在是不是在调工具」。

说明:若改用浏览器原生 EventSource,带了 event: 字段的消息不会 进 onmessage,必须 addEventListener(事件名, ...)。本文用 fetch 自解析时,是靠 JSON 里的 type 分发,和 EventSource 的命名事件机制不是同一条路径。

前端怎么读流、怎么接到 interrupt

原生 EventSource 只能 GET、不能带请求体和自定义头(跨域最多 withCredentials 带 Cookie)。聊天要 POST,所以用 fetch 读流、自己切事件。

为什么必须用 buffer(先攒起来再切,不能来一段就当完整事件):

  1. 网络一次读到的不一定是完整一条------可能只半截,也可能一次来好几条。
  2. 事件之间靠「空一行」分开。
  3. 空行怎么写因环境而异:有的是 \n,有的是 \r,有的是 \r\n(Windows 常见)。
  4. 解析时先统一收成 \n,再按连续两个换行 \n\n 切开。

下面骨架只负责拆包并回调;UI 自行接 onEvent。

ts 复制代码
// 可运行骨架:POST 聊天并解析 SSE
async function streamChat(
  body: unknown,
  onEvent: (event: StreamEvent) => void
) {
  const response = await fetch('/api/chat', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body)
  })

  if (!response.ok || !response.body) {
    throw new Error(`请求失败:${response.status}`)
  }

  const reader = response.body.getReader()
  const decoder = new TextDecoder() // SSE 规定 UTF-8
  let buffer = ''

  while (true) {
    const { done, value } = await reader.read()
    if (done) break

    buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n').replace(/\r/g, '\n')

    let boundary: number
    while ((boundary = buffer.indexOf('\n\n')) >= 0) {
      const block = buffer.slice(0, boundary)
      buffer = buffer.slice(boundary + 2)

      // 「: ...」是注释行,没有 data:,得到空串就跳过
      const data = block
        .split('\n')
        .filter(line => line.startsWith('data:'))
        .map(line => {
          const value = line.slice(5)
          return value.startsWith(' ') ? value.slice(1) : value
        })
        .join('\n')

      if (data) onEvent(JSON.parse(data) as StreamEvent)
    }
  }
}

// 接到事件后怎么处理(示意,不是完整 UI)
function handleStreamEvent(event: StreamEvent) {
  switch (event.type) {
    case 'text_delta':
      appendAssistantText(event.delta) // 追加字,不要整段替换
      break
    case 'tool_started':
      showStep(event.callId, event.tool, 'running')
      break
    case 'tool_finished':
      finishStep(event.callId, event.status, event.summary)
      break
    case 'interrupt':
      // 正常等待人:弹确认;保存 interruptId 供 resume
      openConfirmCard(event.interruptId, event.payload)
      break
    case 'done':
      markRunDone(event.messageId)
      break
    case 'error':
      showError(event.code, event.message)
      break
    default:
      break
  }
}

// 用户点确认:另发 resume,不是再 POST 一条「新聊天」
async function approveInterrupt(runId: string, interruptId: string) {
  await fetch(`/api/runs/${runId}/resume`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      interruptId,
      decision: 'approve',
      idempotencyKey: `resume_${interruptId}_1` // 同一确认动作固定一键,防双击
    })
  })
  // resume 若仍用 SSE 推进度:再发一次请求把 SSE 接上,或按你们约定复用同一条连接
}

连接别被空闲断开;断了也别指望浏览器替你补进度

推流时若半天没有业务事件,nginx / 网关常会按「空闲太久」把连接断开。做法很简单:隔一段时间发一行以 : 开头的注释(不是业务事件,前端解析时直接跳过):

text 复制代码
: keep-alive

间隔自己对着网关超时测(常见从 15~30 秒试起;nginx 的 proxy_read_timeout 默认往往是 60 秒)。

本文用的是 POST + fetch 读流,要心里有数:

  • 连接断了,浏览器不会自动再连,也不会帮你把没收到的进度拉回来。
  • 第一版优先保证:Run 状态在数据库里、断开或刷新后还能查到最终结果和确认卡;页面要继续看进度,就再发一次请求把 SSE 接上。
  • 以后若要做「断开之后,把没收到的进度再向服务端拉回来」,靠 JSON 里的 sequence 告诉服务端「我已经收到几号了,从下一号继续给我」------前提是这些事件当时已经存进数据库;没存就拉不回来。

补充一句:浏览器原生的 EventSource 往往会自动重连,并带上 Last-Event-ID。那也只是告诉服务端「我上次收到哪条」;浏览器照样不会自动把中间没收到的进度补全 ,逻辑仍在服务端。聊天要 POST,本文不以 EventSource 为主路径。

HITL:暂停等人,不要当成报错

缺参数、要审批等时,这次 Run 应记成 interrupted(暂停等人),不要 记成 failed。前端展示「等你确认 / 请补参数」,不要甩红字失败。

暂停时,至少留下两样东西:

  1. 一条中断记录(给前端弹确认卡、恢复时对得上号);
  2. 一份断点(确认后从哪一步接着跑)。

中断记录与断点

ts 复制代码
type Interrupt = { // 中断记录(等人确认 / 补参)
  id: string                 // 中断 id,恢复时必须原样带回
  runId: string              // 所属 Run
  type: 'confirm_action' | 'select_option' | 'provide_input' // 确认 / 选择 / 补参
  status: 'pending' | 'resolved' | 'expired' | 'cancelled' // 只有 pending 才能恢复
  payload: unknown           // 确认卡上要展示的内容(已脱敏)
  expiresAt: string          // 过了这个时间不能再点「同意」
}

type Checkpoint = { // 断点:继续跑所需的最小状态
  runId: string
  nextStep: string           // 恢复后从哪一步继续
  safeToolArgs: Record<string, unknown> // 已脱敏、恢复前还要再鉴权的参数
  conversationVersion: number // 防止恢复期间会话被别人改乱
  createdAt: string
}

payload 里不要塞数据库连接串、访问令牌、完整工具参数或内部堆栈。断点也只留继续跑真正需要的字段。

恢复接口

用户点「确认 / 拒绝 / 补参」时,打恢复接口,不是再发一条新聊天:

ts 复制代码
// POST /api/runs/:runId/resume 请求体
{
  interruptId: 'int_456', // 仍为 pending 的中断 id
  decision: 'approve', // approve | reject | 补参等
  idempotencyKey: 'resume_int_456_1' // 同一确认动作固定一把键,防双击
}

服务端不能直接信中断里那份旧参数。下面骨架里的 authorize / reloadResource / continueFrom 换成你们自己的实现即可,顺序不要乱:

ts 复制代码
type ResumeInput = {
  runId: string
  interruptId: string
  decision: 'approve' | 'reject' | { fill: Record<string, unknown> }
  idempotencyKey: string
}

// 1) 找到仍 pending 的中断  2) 用当前登录态鉴权  3) 防重复提交
// 4) 拒绝则只改状态  5) 重读资源  6) 再验权限  7) 从断点续跑
async function resumeRun(input: ResumeInput, auth: AuthContext) {
  const interrupt = await interruptRepo.findPending(input.interruptId)
  if (!interrupt || interrupt.runId !== input.runId) {
    throw new Error('INTERRUPT_NOT_FOUND')
  }

  await authorize(auth, interrupt) // 身份只信这次请求的登录态
  await assertNotExpired(interrupt) // 看 expiresAt;过期直接拒绝
  await idempotency.acquire(input.idempotencyKey) // 同一把键第二次进来:返回上次结果,别再执行

  if (input.decision === 'reject') {
    await markRejected(interrupt) // 只改状态
    return { status: 'cancelled' as const } // 不要再调会改数据的工具
  }

  const checkpoint = await checkpointRepo.load(input.runId)
  const latestResource = await reloadResource(checkpoint) // 等待期间实例可能已变
  await revalidate(latestResource, auth)

  return continueFrom(checkpoint, input.decision) // 按 nextStep 续跑
}

从弹出确认到用户点确认,资源和权限都可能变。所以,必须重读资源、重新鉴权。用户点拒绝时,只更新中断和 Run 状态,不要再去执行写操作。

记得要防双击、防重试打两次:

比如说:用户连点「确认」、网络抖一下浏览器自动重试、两个标签页各点一次,恢复接口都可能被打两遍。

所以我们的目标是:同一次确认,后面的写操作只真正执行一次。(在 OpsPilot 里就是「重启实例」只发生一次,不要重启两遍,这种动作很危险。)

常见两道防线,都写在恢复接口的后端代码里,由程序去访问数据库------不是让你打开数据库客户端手敲:

  1. 请求键 idempotencyKey:前端对「这一次点确认」生成一把固定的键(同一确认动作、连点、自动重试都带同一把)。服务端先查:这把键是不是已经处理过?处理过就直接返回上次结果,后面的重启逻辑不再跑。

  2. 改中断状态时加条件 :真正去执行写操作之前,用代码执行一条带条件的更新------「只有这条中断现在还是 pending,才改成 resolved」。两个请求几乎同时进来时,数据库里只会有一个更新成功(改动行数 = 1);另一个改动行数 = 0,说明已经有人抢先处理了,这个请求就不要再调重启工具。

ts 复制代码
// 写在 resume 接口里,通过数据库客户端执行(示意)
const result = await db.query(
  `UPDATE interrupts
   SET status = 'resolved', resolved_at = NOW()
   WHERE id = $1 AND status = 'pending'`,
  [interruptId]
)

if (result.rowCount === 0) {
  // 已经处理过、过期或取消了 → 直接返回,禁止再执行工具
  return alreadyHandledResponse()
}

// rowCount === 1:这是第一个抢到的请求,才允许继续重读资源、执行重启

记住一点就行:两个请求一起来时,靠上面这条带条件的更新,让数据库选出第一个成功的;只有它才能继续执行危险操作,另一个直接返回。

SSE 断了,后台要不要停

和「用户点取消」不是一回事。推流断开只表示前端暂时听不到进度;Run 要不要跟着停,看任务性质:

  • 查询类:客户端断开后可以取消,省模型和工具费用。
  • 发布、审批、重启实例等:通常应和 HTTP 连接拆开------后台 Run 继续跑,结果写进数据库;用户刷新后再把状态读回来。
ts 复制代码
// 默认只停推送;是否取消 Run 看策略
// Node:别听 req 的 close(POST 读完请求体就可能触发);听 res 的 close
// 正常 res.end() 也会触发 close:用 !writableFinished 区分「客户端提前断开」
res.on('close', () => {
  subscriber.detach(runId) // 停推送;不等于用户点了取消

  if (!res.writableFinished && runPolicy.cancelWhenNoSubscriber) {
    // 仅客户端提前断开;写操作 / 审批类通常不因断连取消
    runController.cancel(runId)
  }
})

和 LangGraph 怎么对照(点到为止)

不少团队用 LangGraph 跑 Agent 循环。它和本文解决的是同一类问题,分工不同:

  • 本文定的是对外协议:会话、Run、SSE 业务事件、恢复接口怎么跟前端约定。
  • LangGraph 管的是图怎么跑、中途状态怎么存。函数名、参数以你安装的版本文档为准,这里只记概念,避免版本一变例子就废。
本文 LangGraph 里常见对应(概念)
Run + 断点写进数据库,中断后还能接着跑 checkpointer 存图状态;用稳定的线程 id(常叫 thread_id)找回这份进度
同一会话里多次发送、状态接着用 同一条线程 id 上反复调图;一次性任务就每次换新 id
HITL:挂起等人,确认后再继续 节点里 interrupt,人补完后再 resume;挂起前要先开好 checkpointer
SSE 推「刚发生的一步」 图往往吐的是「到目前为止的全部状态」;网关/前端要自己对比,只把新多出来的步写成业务事件,避免同一步推两遍
恢复时重新鉴权、防双击、确认事件 框架不替你做;仍是会话服务 / Tool Runtime 的事

一句话:可以用 LangGraph 跑循环、存断点,但 SSE 事件名、恢复接口、鉴权和防双击仍要按本文自己定------接上框架不等于这些都齐了。

怎么测、常见误区与落地顺序

先测这些(上线前尽量跑一遍):

  • SSE:一个事件被拆成好几块、一块里塞了多条、中文跨块切开、网关空闲超时、断网、刷新页面、重连后同一步是不是又画了一遍。
  • HITL:双击「确认」、确认过期、恢复前权限被撤、实例在等待期间被别人改过、取消之后又点恢复。
  • 会话上下文(顺带) :「它 / 刚才那个」有没有认对;工具失败后有没有误写入 state。

落地顺序(建议,不是行业标准):

  1. 先做 POST + SSE、统一业务事件、前端 buffer 解析;消息和工具结果写进数据库。
  2. 再上 Run、中断、断点、独立恢复接口、防双击、恢复前重新鉴权。
  3. 事件序号、断开后按号把没收到的进度拉回来、多实例下的进度推送、摘要与会话 state,可以后做。

刷新或断线之后,你还能查到任务停在哪、确认卡还能不能点、恢复前该重新验什么------这才比较接近能上线。

小结

常见误区:

  • 把「等人确认」当成报错:报错是没法继续;中断是在等用户。页面、状态和恢复方式都不一样。
  • 等待状态只放内存:进程一重启或一扩容就丢。Run、中断记录、断点都要写进数据库。
  • 确认后直接拿旧参数去执行:等待期间资源和权限都可能变,恢复前要重读、再验。
  • 把 SSE 当成任务本身:SSE 只负责推进度;任务停在哪,以数据库里的 Run 为准。
  • 整段聊天记录塞进模型:贵,还容易被旧话带偏(见上文三层拼装)。

就这三条:

  1. 进度用 SSE 推业务事件(开始了、在调工具、弹出确认),别让前端靠读自然语言猜现在走到哪。
  2. 等人确认是正常流程,不是报错;用户点确认后走恢复接口,带着 interruptId 接着这次 Run 跑,不要当成新开一轮对话。
  3. 连接断了不等于任务没了:Run / 中断 / 断点要写进数据库;恢复前重新验权限、重读资源,并防双击执行两遍。

希望这篇文章对你有所帮助、有所借鉴。

相关推荐
java1234_小锋1 小时前
【技术专题】CSS3 网页技术 - CSS3 单位、颜色、字体与文本
前端·css·css3
biaov1 小时前
Jenkins + Docker 搭建前端 CI:前端一键打包部署
前端·docker·jenkins
rich_Chan.1 小时前
阿里云百炼联网搜索教程:web_search 工具开启方法、web_extractor 网页抓取与 json 检索轨迹
前端·阿里云·json
Lzh编程小栈1 小时前
STM32TIM基础定时器深度解析:时基原理、核心寄存器、影子寄存器、基础定时器和中断与DMA联动、代码实战及面试考点全解
stm32·单片机·面试
瓦瓦gogo1 小时前
实习中接触前端开发后,我开始了解 React
前端·react.js·前端框架
梦曦i1 小时前
uni-app x 新组件库v0.1.0发布,多端统一开发
前端·ui·uni-app x
Dovis(誓平步青云)1 小时前
切换语言后按钮被截断,本地化审计怎样发现长度风险
开发语言·前端·javascript·人工智能·机器学习·华为·harmonyos