Onyx 移动端 Chat 移植的高层架构设计:Hybrid Seams 方案下的 NDJSON 流式聊天实现
导读
本文深入解析 Onyx(danswer)移动端原生应用如何以「Hybrid Seams(混合接缝)」方案,将 Web 端的聊天体验完整移植到 React Native 客户端:应用通过 expo/fetch 直连未做任何改动的后端,以换行分隔 JSON(NDJSON)流式协议驱动逐 token 渲染,同时保持 Web 端代码零改动。读完本文,你将掌握移动端聊天的端到端调用链、useChatController 与 zustand 双层状态设计、纯 TS 移动端原生数据层(NDJSON 解析器、消息树、历史重建)的实现细节,以及 expo/fetch 与 apiFetch 的分工依据,可直接对照仓库源码复现整套架构。
说明:本文对应设计文档 docs/mobile-chat/02-high-level-design.md,属于
mobile-chat系列(00-index / 01-research / 02-high-level-design / 03-detailed-design / 04-implementation-plan / 05-pr-roadmap / 06-unified-chat-surface)中的高层设计章节。
一、设计目标:把聊天体验搬到原生移动端,后端与 Web 零改动
1.1 这个设计要解决什么
移动端 Chat 移植要达成的核心体验是:用户打开 App → 选择 Agent(或使用默认)→ 围绕企业知识库进行流式对话,消息可以可选地限定在某个项目(project)内,也可以携带文档/照片附件。它完整镜像 Web 产品的行为,但关键约束是:只新增客户端,后端保持不变——移动端与 Web 端共享的是同一套后端 API、同一套流式协议,而不是共享代码。
从源码结构看,这一目标被严格贯彻:移动端聊天相关逻辑全部收敛在 mobile/src/chat/ 目录(解析器、消息树、历史重建、文件描述符等),而 Web 端保留自己的副本,mobile/src/chat/ndjson.ts 的注释明确写着「(web's handleSSEStream internals.)」——即移植自 Web 的 handleSSEStream 内部逻辑,但二者是各自独立的实现。
1.2 方案代号:Approach C — Hybrid Seams
文档为本次移植定下的方案代号是 Approach C(Hybrid Seams,混合接缝)。其含义是:移动端通过标准 HTTP API 与后端对接(接缝在客户端与后端的协议边界上),客户端内部则采用「共享查询层 + 移动端原生聊天层」的混合结构。经过 2026-06-26 的 PR 2 Decision 调整后,最终方案进一步收敛为「不共享任何聊天代码」:Web 保持原样、零改动,移动端在 mobile/src/chat/ 自建纯 TS 层。
二、端到端工作流:从发消息到流式渲染
2.1 前置基础:AuthGate 与 apiFetch
移动应用启动时已经通过 AuthGate 进入已认证的 Shell 结构,具备可用的侧边栏,以及一个名为 apiFetch 的 HTTP 层——它负责注入用户的 bearer token 并解析服务器 URL。新的聊天功能是在现有 (auth) 分组旁新增一个已认证的聊天路由组 (app),包含 new-chat、chat/[id]、history、projects 四个页面。
2.2 发送消息的三步编排
当用户点击发送,移动端的编排 Hook(useChatController)做三件事:
- 会话不存在则创建:调用
POST /api/chat/create-chat-session,携带所选 Agent 的persona_id与当前激活的project_id; - 乐观更新消息树:在内存中的 message tree 里立刻放下一条用户气泡和一条空的助手气泡;
- 打开流式请求:向
/api/chat/send-chat-message发起流式 POST。
后端对应实现位于 backend/onyx/server/query_and_chat/chat_backend.py:
POST /create-chat-session(chat_backend.py#L462-L489)接收ChatSessionCreationRequest,依赖require_permission(Permission.WRITE_CHAT, allow_anonymous=True)权限校验,返回CreateChatSessionID(含chat_session_id与incognito标志);persona 或项目越权时抛 403,非法 persona 抛 400。POST /send-chat-message(chat_backend.py#L771-L867)默认stream=True返回text/event-stream的StreamingResponse,stream=false时返回完整ChatFullResponseJSON;还支持llm_overrides多模型并行流式(2-3 个 LLM 并行,仅流式模式可用)。
2.3 流的核心:expo/fetch + NDJSON
流式请求是全 App 唯一不走 apiFetch 的 HTTP 调用,改用 expo/fetch,因为只有它暴露可读的字节流。后端以**换行分隔 JSON(NDJSON)**回复——每行一个数据包。移动端用纯 TS 的解析器(镜像 Web 的 line-buffering 逻辑)把字节块转成类型化数据包。
这个解析器的真实实现是 mobile/src/chat/ndjson.ts 中的 createNdjsonBuffer:
pushChunk(text):追加文本,按\n切分,尾部的半行保留到下一次调用(跨 chunk 的包完整还原);- 每行
JSON.parse,解析失败时尝试用/\{[^{}]*\}/g抢救扁平 JSON,嵌套对象不可恢复; flush():流结束时解析残留的半行,无花括号恢复,畸形尾部直接丢弃(与 Web 行为一致)。
数据包类型定义在 mobile/src/chat/streamingModels.ts,PacketType 枚举覆盖核心消息包 message_start / message_delta / message_end、stop、error,以及搜索工具(search_tool_*)、图片生成、Python 工具、open_url_*(Web 端叫 FETCH_TOOL_*)、工具调用参数、推理(reasoning_start/delta/done)等全套包类型。
2.4 移动端 Hook 的处理策略
移动端 Hook 读取数据包后:
- 忽略心跳包(heartbeat);
- 核心体验只关心文本包(
MESSAGE_START/MESSAGE_DELTA/MESSAGE_END)与STOP/ERROR; - 把流式文本追加到助手气泡上,约每 50ms 批量 flush 一次 UI,避免屏幕频繁重绘抖动。
收到 STOP 包后,对话回到空闲态,助手气泡获得真实的服务器消息 ID(message-id)。
2.5 渲染层:FlashList v2 与 StreamingMarkdown
聊天屏用 FlashList v2 渲染消息树,采用**非倒置(non-inverted)**模式,流式期间自动吸附到底部。流式助手气泡通过 React Native markdown 组件渲染不断累积的文本。
2.6 历史重开与会话恢复
重开历史会话时,从后端拉取历史记录,用移动端原生实现的 processRawChatHistory(镜像 Web 逻辑)重建消息树。实现位于 mobile/src/chat/chatHistory.ts:packets 按序数与助手消息一一对应(每个助手回合一个包列表),以 message_id 复用为 nodeId,错误回合用 error 字段渲染为 error 类型消息(Web parity),并依据 parent_message 重建父子关系、按 message_id 排序子节点;重开回合没有起始时间,因此直接用 processing_duration_seconds 填充耗时显示。
会话、Agent、项目列表均通过 TanStack Query 获取(已接入并持久化到 MMKV),以服务器 URL 为键——切换后端时不会串出脏数据。
三、组件交互架构
文档给出如下组件交互图(本节按原文结构转写并标注实现文件):
┌─────────────────────────────────────────────┐
│ mobile/src/app/(app)/ (expo-router group) │
│ new-chat · chat/[id] · history · projects │
└───────────────┬───────────────────────────────┘
│ renders
┌───────────────▼───────────────┐ ┌──────────────────────┐
│ RN UI (mobile-only) │ │ TanStack Query │
│ MessageList (FlashList v2) │◄────┤ sessions · agents · │
│ StreamingMarkdown · InputBar │ │ projects · files │
└───────────────┬───────────────┘ │ (apiFetch + MMKV) │
subscribes│ └──────────┬─────────────┘
┌───────────────▼───────────────┐ │ apiFetch (JSON)
│ chatSessionStore (zustand) │ ▼
│ per-session messageTree, │ ┌─────────────┐
│ chatState, AbortController │ │ Onyx │
└───────────────┬───────────────┘ │ backend │
drives ▲ │ updates │ (unchanged) │
┌──────────┴─────▼───────────────┐ └──────▲──────┘
│ useChatController (mobile) │ │ expo/fetch
│ onSubmit · drain stream · flush│─────────────────┘ (NDJSON stream)
└───────────────┬─────────────────┘
│ calls
┌─────────────────────▼──────────────────────┐
│ mobile/src/chat/ (pure TS, mobile-native) │
│ ndjson parser · contracts (chat/streaming/ │
│ files/agents/proj) · messageTree · │
│ processRawChatHistory · fileDescriptors │
└─────────────────────────────────────────────┘
(web keeps its own copies — nothing shared)
从源码看,mobile/src/chat/ 目录与上图完全对应:ndjson.ts(NDJSON 解析器)、messageTree.ts(消息树数学)、chatHistory.ts(历史重建)、messageProcessor.ts(增量包→状态归约)、streamingModels.ts(包类型契约)、contracts/(documents、projects 契约)、timeline/(推理状态、工具展示、分组)、以及 agents.ts / fileDescriptors.ts / citations.ts / sources.ts / tools.ts 等模块,并配套 mobile/src/chat/tests/ 下的 ndjson.test.ts、messageTree.test.ts、chatHistory.test.ts、messageProcessor.test.ts、fileDescriptors.test.ts 等单测。
3.1 消息树:纯函数式的增量更新
消息树是按 nodeId 键控的 Map,根节点是合成的系统节点(SYSTEM_NODE_ID = -3),通过 latestChildNodeId 实现分支。见 mobile/src/chat/messageTree.ts:updateParentInMap 负责把子节点挂到父节点,并依据「强制最新 / 唯一子节点 / 新添加」三条规则更新 latestChildNodeId;getMessageByMessageId 提供按服务器消息 ID 反查。该文件头注释说明其「几乎逐字移植自 Web 的 messageTree.ts,唯一差异是裁剪后的最小 Message 类型」。
3.2 增量包处理:带游标的归约器
mobile/src/chat/messageProcessor.ts 是 Web 端 packetProcessor 的忠实移植:维护 nextPacketIndex 游标,只处理游标之后的包;将包按回合(turn)/标签页(tab)分组为时间线条目,合成 SECTION_END 让一步骤完整闭合;同时维护 9a 引文/文档/完成度跟踪(citationMap、documentMap、isComplete、stopReason、工具处理时长等)。这解释了文档「每个 ~50ms 批量 flush」背后的增量语义——每次只把新到包归约进对应助手节点,而非整体重建。
四、关键组件清单
| 组件 | 职责 | 类型 |
|---|---|---|
(app) 路由组 |
已认证聊天屏:new-chat、chat/[id]、history、projects |
新增,移动端 |
useChatController / useChatSessionController |
移动端编排:提交、驱动流、批量 flush、停止、加载/恢复历史 | 新增,移动端 |
chatSessionStore(zustand) |
会话级瞬态状态:消息树、chat state、AbortController。不持久化 | 新增,移动端 |
| expo/fetch 流包装器 | 唯一的流式 HTTP 调用,把字节喂给移动端原生解析器 | 新增,移动端 |
| TanStack Query hooks | sessions、agents、projects、files 列表(持久化,按服务器 URL 键控) | 新增,移动端 |
| RN UI | MessageList(FlashList v2)、StreamingMarkdown、InputBar(键盘吸附)、Agent 选择器、项目屏、附件 chips |
新增,移动端 |
移动端原生聊天数据层(mobile/src/chat/) |
NDJSON 解析器 + 包/聊天/文件类型 + 消息树数学 + 历史重建 + 文件描述符辅助;从 Web 移植,零共享 | 新增,移动端 |
五、端到端场景与关键操作序列
5.1 完整用户场景
- 用户打开 App →
AuthGate将其送入(app)聊天首页;侧边栏通过 TanStack Query 展示近期会话; - 用户在选择器中点选一个 Agent → 选中态被保存;空聊天屏展示 starter prompts;
- 用户输入「Summarize the Q3 board deck」并点发送;
useChatController创建会话(persona_id= 所选 Agent)→ 拿到chat_session_id,导航到chat/[id];- 在消息树中放下用户气泡 + 空助手气泡(
chatState='loading'); - 通过
expo/fetchPOST 消息;后端流式返回 NDJSON 包; - 移动端原生解析器产出数据包;Hook 把
MESSAGE_DELTA文本追加到助手气泡,每 ~50ms flush(chatState='streaming'); - FlashList 保持视图吸附底部;助手气泡边增长边渲染 markdown;
STOP到达 →chatState='input',助手气泡获得服务器 message-id,会话列表 refetch 使历史反映新回合;- 用户在回答中途把 App 切到后台再返回 → 重开会话时回放缓冲的数据包,并重新挂接到进行中的运行。
5.2 关键操作序列
- 解析认证 + 服务器 URL(现有
AuthGate/sessionManager); - 若无会话则创建(
persona_id、project_id)→chat_session_id; - 乐观播种消息树(用户 + 空助手节点);
- 打开带 bearer + JSON body 的
expo/fetchPOST 流; - 解码字节 → 移动端原生 NDJSON 解析器 → 类型化数据包;丢弃心跳;
- 将
MESSAGE_*包归约进助手节点;批量 flush 到 zustand(~50ms); - 收到
STOP/ERROR/ abort:结算聊天状态、释放 reader、捕获 message-id、refetch 会话; - 重开/恢复:拉取历史 → 移动端原生
processRawChatHistory→ 消息树;若运行仍在进行,尾随resume-stream。
第 8 步对应的后端断点续流端点真实存在:GET /chat-session/{session_id}/resume-stream(chat_backend.py#L1280),返回 StreamingResponse,这正是「重新挂接到进行中的运行」的服务端支撑。
六、关键设计决策与理由
6.1 流式用 expo/fetch,其余用 apiFetch
RN 的 legacy fetch 没有可读响应体;expo/fetch(SDK 56 起为默认)暴露 response.body.getReader(),从而可以复用 Web 完全一致的 NDJSON 解析逻辑。而所有 JSON 列表请求仍走既有 apiFetch 关口(bearer 注入 + 错误归一化),保持单一收口。
6.2 聊天纯逻辑层全部移动端原生,零共享
解析器、消息树、历史重建、包→展示映射全部写在 mobile/src/chat/,Web 保留自己的副本。理由:共享包机制(@onyx-ai/shared 工具 + Web 重指向 + jest/dist 耦合)带来的活动部件,比它消除的约 200 行重复代码更多;且生产前后端协议稳定,漂移风险低、成本小,若日后真被咬到再抽取也不迟。Web 保持不被触碰(PR 2 Decision,2026-06-26)。
6.3 双层状态:列表走 TanStack Query,直播流走 zustand
列表数据受益于既有 MMKV 持久化 + refetch;而流式消息树持有 AbortController,绝不能持久化,因此放在独立的瞬态 zustand store(chatSessionStore)中。
6.4 FlashList v2 非倒置 + maintainVisibleContentPosition
这是现代 v2 聊天的标准模式:流式时吸附底部,同时不打扰已经上滑阅读的用户。
七、对既有行为的影响
- 移动端:全新功能,不删除任何既有内容;
- Web:行为不变且零改动——移动聊天移植不与 Web 共享代码,没有 Web 文件被修改或重指向;
- 后端:无任何改动。
八、延伸阅读
- 设计系列:docs/mobile-chat/00-index.md、docs/mobile-chat/01-research.md、docs/mobile-chat/03-detailed-design.md、docs/mobile-chat/04-implementation-plan.md、docs/mobile-chat/05-pr-roadmap.md、docs/mobile-chat/06-unified-chat-surface.md
- 移动端聊天纯 TS 层:mobile/src/chat/ndjson.ts、mobile/src/chat/messageTree.ts、mobile/src/chat/chatHistory.ts、mobile/src/chat/messageProcessor.ts、mobile/src/chat/streamingModels.ts
- 后端流式 API:backend/onyx/server/query_and_chat/chat_backend.py(
/create-chat-session、/send-chat-message、/chat-session/{session_id}/resume-stream) - 测试佐证:mobile/src/chat/tests/ndjson.test.ts、mobile/src/chat/tests/messageTree.test.ts、mobile/src/chat/tests/chatHistory.test.ts
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0631
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
video-shotcraftAI宣传片skill,使用 Remotion 制作电影级产品视频:提供106 张镜头配方卡和可复用的视频魔板。适用于 Claude Code 与 Codex以及所有其他智能体Markdown00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python09
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00