Onyx 移动端聊天移植实施指南:基于 expo/fetch 流式传输的分阶段落地方案
本文是 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.ts 的 apiFetch 的调用,因为只有它需要可读字节流。
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.ts、messageTree.ts、fileUtils.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.tsx 的 AuthGate 下新增 (app) expo-router 路由组:新聊天首页、chat/[id] 脚手架(空状态 + 不可用的输入壳)、历史列表。新增 chatSessions(serverUrl) / chatSession(...) query key 与基于 apiFetch 的会话列表 hook。本 PR 强制项:在任何历史持久化之前,将聊天会话/消息 query key 加入 mobile/src/query/client.ts 的 dehydrateOptions PII 排除列表。侧边栏接入会话列表。本阶段不做流式。
从 05-pr-roadmap.md 的落地记录看,PR 1 的实际实现采用了「侧边栏即历史」方案:用 useInfiniteQuery 调 GET /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.ts:expo/fetch生成器 + P2 移动端原生解析器 +AbortController;state/chatSessionStore.ts:会话级 zustand store;hooks/useChatController.ts:以persona_id=0创建会话、用 P2 构建器生成乐观节点、驱动流、约 50ms 冲刷、停止;- 包渲染器地基:
components/chat/renderers/registry.ts(MessageRenderer契约 +findRenderer分发,镜像 Web 的renderMessageComponent),本阶段只注册renderers/MessageTextRenderer.tsx,配合hooks/usePacketDisplay.ts(分组 + 分发); components/chat/{MessageList,MessageRow,StreamingMarkdown,InputBar};- 通过
GET get-chat-session+ P2processRawChatHistory水合既有会话。
本阶段开始时必须先跑两个 spike(见第四节)。注册表是 PR 9 的扩展接缝——现在构建它几乎不增加成本,但不要在核心阶段构建富渲染器。
P4 — 恢复进行中的运行 + 历史打磨
useChatSessionController 的恢复尾部(resume-stream?cursor=)带 stillCurrent 守卫;onStartReached 旧消息分页(需守卫短列表);首条消息后自动重命名 + 历史刷新。
P5 — Agent 选择
contracts/agents.ts、api/chat/agents.ts(GET /api/persona)、components/chat/AgentPicker.tsx;选择在创建时写入 persona_id;空屏展示起始提示(starter prompts)。不做创建/编辑。落地时还实现了全屏画廊路由 + 侧边栏置顶 rail,选择后自动置顶(PATCH /user/pinned-assistants)。
P6 — 项目:列表 + 选择 + 项目内聊天
contracts/projects.ts、api/chat/projects.ts(列表/详情/文件)、项目列表与详情屏;「在项目中新建聊天」传递 project_id。不做项目 CRUD。落地时选择了 Web 忠实路径:侧边栏 Projects 区 + (app)/projects/[id].tsx 详情屏,聊天列表直接读 GET /user/projects/{id}/details 内嵌的 chat_sessions[]。
P7 — 项目文件管理
api/files/upload.ts(expo-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 不行):
expo/fetch流式验证:在物理设备/模拟器上确认response.body.getReader()可用(后备方案:XHR progress 喂同一个共享缓冲)。从 05-pr-roadmap.md 的记录看,该 spike 已通过:iOS 模拟器上实际拿到 HTTP 200、getReader()存在、NDJSON 包增量到达(message_start→ N×message_delta→stop),并发现两个关键事实——后端MessageOrigin枚举原本没有"mobile"值(spike 期间先发origin: "unknown",PR 3 落地时选择给后端枚举加了MessageOrigin.MOBILE,这是移植过程中唯一的有意后端触碰);流中混合了{placement, obj:{type}}包装与根级控制对象两种形状,解析器必须同时处理。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/end、stop、section_end、error、chat_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 近乎逐字移植,唯一差异是裁剪后的最小 Message。SYSTEM_MESSAGE_ID/SYSTEM_NODE_ID 均为 -3(合成系统节点);upsertMessages 用 Map<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/finally 时 reader.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.ts(createChatSession/getChatSession/renameChatSession/DEFAULT_PERSONA_ID),Agent 在 mobile/src/api/chat/agents.ts(GET /api/persona),项目在 mobile/src/api/chat/projects.ts(useProjects/useProjectDetails)。这些与计划中「TanStack Query 管列表、zustand 管流」的双层设计完全对应。
八、演进:统一聊天界面(post-PR 6 重构)
PR 6 之后,计划之外又产生了一个结构性重构(详见 docs/mobile-chat/06-unified-chat-surface.md):把分叉的 ChatConversation + ProjectView 两套聊天外壳折叠为一个常驻的 ChatSurface,挂在 (app)/_layout.tsx 的 <Stack> 之上作为绝对定位覆盖层。路由文件(index、chat/[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 把核心产品能力补齐到移动端的关键工程,其方法论要点可提炼为:
- 后端契约是锚:流式走 NDJSON +
expo/fetch,其余全部走apiFetch,后端零改动(唯一例外是 PR 3 有意为枚举补充MessageOrigin.MOBILE); - 纯层移动端原生、测试先行:NDJSON 解析器、消息树、历史重建在
mobile/src/chat/落地并配最高优先级的 Jest 单元测试; - 渲染器注册表是扩展接缝:P3 只建分发骨架不建富渲染器,让 P9 的五个富聊天功能变成纯增量注册;
- PII 是红线:聊天历史永远不落 MMKV,查询 key 必须排除在持久化之外;
- 两个 spike 门禁:流式与流式 Markdown 必须在设备 dev build 上先验证,再有 P3;
- 每次 PR 独立可合并:从约 150 LOC 的 throwaway spike 到约 700 LOC 的核心切片,每个阶段都保持
main可构建、应用可用。
最终,用户在移动端获得与 Web 对齐的完整聊天体验:选 Agent → 流式对话 → Markdown 渲染 → 会话历史 → 项目内聊天与文件管理 → 消息附件,而 Web 与后端在整个移植过程中保持完全不动。
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