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 边界识别:多步骤 Agent 流中,AI 可能先生成消息 A、调工具、再生成消息 B,所有 delta 走同一个 handler。后端给每个内容块生成 ULID(含时间戳的唯一 ID),前端追踪 identifier,一旦变化就 archive 当前消息、开新容器。不做这一层的话,消息 B 的文本会直接追加到消息 A 后面,用户看到的就是一坨。
保序解决之后,上层是一套完整的事件状态机。从 response.created → output_item.added → output_text.delta → mcp_call → done,每个阶段有明确的状态转换和渲染逻辑。不同事件类型走不同分支,比如文本流走增量 DOM 更新,Tool Call 走审批 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 审批与中断恢复"是什么场景?技术上怎么实现?
  • 你说"线上渲染问题清零",之前有什么问题?

整体架构

用户点击发送
  │
  ├─ 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()  ← 不入队,立即执行(后台标签兼容)
  │     archiveCurrentMessage()
  │     checkAutoApproveAfterStreaming()  ← 可能触发新一轮 SSE
  │     eventSource.close() → resolve(message)
  │
  └─ 4. 流后请求
        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 事件处理(收尾)
│                                                                                    │
│  1. sendAnswerReadyNotification() — 浏览器通知(不入队,立即执行)                  │
│  2. 执行 deferredFunctions(延迟回调:更新文件计数等)                              │
│  3. archiveCurrentMessage() — 归档到历史列表                                       │
│  4. checkAutoApproveAfterStreaming() — 检查 pending MCP → 可能触发新一轮 SSE       │
│  5. eventSource.close() — 关闭 SSE 连接                                           │
│  6. resolve(message) — Promise resolve,释放调用方                                 │
└──────────────────────────────────┬───────────────────────────────────────────────────┘
                                   │
                                   ▼
┌──────────────────────────────────────────────────────────────────────────────────────┐
流结束后的接口请求(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 归属到正确的消息

3. 事件状态机

  • SSE 事件按照严格的生命周期推送,前端用状态机来管理每条消息的渲染流程:
事件流转(正常对话)
// 1. 连接建立
response.created → response.in_progress  // 状态: THINKING

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

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

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

// 5. 流结束
done → 发送通知 → 执行 deferred 回调 → 关闭连接

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_call → 暂停流式渲染 → 弹审批 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 状态
中断恢复伪代码
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'
  • 用户偏好只在后端已经允许自动执行的前提下才生效 — 本质是用户在「允许自动」范围内的个人记忆
为什么 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 或混入公共包,缓存失效率高

③ 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 构造函数)

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,一行代码接入。项目里 20 多个数据拉取场景都用了这个函数。
面试官想听什么
  • 竞态问题具体是什么表现?
  • 为什么防抖不够,还要用 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 阶段修改了代码但没返回正确的 sourcemap,导致整条 sourcemap chain 断了。我的解法是包装这个插件,在调试模式下通过环境变量跳过它的 transform,保持 sourcemap 完整。 第二个是 UnoCSS 和 SSR 的兼容问题。UnoCSS 是原子化 CSS 引擎,在构建时扫描源码来按需生成 CSS。但动态拼接的 class 名(比如根据状态切换 spacing)在构建时扫描不到,SSR 阶段就缺少这些样式。解法是用 safelist 显式声明动态 class,确保它们始终被生成。
面试官想听什么
  • Sourcemap 错位是什么表现?怎么定位到是插件的问题?
  • UnoCSS 和 SSR 有什么兼容问题?
  • 这些问题说明你对构建工具有多深的理解?

我会怎么讲

1. Vite Sourcemap 错位问题

  • 现象:开发时 Chrome DevTools 断点位置和实际代码不对应,点击错误堆栈跳到错误的行
  • 定位过程:逐个禁用 Vite 插件,发现去掉 @suid/vite-plugin(Material UI for Solid 的编译插件)后恢复正常
  • 根因:SUID 插件在 transform 阶段修改了代码(将 JSX 中的 sx prop 转为运行时调用),但没有正确返回 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。但动态拼接的 class 在构建时不可见:
动态 class 丢失问题
// 这些 class 构建时能被扫描到 ✅
// 这些 class 构建时看不到完整字符串 ❌ const spacing = isCompact ? 'space-y-4' : 'space-y-12';
// UnoCSS 只看到变量名 spacing,不知道要生成 space-y-12 的样式
  • 解法:在 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 就是:它修改了代码但没返回正确的 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 开销