首页
/ Onyx 移动端 Chat 移植的高层架构设计:Hybrid Seams 方案下的 NDJSON 流式聊天实现

Onyx 移动端 Chat 移植的高层架构设计:Hybrid Seams 方案下的 NDJSON 流式聊天实现

2026-09-09 18:02:12作者:宣海椒Queenly

导读

本文深入解析 Onyx(danswer)移动端原生应用如何以「Hybrid Seams(混合接缝)」方案,将 Web 端的聊天体验完整移植到 React Native 客户端:应用通过 expo/fetch 直连未做任何改动的后端,以换行分隔 JSON(NDJSON)流式协议驱动逐 token 渲染,同时保持 Web 端代码零改动。读完本文,你将掌握移动端聊天的端到端调用链、useChatController 与 zustand 双层状态设计、纯 TS 移动端原生数据层(NDJSON 解析器、消息树、历史重建)的实现细节,以及 expo/fetchapiFetch 的分工依据,可直接对照仓库源码复现整套架构。

说明:本文对应设计文档 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 发送消息的三步编排

当用户点击发送,移动端的编排 HookuseChatController)做三件事:

  1. 会话不存在则创建:调用 POST /api/chat/create-chat-session,携带所选 Agent 的 persona_id 与当前激活的 project_id
  2. 乐观更新消息树:在内存中的 message tree 里立刻放下一条用户气泡和一条空的助手气泡;
  3. 打开流式请求:向 /api/chat/send-chat-message 发起流式 POST。

后端对应实现位于 backend/onyx/server/query_and_chat/chat_backend.py

  • POST /create-chat-sessionchat_backend.py#L462-L489)接收 ChatSessionCreationRequest,依赖 require_permission(Permission.WRITE_CHAT, allow_anonymous=True) 权限校验,返回 CreateChatSessionID(含 chat_session_idincognito 标志);persona 或项目越权时抛 403,非法 persona 抛 400。
  • POST /send-chat-messagechat_backend.py#L771-L867)默认 stream=True 返回 text/event-streamStreamingResponsestream=false 时返回完整 ChatFullResponse JSON;还支持 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.tsPacketType 枚举覆盖核心消息包 message_start / message_delta / message_endstoperror,以及搜索工具(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.tspackets 按序数与助手消息一一对应(每个助手回合一个包列表),以 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.tsmessageTree.test.tschatHistory.test.tsmessageProcessor.test.tsfileDescriptors.test.ts 等单测。

3.1 消息树:纯函数式的增量更新

消息树是按 nodeId 键控的 Map,根节点是合成的系统节点(SYSTEM_NODE_ID = -3),通过 latestChildNodeId 实现分支。见 mobile/src/chat/messageTree.tsupdateParentInMap 负责把子节点挂到父节点,并依据「强制最新 / 唯一子节点 / 新添加」三条规则更新 latestChildNodeIdgetMessageByMessageId 提供按服务器消息 ID 反查。该文件头注释说明其「几乎逐字移植自 Web 的 messageTree.ts,唯一差异是裁剪后的最小 Message 类型」。

3.2 增量包处理:带游标的归约器

mobile/src/chat/messageProcessor.ts 是 Web 端 packetProcessor 的忠实移植:维护 nextPacketIndex 游标,只处理游标之后的包;将包按回合(turn)/标签页(tab)分组为时间线条目,合成 SECTION_END 让一步骤完整闭合;同时维护 9a 引文/文档/完成度跟踪(citationMapdocumentMapisCompletestopReason、工具处理时长等)。这解释了文档「每个 ~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)、StreamingMarkdownInputBar(键盘吸附)、Agent 选择器、项目屏、附件 chips 新增,移动端
移动端原生聊天数据层(mobile/src/chat/ NDJSON 解析器 + 包/聊天/文件类型 + 消息树数学 + 历史重建 + 文件描述符辅助;从 Web 移植,零共享 新增,移动端

五、端到端场景与关键操作序列

5.1 完整用户场景

  1. 用户打开 App → AuthGate 将其送入 (app) 聊天首页;侧边栏通过 TanStack Query 展示近期会话;
  2. 用户在选择器中点选一个 Agent → 选中态被保存;空聊天屏展示 starter prompts;
  3. 用户输入「Summarize the Q3 board deck」并点发送;
  4. useChatController 创建会话(persona_id = 所选 Agent)→ 拿到 chat_session_id,导航到 chat/[id]
  5. 在消息树中放下用户气泡 + 空助手气泡(chatState='loading');
  6. 通过 expo/fetch POST 消息;后端流式返回 NDJSON 包;
  7. 移动端原生解析器产出数据包;Hook 把 MESSAGE_DELTA 文本追加到助手气泡,每 ~50ms flush(chatState='streaming');
  8. FlashList 保持视图吸附底部;助手气泡边增长边渲染 markdown;
  9. STOP 到达 → chatState='input',助手气泡获得服务器 message-id,会话列表 refetch 使历史反映新回合;
  10. 用户在回答中途把 App 切到后台再返回 → 重开会话时回放缓冲的数据包,并重新挂接到进行中的运行

5.2 关键操作序列

  1. 解析认证 + 服务器 URL(现有 AuthGate / sessionManager);
  2. 若无会话则创建(persona_idproject_id)→ chat_session_id
  3. 乐观播种消息树(用户 + 空助手节点);
  4. 打开带 bearer + JSON body 的 expo/fetch POST 流;
  5. 解码字节 → 移动端原生 NDJSON 解析器 → 类型化数据包;丢弃心跳;
  6. MESSAGE_* 包归约进助手节点;批量 flush 到 zustand(~50ms);
  7. 收到 STOP / ERROR / abort:结算聊天状态、释放 reader、捕获 message-id、refetch 会话;
  8. 重开/恢复:拉取历史 → 移动端原生 processRawChatHistory → 消息树;若运行仍在进行,尾随 resume-stream

第 8 步对应的后端断点续流端点真实存在:GET /chat-session/{session_id}/resume-streamchat_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 文件被修改或重指向;
  • 后端无任何改动

八、延伸阅读

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.76 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
860
1.35 K
docsdocs
暂无描述
Markdown
899
5.83 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
925
1.85 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.84 K
1.02 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
533
601
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.03 K
525
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.37 K
1.46 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
395