Web 交互与数据持久化
这四个问题串起了 CLI 到 Web 的最后一段路Agent 的结果要送到浏览器浏览器的用户要能随时回来接着聊。事件怎么到浏览器SSE 还是 WebSocketClaude Code 的输出端是终端渲染器Ink React事件通过 AsyncGenerator 直接传给 React 组件没有网络传输这一步。V2 需要一套从服务端到浏览器的流式协议。两个选项WebSocket 还是 SSEWebSocket 双向通信能力更强。但 V2 的交互模式本质是单向的服务端推送流式结果客户端只在初始请求和点停止时发信号。SSE 更轻原生支持断线重连Hono 内置支持不需要额外库。因此选了 SSE。对于前端接收方式浏览器有 EventSource API原生处理 SSE但它不支持自定义 headers加不了 JWT token。所以用 fetch ReadableStream 手动解析 SSE 字节流。多写了点代码但认证链路不需要妥协。线缆格式很直接event: textdata: {“content”: “…”}event: tool_resultdata: {“tool_use_id”:“xxx”,“content”:“…”,“is_error”:false}定义了 10 种事件类型覆盖对话全部状态事件 触发时机 负载text LLM 输出文本 token { content: string }content_block_start LLM 开始输出 tool_use { tool_use_id, tool_name }content_block_delta tool_use 参数流式增量 { tool_use_id, input }content_block_stop tool_use 参数接收完毕 { tool_use_id }tool_result Tool 执行完成 { tool_use_id, content, is_error }usage 每轮 LLM 调用结束 { input_tokens, output_tokens, cache_* }done Agent 循环正常结束 {}error 循环异常 { message }user 中断插入的消息 { content }user_question AskUserQuestion 触发 { tool_use_id, questions[] }一轮 query() 调用中事件的典型时序时间 →text ─── text ─── content_block_start ─── content_block_delta* ─── content_block_stop ─── tool_result ─── usage ─── done(LLM 思考) (开始调 Tool) (参数逐个到达) (参数完整) (执行结果) (token 统计) (结束)中间可能穿插多轮… ─── content_block_start ─── content_block_delta* ─── tool_result ─── text ─── content_block_start ─── …(第二轮 Tool 调用) (LLM 基于结果继续分析)事件命名有个小插曲。最初是沿用了V1版本中类似消息类型的名字后来翻 Claude Code 源码发现它的事件名更简洁text、tool_use、tool_result就统一过去了。多用户多会话状态怎么隔离SSE 把事件推到了浏览器但一个用户可能同时开着好几个Session也就是对话。Claude Code 也有多会话——一个进程里切来切去、–resume 恢复历史对话。但那是单用户的所有会话共享同一个文件系统和权限上下文。V2 是 Web 应用多个用户同时在线各自的会话各自跑谁也不能看到别人的消息、串到别人的 SSE 流里。Zustand store 按 sessionId 分区。每个会话的消息、SSE 连接状态、工具执行状态都存在独立的分区里。用户从会话 A 切到 B 时A 的 SSE 流在后台继续跑到结束不会因为切走了就被强行终止。B 的消息从独立分区加载不会和 A 的数据串到一起。这个设计踩过一个坑切换会话时 SSE 事件串到了另一个会话里。排查发现是 store 没有严格按 sessionId 隔离收到 SSE 事件后直接往当前会话的数组里 push没检查事件的 sessionId 和当前显示的 sessionId 是否一致。修完之后加了分区边界检查tool_use 的 running 状态也按 sessionId 独立追踪。切会话后后台流继续跑的设计有一个副作用后台流跑完最后一轮时 tool_result 事件没有地方显示。用户切回来时看到的是完整结果看不到工具执行的中间状态。体验不够好但业务侧的对话轮数通常在 3 到 5 轮后台跑完很快。没做更复杂的恢复逻辑属于知道不完美但优先级不够的范畴。打断对话中断不只是 abort多会话跑起来了用户点了停止按钮要怎么办这件事就绕不开了。Claude Code 的中断通过 AbortController 实现调用 abort() 终止 LLM 流式请求清理当前轮次的中间状态。V2 延续了这个机制但 Web 场景多了一个硬要求前端要给即时反馈。用户点了停止不能等后端处理完才更新 UI那体验太差了。前端的设计点停止瞬间立即将所有 running 状态的 tool_use 标记为 error、清除 streaming 状态。用户点下去就看到 UI 反应。后端的中断是异步完成的前端不用等。AbortController 存在 sessionAbortControllers 这个 Map 里按键是 sessionId。中断触发后后端的处理链路如下用户点停止│▼POST /api/sessions/:id/interrupt│▼abortController.abort(‘user_interrupt’)│▼query() 循环内 5 个检查点按执行时序│├──[1] 每一轮循环开始前│ 如果已中断 → yield user 消息return│├──[2] LLM 流式处理期间每收到一个事件都检查│ 正在接收 tool_use → 为当前 已收集的全部 tool_use 生成 synthetic tool_result│├──[3] LLM 调用抛错后的 catch 块│ 区分LLM 自己报错还是中断导致抛错 → 后者走 synthetic tool_result 逻辑│├──[4] 工具执行前│ LLM 返回了 tool_use 但还没执行 → 为所有 tool_use 生成 synthetic tool_result│└──[5] 工具执行期间Promise.race([executionPromise, abortPromise]) → 中断胜出 → synthetic tool_resultsynthetic tool_result 的核心作用假设 LLM 刚返回了 3 个 tool_use 块读文件、查数据库、写报告用户点了停止。这 3 个 tool_use 成了孤儿——LLM 发出了指令但没有 tool_result 返回给它。如果下一轮对话以这个不一致的状态开始LLM 会困惑上一轮它要求执行了 3 个操作为什么没有结果解决办法为每个孤儿tool_use 生成一个占位结果内容为 [工具执行已被用户中断]标记为 error。下一轮对话开始时消息列表是自洽的每个 tool_use 都有对应的 tool_result虽然结果是中断但状态一致。数据持久化消息、元数据、大文件各放各的SSE 流通了消息到了前端但一刷新就没了。Claude Code 的消息持久化依托于 transcript 文件。进程活着时在内存里进程退出时写进文件。这是单机 CLI 的心智模型。Web 用户随时可能刷新页面或关闭浏览器多用户系统也不能假设只有一个进程。面对一堆风格不同的数据我选择了三种存储机制机制 存什么 写入模式 查询方式 集群迁移方向JSONL 会话消息 append-only一行一条 逐行加载按 sessionId 定位文件 sticky session 亲和SQLite 用户/项目/会话/配置元数据 CRUDWAL 模式 SQL 筛选/排序/JOIN 迁 PostgreSQL/MySQL磁盘文件 上传文件、大工具结果 一次写入按 projectId 分目录 路径直读 迁 MinIO/S3JSONL 存消息内容消息的写入模式很单一流式追加。LLM 每吐一个字、每个 tool_result 产生都是 append 到末尾。没有修改、没有删除、没有复杂查询。JSONL 天然匹配这个模式每行一个 JSON 对象文件路径 data/messages/{sessionId}.jsonlappend 就完事不需要事务、建索引、migration。读的时候逐行加载解析成本比 SQL 查询低。删除会话时直接删文件和生命周期绑定。SQLite 存结构化元数据用户、项目、会话、文件元信息、技能注册表、LLM 配置、MCP 服务器配置、系统配置。这些数据需要关联查询、筛选、排序。比如某用户启用了哪些技能要 JOIN 两张表文件系统做这件事要么遍历所有文件要么自己维护索引。这正是关系型数据库最擅长的。为什么是 SQLite 而不是 MySQL 或 PostgreSQL最直接的理由是零运维。Bun 内置 bun:sqlite编译进二进制没有独立进程、没有连接字符串、没有端口。WAL 模式下的并发读性能不差单文件备份和恢复极其简单。但还有一个往前看的考虑为集群部署预留迁移路径。 SQLite 的局限很清楚只有一个写入者做不到多实例共享。但 SQLite 的 schema 设计可以按关系型数据库的方式做——建表、建索引、理清关联关系。将来需要多实例部署时这些表结构迁移到 PostgreSQL 或 MySQL 直接能用不用重新设计数据模型。当前用 SQLite但不是将就着用而是按标准设计当前用最简单的实现。哪些数据适合关系型数据库哪些适合文件系统这个划分靠数据的访问模式决定不靠直觉。适合关系型数据库的 用户、项目、会话、文件元信息、技能注册表、用户技能禁用记录、LLM 配置、MCP 服务器配置、系统配置。共同特征结构化字段、需要条件筛选和关联查询、写入频率低、需要事务保证一致性。适合文件系统的 消息 JSONL、上传文件、工具执行结果、技能 SKILL.md 正文。共同特征体积大、顺序读写、不需要复杂查询、按 ID 或路径直接定位。上传文件是二进制大对象放数据库里会让备份和迁移变得非常重。技能 SKILL.md 有一个额外的理由LLM 运行时通过 Read 工具直接读文件系统放数据库里要多一层转换。集群化文件系统怎么做目前没实现但分析过可行方向。四个方案按复杂度从低到高方案一共享存储挂载。 所有实例挂同一个 NAS/NFS 卷data/ 目录指向共享路径。最简单但 NAS 本身是单点网络延迟对 JSONL 频繁 append 的影响需要实测。方案二对象存储。 上传文件和工具结果迁到 MinIO 或 S3。适合大文件但对 JSONL 的逐行追加语义支持不好需要额外封装。方案三分布式文件系统。 GlusterFS 或 Ceph多节点冗余、自动分片。运维负担重以当前的业务体量杀鸡用牛刀。方案四应用层会话亲和。 不做文件系统层面的集群化而是让同一个 sessionId 的请求始终路由到同一个实例。Sticky session 把问题从文件系统怎么共享变成路由怎么亲和零存储改造。代价是实例宕机时该会话暂时不可用但恢复后文件在本地磁盘上不会丢。当前业务体量下这个方案最务实。当前是单实例不需要集群化文件系统。但四个方向在架构设计时都考虑过确保将来不会因为数据放文件系统所以改不了卡住。已存储的关系型数据当前 SQLite 有 9 张表统一用 aac_ 前缀命名表名 用途aac_users 用户账号用户名、密码哈希、角色、启用状态、个人配置aac_projects 项目归属用户、名称。一个用户可以有多个项目aac_sessions 会话归属项目、名称。消息内容在 JSONL 文件里这里只存会话元信息aac_files 上传文件元信息原始文件名、存储名、类型、大小、预览数据。实际文件在磁盘aac_skill_registry 技能注册表技能名、展示名、描述、来源、scope、启用状态aac_user_disabled_global_skills 用户技能禁用记录哪个用户禁用了哪个全局技能aac_llm_configs LLM 配置API 地址、加密 token、模型列表、当前激活的模型aac_mcp_servers MCP 服务器配置名称、连接配置 JSON、工具缓存、启用状态aac_sys_config 系统配置全局开关如 LLM 日志模式、工具展示模式、缓存策略前四张是核心业务链路用户→项目→会话→文件。后面五张是配置和能力扩展分别对应技能系统、MCP 集成、LLM 接入和系统级开关。表之间的关系aac_users (1) ────── (N) aac_projects (1) ────── (N) aac_sessions│ ││ └── (N) JSONL 消息文件│ (data/messages/{sessionId}.jsonl)│└── (N) aac_files│└── (N) 磁盘文件(data/projects/{projectId}/files/)aac_skill_registry (N) ──── (N) aac_user_disabled_global_skills│ │└── (N) 磁盘文件 └── 用户级禁用开关(data/skills/{name}/SKILL.md)aac_sys_config ─── 独立配置项无外键关联aac_llm_configs ── 独立配置项运行时按 is_active 选择aac_mcp_servers ── 独立配置项启动时初始化连接核心链路是两条业务链和能力链。业务链 用户 → 项目 → 会话 → 文件 的设计思路用户登录后首先面对的是项目列表。一个项目对应一个业务场景——比如2025年度审计、“供应商对账”。项目是文件的上传边界和会话的组织容器上传 Excel 对账单到项目 A在项目 B 的会话里是看不到这些文件的。会话挂在项目下一个项目可以有多个会话——可能是一次分析任务的完整对话也可能拆成数据清洗“分析”写报告三个独立会话取决于用户的习惯。消息 JSONL 跟着会话走删除会话时对应的消息文件一并清理。文件属于项目而非会话这是一个刻意设计。同一个 Excel 对账单可能在多个会话里被引用——先在一号会话里清理数据再在二号会话里做分析。如果文件跟着会话走换个会话就要重新上传。文件元信息存 aac_files 表实际文件在 data/projects/{projectId}/files/这样不管从哪个会话引用路径都是稳定的。能力链是另一条独立的配置链路技能注册表 ↔ 用户禁用开关MCP 服务器配置、LLM 接入配置、系统配置各自独立。对于aac_skill_registry和aac_user_disabled_global_skills的实际作用在下一篇文章中会再做分析。项目目录下的文件管理上传、生成和保护除了 JSONL还有两类数据直接落磁盘用户上传的文件、Agent 生成的输出。都放在 data/projects/{projectId}/files/ 下同一个目录但通过数据库aac_files表的 source 字段区分身份user_upload用户上传的原始文件Excel、PDF、Word。Agent 可以读不能改也不能删。tempAgent 在执行过程中生成的中间文件和输出。Agent 可以自由读写。final_output用户手动标记的输出文件。和上传文件一样Agent 不能动。用数据库字段而不是目录结构来区分是因为一个文件的身份可能变——Agent 生成的报告初稿是 temp用户审查完标记为定稿就变成 final_output。如果是按目录隔离上传放 /uploads/生成放 /output/改身份就得移动文件、更新所有引用路径。改一个字段比搬运文件省事得多。上传文件的保护在 02 的 pathGuard 里已经提过checkFileWritable 检查 source遇到 user_upload 或 final_output 直接拒绝写入。从存储视角看这意味着 Agent 可以在项目目录里自由创建、修改文件但用户手动上传的原始数据和用户确认的最终结果被锁住了不会因为 LLM 的一次误操作被覆盖。工具执行的大结果走另一条路超过 100KB 的不回传给 LLM持久化到 data/projects/{projectId}/tool-results/{toolUseId}.txtLLM 只收到路径加前 2000 字节的预览。单个响应内所有工具结果的总字符数有 20 万上限防止一轮对话里几个大文件同时返回把上下文窗口撑爆。Chat 路由里的持久化逻辑很保守先写盘再推 SSE。SSE 推失败客户端断了消息已经落盘了。不会出现看到一半刷新页面中间几条消息丢了的情况。下一篇聊能力扩展层Skill 系统和 MCP 集成在 Web 多租户下的设计。