news 2026/9/10 4:11:43

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Onyx 移动端 Chat 移植的高层架构设计:Hybrid Seams 方案下的 NDJSON 流式聊天实现

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

【免费下载链接】danswerOpen Source AI Platform - AI Chat with advanced features that works with every LLM项目地址: https://gitcode.com/GitHub_Trending/da/danswer

导读

本文深入解析 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-session(chat_backend.py#L462-L489)接收ChatSessionCreationRequest,依赖require_permission(Permission.WRITE_CHAT, allow_anonymous=True)权限校验,返回CreateChatSessionID(含chat_session_idincognito标志);persona 或项目越权时抛 403,非法 persona 抛 400。
  • POST /send-chat-message(chat_backend.py#L771-L867)默认stream=True返回text/event-streamStreamingResponsestream=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_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.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.tsmessageTree.test.tschatHistory.test.tsmessageProcessor.test.tsfileDescriptors.test.ts等单测。

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

消息树是按 nodeId 键控的 Map,根节点是合成的系统节点(SYSTEM_NODE_ID = -3),通过latestChildNodeId实现分支。见 mobile/src/chat/messageTree.ts:updateParentInMap负责把子节点挂到父节点,并依据「强制最新 / 唯一子节点 / 新添加」三条规则更新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 hookssessions、agents、projects、files 列表(持久化,按服务器 URL 键控)新增,移动端
RN UIMessageList(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/fetchPOST 消息;后端流式返回 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/fetchPOST 流;
  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-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

【免费下载链接】danswerOpen Source AI Platform - AI Chat with advanced features that works with every LLM项目地址: https://gitcode.com/GitHub_Trending/da/danswer

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/10 4:11:35

GE图引擎EsCTensorHolder构造与析构

EsCTensorHolder构造函数和析构函数 【免费下载链接】ge GE(Graph Engine)是面向昇腾的图编译器和执行器,提供了计算图优化、多流并行、内存复用和模型下沉等技术手段,加速模型执行效率,减少模型内存占用。 GE 提供对 …

作者头像 李华
网站建设 2026/9/10 4:10:12

前端如何构建Agent记忆模块:Redis+BM25语义缓存实战

1. 为什么前端工程师突然开始写“记忆模块”?——从DOM操作到Agent状态管理的认知跃迁你有没有在某个深夜改完第17版登录页动效后,盯着控制台里一闪而过的fetch请求发呆:这个用户刚输错密码三次,下一次他点“忘记密码”时&#xf…

作者头像 李华
网站建设 2026/9/10 4:04:47

大专以下转行嵌入式?这行真正卡人的不是学历,而是这三样

网上有句话流传挺广:“张雪峰来了都不建议大专以下转行嵌入式。”我第一次看到这句话的时候,正在给一块STM32板子调串口,屏幕上一片乱码,说实话那一瞬间有点想笑,也有点被刺痛。但冷静下来想想,这句话能火&…

作者头像 李华
网站建设 2026/9/10 4:03:46

TVBoxOSC 安装教程:3 步让闲置电视盒子变免费媒体中心

TVBoxOSC 安装教程:3 步让闲置电视盒子变免费媒体中心 【免费下载链接】TVBoxOSC TVBoxOSC - 一个基于第三方项目的代码库,用于电视盒子的控制和管理。 项目地址: https://gitcode.com/GitHub_Trending/tv/TVBoxOSC 抽屉里吃灰的旧电视盒子&#…

作者头像 李华