首页
/ Onyx 移动端聊天移植实施指南:基于 expo/fetch 流式传输的分阶段落地方案

Onyx 移动端聊天移植实施指南:基于 expo/fetch 流式传输的分阶段落地方案

2026-09-09 18:05:34作者:吴年前Myrtle

本文是 Onyx(danswer)仓库中 Mobile Chat Port(移动端聊天移植) 任务的实施计划详解,核心对应 docs/mobile-chat/04-implementation-plan.md。该计划解决的核心问题:移动端应用已经具备认证、导航、侧边栏、HTTP 层和 UI 原语,却独缺产品最核心的「聊天」能力。读者读完本文后,将掌握把 Onyx Web 聊天体验移植到 React Native + Expo 的分阶段工程方法——包括 NDJSON 流式传输的实现路径、纯数据层与 UI 的切分边界、PII 安全约束、以及可独立合并的 PR 节奏。

一、背景与目标:把 Web 聊天搬进原生移动端

Onyx 移动端(React Native 0.85 + Expo SDK 56 + NativeWind)目前已有完整的脚手架:AuthGate 认证、侧边栏导航、apiFetch HTTP 层(自动注入 Bearer Token)以及基础 UI 原语。本次移植的目标是让移动用户能够:

  • 选择一个 Agent(或使用默认 Agent),发起一场基于 Onyx 企业知识的流式对话;
  • 浏览与恢复历史会话;
  • 在项目(Project)内工作(含项目文件管理);
  • 在消息中附加文档 / 照片。

计划明确要求:后端完全不变(复用现有接口),设计令牌来自 @onyx-ai/shared,但聊天纯逻辑层在移动端原生重写而非共享(详见 PR 2 决策)。交付方式是一系列可独立合并的阶段,而非一次性大爆炸式合并。完整的规格索引见 docs/mobile-chat/00-index.md,前期调研见 docs/mobile-chat/01-research.md

二、关键决策:约束整个实施的七条原则

2.1 流式传输:expo/fetch + NDJSON,而非 SSE

POST /api/chat/send-chat-message 返回的是 NDJSON(换行分隔的 JSON),而非 SSE 帧格式(已确认于 web/src/app/app/services/lib.tsx:189)。因此必须使用 expo/fetch(SDK 56 默认 fetch)的 response.body.getReader() 读取流;RN 传统 fetch 没有可读的 response body。这是全应用唯一绕过 mobile/src/api/client.tsapiFetch 的调用,因为只有它需要可读字节流。

2.2 不共享聊天代码:纯层全部移动端原生实现

NDJSON 解析器、消息树、processRawChatHistory 以及全部聊天/流式/文件类型都写在 mobile/src/chat/(PR 2),Web 保留自己的副本。团队曾考虑共享整个纯层、甚至只共享约 40 行的解析器,最终全部放弃——共享包机制(@onyx-ai/shared 工具 + Web 重定向 + jest 映射 + dist 构建耦合)的复杂度超过了它消除的约 200 行重复代码;而产品尚未上线、协议稳定,漂移风险低且后期再抽取成本极低。

2.3 零 Web 改动

移动端聊天移植不修改任何 Web 文件web/src/lib/search/streamingUtils.tsmessageTree.tsfileUtils.ts 继续归 Web 所有,移动端在 mobile/src/chat/ 原生重写解析器、树与文件描述符逻辑。

2.4 双层状态架构

  • 列表类数据:TanStack Query(已配置 MMKV 持久化、按 serverUrl 做 key 隔离);
  • 实时流状态:独立的、不持久化的 zustand chatSessionStore(因为它持有 AbortController 和活动流)。

2.5 PII 排除:强制项而非可选项

聊天历史(消息、文件名、回答)属于敏感数据,因此聊天会话/消息的 query key 必须在 PR 1 中加入 dehydrateOptions 的 PII 排除列表(mobile/src/query/client.ts),时机在任何历史数据写入 MMKV 之前(遵循仓库 CONTRIBUTING 的多租户 PII 立场)。净效果:聊天内容不落盘、启动时重新拉取——这是安全的默认行为,与既有 me-key 排除模式一致。

2.6 性能基线

  • 包→UI 更新按约 50ms 批量冲刷
  • FlashList 行按 packetCount 记忆化(memoize),而非按数组引用;
  • FlashList v2 使用非翻转(non-inverted)模式 + maintainVisibleContentPosition,而非 inverted

2.7 隐式选择与发送门控

  • Agent(persona_id)和项目(project_id)在会话创建时绑定;没有按消息的 agent 参数,也没有后端 API 能中途更换会话的 Agent(与 Web 一致——换 Agent 就开新会话);
  • 发送门控:附件未完成索引(token_count != null)时禁止发送;用 3s 状态轮询暴露卡住/FAILED 状态,而不是无限期阻塞(镜像 web/src/providers/ProjectsContext.tsx)。

三、实施策略:九个阶段(P1–P9+)的路线图

计划将实施拆为 9+ 个有序步骤,每步都是自洽的变更,方括号标注所属 PR 阶段。完整的 PR 交付序列(含估算 LOC、依赖关系与验收清单)见 docs/mobile-chat/05-pr-roadmap.md

P1 — 认证聊天壳 + 会话列表

mobile/src/app/_layout.tsxAuthGate 下新增 (app) expo-router 路由组:新聊天首页、chat/[id] 脚手架(空状态 + 不可用的输入壳)、历史列表。新增 chatSessions(serverUrl) / chatSession(...) query key 与基于 apiFetch 的会话列表 hook。本 PR 强制项:在任何历史持久化之前,将聊天会话/消息 query key 加入 mobile/src/query/client.tsdehydrateOptions PII 排除列表。侧边栏接入会话列表。本阶段不做流式

05-pr-roadmap.md 的落地记录看,PR 1 的实际实现采用了「侧边栏即历史」方案:用 useInfiniteQueryGET /chat/get-user-chat-sessions?page_size=50&only_non_project_chats=true(镜像 Web 的 useSWRInfinite 游标分页),name 为 null 的行回退显示 "New Chat";落地页镜像 Web 的 WelcomeMessage

P2 — 移动端原生聊天数据层(已实现,核心资产)

创建 mobile/src/chat/,包含四个纯函数模块:

文件 职责
ndjson.ts 纯 NDJSON 行缓冲解析器,镜像 Web handleSSEStream 的解析核心
contracts/* 最小化 packet / Message / BackendMessage / FileDescriptor 类型
messageTree.ts 基于最小 Message 的 upsert / 遍历 / 构建器
chatHistory.ts processRawChatHistory,仅做结构重建

Web 与 @onyx-ai/shared 完全不动。 该层配 Jest 单元测试,是整条链路中价值最高的测试资产。

P3 — 核心聊天:发送 → 流式 → Markdown(步行骨架)

  • mobile/src/api/chat/stream.tsexpo/fetch 生成器 + P2 移动端原生解析器 + AbortController
  • state/chatSessionStore.ts:会话级 zustand store;
  • hooks/useChatController.ts:以 persona_id=0 创建会话、用 P2 构建器生成乐观节点、驱动流、约 50ms 冲刷、停止;
  • 包渲染器地基components/chat/renderers/registry.tsMessageRenderer 契约 + findRenderer 分发,镜像 Web 的 renderMessageComponent),本阶段只注册 renderers/MessageTextRenderer.tsx,配合 hooks/usePacketDisplay.ts(分组 + 分发);
  • components/chat/{MessageList,MessageRow,StreamingMarkdown,InputBar}
  • 通过 GET get-chat-session + P2 processRawChatHistory 水合既有会话。

本阶段开始时必须先跑两个 spike(见第四节)。注册表是 PR 9 的扩展接缝——现在构建它几乎不增加成本,但不要在核心阶段构建富渲染器

P4 — 恢复进行中的运行 + 历史打磨

useChatSessionController 的恢复尾部(resume-stream?cursor=)带 stillCurrent 守卫;onStartReached 旧消息分页(需守卫短列表);首条消息后自动重命名 + 历史刷新。

P5 — Agent 选择

contracts/agents.tsapi/chat/agents.tsGET /api/persona)、components/chat/AgentPicker.tsx;选择在创建时写入 persona_id;空屏展示起始提示(starter prompts)。不做创建/编辑。落地时还实现了全屏画廊路由 + 侧边栏置顶 rail,选择后自动置顶(PATCH /user/pinned-assistants)。

P6 — 项目:列表 + 选择 + 项目内聊天

contracts/projects.tsapi/chat/projects.ts(列表/详情/文件)、项目列表与详情屏;「在项目中新建聊天」传递 project_id不做项目 CRUD。落地时选择了 Web 忠实路径:侧边栏 Projects 区 + (app)/projects/[id].tsx 详情屏,聊天列表直接读 GET /user/projects/{id}/details 内嵌的 chat_sessions[]

P7 — 项目文件管理

api/files/upload.tsexpo-file-system createUploadTask,multipart,字段名 files)、state/uploadStore.ts + 3s 状态轮询、expo-document-picker + expo-image-picker、链接/取消链接、带状态徽章的文件列表 UI。落地时确认了后端接口(backend/onyx/server/features/projects/api.py):上传 POST /user/projects/file/upload、状态 POST /user/projects/file/statuses、取消链接 DELETE /user/projects/{id}/files/{fileId}、最近文件 GET /user/files/recent,并修复了 UserFileStatus 枚举缺失 INDEXING 的问题。

P8 — 输入栏附件(按消息)

复用 P7 的选择器/上传器;components/chat/AttachmentChips.tsx(落地时改为统一使用 FileCard,与 Web 一致);mobile/src/chat/fileDescriptors.ts(移动端原生 projectFilesToFileDescriptors);发送时构建 file_descriptors[]按索引进度门控发送;图片预览走 GET /api/chat/file/{file_id}。相机推迟。落地时的关键证据:按消息附件与项目附件走同一个上传端点,只是省略 project_id 表单参数(后端 project_id: int | None = Form(None) 本就支持),因此 PR 8 无需后端改动

P9+ — 延后的富聊天(每个独立成阶段)

引用/来源 · agentic 时间线(推理/搜索/工具子步骤)· 重新生成/编辑/反馈 · 跟进建议 · 图像生成。每个功能只需:向移动端 mobile/src/chat/contracts/ 添加富 packet 类型,并向 P3 的分发器注册一个新的 MessageRenderer(无需核心重写);agentic 时间线阶段额外构建 AgentTimeline 组合层,后续时间线渲染器插在其上。Web 的 usePacketProcessor 保持 Web-only。

四、PR 0 前置 Spike:核心的双重门禁

两个早期 spike 是 P3 的硬门禁,必须在设备 dev build 上验证(需要 expo-dev-client,Expo Go 不行):

  1. expo/fetch 流式验证:在物理设备/模拟器上确认 response.body.getReader() 可用(后备方案:XHR progress 喂同一个共享缓冲)。从 05-pr-roadmap.md 的记录看,该 spike 已通过:iOS 模拟器上实际拿到 HTTP 200、getReader() 存在、NDJSON 包增量到达(message_start → N×message_deltastop),并发现两个关键事实——后端 MessageOrigin 枚举原本没有 "mobile" 值(spike 期间先发 origin: "unknown",PR 3 落地时选择给后端枚举加了 MessageOrigin.MOBILE,这是移植过程中唯一的有意后端触碰);流中混合了 {placement, obj:{type}} 包装与根级控制对象两种形状,解析器必须同时处理。
  2. react-native-streamdown 在 RN 0.85 上的构建/渲染验证(后备方案:react-native-marked)。该 spike 被推迟到 PR 3 前置工作,是 P3 锁死 Markdown 组件前的唯一遗留门禁。

五、测试策略:每种类型只测一种形态,不过度测试

阶段 测试类型 覆盖内容
P2(纯辅助函数) Jest 单元测试(mobile runner) NDJSON 缓冲(部分行、花括号恢复、心跳透传、尾部冲刷)+ upsertMessages / getLatestMessageChain / processRawChatHistory这是最高价值测试,保护移动端自有的解析器 + 树 + 历史。Web 流式路径不变,仍由既有测试覆盖。
P1、P3–P8(hooks + 组件) @testing-library/react-native,流式传输 mock 化(喂入脚本化的包序列) 令牌增量渲染、停止即中止、发送门控在索引完成前阻塞、agent/project 选择正确串联 persona_id/project_id、附件芯片反映上传状态。复用现有 mobile jest mocks(jest.setup.ts__mocks__/)。
真实后端流式 每个阶段的手动设备验证(dev build) 发送、流式、停止、重开、后台返回后恢复。仓库内没有 RN e2e 测试框架;Web 的 Playwright 套件只管 Web。
后端 无新增 后端完全不变。

从落地记录看,测试数字一路增长:PR 1 结束时 83 个 jest 测试,PR 2 新增 31 个,PR 3 达到 129 个,PR 4 达到 143 个,PR 6 达到 145 个,PR 7 达到 228 个,PR 8 达到 250 个,统一聊天界面重构后为 198 个(重构是净删除)。每次 PR 还附带 adversarial 多 agent 评审与 GitHub bot 评审,确认并修复真实缺陷(例如 PR 4 的 stillOurs() 所有权令牌守卫、心跳透传修复)。

六、Plan Challenge 结果:六个维度评审结论

1. 可扩展性与可伸缩性:PASS

垂直切片的阶段把延后的富聊天挂载到稳定核心上;移动端原生契约增量增长;usePacketDisplay 每个功能只扩展一个分支;长历史由 onStartReached 分页 + FlashList v2 处理。仅有的两个可调参数(约 50ms 批量、3s 状态轮询)是配置而非硬编码上限。

2. 脆弱性:CONCERN(已识别 + 已加固)

三个脆弱点各有缓解:(a) Web↔移动端聊天代码重复——解析器/树/历史现在存在于两处,后端协议变更必须两端同步修改 → 为移动端副本标注镜像自 Web 的来源,且仅在漂移真正发生时再抽取到 @onyx-ai/shared(产品未上线、协议稳定,代价低);(b) 外部依赖风险expo/fetch 设备流式、streamdown 在 RN 0.85 上)→ 具名后备方案(XHR→同一缓冲;react-native-marked)+ 两个早期 spike 门禁 P3;(c) 流/会话串扰→ 移植 Web 的 stillCurrent/AbortController 守卫,确保后台流不会写进错误会话。

3. 行业标准:VERIFIED(2026 年 6 月联网核实)

  • expo/fetch 是 SDK 56 在 iOS/Android 上的默认全局 fetch(2026-05-21 发布),支持增量 response.body.getReader(),是 RN 流式标准路径;
  • FlashList v2 非翻转 + maintainVisibleContentPosition + startRenderingFromBottom 是 v2 聊天模式官方文档写法(inverted 已废弃,旧消息用 onStartReached);已知 issue #1844/#2050/#1872 恰是计划已标记的坑;
  • react-native-keyboard-controller 是 2026 年聊天输入推荐方案(KeyboardStickyView/KeyboardChatScrollView);Expo 官方称 KeyboardAvoidingView 只是原型级方案;
  • react-native-streamdown(Software Mansion,基于 worklet)是当前流式 Markdown 的选择,生态已越过普通的 react-native-markdown-display(它已无人维护)。

4. 事实核查:PASS(一处细化)

「FormData 导致 OOM」的表述需要细化:RN FormData 若引用文件 URI(而非 base64)同样会流式传输,中小文件不会内存爆炸;expo-file-system createUploadTask 的真正优势是上传进度 + 超大文件。因此 P7/P8 应把 createUploadTask 视为「为进度和大文件」的选择,而非「FormData 全面不可用」。落地时 PR 7 据此采用了 new File(uri).upload() 的 SDK-56 对象 API(createUploadTask 也保留),PR 8 也走同一上传器。

5. 可维护性:PASS

镜像 Web 结构与既有移动端模式(apiFetch、query-keys、zustand、UI 原语);共享/移动端边界清晰;每个阶段都是自洽、可独立评审的切片。细节:usePacketDisplay(移动端)与 usePacketProcessor(Web)的命名差异是有意为之并已文档化。

6. Patch vs. Fix:PROPER FIX

这是针对真实后端契约的功能构建,不是 workaround。传输选型(expo/fetch)、批量冲刷、状态轮询都是标准模式而非症状掩盖。

结论:可以推进。 无失败项;脆弱性担忧已预先缓解;一处事实核查细化已并入 P7/P8 指南;两个 P3 前 spike 是唯一硬门禁。

七、源码级印证:移动端聊天数据层的真实实现

实施计划中的设计已在 mobile/src/chat/ 落地,以下关键实现可以直接在仓库中核验:

NDJSON 行缓冲解析器mobile/src/chat/ndjson.ts):createNdjsonBuffer<T>() 返回 { pushChunk(text): T[]; flush(): T[] }pushChunk 把新文本追加到内部缓冲、按 \n 切分、保留尾部不完整行、对完整行 JSON.parse;解析失败时用 /\{[^{}]*\}/g 做扁平 JSON 抢救(嵌套对象无法恢复,真正的断行完成靠缓冲结转)。flush 处理流结束时的残留部分行,畸形尾部直接丢弃(与 Web 一致)。该模块零平台耦合——不 import fetch/TextDecoder,由 transport 喂解码后的文本。

包协议契约mobile/src/chat/streamingModels.ts):定义了完整 PacketType 枚举——核心子集 message_start/delta/endstopsection_enderrorchat_heartbeat,以及后续阶段陆续加入的 search_tool_*reasoning_*citation_*image_generation_*open_url_*(Web 命名 FETCH_TOOL_*,线格式是 open_url_*)、python_tool_*custom_tool_*file_reader_*memory_tool_*deep_research_* 等。每个 packet 有 {placement: {turn_index, tab_index?, sub_turn_index?, model_index?}, obj: {type, ...}} 包装。文件底部还有移动端专属的根级类型:MessageResponseIDInfo(根级 message_id_info不能靠 obj.type 判别)、StreamingError(根级 error 字段,丢弃它会让回合卡在「…」),以及 buildUserCancelledStopPacket——用户主动停止时 reader 会被中止、后端自己的 stop 包来不及到达,需要合成一个 USER_CANCELLED 的 stop 包避免回合一直显示为流式中。这正是 PR 0 spike 发现的「混合包形状」问题的产物。

消息树mobile/src/chat/messageTree.ts):约 302 行的纯函数集,从 Web 的 messageTree.ts 近乎逐字移植,唯一差异是裁剪后的最小 MessageSYSTEM_MESSAGE_ID/SYSTEM_NODE_ID 均为 -3(合成系统节点);upsertMessagesMap<nodeId, Message> 做去重 upsert 并按 latestChildNodeId 维护分支;buildEmptyMessage-Date.now()-offset 生成临时 id 保证乐观节点碰撞安全。落地记录特别标注了一个移植陷阱:发送流程必须实现 Web 调用点的 -3 → null 守卫(parentId === SYSTEM_MESSAGE_ID ? null : parentId),避免把合成 id 当作 parent_message 发出。

流式传输mobile/src/api/chat/stream.ts):PR 3 的 streamChatMessage(body, signal) 生成器拥有 expo/fetch POST + getReader() + TextDecoder 循环,喂给 PR 2 的缓冲器、过滤心跳、abort/finallyreader.cancel()。发送体(最小核心):{message, chat_session_id, parent_message_id, file_descriptors:[], deep_research:false, origin:"mobile"}。PR 4 把 NDJSON 读取重构为共享的 readNdjson,供发送与恢复两条路径复用,并新增 resumeChatMessage(GET、带 Bearer、心跳透传 keepHeartbeats)。

其他落地印证:会话 hook 在 mobile/src/api/chat/sessions.tscreateChatSession/getChatSession/renameChatSession/DEFAULT_PERSONA_ID),Agent 在 mobile/src/api/chat/agents.tsGET /api/persona),项目在 mobile/src/api/chat/projects.tsuseProjects/useProjectDetails)。这些与计划中「TanStack Query 管列表、zustand 管流」的双层设计完全对应。

八、演进:统一聊天界面(post-PR 6 重构)

PR 6 之后,计划之外又产生了一个结构性重构(详见 docs/mobile-chat/06-unified-chat-surface.md):把分叉的 ChatConversation + ProjectView 两套聊天外壳折叠为一个常驻的 ChatSurface,挂在 (app)/_layout.tsx<Stack> 之上作为绝对定位覆盖层。路由文件(indexchat/[id]projects/[id])退化为空壳的 focus 报告屏,deriveFocus(pathname)mobile/src/chat/chatFocus.ts)把路径映射为 new | chat | project 三种 focus,头部 + 输入栏(composer)跨对话常驻,仅正文区域做约 150ms 的 reanimated 交叉淡入淡出。效果是「同一屏内容原地变形」而非屏幕切换,镜像 Web 的 AppPage 永不重挂载模型。该重构由 renderRouter 无头 spike 验证通过(挂载次数恒定、focus 逐跳切换),并为 PR 7/PR 8 提供了单一 composer 注入点——附件状态必须随 [sessionId, projectId] 变化重置,否则会泄漏到错误的对话。

九、总结与落地要点

Mobile Chat Port 是 Onyx 把核心产品能力补齐到移动端的关键工程,其方法论要点可提炼为:

  1. 后端契约是锚:流式走 NDJSON + expo/fetch,其余全部走 apiFetch,后端零改动(唯一例外是 PR 3 有意为枚举补充 MessageOrigin.MOBILE);
  2. 纯层移动端原生、测试先行:NDJSON 解析器、消息树、历史重建在 mobile/src/chat/ 落地并配最高优先级的 Jest 单元测试;
  3. 渲染器注册表是扩展接缝:P3 只建分发骨架不建富渲染器,让 P9 的五个富聊天功能变成纯增量注册;
  4. PII 是红线:聊天历史永远不落 MMKV,查询 key 必须排除在持久化之外;
  5. 两个 spike 门禁:流式与流式 Markdown 必须在设备 dev build 上先验证,再有 P3;
  6. 每次 PR 独立可合并:从约 150 LOC 的 throwaway spike 到约 700 LOC 的核心切片,每个阶段都保持 main 可构建、应用可用。

最终,用户在移动端获得与 Web 对齐的完整聊天体验:选 Agent → 流式对话 → Markdown 渲染 → 会话历史 → 项目内聊天与文件管理 → 消息附件,而 Web 与后端在整个移植过程中保持完全不动。

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

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
docsdocs
暂无描述
Markdown
899
5.83 K
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
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
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
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.04 K
525