Helios AI 助手平台 · 简历关键词速查
1 SSE 流式渲染与事件状态机
口语版
口语版:我负责整个 AI 对话的流式交互链路,对应简历上两条,我分别讲。
第一条,两层保序 + 事件状态机。保序分两层:
第二条,Tool Call 三级权限审批与中断恢复。分审批和中断恢复两部分。
第一条,两层保序 + 事件状态机。保序分两层:
- 第一层 StreamQueue:SSE 虽然按序推送,但浏览器事件回调是异步的,前一个还在 await,后一个就进来了。我设计了串行队列,所有 handler 入队,上一个完成才执行下一个。timeout 设 1ms 让出事件循环给浏览器渲染。
- 第二层 ULID 边界识别:多步骤 Agent 流中,AI 可能先生成消息 A、调工具、再生成消息 B,所有 delta 走同一个 handler。后端给每个内容块生成 ULID(含时间戳的唯一 ID),前端追踪 identifier,一旦变化就 archive 当前消息、开新容器。不做这一层的话,消息 B 的文本会直接追加到消息 A 后面,用户看到的就是一坨。
第二条,Tool Call 三级权限审批与中断恢复。分审批和中断恢复两部分。
-
审批:AI 调用 MCP 工具时,是否需要用户确认由三层角色控制:
- ① Server Manager — 设工具的 execution_mode(工具级别)
- ② Assistant Manager — 设助手级别策略(助手级别)
- ③ 用户偏好 — 两层缓存:thread 级别存 localStorage(本对话内有效),global 级别走 API 持久化(跨设备同步)
- 中断恢复:用户触发了审批但没处理就离开了,下次回来时 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 选择渲染组件): │ │ ┌─────────────────────────┬─────────────────────────────────────────┐ │ │ │ message │ NormalMessageContent markdown 渲染 │ │ │ │ thinking │ ThinkingMessageContent 可折叠思考区 │ │ │ │ streaming + 空内容 │ ThinkingPlaceholderContent 加载动画 │ │ │ │ mcp_call │ MCPToolCallContent 工具调用+结果折叠 │ │ │ │ mcp_approval_request │ MCPApprovalContent 审批按钮 UI │ │ │ │ internal_tool (todo_write)│ TodoListContent 待办列表 │ │ │ │ internal_tool (其他) │ InternalToolCallContent 工具状态卡片 │ │ │ │ token_usage │ TokenUsageDisplay 用量统计(无头像) │ │ │ │ followup │ NormalMessageContent + 可点击选项按钮 │ │ │ └─────────────────────────┴─────────────────────────────────────────┘ │ │ │ └─ 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.added 的 metadata.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,fallbackrun_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 →
saveApprovalPreference→continueSSERun({ 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 智能滚动机制
口语版
口语版: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事件触发非常频繁(每帧都触发),需要节流,且读取scrollTop、scrollHeight会触发强制同步布局(layout thrashing)- IntersectionObserver 是浏览器原生优化的,在合成层(compositor)做判断,不阻塞主线程,性能好很多
- wheel 事件只在用户主动滚轮时触发,比 scroll 事件更精确地表达"用户意图"
ResizeObserver 解决什么问题?为什么不只用 MutationObserver?
- 流式渲染时,DOM 变化(文本追加)和容器尺寸变化(高度增长)是两回事。MutationObserver 能检测 DOM 变化,但拿到的时候布局可能还没更新
- ResizeObserver 在布局计算之后触发,这时 scrollHeight 已经是最新值,适合做滚动跟随
- 实际上 ResizeObserver + IntersectionObserver 搭配就够了,不需要 MutationObserver
移动端触摸滑动怎么处理?
- 当前用
wheel事件检测桌面端滚轮。移动端可以补充touchstart+touchmove检测手势方向 - 不过这个项目是企业内部 B 端平台,主要在桌面端使用,所以优先级没那么高
3 Monaco Editor 与 Astro 集成
口语版
口语版:我们产品的 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 Worker:
optimizeDeps会把依赖预构建到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 竞态控制与请求管理
口语版
口语版:项目里很多地方有搜索和列表加载,用户快速输入或切换条件会产生并发请求,后发先至就会导致旧数据覆盖新数据。
我的方案是防抖加 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 表单脏检查与数据防丢
口语版
口语版: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')判断焦点位置
- 内层 TextEditDialog:在 capture 阶段监听 ESC,检测 Monaco 有无弹出层(
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 错位。开发时断点位置完全对不上,排查后发现是 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 中的sxprop 转为运行时调用),但没有正确返回 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 的
config、ssr等其他 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.ts的safelist中声明这些动态 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 的插件体系。每个插件的
transformhook 接收代码,返回{ 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 开销