Helios AI 助手平台 · 简历关键词速查

Cisco · 前端工程师 | AI 助手平台 | 驻场合作 · 2025.01 – 至今
企业级 AI 助手平台,技术栈 Astro 5 + Solid.js + UnoCSS + Monaco Editor,后端 FastAPI + SSE

1 SSE 流式渲染与事件状态机

简历原文①:负责 AI 对话 SSE 流式交互链路建设,设计 StreamQueue + ULID 边界识别的两层事件保序机制,配合流式事件状态机管理 13+ 种事件类型的渲染生命周期,推动相关线上渲染问题清零。
简历原文②:设计 MCP Tool Call 三级权限审批中断恢复机制,支持用户偏好双层缓存(localStorage + API),实现跨会话的审批记忆与断点续接。

口语版

口语版:我负责整个 AI 对话的流式交互链路,对应简历上两条,我分别讲。

第一条,两层保序 + 事件状态机。保序分两层:
  • 第一层 StreamQueue:SSE 虽然按序推送,但浏览器事件回调是异步的,前一个还在 await,后一个就进来了。我设计了串行队列,所有 handler 入队,上一个完成才执行下一个。timeout 设 1ms 让出事件循环给浏览器渲染。
  • 第二层 ULID 边界识别(Legacy API 路径):多步骤 Agent 流中,AI 可能先生成消息 A、调工具、再生成消息 B,所有 delta 走同一个 handler。后端给每个内容块生成 ULID(含时间戳的唯一 ID),前端追踪 identifier,一旦变化就 archive 当前消息、开新容器。不做这一层的话,消息 B 的文本会直接追加到消息 A 后面,用户看到的就是一坨。新 Responses API 路径不需要这层,因为新 API 通过 output_item.added 事件显式标记新消息块的开始。
保序解决之后,上层是一套完整的事件状态机。从 response.created → output_item.added → output_text.delta → mcp_call → done,每个阶段有明确的状态转换和渲染逻辑。不同事件类型走不同分支,比如文本流走增量 DOM 更新,mcp_approval_request 走审批 UI 分支。总共管理了 13+ 种事件类型,后续加新类型只需要加分支,不影响已有逻辑。上线后相关渲染问题清零。

第二条,Tool Call 三级权限审批与中断恢复。分审批和中断恢复两部分。
  • 审批:AI 调用 MCP 工具时,是否需要用户确认由三层角色控制:
    • ① Server Manager — 设工具的 execution_mode(工具级别)
    • ② Assistant Manager — 设助手级别策略(助手级别)
    • ③ 用户偏好 — 两层缓存:thread 级别存 localStorage(本对话内有效),global 级别走 API 持久化(跨设备同步)
    前端遇到 Tool Call 先查 global 再查 thread,命中就自动批准不弹窗。
  • 中断恢复:用户触发了审批但没处理就离开了,下次回来时 API 返回 pending 状态,前端先走一遍自动批准,不能自动才弹窗。整体是"断开-审批-重连"模式。
面试官想听什么
  • 你能说清楚为什么选 SSE 不选 WebSocket 吗?
  • "事件并发乱序"具体是什么情况?你怎么保证事件顺序?
  • "流式事件状态机"是怎么设计的?有哪些状态和转换?
  • "Tool Call 审批与中断恢复"是什么场景?技术上怎么实现?
  • 你说"线上渲染问题清零",之前有什么问题?
量化对照(清零之前 vs 之后)
现象(清零前) 根因 修复方案
多步骤回答中,第二段消息文本"糊"在第一段后面,没有分块 所有 delta 走同一个 handler,无法识别消息边界,新消息的 delta 直接 append 到旧消息的 DOM 节点上 引入 ULID 边界识别content_block_ulid),identifier 变化即 archive 旧消息、新建容器(messageUtils.ts:208
偶发空消息容器,文本追加到了不该追加的位置 output_item.added 内部是 async,还没设置好 currentMessageoutput_text.delta 已经触发并 append 到上一条消息 引入 StreamQueue 串行化所有 handler(library/stream_queue.ts),保证 added 完成才执行 delta
用户切对话回来,原本待审批的 MCP 调用直接消失 前端没有检测历史消息中的 approval_status: 'pending',当成普通已完成消息处理 checkForPendingToolCalls + checkAndAutoApproveMcpMessages 双重判断(GeneratorHelper.tsx:1099
用户审批后偏好不记忆,每次同一工具都要点 无双层缓存,每次 output_item.added → mcp_approval_request 都弹审批 UI once / thread / global 三档 scope,thread 进 localStorage、global 进 API(ToolCallConfirmation.tsx:61-118
大段文字一次性显示,看起来"卡顿" handler 同步 await 多个 DOM 更新,浏览器没机会绘制 StreamQueue 每个 handler 之间 setTimeout(0/1ms) 让出事件循环(stream_queue.ts:52
  • 面试讲法:先说"线上反馈集中在三类问题:消息糊在一起、空容器、审批丢失",再分别说每类的根因和修复,最后才说"上线后这三类工单清零"。不要先说清零,再补根因,那样像吹牛。

整体架构

用户点击发送
  │
  ├─ 1. 选端点(根据场景选择 SSE 入口)
  │     新建对话 + 首条消息POST /api/conversations/responses?stream=true已有对话追加消息POST /api/conversations/{id}/responses/continue?stream=true重新生成回答POST /api/threads/{id}/messages/{mid}/regenerate?stream=true
  │
  ├─ 2. SSE 流式推送(sse.js POST → text/event-stream)
  │     │
  │     ├─ StreamQueue 保序:所有事件入队串行 await,防止异步乱序
  │     │
  │     ├─ 核心事件流:
  │     │   response.created
  │     │     ↓
  │     │   output_item.added ── 按 metadata.type 分发(10 种):
  │     │     │                    message  thinking  mcp_call
  │     │     │                    mcp_approval_request  followup
  │     │     │                    internal_tool  token_usage
  │     │     │                    thread.saved  mistake_limit  max_requests
  │     │     ↓
  │     │   output_text.delta  ← 逐字追加,可能收到多次
  │     │     ↓
  │     │   output_text.done
  │     │     ↓
  │     │   output_item.done   ← 归档消息到历史列表
  │     │     ↓
  │     │   done               ← 关闭连接
  │     │
  │     ├─ metadata 更新流程(每条消息在流式过程中逐步填充):
  │     │   output_item.added  → 设初始 metadata:{ type, subtype, text_type, ... }
  │     │   output_text.delta  → 追加文本 + 合并 metadata:{ run_step_uuid, content_block_ulid }
  │     │   output_text.done   → 合并最终 metadata:{ options }(followup 追问的选项)
  │     │   output_item.done   → 写入完成状态:{ status: completed/error, error, items }(internal_tool)
  │     │
  │     ├─ type → UI 组件映射(MessageItem 根据 metadata 选择渲染组件):
  │     │   ┌─────────────────────────┬─────────────────────────────────────────┐
  │     │   │ messageNormalMessageContent markdown 渲染       │
  │     │   │ thinkingThinkingMessageContent 可折叠思考区     │
  │     │   │ streaming + 空内容ThinkingPlaceholderContent 加载动画   │
  │     │   │ mcp_callMCPToolCallContent 工具调用+结果折叠   │
  │     │   │ mcp_approval_requestMCPApprovalContent 审批按钮 UI        │
  │     │   │ internal_tool (todo_write)│ TodoListContent 待办列表              │
  │     │   │ internal_tool (其他)      │ InternalToolCallContent 工具状态卡片 │
  │     │   │ token_usageTokenUsageDisplay 用量统计(无头像)  │
  │     │   │ followupNormalMessageContent + 可点击选项按钮 │
  │     │   └─────────────────────────┴─────────────────────────────────────────┘
  │     │
  │     └─ MCP 审批流(type=mcp_approval_request 时触发)
  │         isToolAutoApproved()? → YES 自动批准 / NO 内联审批 UI
  │         用户选择 once / thread / global → continueSSERun → 新 SSE 连接
  │
  ├─ 3. done → 收尾
  │     sendAnswerReadyNotification()  ← 不入队,立即执行(后台标签兼容)
  │     执行 deferredFunctions          ← 延迟回调(更新文件计数等)
  │     eventSource.close() → resolve(message)
  │
  ├─ 4. resolve 后(Generator 调用方)
  │     archiveCurrentMessage()         ← 归档到历史列表
  │     checkAutoApproveAfterStreaming() ← 可能触发新一轮 SSE
  │
  └─ 5. 流后请求
        GET  /api/assistants/{id}          ← 条件触发,有缓存
        PATCH /api/threads/{id}/summary    ← 仅新对话,生成标题
        GET  /api/threads/{id}             ← 必定触发,同步状态
SSE 流式交互全链路架构图(完整版)
┌──────────────────────────────────────────────────────────────────────────────────────┐
│                              用户操作触发 SSE 请求                                  │
└─────────────────────────────────────┬────────────────────────────────────────────────┘
                                      │
    ┌──────────────┬──────────────────┼──────────────────┬─────────────────┐
    ▼              ▼                  ▼                  ▼                 ▼
  新建对话     已有对话追加       已有对话追加       重新生成回答    MCP 审批继续
  + 首条消息   (非 Agentic)      (Agentic 模式)
  创建thread+run  新建 Run          继续已有 Run

═══ API 端点路由(两套 API 并存,运行时按 mode 切换)═════════════════════════════════════

┌───── Responses API (新) ────────────────────┬───── Threads/Runs API (老) ──────────────┐
 条件: USE_RESPONSES_API=true + agent/act    条件: 其余情况                          
                                                                                     
 新建:                                       新建:                                    
  POST /api/conversations                     POST /api/threads                       
    /responses?stream=true                      /runs?stream=true                     
                                                                                     
 追加(非Agentic + Agentic 共用):              追加(非Agentic):                         
  POST /api/conversations                     POST /api/threads/{id}                  
    /{id}/responses/continue?stream=true        /runs?stream=true                     
                                                                                     
                                             追加(Agentic):                           
                                              POST /api/threads/{id}                  
                                                /runs/continue?stream=true            
                                                                                     
 审批继续:                                   审批继续:                                
  POST /api/conversations                     POST /api/threads/{id}                  
    /{id}/responses/continue?stream=true        /runs/{rid}/continue?stream=true      
  payload: { approve, auto_approve }          payload: { approve, auto_approve }      
├────────────────────────────────────────────┴─────────────────────────────────────────┤
 重新生成(共用): POST /api/threads/{id}/messages/{mid}/regenerate?stream=true         
└─────────────────────────────────────┬────────────────────────────────────────────────┘┌──────────────────────────────────────────────────────────────────────────────────────┐
SSE 连接建立(sse.js POST)                                                      │
│  请求体: { assistant_id, messages, mode, attachment_file_ids, ... }                │
│  响应: Content-Type: text/event-stream                                            │
└─────────────────────────────────────┬────────────────────────────────────────────────┘
                                      │
                                      ▼
┌──────────────────────────────────────────────────────────────────────────────────────┐
StreamQueue(串行异步队列)
│  所有事件回调 → queue.addToQueue(handler) → 串行 await 执行                       │
│  timeout = 1ms(让出事件循环,给浏览器渲染机会)                                    │
└───────────────────┬─────────────────────────────────────────┬────────────────────────┘
                    │                                         │
  ┌─────────────────▼──────────────────────┐   ┌──────────────▼──────────────────────────────┐
  │ Responses API 事件处理                  │   │ 老 API (Threads/Runs) 事件处理              │
  │ setupResponsesEventListeners            │   │ setupAllEventListeners                     │
  │                                        │   │                                             │
  │ 阶段1: 连接                             │   │ 消息生命周期:                              │
  │   response.created                     │   │   thread.message.created                    │
  │   response.in_progress                 │   │   thread.message.delta                      │
  │                                        │   │   thread.message.completed                  │
  │ 阶段2: 流式输出                         │   │                                             │
  │   output_item.added ← [type路由]      │   │ Thread & Run:                              │
  │   output_text.delta                    │   │   thread.completed                          │
  │   output_text.done                     │   │   thread.run.completed                      │
  │   content_part.added / done            │   │                                             │
  │   mcp_call_arguments.delta / done      │   │ Agentic 1.0:                               │
  │   mcp_call.in_progress                 │   │   tool.message.initialized                  │
  │   mcp_call.completed                   │   │   tool.router.in_progress / completed       │
  │   mcp_call.failed                      │   │   tool.output.start / completed             │
  │                                        │   │   reasoning.evaluation.start / completed    │
  │ 阶段3: 结束                             │   │                                             │
  │   output_item.done                     │   │ Agentic 2.0:                               │
  │   response.completed                   │   │   tool_call.intent                          │
  │   done → 关闭连接                      │   │   tool_call.approval  → MCP审批             │
  │                                        │   │   tool_call.in_progress                     │
  │                                        │   │   tool_call.completed                       │
  │                                        │   │   thread.message.notification               │
  │                                        │   │                                             │
  │                                        │   │ 其他:                                      │
  │                                        │   │   assistant.router.completed                │
  │                                        │   │   support_case_details.completed            │
  │                                        │   │   done → 关闭连接                          │
  └──────────────────┬─────────────────────┘   └─────────────────────────────────────────────┘
                     │
                     ▼
┌──────────────────────────────────────────────────────────────────────────────────────┐
output_item.added 的 metadata.type 子路由(Responses API 核心分发)               │
│                                                                                    │
thread.saved          → 保存 Thread 数据(id, metadata)                          │
message               → 创建消息容器,逐字追加文本,触发智能滚动                  │
thinking              → 显示"正在思考"折叠区                                      │
mcp_approval_request  → MCP 审批请求(生成 use_mcp 内联消息)                     │
mcp_call              → MCP 工具调用(支持并行,每个调用独立消息)                 │
followup              → 追问问题(含可选项 options)                                │
internal_tool         → 内部工具(write_to_file / todo_write)                     │
token_usage           → Token 用量统计                                             │
mistake_limit         → 错误次数限制通知                                           │
max_requests          → 最大请求数限制通知                                         │
└──────────────────────────┬───────────────────────────────────────────────────────────┘
                           │
                           │ 当 type=mcp_approval_request 或老API的 tool_call.approval┌──────────────────────────────────┐
              MCP Tool Call 审批流
              │                                  │
              │ mcp_call_arguments.delta / done  │
              │ mcp_call.in_progress             │
              │   ↓                              │
              │ isToolAutoApproved()?            │
              │   ├─ YES → 自动批准              │
              │   └─ NO  → 内联审批等待用户      │
              │            用户选择:              │
              │            once / thread / global │
              │   ↓                              │
              │ continueSSERun(approve)           │
              │   ↓                              │
              │ 新 SSE 连接从 checkpoint          │
              │ 继续推送后续事件                  │
              └──────────────────────────────────┘

┌──────────────────────────────────────────────────────────────────────────────────────┐
done 事件处理(收尾)
│                                                                                    │
│  done handler 内部:                                                               │
│  1. sendAnswerReadyNotification() — 浏览器通知(不入队,立即执行)                  │
│  2. 执行 deferredFunctions(延迟回调:更新文件计数等)                              │
│  3. eventSource.close() — 关闭 SSE 连接                                           │
│  4. resolve(message) — Promise resolve,释放调用方                                 │
│                                                                                    │
│  resolve 后(Generator 调用方):                                                  │
│  5. archiveCurrentMessage() — 归档到历史列表                                       │
│  6. checkAutoApproveAfterStreaming() — 检查 pending MCP → 可能触发新一轮 SSE       │
└──────────────────────────────────┬───────────────────────────────────────────────────┘
                                   │
                                   ▼
┌──────────────────────────────────────────────────────────────────────────────────────┐
流结束后的接口请求(requestWithLatestMessage 收尾阶段)                           │
│                                                                                    │
GET /api/assistants/{id}          ← 条件触发(有缓存),获取 Assistant 信息        │
PATCH /api/threads/{id}/summary   ← 仅新对话,调用后端生成标题                     │
GET /api/threads/{id}             ← 必定触发,拉取最新 Thread 数据                │
└──────────────────────────────────────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────────────────────────────────────┐
中断恢复:用户重新进入对话
│                                                                                    │
GET /api/threads/{id}/messages  ← 加载历史消息                                    │
│    ↓                                                                               │
│  checkForPendingToolCalls(lastMessage)                                             │
│    ├─ run_step_name='tool_inputs' → Agentic 1.0 pending                            │
│    └─ subtype='use_mcp' + approval_status='pending' → Agentic 2.0 pending          │
│    ↓                                                                               │
│  isToolAutoApproved() → 查 Global(API) → 查 Thread(localStorage)                   │
│    ├─ YES → 自动批准 → continueSSERun → 新 SSE 流                                  │
│    └─ NO  → 展示审批 UI(内联)等待用户操作                                         │
└──────────────────────────────────────────────────────────────────────────────────────┘
API 接口清单

SSE 流式接口(两套 API 并存)

触发场景 Responses API (新) Threads/Runs API (老) 说明
新建对话 POST /api/conversations/responses?stream=true POST /api/threads/runs?stream=true 同时创建 thread + run
追加消息(非Agentic) POST /api/conversations/{id}/responses/continue?stream=true POST /api/threads/{id}/runs?stream=true 新建 Run
追加消息(Agentic) POST /api/threads/{id}/runs/continue?stream=true 继续已有 Run
重新生成 POST /api/threads/{id}/messages/{mid}/regenerate?stream=true 两套 API 共用此端点
审批后恢复 POST /api/conversations/{id}/responses/continue?stream=true POST /api/threads/{id}/runs/{rid}/continue?stream=true payload: { approve, auto_approve }

流结束后请求

接口 触发条件 说明
GET /api/assistants/{id} 条件触发(有本地缓存) 流返回的 message 含 assistant_id 时获取 Assistant 信息
PATCH /api/threads/{id}/summary 仅新对话 调用后端 LLM 生成会话标题
GET /api/threads/{id} 必定触发 拉取最新 Thread 数据同步前端状态

其他接口

接口 说明
GET /api/threads/{id}/messages 加载历史消息,返回后检测 pending Tool Call(中断恢复)
PATCH /api/users/{id}/metadata 保存 Global 审批偏好,持久化 tool approval 配置
SSE 事件类型完整清单

Responses API 事件(setupResponsesEventListeners

事件名 入队 处理逻辑
response.created 设状态为 THINKING
response.in_progress 保持 THINKING 状态
response.output_item.added metadata.type 路由,共 10 种子类型(见下表)
response.output_text.delta 追加文本到当前消息 + 触发滚动
response.output_text.done 文本输出完成,合并最终 metadata(含 followup options)
response.content_part.added 内容片段添加(仅日志)
response.content_part.done 内容片段完成(仅日志)
response.mcp_call_arguments.delta MCP 调用参数流式输出
response.mcp_call_arguments.done MCP 调用参数完成,解析 JSON 更新消息
response.mcp_call.in_progress MCP 调用执行中,更新状态栏 tool 名称
response.mcp_call.completed MCP 调用完成,更新指定消息内容
response.mcp_call.failed MCP 调用失败,写入错误信息到消息
response.output_item.done 归档消息;处理 followup options / internal_tool 完成 / mcp 完成
response.completed 响应完成(仅日志)
done 关闭连接 + 执行 deferred 回调 + resolve Promise
error 解析错误 JSON → 展示友好提示

output_item.addedmetadata.type 子路由

type 值 处理函数 说明
thread.saved handleThreadSaved 保存 Thread 数据(id, metadata)
message handleMessageAdded 创建消息容器,后续由 output_text.delta 逐字追加
thinking handleThinkingAdded 显示"正在思考"折叠区,提取 model name
mcp_approval_request handleMcpApprovalRequest MCP 审批请求 → 生成 use_mcp 内联消息
mcp_call handleMcpToolAdded MCP 工具调用(支持并行,每个调用独立 mcp_response 消息)
followup handleFollowupAdded 追问问题,options 由 output_item.done 补充
internal_tool handleInternalToolAdded 内部工具(write_to_file / todo_write)
token_usage handleTokenUsageAdded Token 用量统计
mistake_limit handleNotifyAdded 错误次数限制通知
max_requests 最大请求数限制通知

老 API (Threads/Runs) 事件(setupAllEventListeners

分类 事件名 入队 处理逻辑
消息生命周期 thread.message.created 设状态为 GENERATING
thread.message.delta 追加文本 + 消息边界检测(ULID 切换时归档)
thread.message.completed 归档消息,合并 metadata,设 thread_id
Thread & Run thread.completed 更新 thread 数据
thread.run.completed 记录 runId
Agentic 1.0 tool.message.initialized 设 pending tool calls + 更新 ToolCallCache
tool.router.in_progress 状态: SEARCHING_TOOLS
tool.router.completed 重置状态为 THINKING
tool.output.start 状态: EXECUTING_TOOL + 显示 tool/server 名称
tool.output.completed 重置状态为 THINKING
reasoning.evaluation.* start → REVIEWING / completed → THINKING
Agentic 2.0 tool_call.intent 状态: SEARCHING_TOOLS
tool_call.approval 生成 use_mcp 内联消息 → 进入审批流
tool_call.in_progress 状态: EXECUTING_TOOL + tool/server 名称
tool_call.completed 重置状态为 THINKING
thread.message.notification 通知类消息(ask 类型支持 auto-continue)
其他 assistant.router.completed 获取 Assistant 信息并设置到上下文
support_case_details.completed Support case 表单配置
done 关闭连接 + 通知 + auto-approve 检查 + resolve
  • 标记 的事件通过 StreamQueue 串行处理,保证有序;标记 的事件直接同步处理(无状态变更或仅做日志)
  • done 中的 sendAnswerReadyNotification 不入队直接执行 — 因为浏览器后台标签会节流定时器,通知必须立即发
  • 两套事件体系完全独立,运行时根据 USE_RESPONSES_API + agenticMode 选择其一

我会怎么讲

1. 为什么选 SSE 不选 WebSocket

  • LLM 生成文本是服务端单向推送,不需要客户端持续向服务端发数据,SSE 的语义更匹配
  • SSE 基于 HTTP,天然兼容现有的认证、代理、负载均衡基础设施,不需要额外协议升级
  • 断线自动重连是 SSE 的内建能力;WebSocket 需要手动实现心跳和重连
  • 标准 EventSource 只支持 GET,但我们的请求体很大(消息列表、附件 ID、模型配置等),所以用了 sse.js 库,支持 POST 方式发起 SSE

2. 事件保序:StreamQueue + ULID 边界识别

第一层:StreamQueue — 异步处理串行化

  • SSE 是单连接按序推送的,但浏览器的事件回调是异步的 — 当上一个事件的异步处理还没完成,下一个事件回调就进来了,导致状态更新乱序
  • 比如:output_item.added(创建消息容器)还没执行完,output_text.delta(追加文本)就到了,结果文本追加到了空容器
  • 解法:设计了 StreamQueue — 一个串行异步队列,所有事件处理函数按顺序入队,上一个完成才执行下一个
StreamQueue 伪代码
class StreamQueue {
  constructor(timeout = 1) {
    this.queue = [];
    this.isProcessing = false;
    this.timeout = timeout;
  }

  push(asyncFn) {
    this.queue.push(asyncFn);
    if (!this.isProcessing) {
      this.isProcessing = true;
      setTimeout(() => this.processQueue(), 10);
    }
  }

  async processQueue() {
    while (this.queue.length > 0) {
      const fn = this.queue.shift();
      if (fn) await fn();
      await new Promise(r => setTimeout(r, this.timeout));
    }
    this.isProcessing = false;
  }
}
  • 关键设计:timeout 设 1ms,目的不是节流,而是让出事件循环让 UI 有机会渲染,避免大量 DOM 更新积压导致卡顿
  • 初始 10ms 延迟是为了让第一批事件先入队再开始处理,避免频繁启停

第二层:ULID 边界识别 — 多步骤消息归属

  • 问题:多步骤 Agent 流中,AI 可能先生成消息 A → 调用工具 → 再生成消息 B。所有 delta 事件走的是同一个 SSE 连接和同一个 handler,如果不区分,消息 B 的文本会直接追加到消息 A 的尾部,用户看到一坨糊在一起的文本
  • 解法:后端用 StreamingUlidsCollector 为每个内容块生成 ULID(Universally Unique Lexicographically Sortable Identifier,含时间戳+随机数),通过 SSE 事件的 metadata 下发
  • 前端用 getMessageIdentifier() 提取(优先 content_block_ulid,fallback run_step_uuid),追踪 currentIdentifier:一旦 identifier 变化,说明切换到了新消息 → archive 当前消息,创建新消息容器
ULID 边界检测伪代码
let currentIdentifier = '';

onDelta(event) {
  const identifier = getMessageIdentifier(event.metadata);
  // content_block_ulid ?? run_step_uuid

  if (currentIdentifier && currentIdentifier !== identifier) {
    queue.addToQueue(async () => {
      archiveCurrentMessage();  // 消息 A 定稿 → 推入 messageList
    });
  }
  currentIdentifier = identifier;

  queue.addToQueue(async () => {
    appendDelta(event.delta);   // delta 追加到新的 currentMessage
  });
}
  • 为什么用 ULID 而不是自增序号? ULID 天然含时间戳且全局唯一,后端多个 agent step 并行生成时不需要全局计数器协调
  • 这两层配合:StreamQueue 保证 handler 串行执行,ULID 保证每个 delta 归属到正确的消息
为什么不这么做(被否定的方案)

替代方案 A:让后端把每条消息聚合完再下发,前端只做拼接

  • 否定原因:违背"流式"产品诉求。LLM 生成第一段消息时,第二段还没开始;要等到聚合完,前端就退化成一次性渲染,用户看到的是几秒静止后突然出现一坨文字。
  • 额外问题:tool call 阶段无法"边调用边展示进度",整个 Agent 流的可观察性被毁掉。

替代方案 B:用 message_id(自增数字)代替 ULID 做边界识别

  • 否定原因:后端是多 worker 异步生成的,跨 step 的 message_id 在落库前还不知道。要么前端等 message_id 才渲染(破坏流式),要么前端用临时 id(又得做 id 映射)。
  • ULID 由后端在内存里生成不依赖 DB 写入,自带时间戳排序、全局唯一,可以在第一个 delta 上就携带出来。
  • 对比:UUID v4 也行但失去时序,调试时无法按时间线排查;snowflake 需要中心化协调器,对前端透明性差。

替代方案 C:每条消息开新 SSE 连接(一连一消息)

  • 否定原因:Agent 流里 tool call 经常 5-10 次往返,连接开销+鉴权成本会把延迟拖到秒级。
  • 且 SSE 连接内的 token 顺序天然有保证,没必要把多步骤拆成多连接,后端用一个 ULID 就能还原边界。

替代方案 D:StreamQueue 用 microtask(queueMicrotask)而不是 setTimeout

  • 否定原因:microtask 不让出渲染机会。一次 SSE 推送可能在一个 macrotask 里堆积数十个 delta,全部走 microtask 会阻塞 paint,结果就是"白屏一段时间然后整段刷出来"。
  • setTimeout(fn, 1) 的 1ms 不是延迟而是给浏览器一个绘制窗口,肉眼无感但帧率正常。

3. 事件状态机

  • SSE 事件按照严格的生命周期推送,前端用状态机来管理整个流每条消息两层状态:
  • 流级状态机:connecting → thinking → streaming → tool_pending(可选) → done
  • 消息级状态机:每条消息独立,由 output_item.added 创建,output_item.done 归档
事件流转(正常对话)
// 1. 连接建立
response.created → response.in_progress  // 状态: THINKING

// 2. 输出项创建(按 type 路由)
response.output_item.added
  → type=message    → 创建消息容器,开始逐字渲染
  → type=thinking   → 显示"正在思考"折叠区
  → type=mcp_call   → 展示工具调用过程
  → type=mcp_approval_request → 进入 MCP 审批流
  → type=internal_tool → 显示内部工具调用

// 3. 文本流式输出
response.output_text.delta  → 追加文本 + 触发滚动
response.output_text.done   → 文本完成

// 4. 输出项完成
response.output_item.done   → 归档消息到历史列表

// 5. 流结束
done → 发送通知 → 执行 deferred 回调 → 关闭连接
流级状态机图(白板可画版)
                          [INIT]
                            │
                            │ POST /responses?stream=true
                            ▼
                       [CONNECTING]
                            │
                            │ response.created
                            ▼
                        [THINKING] ◄─────────────────┐
                            │                       │
              ┌─────────────┼─────────────┐         │ mcp_call.completed
              │             │             │         │ output_item.done (mcp/tool)
              │             │             │         │
              ▼             ▼             ▼         │
       [STREAMING_TEXT] [STREAMING_MCP] [STREAMING_TOOL]
              │             │             │
              │             │             │
              │       (mcp_approval_request)
              │             │             │
              │             ▼             │
              │      [TOOL_PENDING]      │
              │             │             │
              │  ┌──────────┼──────────┐  │
              │  │auto_approved│ user_action│
              │  │            │            │
              │  ▼            ▼            │
              │ continueSSERun(approve=true/false)
              │  │            │            │
              │  └──────┬─────┘            │
              │         │ 新 SSE 连接       │
              │         ▼                  │
              │     [THINKING] ◄───────────┤
              │                            │
              │ output_text.done           │
              ▼                            │
       [ARCHIVING] ◄────────────────────┘
              │
              │ output_item.done → archiveCurrentMessage()
              ▼
        [THINKING] (等下一个 output_item.added)
              │
              │ done event
              ▼
          [DONE]
              │
              │ eventSource.close() + resolve(message)
              ▼
         [CLOSED]

异常路径:
   任意状态 ──onerror──► [ERROR] ──parseApiError──► reject(e)
   任意状态 ──用户刷新──► [INIT] ──fetchMessageData──► checkForPendingToolCalls
                                                       │
                                                       ▼
                                                 [TOOL_PENDING](中断恢复入口)
  • 关键不变量:流级状态在 THINKING ↔ STREAMING_* 之间循环,done 事件是唯一的终态触发。
  • TOOL_PENDING 是唯一会"暂停流"的状态:原 SSE 连接实际上已经在服务端关闭(推完 mcp_approval_request 就停),新 SSE 连接由用户审批后通过 continueSSERun 重建。
  • 中断恢复是另一条进入 TOOL_PENDING 的路径(绕开了 [STREAMING_*]),所以恢复逻辑要独立于流式 handler。
消息级状态机(每条消息独立)
                  [NULL]
                    │
                    │ output_item.added (按 metadata.type 选分支)
                    │
       ┌────────┬────┴────┬─────────┬────────────┬─────────┐
       ▼        ▼         ▼         ▼            ▼         ▼
   [message] [thinking] [mcp_call] [mcp_approval] [internal] [followup]
       │        │         │         │            │         │
       │ delta  │ delta   │ args.   │ pending    │ in_prog  │ delta
       │ ↓      │ ↓       │ delta   │ ↓          │ ↓        │ ↓
       │ 追加   │ 追加     │ ↓       │ 等用户     │ 执行     │ 追加
       │        │         │ 累积    │            │           │
       ▼        ▼         ▼         ▼            ▼          ▼
   [STREAMING] [STREAMING] [STREAMING] [PENDING] [STREAMING] [STREAMING]
       │        │         │         │            │           │
       │        │         │  approve/deny        │           │
       │        │         │         ↓            │           │
       │        │         │  approval_status=    │           │
       │        │         │   approved/denied    │           │
       │        │         │  (auto_approve字段)   │           │
       │        │         ▼         │            │           │
       │        │      mcp_call.   ↓            │           │
       │        │      completed/  恢复SSE       │           │
       │        │      failed     │            │           │
       │        │         │       │            │           │
       └────────┴────┬────┴───────┴────────────┴───────────┘
                    │ output_item.done
                    ▼
               [ARCHIVING]
                    │
                    │ archiveCurrentMessage() → push 到 messageList
                    ▼
              [ARCHIVED]
                    │
                    │ status: completed | error | denied
                    ▼
              [FINAL]
  • 为什么要分两层状态机:流级状态决定"还能不能继续推"(是否要等用户、是否要新建连接);消息级状态决定"这条消息渲染成什么组件"(不同 type 对应 7 个 UI 组件)。耦合在一起会让 type 路由膨胀到不可维护。
  • 新增事件类型的成本:在消息级状态机里加一个分支 + 一个组件,不动流级,所以可以"加新类型不影响已有逻辑"。

4. Tool Call 审批与中断恢复

  • MCP(Model Context Protocol)是 AI 调用外部工具的协议。AI 决定调用 MCP Tool(比如查数据库、发邮件)时,是否需要用户确认,由三层角色控制:
三级审批架构
// 第 1 级:MCP Server Manager(管理员)
// 在 MCP 管理后台配置每个 Tool 的 execution_mode
tool_execution_mode: 'autonomous' | 'approval'
// autonomous = 自动执行(低风险工具,如查天气)
// approval   = 需要审批(高风险工具,如发邮件、删数据)

// 第 2 级:Assistant Manager(助手管理员)
// 在 Assistant 配置中管理该助手调用某个 MCP Tool 时是否自动 approve

// 第 3 级:用户自己的审批偏好(前端实现重点)
type ApprovalScope = 'once' | 'thread' | 'global'
// once   → 仅本次(不记忆)
// thread → 本次对话内记忆(localStorage)
// global → 所有对话记忆(API 持久化)

用户审批偏好的双层缓存

isToolAutoApproved 检查流程
function isToolAutoApproved(toolName, serverName, threadId) {
  // 先查 Global(API 持久化) — 用户级别
  if (UserPreferenceStore.isToolGloballyApproved(toolName, serverName)) {
    return true;  // 用户曾选"所有对话都自动批准"
  }
  // 再查 Thread(localStorage) — 对话级别
  const threadPrefs = loadThreadApprovalPreferences();
  const threadTools = threadPrefs.threads[threadId]?.[serverName];
  if (threadTools?.includes(toolName)) {
    return true;  // 用户曾在这个对话里选"本次对话自动批准"
  }
  return false;  // 需要弹窗让用户手动确认
}

function saveApprovalPreference(scope, toolName, serverName, threadId) {
  if (scope === 'once') return;
  if (scope === 'global') {
    UserPreferenceStore.addGlobalToolApproval(toolName, serverName); // API 持久化
  } else if (scope === 'thread') {
    // 写 localStorage → 当前对话内有效
    const prefs = loadThreadApprovalPreferences();
    prefs.threads[threadId][serverName].push(toolName);
    localStorage.setItem('tool_approval_thread_preferences', JSON.stringify(prefs));
  }
}
  • Thread 级别用 localStorage:单次对话偏好不走 API,刷新后仍在、关浏览器自然失效
  • Global 级别用 API:需跨设备同步,用户的持久设置

审批交互流程

  • SSE 推送 mcp_approval_request → 暂停流式渲染 → 弹审批 UI(工具名、参数、服务器名)
  • 用户选 once/thread/global → saveApprovalPreferencecontinueSSERun({ approve: true, auto_approve: false }) 恢复 SSE
  • 如果 isToolAutoApproved 命中 → 自动批准不弹窗 → { approve: true, auto_approve: true },UI 显示"Auto approved"
  • SSE 恢复是"断开-审批-重连"模式:continueSSERun 创建新 SSE 连接,服务端从 checkpoint 继续推送

中断恢复

  • 场景:AI 触发了 Tool Call 审批,但用户没处理就离开了(关页面、切对话、关机)
  • 恢复:下次进入这个对话时,API 返回的最后一条消息带 pending 状态
Tool Call 审批 + 中断恢复 完整决策树(白板可画版)
                  [Tool Call 触发入口]
                  ┌───────────┴───────────┐
                  ▼                       ▼
        A. 流式过程中触发          B. 中断恢复触发
        (mcp_approval_request          (打开对话时检测到
         事件 / tool_call.approval)     pending 消息)
                  │                       │
                  │                       │ checkForPendingToolCalls()
                  │                       │  ├─ Agentic 1.0: run_step_name='tool_inputs'
                  │                       │  └─ Agentic 2.0: subtype='use_mcp'
                  │                       │              + approval_status='pending'
                  │                       │
                  └───────────┬───────────┘
                              ▼
                  [查后端策略下发]
                  tool_execution_mode = ?
                              │
                  ┌───────────┴───────────┐
                  ▼                       ▼
            'autonomous'              'approval'
            (Server Manager           (Server Manager 强制审批,
             配置工具自治)              用户偏好不能覆盖)
                  │                       │
                  │  跳过审批,               │
                  │  直接 continueSSERun       │
                  │  (approve=true,            ▼
                  │   auto_approve=true)   [查 Assistant 策略]
                  │                       Assistant Manager 是否
                  │                       为该助手放通?
                  │                              │
                  │                  ┌───────────┴───────────┐
                  │                  ▼                       ▼
                  │              放通                     不放通
                  │              (回到 autonomous           │
                  │               分支处理)                 ▼
                  │                                [前端用户偏好双层缓存]
                  │                                isToolAutoApproved(tool, server, threadId)
                  │                                       │
                  │                          ┌────────────┼────────────┐
                  │                          ▼            ▼            ▼
                  │                    Global 命中     Thread 命中     都不命中
                  │                    (UserPref      (localStorage    │
                  │                     Store API)    threadPrefs)    ▼
                  │                          │            │       [弹审批 UI]
                  │                          │            │       Agentic 1.0:
                  │                          │            │         Dialog 弹窗
                  │                          │            │         (ToolCallConfirmation)
                  │                          │            │       Agentic 2.0:
                  │                          │            │         消息内联审批
                  │                          │            │         (MCPApprovalContent)
                  │                          │            │            │
                  │                          │            │   用户选择 scope:
                  │                          │            │   ┌─────┬──────┬───────┐
                  │                          │            │   ▼     ▼      ▼       │
                  │                          │            │  once  thread global   │
                  │                          │            │   │     │      │       │
                  │                          │            │   │     │  saveApproval │
                  │                          │            │   │     │  Preference   │
                  │                          │            │   │     │  (API write)  │
                  │                          │            │   │  saveApproval       │
                  │                          │            │   │  Preference         │
                  │                          │            │   │  (localStorage)     │
                  │                          │            │   │     │      │       │
                  │                          ▼            ▼   ▼     ▼      ▼       │
                  │                       自动批准(不弹窗)        手动批准/拒绝     │
                  │                          │                       │              │
                  │                          │                  ┌────┴────┐         │
                  │                          │                  ▼         ▼         │
                  │                          │              approve     deny        │
                  │                          │                  │         │         │
                  │                          └──────┬───────────┘         │         │
                  │                                 │                     │         │
                  └─────────────────────────────────┴─────────────────────┘         │
                                    │                                               │
                                    ▼                                               │
                       continueSSERun()                                           │
                       POST .../continue?stream=true                                │
                       payload: { approve, auto_approve }                           │
                                    │                                               │
                                    ▼                                               │
                       新 SSE 连接,从 checkpoint_id 继续推送                         │
                                    │                                               │
                                    └───────────────────────────────────────────────┘
                                                       (拒绝路径:approval_status='denied',
                                                        UI 显示拒绝标记,流终止)
  • 层级关系:Server Manager > Assistant Manager > 用户偏好。后端两级是强约束,前端用户偏好只在"已经允许自动"前提下生效。
  • 缓存查询顺序:Global(API) → Thread(localStorage) → 弹窗。Global 优先是因为它代表"用户跨设备的明确意图"。
  • 中断恢复路径(B)和正常路径(A)合流在"查后端策略"这一步,所以两套入口共用同一套审批/缓存逻辑,不需要写两遍。
  • 拒绝(deny)也走 continueSSERun,只是 approve=false,让后端知道"用户明确不要执行",不是悬挂状态。

为什么不这么做(被否定的方案)

替代方案 A:审批走单独的 HTTP 接口(不走 SSE)

  • 否定原因:审批后要继续接收 LLM 后续输出(agent 还在工作),单独 HTTP 之后还得再发起新 SSE,等于把"审批-恢复"拆成两次往返,UI 得维护"等待审批 → 等待恢复 SSE"两个 loading 态。
  • 现方案"断开-审批-重连"用的是同一个 continue 端点,前端只有一个 loading 模型。

替代方案 B:只做 once / global 两档,砍掉 thread

  • 否定原因:用户在某个调试对话里临时放通"删数据库"工具,不希望这条偏好跨对话泄漏到生产对话。
  • thread 级是会话作用域的"沙箱式记忆",离开对话自动失效(但同一对话刷新后还在 → 所以用 localStorage 不用 sessionStorage)。

替代方案 C:thread 偏好也存 API(统一存储)

  • 否定原因:每次审批要等一次网络写入再继续 SSE,弹窗"卡顿感"放大。
  • thread 偏好本来就是"临时记忆",用户换设备继续对话的概率低,写 localStorage 是合理的弱一致性
  • 额外考虑:减小 API 写入压力,每个 thread 可能产生几十次审批写入。

替代方案 D:中断恢复时不查偏好,一律弹窗让用户重新确认

  • 否定原因:用户在一个长 Agent 任务里点了 5 次"thread 自动批准",关电脑明天回来要再点 5 次,审批疲劳。
  • 恢复时"先尝试自动批准 → 不行才弹窗"是为了让中断对用户透明(这是中断恢复的核心价值)。

中断恢复伪代码
async function fetchMessageData() {
  const responseData = await loadMessages(threadId);

  // 检测 pending Tool Call
  checkForPendingToolCalls(responseData, context);
  // Agentic 1.0: lastMessage.metadata.tool_calls → run_step_name='tool_inputs'
  // Agentic 2.0: lastMessage.subtype='use_mcp' + approval_status='pending'

  // 先尝试自动批准(复用已保存的偏好)
  if (pendingToolCalls().length > 0) {
    await checkAndAutoApproveToolCallV1(context, isToolAutoApproved);
  }
  await checkAndAutoApproveMcpMessages(responseData, context, isToolAutoApproved);
  // 不能自动批准 → 弹窗 / 内联审批 UI
}
  • 恢复后先查偏好,命中就自动批准不弹窗 → 用户体验连续
  • Agentic 1.0 用 Dialog 弹窗,Agentic 2.0 用消息内联审批
可追问点
SSE vs WebSocket vs 长轮询对比?
  • SSE:单向推送、HTTP 协议、自动重连、适合 LLM 输出
  • WebSocket:双向全双工、需要协议升级、适合即时通信/协作编辑
  • 长轮询:兼容性最好但效率最低,每次都要新建请求
  • 我们选 SSE 因为不需要客户端实时发消息,且公司网关对 WebSocket 有额外限制
StreamQueue 的 timeout 设成 0 可以吗?
  • 可以但不推荐。设 0 等于 setTimeout(fn, 0),虽然也让出了事件循环,但在高频事件场景下 DOM 更新会堆积
  • 设 1ms 给浏览器微小的渲染窗口,肉眼不可见但能避免"白屏后一次性出现大段文字"的体验问题
三级审批中,如果三层配置冲突了怎么办?
  • 产品设计上优先级从高到低:MCP Server Manager > Assistant Manager > 用户偏好
  • 如果 Server Manager 设了 approval(必须审批),即使用户偏好是自动批准,后端也会要求前端走审批流程(tool_execution_mode: 'approval'
  • 用户偏好只在后端已经允许自动执行的前提下才生效 — 本质是用户在「允许自动」范围内的个人记忆
  • 前端实现范围:前端通过 isToolAutoApproved 实现用户偏好层的自动审批判断(全局 API + thread localStorage 双层缓存);Server/Assistant 层级的策略由后端强制执行,前端通过 tool_execution_mode 字段和管理后台 UI 间接体现
为什么 Thread 级别偏好用 localStorage 而不是 sessionStorage?
  • sessionStorage 在标签页关闭后就清空了,但用户可能刷新页面后继续同一个对话
  • localStorage 持久化,刷新后偏好还在。以 threadId + serverName + toolName 为 key 结构化存储
  • 不用 API 是因为 Thread 级别偏好是临时性的,没必要走网络请求增加延迟
中断恢复时如果用户的偏好已经过期了怎么办?
  • Global 偏好从 API 加载,永不过期(除非用户主动移除)
  • Thread 偏好在 localStorage 中,浏览器不清缓存就一直在
  • 如果都不匹配(比如换了浏览器),就走正常的弹窗审批流程 — 降级到手动确认是最安全的默认行为
如果用户在流式输出过程中刷新页面怎么办?
  • 服务端的 LLM 调用是异步的,前端断开不影响服务端完成生成
  • 刷新后重新加载页面,从 API 拉取历史消息(包括已完成或部分完成的回答)
  • 如果回答尚未完成,页面会显示"生成中"状态,但不会再建立新的 SSE 连接(避免重复生成)
  • 如果有 pending 的 Tool Call,会通过中断恢复机制检测到并弹窗

2 智能滚动机制

简历原文:针对长文本流式回答场景,基于 IntersectionObserver 设计用户意图感知的智能滚动机制,减少自动滚动对用户阅读的打断,提升回答浏览体验。

口语版

口语版:AI 流式回答时文本不断追加,普通的自动滚动会打断用户阅读。我设计了一个三层检测的智能滚动机制:第一层用 IntersectionObserver 监听底部哨兵元素,判断用户是否在底部;第二层监听 wheel 事件,用户一旦向上滚就停止自动滚;第三层用 ResizeObserver 监听容器高度变化,在自动滚模式下跟随内容增长。 几个关键细节:用 rootMargin 100px 做提前触发,不需要精确滚到底才恢复;用双 requestAnimationFrame 确保布局完成后再滚动;wheel 事件标记 passive 不阻塞渲染。整体用一个 shouldStickToBottom 信号驱动,逻辑清晰,性能也好。
面试官想听什么
  • 什么叫"智能"?和普通自动滚动有什么区别?
  • 怎么判断用户是在阅读还是在等新内容?
  • 为什么用 IntersectionObserver 而不是 scroll 事件?

我会怎么讲

1.1 问题场景

  • AI 流式回答时,文本不断追加,容器高度持续增长
  • 如果无脑 scrollTo(bottom),用户想回看上面的内容时会被反复拉回底部
  • 如果完全不自动滚动,用户看新内容要不停手动划
  • 需求:用户在底部时自动滚,滚上去看内容时停止自动滚,回到底部后恢复

1.2 核心难点

  • 难点 1:自动滚动要支持用户取消 + 重新贴底后恢复
    • IntersectionObserver 监听底部哨兵元素:可见 → 恢复自动滚,不可见 → 停止
    • 同时监听 wheel 事件判断用户是否主动向上滚(deltaY < 0),一旦检测到就立即停止自动滚动
    • 两层配合:wheel 做即时取消(用户意图明确),IntersectionObserver 做自动恢复(用户滚回底部时无感恢复)
  • 难点 2:容器高度变化时也要平滑滚动
    • 触发场景:代码块点击 wrap 换行、弹窗/toast 报错撑高容器、用户输入大量内容导致输入框增高
    • ResizeObserver 监听消息容器的高度变化,变化时如果当前处于"贴底"状态则平滑滚到新底部
    • 比单纯在 DOM 变更(MutationObserver)时滚动更精确 — 直接响应布局尺寸变化而非 DOM 结构变化

2. 三层检测架构

useScrollManager 伪代码
function useScrollManager() {
  const [shouldStickToBottom, setShouldStickToBottom] = createSignal(true);
  const shouldShowScrollButton = () => !shouldStickToBottom();

  // === 层 1: IntersectionObserver — 检测是否在底部 ===
  const observer = new IntersectionObserver(
    entries => {
      if (entries[0].isIntersecting) {
        setShouldStickToBottom(true); // 看到底部哨兵 → 恢复自动滚
      }
    },
    { rootMargin: '0px 0px 100px 0px' } // 提前 100px 触发
  );
  observer.observe(bottomSentinel); // 底部 1px 高的隐藏 div

  // === 层 2: wheel 事件 — 检测手动向上滚 ===
  container.addEventListener('wheel', (e) => {
    if (e.deltaY < 0) {
      setShouldStickToBottom(false); // 向上滚 → 停止自动滚
    }
  }, { passive: true });

  // === 层 3: ResizeObserver — 容器高度变化时跟随 ===
  const resizeObserver = new ResizeObserver(() => {
    if (shouldStickToBottom()) {
      scrollToBottom(); // 内容增长时保持在底部
    }
  });
  resizeObserver.observe(scrollContainer);

  function scrollToBottom() {
    // 双 rAF:等当前帧渲染完 + 下一帧布局完成再滚
    requestAnimationFrame(() => {
      requestAnimationFrame(() => {
        window.scrollTo({ top: document.body.scrollHeight });
        container.scrollTo?.({ top: container.scrollHeight });
      });
    });
  }

  return { shouldShowScrollButton, scrollToBottom, bindBottomSentinel };
}

3. 关键设计细节

  • 底部哨兵:在消息列表最底部放一个 1px 高的 div,IntersectionObserver 监听它是否可见。比用 scrollTop + clientHeight >= scrollHeight 更精确且不触发重排
  • rootMargin 100px:不需要滚到最底才恢复自动滚,距底 100px 就算"在底部",体验更自然
  • 双 requestAnimationFrame:流式渲染时 DOM 更新和布局计算可能跨帧,单个 rAF 可能拿到旧的 scrollHeight。双 rAF 确保在布局完成后再滚动
  • passive: true:wheel 事件标记为 passive,不阻塞滚动,保证流畅性
  • SSR 兼容typeof window === 'undefined' 守卫,Astro SSR 阶段不执行 DOM 操作
可追问点
为什么不直接监听 scroll 事件来判断是否在底部?
  • scroll 事件触发非常频繁(每帧都触发),需要节流,且读取 scrollTopscrollHeight 会触发强制同步布局(layout thrashing)
  • IntersectionObserver 是浏览器原生优化的,在合成层(compositor)做判断,不阻塞主线程,性能好很多
  • wheel 事件只在用户主动滚轮时触发,比 scroll 事件更精确地表达"用户意图"
ResizeObserver 解决什么问题?为什么不只用 MutationObserver?
  • 流式渲染时,DOM 变化(文本追加)和容器尺寸变化(高度增长)是两回事。MutationObserver 能检测 DOM 变化,但拿到的时候布局可能还没更新
  • ResizeObserver 在布局计算之后触发,这时 scrollHeight 已经是最新值,适合做滚动跟随
  • 实际上 ResizeObserver + IntersectionObserver 搭配就够了,不需要 MutationObserver
移动端触摸滑动怎么处理?
  • 当前用 wheel 事件检测桌面端滚轮。移动端可以补充 touchstart + touchmove 检测手势方向
  • 不过这个项目是企业内部 B 端平台,主要在桌面端使用,所以优先级没那么高

3 Monaco Editor 与 Astro 集成

简历原文:重构 Markdown 代码块交互体验,引入 Monaco Editor,解决其与 Astro ClientRouter 的兼容冲突,通过 Lazy 加载 + manualChunks 独立分包 + Worker ESM 配置控制 2.5MB 编辑器对首屏的性能影响。

口语版

口语版:我们产品的 AI 回答包含大量代码,需要支持语法高亮、复制、甚至编辑(比如配置 System Prompt),所以引入了 Monaco Editor。 最大的挑战是 Monaco 和 Astro ClientRouter 的冲突。ClientRouter 做客户端路由时不会完整销毁页面,但 Monaco 的 Web Worker 是全局单例,导致导航后 Worker 泄漏和多实例冲突。解法是:包含 Monaco 的路由走全量刷新,其他路由继续用 ClientRouter 做快速切换,实现了一个 navigateWithRefresh 工具函数来统一管理。 性能方面,Monaco 本身很大(约 2.5MB),我用了三层优化:lazy 动态加载(用户打开编辑器时才下载)、manualChunks 独立打包(不影响主 bundle 体积)、Worker 通过 Vite 的 ?worker 语法正确打包。SSR 阶段也做了守卫,避免服务端执行 DOM 和 Worker 代码。
面试官想听什么
  • Monaco Editor 是什么?为什么不用更轻量的方案(CodeMirror / highlight.js)?
  • Monaco 和 Astro ClientRouter 有什么冲突?你怎么解决的?
  • 这么重的编辑器怎么做性能优化?

我会怎么讲

1. 为什么选 Monaco

  • 这个产品是 AI 助手,回答中大量包含代码。用户需要查看、复制甚至编辑代码块(比如 System Prompt 编辑),不是纯展示场景
  • Monaco 是 VSCode 的核心编辑器,支持语法高亮、智能补全、多语言、查找替换、minimap 等,用户体验远超纯静态高亮
  • Solid.js 生态有 solid-monaco 封装,集成成本可控

2. 核心冲突:ClientRouter 下 Monaco 多实例问题

  • Astro 的 ClientRouter(前身 ViewTransitions)做客户端路由切换时,不会完整卸载页面,而是 swap 页面内容(类似 SPA)
  • Monaco 依赖 Web Worker 做语法分析和补全推理。Worker 挂在 window.MonacoEnvironment 上,是全局单例
  • 问题:从包含 Monaco 的页面 A 导航到页面 B 再回来,旧 Worker 没销毁,新 Monaco 实例又创建新 Worker → 内存泄漏 + Worker 冲突
  • 解法:包含 Monaco 的路由走全量刷新,绕过 ClientRouter 的 swap 机制
navigateWithRefresh 路由策略
// 需要全量刷新的路由(含 Monaco 的页面)
const FULL_REFRESH_ROUTES = [
  '/assistant_management', // Monaco 用于 System Prompt 编辑
  '/thread',               // Monaco 用于代码块渲染
  '/document_management',  // Monaco 用于文档内容
  '/',                     // 首页(聊天页)
];

function navigateWithRefresh(url, forceRefresh = false) {
  const path = new URL(url, location.origin).pathname;
  const needsRefresh = forceRefresh ||
    FULL_REFRESH_ROUTES.some(route =>
      route === '/' ? path === '/' : path.startsWith(route)
    );

  if (needsRefresh) {
    window.location.href = url; // 全量刷新,完整销毁 + 重建
  } else {
    navigate(url); // Astro ClientRouter 客户端导航
  }
}
  • 侧边栏链接同时加 data-astro-reload(Astro 原生属性)作为兜底
  • 非 Monaco 页面之间仍然用 ClientRouter 做快速切换,不是全站降级

3. 性能优化:Lazy + manualChunks + Worker 配置

三层优化策略

① Lazy 加载 — 按需下载,不阻塞首屏

const MonacoEditor = lazy(async () => {
  await import('@/library/monaco');        // 初始化 Worker
  const { MonacoEditor } = await import('solid-monaco');
  return { default: MonacoEditor };
});

// 使用时包 Suspense
}>
  
  • 效果:首屏 JS 零 Monaco 代码;只有用户打开编辑器(或展开代码块)时才触发下载
  • 原理lazy() 内部就是 import(),Vite 会把它拆成独立 chunk,首次加载时才 fetch

② manualChunks — 独立分包,利用浏览器缓存

// astro.config.mjs → vite.build.rollupOptions
output: {
  manualChunks: {
    monaco: ['monaco-editor']  // ~2.5MB 独立 chunk
  }
}
  • 效果:Monaco 代码打成一个独立 chunk(monaco.[hash].js),不混入主 bundle
  • 好处:主 bundle 体积不膨胀;Monaco 更新频率低,chunk hash 基本不变 → 用户多次访问命中浏览器缓存
  • 不配的话 Rollup 可能把 Monaco 拆散到多个 chunk 或混入公共包,缓存失效率高
💡 manualChunks 是什么?(面试追问准备)

一句话:Rollup 提供的配置项,让你手动指定"哪些模块归一组,打成一个独立的 JS 文件(chunk)"。

默认行为 vs 手动控制

  • 默认:Rollup 自动分析依赖图决定怎么拆 chunk。它可能把 Monaco 的代码打散到多个 chunk,或者混进公共包——你无法控制
  • manualChunks:你手动画线——凡是 import 路径匹配 'monaco-editor' 的模块,全部打进一个叫 monaco 的独立 chunk

打包结果对比

// 不配 manualChunks(Rollup 自动拆分)
dist/
  index-a1b2c3.js       ← 主 bundle(可能混入 monaco 代码,体积大)
  chunk-d4e5f6.js       ← 自动拆出的公共依赖(可能含 monaco 片段)

// 配了 manualChunks
dist/
  index-a1b2c3.js       ← 主 bundle(不含 monaco,体积小)
  monaco-x7y8z9.js      ← monaco 独立 chunk(~2.5MB)

为什么这对缓存至关重要?

  • 文件名中的 hash(如 x7y8z9)是根据文件内容算的
  • Monaco 几乎不更新 → hash 长期不变 → 用户第二次访问直接命中浏览器强缓存,不用重新下载 2.5MB
  • 业务代码经常改 → index-a1b2c3.js 的 hash 会变 → 但用户只需重新下载这个小文件
  • 如果不分开:业务代码一改,整个大 bundle 的 hash 就变了,用户每次都要重新下载包含 Monaco 的 2.5MB

配置语法解读

manualChunks: {
  // key: 输出的 chunk 名称(生成 monaco.[hash].js)
  // value: 模块匹配数组,import 路径包含这些字符串的模块都归入此 chunk
  monaco: ['monaco-editor'],

  // 也可以用函数形式做更复杂的判断
  // manualChunks(id) {
  //   if (id.includes('monaco-editor')) return 'monaco';
  // }
}

和 lazy 的区别与配合

  • lazy(动态 import):控制加载时机——什么时候下载这个 chunk(用户触发时才加载)
  • manualChunks:控制打包边界——哪些模块打成一个 chunk(确保 Monaco 独立成文件)
  • 两者配合:lazy 保证首屏不加载 Monaco,manualChunks 保证 Monaco 独立打包利用缓存

③ Worker ESM 配置 — Worker 自包含打包

// Worker 注册(src/library/monaco.ts)
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
import TsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker';

// SSR 守卫:服务端渲染时不执行
if (typeof window !== 'undefined') {
  window.MonacoEnvironment = {
    getWorker(_moduleId, label) {
      if (label === 'typescript' || label === 'javascript') return new TsWorker();
      if (label === 'json') return new JsonWorker();
      return new EditorWorker();
    }
  };
}
// Vite 配置(astro.config.mjs)
vite: {
  ssr: { noExternal: ['monaco-editor'] },  // SSR 阶段不外部化,避免 Node resolve 路径问题
  optimizeDeps: {
    include: ['monaco-editor'],            // 预构建核心,dev 启动更快
    exclude: [/* Worker 文件排除预构建 */]  // Worker 必须走 ?worker 单独打包
  },
  worker: { format: 'es' }                 // Worker 输出 ESM 格式
}
  • Worker ESM 是什么:传统 Worker 用 IIFE 格式,不支持 import/export;设成 format: 'es' 后 Vite 以 ES Module 格式输出 Worker → Worker 内部的依赖全部 bundle 成一个自包含文件
  • 为什么 exclude WorkeroptimizeDeps 会把依赖预构建到 node_modules/.vite;Worker 需要走 ?worker 插件单独打包,预构建会破坏这个流程导致 Worker 加载失败
  • SSR 守卫typeof window !== 'undefined' 防止服务端执行 Worker 代码(Node 无 Worker 构造函数)
💡 Worker ESM 配置到底在干什么?(面试追问准备)

一句话:让 Monaco 的 Web Worker 文件在 Vite + Astro 项目里能被正确打包、正确加载,同时兼容 SSR。不配的话 Worker 加载失败,Monaco 直接不能用。

Monaco 为什么需要 Worker?

  • Monaco 把语法分析、自动补全这些CPU 密集型任务放到 Web Worker 里跑,不阻塞主线程渲染
  • 它需要 3 个 Worker:editor.worker(基础编辑)、json.worker(JSON 语言服务)、ts.worker(TS/JS 语言服务)

不配会怎样?(三个层面都会出问题)

  • 路径找不到:Monaco 默认用 CDN 或相对路径找 Worker 文件,但 Vite 打包后的文件结构和它预期的不一样 → Worker 404
  • 格式不兼容:Worker 默认是 IIFE 格式,不认识 import/export,在 Vite 的 ESM 体系里跑不通
  • SSR 报错:Astro 有服务端渲染阶段,new Worker() 在 Node.js 里不存在,直接报错中断构建

三行配置各解决什么?

// ① ?worker 语法 — 解决"路径找不到"
import EditorWorker from '...editor.worker?worker';
// Vite 看到 ?worker 后缀 → 把这个文件单独打包成一个自包含的 Worker 文件
// import 得到的是一个 Worker 构造函数,new EditorWorker() 就能用
// "自包含"= 所有依赖都 inline 进这一个文件,浏览器加载它就够了,零额外请求

// ② SSR 守卫 — 解决"Node.js 报错"
if (typeof window !== 'undefined') { /* 注册 Worker */ }
// Node.js 里没有 Worker API,不守卫的话 Astro build 阶段直接炸

// ③ worker: { format: 'es' } — 解决"格式不兼容"
worker: { format: 'es' }
// Worker 输出 ESM 格式,依赖全部打进一个自包含文件

"自包含文件"是什么意思?

  • 就是这个文件自己就能跑,不依赖外部任何东西
  • Vite 用 ?worker + format: 'es' 打包时,会把 Worker 文件及其所有 import 的依赖递归地打包到一个文件里
// 打包前:Worker 依赖一堆模块
ts.worker.js → import moduleA → import moduleB → import moduleC ...

// 打包后:一个自包含文件
ts.worker-x7y8z9.js  (所有依赖已 inline,零外部依赖)

和前面两层优化的关系

  • Lazy:控制何时加载 Monaco(用户触发时)
  • manualChunks:控制怎么打包 Monaco 主体(独立 chunk 利用缓存)
  • Worker ESM:控制Worker 怎么打包和运行(自包含 + SSR 安全)
  • 三层各管一件事,缺一不可:少了 Lazy 首屏就慢,少了 manualChunks 缓存就废,少了 Worker 配置编辑器就挂

4. 引入步骤总览(快速回忆用)

做什么为什么
① Worker 注册 用 Vite ?worker 导入各语言 Worker,通过 MonacoEnvironment.getWorker 按 label 分发 Monaco 依赖 Worker 做语法分析/补全,不注册会报错
② Vite 打包 manualChunks 独立分包 · ssr.noExternal 避免 Node resolve 路径问题 · optimizeDeps.exclude Worker 排除预构建 · worker.format:'es' 输出 ESM ~2.5MB 不混主 bundle;Worker 必须走独立打包流程
③ SSR 守卫 typeof window !== 'undefined' 包裹 Worker 注册;Lazy + Suspense 延迟加载组件 SSR 阶段无 window / Worker,直接执行会 crash
④ 路由兼容 navigateWithRefresh 让 Monaco 页面走全量刷新,其余页面继续 ClientRouter ClientRouter swap 不销毁 window,Worker 会残留冲突
可追问点
Lazy loading 和 dynamic import 有什么区别?
  • lazy() 是框架层面的概念(React 的 React.lazy、Solid 的 lazy),专门用于组件级代码分割,配合 Suspense 管理加载状态
  • import() 是语言层面的动态导入,返回 Promise,可以用在任何地方
  • lazy 内部就是调用 import(),但额外处理了组件的生命周期和 fallback 渲染
为什么 Worker 文件要 exclude 出 optimizeDeps?
  • Vite 的 optimizeDeps 会把依赖预构建成 ESM bundle,放到 node_modules/.vite
  • Worker 文件需要通过 ?worker 后缀走 Vite 的 Worker 插件单独打包成独立文件
  • 如果 Worker 文件也被 optimizeDeps 预构建,会破坏 ?worker 的处理流程,导致 Worker 无法正确加载
全量刷新对用户体验有影响吗?
  • 有轻微影响(白屏闪烁),但可以接受:一是这些页面本身就需要加载大量资源(Monaco),二是用户切换页面的频率不高
  • 做了 loading 态过渡:导航时先展示全局 loading indicator,减少白屏感知
  • 大多数交互(对话、滚动)发生在同一页面内,不涉及路由切换

4 竞态控制与请求管理

简历原文:负责 Assistant 配置管理后台页面开发,采用 AbortController + Debounce 解决高频请求下的竞态与结果覆盖问题,提升数据一致性与请求性能。

口语版

口语版:项目里很多地方有搜索和列表加载,用户快速输入或切换条件会产生并发请求,后发先至就会导致旧数据覆盖新数据。 我的方案是防抖加 AbortController 双重策略。防抖减少请求数量,AbortController 取消过时请求。我把这个逻辑封装成了一个通用的 fetchDataWithAbort 函数,内部自动管理 AbortController 的生命周期:每次新请求前 abort 上一个,AbortError 静默处理。调用方只需要传配置和状态 setter,一行代码接入。项目里多个数据拉取场景都用了这个模式,有些直接用封装函数,有些手动写 AbortController + fetch 但逻辑一致。
面试官想听什么
  • 竞态问题具体是什么表现?
  • 为什么防抖不够,还要用 AbortController?
  • 你怎么做到"一行代码接入"的?

我会怎么讲

1. 问题场景

  • 典型场景:搜索框输入、分页切换、筛选条件变更 — 用户快速操作产生多个并发请求
  • 竞态表现:先发的请求 A 比后发的请求 B 晚返回,结果 A 覆盖了 B → 展示的是过时数据
  • 项目中 20+ 处数据拉取都有这个问题:Assistant 搜索、用户搜索、AD Group 搜索、文件上传、文档管理等

2. 防抖 + AbortController 双重策略

  • 防抖:减少请求数量。用户停止输入 300ms 后才真正发请求
  • AbortController:取消过时请求。每次新请求发出时,先 abort 上一个还在 pending 的请求
  • 两者互补:防抖是「少发」,AbortController 是「取消旧的」
fetchDataWithAbort 伪代码
async function fetchDataWithAbort(config, state, abortRef) {
  try {
    state.setLoading(true);

    // 1. 取消上一个请求
    if (abortRef.current) {
      abortRef.current.abort('Request aborted');
    }

    // 2. 构建 URL(支持 queryParams 自动拼接)
    let url = config.url;
    if (config.queryParams) {
      url += '?' + new URLSearchParams(config.queryParams).toString();
    }

    // 3. 创建新的 AbortController
    abortRef.current = new AbortController();

    // 4. 发起请求,携带 signal
    const response = await fetch(url, {
      method: config.method || 'GET',
      headers: { 'Content-Type': 'application/json', ...config.headers },
      body: config.body ? JSON.stringify(config.body) : undefined,
      signal: abortRef.current.signal
    });

    if (response.ok) {
      const data = await response.json();
      // 自动处理分页数据和普通数据
      if (data.items && state.setTableData) {
        state.setTableData(data.items);
        state.setPrevPage?.(data.previous_page || null);
        state.setNextPage?.(data.next_page || null);
      } else if (state.setData) {
        state.setData(data);
      }
      return data;
    }
  } catch (error) {
    // AbortError 静默处理,不是真正的错误
    if (error.name === 'AbortError') return null;
    throw error;
  } finally {
    state.setLoading(false);
  }
}

3. 使用方式

// 调用方只需要这样用,不需要手动管理 AbortController
const abortRef = { current: null };

// 搜索
const handleSearch = debounce(async (keyword) => {
  await fetchDataWithAbort(
    { url: '/api/assistants', queryParams: { search: keyword } },
    { setLoading, setTableData, setPrevPage, setNextPage },
    abortRef
  );
}, 300);
  • 封装的好处:abortRef 通过闭包自动跟踪上一个 controller,调用方不需要感知 abort 逻辑
  • 错误处理也统一了:AbortError 静默忽略,真正的网络错误 throw 出去
可追问点
AbortController 真的能取消服务端执行吗?
  • 不能abort() 只是在浏览器端终止了 HTTP 连接,服务端可能还在执行
  • 对于普通 API(搜索、列表),服务端执行很快,取消的价值主要是:避免过时响应覆盖 UI,而不是节省服务端资源
  • 对于 SSE 流式连接,abort 会关闭连接,服务端通过检测连接断开可以提前终止 LLM 调用(如果 LLM API 支持的话)
为什么不用请求 ID 或时间戳来判断是否过时?
  • 也可以,比如 React Query 内部就是用 query key 来管理。但那需要在响应回来后做判断和丢弃
  • AbortController 更直接:直接取消请求,不浪费带宽,catch 里静默处理就行
  • 两种方案可以结合使用:abort 取消请求 + ID 做兜底校验
AbortController 在 SSE 中怎么用?
  • 我们的 SSE 用的是 sse.js 库(非标准 EventSource),它接受一个 AbortSignal
  • 用户点"停止生成"时调用 abort(),sse.js 内部关闭连接
  • 同时用 StreamQueue 确保已入队的事件处理完毕后再清理状态,避免半渲染的消息残留

5 表单脏检查与数据防丢

简历原文:基于脏检查机制识别表单改动状态,并设计多场景保存提示(关闭标签页、ESC、切换 Tab 等 5 种退出路径),降低误操作导致的数据丢失风险。

口语版

口语版:Assistant 配置页面有大量表单,用户改了没保存就离开是最常见的投诉。我用 isDirty signal 追踪修改状态,覆盖了五种退出场景:关闭标签页、按 ESC、点取消、切换 Tab、关闭弹窗。 最复杂的是 ESC 键处理。页面嵌套了 Monaco 编辑器,Monaco 自己也用 ESC 关闭补全菜单和搜索框。我实现了分层拦截:内层在 capture 阶段检测 Monaco 弹出层(补全、右键菜单、搜索等 5 种),有弹出层就让 Monaco 处理;外层在 bubble 阶段判断焦点是否在 Monaco 内。这样保证 ESC 在任何上下文都有正确行为。
面试官想听什么
  • 什么是脏检查?你覆盖了哪些场景?
  • 有哪些边界情况需要特殊处理?
  • 和 React 受控表单的思路有什么不同?

我会怎么讲

1. 业务背景

  • Assistant 配置管理页面有大量表单字段(名称、描述、System Prompt、模型参数、权限、工具配置等),一个 Tab 有 7 个子页签
  • 用户修改了内容但忘记保存就离开 → 丢数据 → 这是 B 端产品最常见的投诉之一
  • 需求:任何退出操作都要检测是否有未保存的修改,给用户确认机会

2. isDirty 信号

const [isDirty, setIsDirty] = createSignal(false);

// 表单字段变更时标记为 dirty
const setAssistantValue = (field) => {
  setFormData(prev => ({ ...prev, [field.name]: field.value }));
  setIsDirty(true);  // 只要有任何修改就标记
};

// 保存成功后重置
const handleSave = async () => {
  await submitForm();
  setIsDirty(false);  // 保存后清除 dirty 标记
};

3. 五种退出场景全覆盖

退出场景处理
// 场景 1: 关闭浏览器标签 / 刷新页面
createEffect(() => {
  const handleBeforeUnload = (e) => {
    if (isDirty()) {
      e.preventDefault();
      return 'You have unsaved changes.';
    }
  };
  window.addEventListener('beforeunload', handleBeforeUnload);
  onCleanup(() => window.removeEventListener('beforeunload', handleBeforeUnload));
});

// 场景 2: ESC 键(最复杂 — 需要避开 Monaco 编辑器)
const handleEscKey = (e) => {
  if (e.key !== 'Escape' || submitting()) return;

  // ⚠️ 如果焦点在 Monaco 编辑器内,不处理
  // (Monaco 用 ESC 关闭自动补全/右键菜单等)
  if (e.target.closest('.monaco-editor')) return;

  // ⚠️ 如果有嵌套 Dialog 打开,不处理(让内层先响应)
  if (document.querySelectorAll('[role="dialog"]').length > 1) return;

  e.preventDefault();
  handleEscKeyAction();
};

// 场景 3: 点击"取消"按钮
const handleCancelEdit = () => {
  if (isDirty()) {
    showConfirmModal('Discard changes?', { onConfirm: resetForm });
  } else {
    resetForm();
  }
};

// 场景 4: 切换 Tab(Basic → Advanced → Tools...)
onChange={(e, newTab) => {
  if (mode() === DIALOG_MODE_EDIT && isDirty()) {
    showConfirmModal('Discard changes?', {
      onConfirm: () => { resetForm(); setActiveTab(newTab); }
    });
  } else {
    setActiveTab(newTab);
  }
}};

// 场景 5: 点击 Dialog 关闭按钮 / 点击遮罩
const handleClose = () => {
  if (mode() === DIALOG_MODE_EDIT && isDirty()) {
    showConfirmModal('Discard changes?', { onConfirm: props.handleCancel });
  } else {
    props.handleCancel();
  }
};

4. ESC 键的 Monaco 冲突处理

  • Monaco Editor 内部用 ESC 关闭自动补全菜单、搜索框、右键菜单等
  • 如果外层 Dialog 也响应 ESC,用户在 Monaco 里按 ESC 想关闭补全结果却关掉了整个 Dialog
  • 解法是分层:
    • 内层 TextEditDialog:在 capture 阶段监听 ESC,检测 Monaco 有无弹出层(hasMonacoPopupVisible()),有则让 Monaco 处理,无则 stopImmediatePropagation 拦截
    • 外层 ManageAssistantDialog:在 bubble 阶段监听 ESC,通过 e.target.closest('.monaco-editor') 判断焦点位置
Monaco 弹出层检测
function hasMonacoPopupVisible() {
  // 自动补全
  const suggest = document.querySelector('.monaco-editor .suggest-widget');
  if (suggest && !suggest.classList.contains('hidden') && isVisible(suggest))
    return true;

  // 右键菜单(挂在 body 上,不在 .monaco-editor 内)
  const contextMenu = document.querySelector('.monaco-menu-container');
  if (contextMenu && isVisible(contextMenu)) return true;

  // 参数提示
  const hints = document.querySelector('.monaco-editor .parameter-hints-widget');
  if (hints && !hints.classList.contains('hidden') && isVisible(hints))
    return true;

  // 搜索/替换
  const findWidget = document.querySelector('.monaco-editor .find-widget');
  if (findWidget && findWidget.classList.contains('visible') && isVisible(findWidget))
    return true;

  return false;
}
  • 这个函数检测了 5 种 Monaco 内部弹出层,确保不误关
  • isVisible() 检查 display/visibility/opacity 三种隐藏方式,因为 Monaco 用不同方式隐藏不同弹出层
可追问点
beforeunload 有什么兼容性问题?
  • Chrome 等现代浏览器不允许自定义 beforeunload 弹窗文案(只能显示浏览器默认提示),这是为了防止恶意网站阻止用户离开
  • 手机浏览器(尤其 iOS Safari)可能不触发 beforeunload
  • 对于这个 B 端项目来说,用户在桌面端 Chrome 上使用,兼容性足够
和 React 受控表单 + useBlocker 的思路有什么不同?
  • React 中做脏检查的标准方案:受控组件(每个 input 的 onChange 更新 state)+ 和初始值对比生成 isDirty + React Router 的 useBlocker 拦截路由
  • Solid.js 中没有 useBlocker,但 Solid 的 Signal 是细粒度响应式,不需要受控组件那套。表单字段直接绑定到 signal,变化自动追踪
  • Astro ClientRouter 下用 beforeunload + data-astro-reload 覆盖路由跳转场景
为什么不用自动保存代替脏检查?
  • 部分 Tab(工具配置、权限管理)确实是自动保存的(每次操作立即调 API)
  • 但 Basic/Advanced 等核心配置有跨字段校验(比如名称 + identifier 联动),不适合逐字段自动保存
  • 而且 System Prompt 经常几百行,用户可能改了一半想撤回。"手动保存"给了用户 undo 的机会

6 Vite 插件调试 + UnoCSS SSR 兼容

简历原文①:通过包装插件方案修复 Vite 插件 sourcemap 错位问题,降低调试定位成本。
简历原文②:解决原子化 CSS 与 Astro SSR 的兼容问题,增强线上渲染稳定性。

口语版

口语版:工程化方面我解决了两个比较典型的问题。 第一个是 Vite sourcemap 错位。开发时断点位置完全对不上,排查后发现是 SUID(Material UI for Solid)的 Vite 插件在 transform 阶段重写了 import 路径做 tree-shaking 优化,但没返回正确的 sourcemap,导致整条 sourcemap chain 断了。我的解法是包装这个插件,在调试模式下通过环境变量跳过它的 transform,保持 sourcemap 完整。 第二个是 UnoCSS 和 Astro SSR 的兼容问题。UnoCSS 是原子化 CSS 引擎,在构建时扫描源码来按需生成 CSS。但在 Astro 岛屿架构下,Solid.js 组件作为 client 岛屿加载,UnoCSS 的扫描器在构建时覆盖不到这些岛屿组件内部的 class,SSR 阶段就缺少这些样式。解法是用 safelist 显式声明这些 class,确保它们始终被生成。
面试官想听什么
  • Sourcemap 错位是什么表现?怎么定位到是插件的问题?
  • UnoCSS 和 SSR 有什么兼容问题?
  • 这些问题说明你对构建工具有多深的理解?

我会怎么讲

1. Vite Sourcemap 错位问题

  • 现象:开发时 Chrome DevTools 断点位置和实际代码不对应,点击错误堆栈跳到错误的行
  • 定位过程:逐个禁用 Vite 插件,发现去掉 @suid/vite-plugin(Material UI for Solid 的编译插件)后恢复正常
  • 根因:SUID 插件在 transform 阶段修改了代码(将 import { Button } from "@suid/material" 重写为按组件的默认导入路径,做 tree-shaking 优化),但没有正确返回 sourcemap,导致 Vite 的 sourcemap chain 断裂
  • 解法:包装原插件,调试时可通过环境变量跳过 transform:
suidNoTransform 插件包装
function suidNoTransform() {
  const base = suidPlugin();
  return {
    ...base,
    name: base.name + '-no-transform',
    transform(code, id) {
      // NO_SUID=1 时跳过 transform,保持 sourcemap 完整
      if (process.env.NO_SUID === '1') return null;
      return base.transform?.call(this, code, id);
    }
  };
}

// 使用
vite: {
  plugins: [suidNoTransform()],
  build: { sourcemap: isDevelopment }
}
  • 保留 SUID 的 configssr 等其他 hook,只覆写 transform
  • 生产构建不受影响(NO_SUID 默认不设),调试时按需开启

2. UnoCSS + Astro SSR 兼容

  • 问题:原子化 CSS 引擎(UnoCSS)在构建时扫描源码中出现的 class 名来生成 CSS。但在 Astro 岛屿架构下,Solid.js 组件作为 client 岛屿,UnoCSS 扫描器覆盖不到这些组件内部的 class:
岛屿组件 class 丢失问题
// Astro 页面中的静态 class → UnoCSS 扫描器能覆盖 ✅
// .astro 文件中的 class 正常生成

// Solid.js 岛屿组件(.tsx)中的 class → 扫描器可能覆盖不到 ❌
// 例如 OnboardingSidebar.tsx:
<div class="space-y-12 relative">
  <div class="absolute left-4 top-8 bottom-8 w-0.5 translate-x-[-50%]" />
</div>
// 这些 class 写法是静态的,但因为在 client 岛屿组件内部
// UnoCSS 构建时扫描不到 → SSR 阶段缺少对应样式
  • 解法:在 unocss.config.tssafelist 中声明这些动态 class:
// unocss.config.ts
export default defineConfig({
  safelist: [
    'space-y-12', 'left-4', 'top-8', 'bottom-8',
    'w-0.5', 'translate-x-[-50%]', 'w-1/2'
  ],
  rules: [
    // text-nowrap 在 UnoCSS 默认规则中有 bug,手动补充
    ['text-nowrap', { 'text-wrap': 'nowrap' }],
  ],
});
  • 补充:自定义 UnoCSS 规则让品牌色(text-atm-color-brand-midnightblue)等设计 token 也能用原子化方式使用,不需要写额外 CSS
可追问点
Vite 插件的 transform hook 和 sourcemap 是什么关系?
  • Vite 基于 Rollup 的插件体系。每个插件的 transform hook 接收代码,返回 { code, map }
  • 多个插件的 transform 像管道一样串联,每个环节的 sourcemap 链接起来形成 sourcemap chain
  • 如果某个插件修改了代码但返回 null(没有 map),chain 就断了,后续所有的源码映射都会偏移
  • SUID 插件的 bug 就是:它重写了 import 路径(tree-shaking 优化)但没返回正确的 sourcemap
UnoCSS 和 Tailwind 有什么区别?
  • 理念相同:都是原子化 CSS,按需生成
  • 引擎不同:Tailwind 基于 PostCSS,UnoCSS 是纯正则匹配引擎,速度快 5-100x
  • 扩展性:UnoCSS 的 preset 系统更灵活,可以自定义规则、shortcuts、transformers
  • Attributify:UnoCSS 支持属性化写法 <div text="sm gray" />,Tailwind 不原生支持
  • 这个项目选 UnoCSS 主要因为 Astro 生态推荐 + 构建速度快 + 自定义规则能力强
Astro 的 Islands 架构和普通 SSR 有什么区别?
  • 传统 SSR(Next.js/Nuxt):整个页面是一个大组件树,服务端渲染后客户端要全量 hydrate
  • Astro Islands:页面默认是纯静态 HTML,只有标记了交互的组件(island)才会 hydrate。其他部分零 JS
  • 好处是初始 JS 量极小,但交互复杂的页面(如我们的聊天页)其实 island 占比很大,此时和传统 SPA 差异不大
  • Astro 5 + ClientRouter 可以把它当 SPA 用,只是静态部分零 JS 开销