首页
/ Onyx Mobile Chat Port 分阶段 PR 路线图:从流式探针到完整聊天体验的实施全景

Onyx Mobile Chat Port 分阶段 PR 路线图:从流式探针到完整聊天体验的实施全景

2026-09-09 18:08:22作者:傅爽业Veleda

本文以 docs/mobile-chat/05-pr-roadmap.md 为主体,结合仓库内 mobile/src/chatmobile/src/api/chatmobile/src/hooks 等实际源码,全面解析 Onyx(danswer)将 Web 聊天体验移植到 React Native + Expo 移动端的 PR 路线图。读者将掌握:每个 PR 的职责边界、依赖关系与验收标准,移动端原生聊天数据层(NDJSON 解析、消息树、历史重建)的实现原理,以及贯穿全程的"Web 对齐"与"不共享聊天逻辑"两大决策的具体落地方式。

背景:移动端缺的恰恰是最核心的聊天

Onyx 移动端(mobile/,React Native + Expo)当时已经具备认证、导航、侧边栏、HTTP 层与 UI 基础组件,但没有聊天功能——而这正是产品的核心。本路线图的目标,是把 Web 端(web/src/app/app/...)成熟的聊天体验完整移植到移动端,使用户能够在手机上:选择 Agent、与基于 Onyx 知识的 Agent 进行流式对话、浏览/恢复历史会话、在项目(Project)内工作(含项目文件管理)、以及为单条消息附加文档或图片。

两个硬性前提贯穿所有 PR:

  1. 后端零改动优先:整体上必须复用现有、不变的后端 API;聊天入口在 PR 1 就可见,但 send 直到 PR 3 才启用。
  2. 分片交付:每个 PR 都是可独立合并的切片(约 500–700 LOC 含测试),合并后 main 始终保持可构建、应用可用,而不是一次大爆炸式合并。

路线图与 docs/mobile-chat/04-implementation-plan.md 一一对应(P0–P9+ 阶段),并在此之上沉淀了大量"实测后修正"(As built)记录与多轮对抗性审查结果。

两条贯穿全局的核心决策

决策覆盖(2026-06-29):聊天逻辑不进共享包

最初方案(Approach C)计划把聊天纯逻辑抽到 @onyx-ai/shared 共享包,但最终决策反转为不共享

  • 移动端拥有自己的副本:NDJSON 解析器、消息树、packet/chat/file 契约、processRawChatHistory 全部落在 mobile/src/chat/ 下,凡是路线图或详细设计中写到的 @onyx-ai/shared/contracts/*@onyx-ai/shared/utils/*,一律读作 mobile/src/chat/*
  • Web 完全不动:无导入重指向、无 shim、无 Web 对齐步骤。代价是解析器/消息树逻辑在 Web 与移动端各存一份,理论上可能漂移。
  • @onyx-ai/shared 继续只收跨平台的设计原语(design tokens、排版、交互契约)。

为什么连约 40 行的 NDJSON 解析器都不共享?决策记录给出的理由非常务实:共享包机制(新增 util + Web 端重指向 + jest module-mapper + dist/build 耦合)带来的活动部件,比它消除的约 200 行重复代码更多。产品处于 pre-production 阶段、后端 NDJSON 帧协议与消息线程稳定,漂移风险低,即便真出问题,事后重新抽取的成本也很低。

Web 对齐原则(2026-06-30):每个组件都要"看起来、用起来"和 Web 一样

从 PR 4 起(PR 0–3 已完成),每个移动组件/界面必须尽可能与 Web 对应组件在布局、间距、尺寸、颜色、交互上一致。注意移动端间距是像素值,要翻译 Web 的 Tailwind 步进值,而不是照抄 class 数字。具体执行顺序:

  1. 优先复用对齐原语@/components/ui/textText@/components/ui/buttonButton 已做到完全 Web 对齐,组合使用它们(以及 components/ui/* 下的 text-inputiconseparator),不要手写裸 RN Text / Pressable / TextInput
  2. 动手前先查现状:扫一遍 components/ui/*components/chat/*@/icons/* 与共享设计 token,看是否已有接近的组件。
  3. 缺原语就先问:若 Web 对齐需要移动端还没有的原语,停下并询问 owner 是否移植(用 port-web-component-to-mobile skill 做像素/行为级 RN 移植),绝不手写一个分叉的"形似"组件来绕过询问。
  4. 记录分歧:每个 PR 的 "As built" 说明必须显式列出移动端与 Web 渲染不同之处及原因(有意简化、平台限制、延后特性)。

(侧边栏对齐由 owner 在聊天 PR 之外单独处理。)

PR 全景与依赖关系

PR 标题 预估 LOC 依赖 核心交付物
0 chore(mobile): chat streaming + markdown spikes ~150(一次性) 在真机验证 expo/fetch 流式 + react-native-streamdown on RN 0.85,确定回退方案。PR 3 的硬门控
1 feat(mobile): authed chat shell + sessions history ~550 PR 0 (app) 路由组、历史列表(真实数据)、聊天屏脚手架;无流式
2 feat(mobile): native chat data layer ~550 PR 1 移动端原生 NDJSON 解析器 + 核心 packet/chat/file 契约 + 消息树 + processRawChatHistorymobile/src/chat/),jest 单元测试;Web 不动、零共享
3 feat(mobile): core chat — send, stream, markdown ~700 PR 2 头号切片——针对默认 Agent 的可工作流式聊天
4 feat(mobile): resume in-flight run + history pagination ~450 PR 3 重开/恢复进行中的 run;翻页旧消息;自动命名
5 feat(mobile): agent selection ~550 PR 3 浏览并选择 Agent;starter prompts;隐式 persona_id
6 feat(mobile): projects — list, select, chat-within ~550 PR 3 浏览项目、打开、在项目内聊天
7 feat(mobile): project file management ~650 PR 6 通过 picker + 流式上传 + 状态管理增删项目文件
8 feat(mobile): input-bar attachments ~550 PR 7 给消息附加文档/图片;索引完成前禁用发送
9a feat(mobile): citations & sources ~500-700 PR 3 (延期 rich-chat)
9b feat(mobile): agentic reasoning timeline ~500-700 PR 3 (延期 rich-chat)
9c feat(mobile): regenerate / edit / feedback ~500-700 PR 3 (延期 rich-chat)
9d feat(mobile): follow-up suggestions ~400 PR 3 (延期 rich-chat)
9e feat(mobile): image generation rendering ~500 PR 3 (延期 rich-chat)

序列图如下:

PR0 spike ─► PR1 shell+history ─► PR2 mobile-native chat data layer (parser+contracts+tree+history) ─► PR3 CORE CHAT (walking skeleton)
                                                                                                          │
                        ┌──────────────────────┬────────────────────────────────────────────────────────────┼───────────────┐
                        ▼                      ▼                                                            ▼                 ▼
                  PR4 resume/paginate     PR5 agents                                                PR6 projects        PR9a–9e rich-chat
                                                                                                         │              (each independent,
                                                                                                         ▼               any order after PR3)
                                                                                                    PR7 project files
                                                                                                         │
                                                                                                         ▼
                                                                                                    PR8 input attachments

要点:PR 3 是脊柱,PR 4/5/6 与全部 PR 9 都从它独立扇出;PR 7→8 是唯一更深的链(附件复用项目上传器);PR 2 是移动端原生纯数据层,独立编写,Web 保留自己的副本,零共享。

PR 0:流式 + Markdown 探针(一次性代码)

目标:在投入 PR 3 设计前,消除两个外部不确定性。

范围内:一个 dev-build 分支上 (1) 通过 expo/fetch POST 到 /api/chat/send-chat-message,并用 response.body.getReader()物理设备上打印解析出的 packet;(2) 用 react-native-streamdown 渲染流式 Markdown。记录结果与所选回退方案。

范围外:任何真实 UI、状态或共享代码;纯一次性/探针代码(可合并为有文档的 spike,或留在分支)。

漂移检查点:若真机上 expo/fetch 缺少 response.body.getReader(),则 PR 3 的传输层切换到 XHR-progress 喂同一个共享 buffer——需在 PR 2 敲定解析器接缝前重新确认。

Step 1 结果:流式探针完成(GO)

  • 决策(2026-06-25):iOS 模拟器(localhost 后端);应用内临时开发屏复用真实 session/token;先验证流式,streamdown 配置延后到 Step 2。
  • 脚手架(PR 3 后删除):mobile/src/app/dev-stream.tsx 探针屏 + mobile/src/app/index.tsx 临时"Dev: streaming spike (PR0)"按钮;无新依赖、无原生配置;create-chat-session 复用 apiFetch,仅 send-chat-message 换成 expo/fetch,请求体镜像 web/src/app/app/services/lib.tsxsendMessage()
  • 屏幕上报内容:base URL · HTTP 状态 · response.body 是否存在 · getReader() 是否存在 · 解析到的 packet 数 · 耗时 · 累积答案 · 最近 40 个 packet 类型 · 任何错误。

Run 1(2026-06-25,HTTP 422——流式还没到就先发现了 body 契约问题)

  • expo/fetch POST + secure-store bearer + create-chat-session 全部工作,真实后端响应经 expo/fetch 返回。
  • parent_message_id: null 对首条消息被接受(未报错)。
  • 关键发现:后端 MessageOrigin 枚举没有 "mobile"——允许值为 webapp | chrome_extension | api | slackbot | widget | discordbot | unknown | unset。探针改为发送 origin: "unknown"。PR 3 决策:给后端枚举加 "mobile" 值(小改动、更好的分析维度)还是继续用 "unknown"——最终选择了前者。

这一点已在仓库源码中得到印证:后端 models.pyMessageOrigin(str, Enum) 现在包含 MOBILE = "mobile",说明 PR 3 期间确实给后端枚举追加了该值,这也是整个移植中少数"有意识触碰后端"的地方之一。

Run 2(修复 origin 后):HTTP 200;response.body 存在 YESgetReader() 存在 YESPR 3 传输层定为 expo/fetch,无需 XHR 回退。NDJSON packet 增量到达——16 个 packet:message_start → N×message_deltastop;消息文本从 message_start/message_delta 累积,成功渲染出 "Hello, Subash! 👋 Hope you're having a great day!"。

两条遗留事项

  • 混合 packet 形态:流里混着 {placement, obj:{type}} 包装对象与根级控制对象(如 message-id-info,探针里显示为 <<no-type>>)。PR 2 的解析器/类型必须同时处理两者(Web 已这么做)。
  • Emoji 字形:👋 在 Hanken Grotesk 下渲染为 tofu——PR 3 的 Markdown 渲染器需要支持 emoji 的字形回退(外观问题)。

Step 2(react-native-streamdown 在 RN 0.85 上构建/渲染)未执行,被移入 PR 3 前置工作,因为 PR 1 与 PR 2 与 Markdown 无关;若构建失败则回退 react-native-marked

结论:PR 0 状态:流式探针完成(GO),Markdown 构建检查带入 PR 3 前置工作,PR 1 与 PR 2 解除阻塞。

PR 1:认证聊天外壳 + 会话历史

目标:一个可达的、认证的聊天界面,显示真实聊天历史;导航端到端工作,暂无流式。

范围内AuthGate 下的 (app) expo-router 分组;新聊天首页(空状态);chat/[id] 脚手架(静态输入壳,send 禁用);基于 TanStack Query hook(sessions-list 端点)的历史列表;chatSessions/chatSession 查询键;侧边栏接入 sessions;并且——必需而非可选——在 MMKV 持久化任何历史之前,把聊天 session/message 查询键加入 dehydrateOptions PII 排除列表(mobile/src/query/client.ts

范围外:流式、消息渲染、Agent、项目、附件。

As built(2026-06-26,"match web")

  • 导航 = 现有 Stack + 可折叠侧边栏浮层(无 tab bar)。
  • 侧边栏即历史——扁平的 "Recents" 列表(镜像 Web 的 RecentsSection),因此没有独立的 history.tsx。演示用 mobile/src/app/index.tsx 被替换:认证首页移入 (app)/index.tsx(路由组对路径透明,(app)/index.tsx = /),侧边栏(components/chat/AppSidebar.tsx)挂载在 (app)/_layout.tsx 覆盖所有认证屏。
  • 数据api/chat/sessions.tsuseChatSessions = useInfiniteQuery 拉取 GET /chat/get-user-chat-sessions?page_size=50[&before=<last.time_updated>]&only_non_project_chats=true(镜像 Web 的 useSWRInfinite 游标翻页);name 为 null 的行回退显示 Web 的 "New Chat"
  • 落地页镜像 Web 的 WelcomeMessage(Onyx logo + 随机问候语,取自 ["How can I help?", "Let's get started."]);chat/[id] 脚手架与落地页共享一个禁用的 InputBar 壳(send 留到 PR 3)。

源码中 sessions.ts 完整保留了这套设计:PAGE_SIZE = 50only_non_project_chats: "true"before 游标取上一页最后一个 session 的 time_updatedrefetchInterval: 60_000,并注释了 before 为排他游标、同时间戳跨页边界可能丢一条会话这一与 Web 一致的已知边界。

验证:mobile tsc 干净、lint 干净、83 个 jest 测试通过(新增 6 个——ChatSessionList + useChatSessions)。真机跑通真实后端是剩余的手动检查。

PR 2:移动端原生聊天数据层 ✅(2026-06-29 已实现)

决策(2026-06-26,修订——不共享聊天代码):聊天纯层——NDJSON 解析器、消息树、processRawChatHistory、所有 chat/streaming/file 契约——原生写在移动端,Web 保留自己的既有副本,任何聊天相关内容不进 @onyx-ai/shared。最初考虑共享整个纯层,后收窄到仅共享约 40 行的 NDJSON 解析器,最终连这也放弃了。

范围内(as built)——mobile/src/chat/ 六个文件:

文件 职责
streamingModels.ts 最小核心 packet 契约:PacketType 子集(message_start/delta/endstopsection_enderror)、MessageStart/Delta/EndStop/StopReasonSectionEndPacketErrorChatHeartbeatPlacementPacketObjTypes、根级 MessageResponseIDInfo;无 OnyxDocument/丰富类型
interfaces.ts 最小 Message(无 documents/citations/multi-model/toolCall)、MessageTypeChatState(4 成员核心)、FileDescriptorChatFileType、最小 session 快照输入类型 BackendMessage + BackendChatSession
ndjson.ts createNdjsonBuffer<T>()pushChunk/flush),从 Web 的 handleSSEStream 拆分出的纯 NDJSON 行缓冲(保留 brace-recovery + 尾部 flush;无 reader/decoder/abort)
messageTree.ts upsertMessages/getLatestMessageChain/getMessageByMessageId/getLastSuccessfulMessageId/setMessageAsLatest/buildEmptyMessage/buildImmediateMessages + SYSTEM_NODE_ID/MessageTreeState,从 Web 近乎逐字移植(基于最小 Message 类型化)
chatHistory.ts processRawChatHistory(BackendMessage[], Packet[][]) → 消息树,从 Web 移植;移动端本地只产出最小 Message(无丰富类型),按序号把 packet 对齐到 assistant 消息
__tests__/{ndjson,messageTree,chatHistory}.test.ts 31 个 jest 单元测试

范围外(延到 PR 3):流传输(expo/fetch generator)与 send/create/session 请求契约(由传输层消费)。

测试bunx jest src/chat):NDJSON 缓冲(部分行、brace-recovery、heartbeat 透传、尾部 flush、混合 wrapped/root 形态)+ upsertMessages/getLatestMessageChain/getMessageByMessageId/getLastSuccessfulMessageId/setMessageAsLatest/builders + processRawChatHistory(nodeId 复用、子节点排序、packet 对齐、错误映射、链遍历)——全部 31 个通过

源码佐证:NDJSON 缓冲器如何工作

ndjson.ts 的实现与文档描述完全一致:

  • pushChunk(text):追加文本 → 按 \n 切分 → 最后一段(不完整行)保留在 buffer 供下次调用 → 对每个完整行 parseLine
  • parseLine:优先 JSON.parse;失败时用正则 /\{[^{}]*\}/g 抢救扁平的(非嵌套)JSON 对象——嵌套对象无法恢复,真正的"跨 chunk 半行"由 buffer 结转解决。
  • flush():流结束时解析残留部分行,无 brace-recovery,畸形尾部直接丢弃(与 Web 一致)。

纯函数、零平台耦合:传输层喂解码后的文本,并自己持有 reader/decoder——这正是 PR 3/PR 4 中 readNdjson 能同时服务发送与恢复两条路径的接缝设计。

源码佐证:消息树的关键细节

messageTree.ts 中有两个"刻意保留、与 Web 一致"的行为(经 Greptile P1 审查确认):

  • buildEmptyMessage-1 * Date.now() - nodeIdOffset 生成负数临时 nodeId,避免与后端真实 messageId 冲突;因 send 有门控,临时 id 不会并发碰撞。
  • getLastSuccessfulMessageId 在无成功消息时返回合成根 -3SYSTEM_MESSAGE_ID)。PR 3 遗留给发送流程的关键约定:send 时必须把 Web 调用点的 -3 → null 守卫移植过来(useChatController.tsparentId === SYSTEM_MESSAGE_ID ? null : parentId),合成 id 绝不能作为 parent_message 发出去。

buildImmediateMessages 总是生成新的 nodeId,这样编辑消息会 fork 出一个兄弟分支,符合消息树分支模型。

PR 3:核心聊天——发送 → 流式 → Markdown ✅(2026-06-30 已实现)

目标:行走骨架(walking skeleton)——用户能给默认 Agent 发消息、看 Markdown 答案流式进入、然后停止。

范围内

  • mobile/src/api/chat/stream.tsexpo/fetch generator + PR 2 移动端原生 NDJSON 解析器 + AbortController);
  • state/chatSessionStore.ts(zustand,不持久化);
  • hooks/useChatController.ts(在 persona_id=0 创建 session、用 PR 2 builders 生成乐观节点、驱动流式、约 50ms 批量 flush、停止);
  • packet 渲染器地基components/chat/renderers/registry.tsMessageRenderer 契约 + findRenderer 分发,镜像 Web 的 renderMessageComponent),仅注册 renderers/MessageTextRenderer.tsx + hooks/usePacketDisplay.ts(分组 + 分发);
  • components/chat/{MessageList,MessageRow,StreamingMarkdown,InputBar}.tsx
  • 通过 GET get-chat-session + PR 2 processRawChatHistory 水合既有 session;启用 send

范围外:恢复(resume)、Agent、项目、附件、所有 rich-chat packet/渲染器、AgentTimeline 组合层(PR 9b 构建)。建好分发接缝,不要建丰富渲染器。

已定决策(2026-06-30,"do it right",编码前已 grill)

owner 在每个门控上都选了完整原生/忠实路径:

  • Markdown = react-native-streamdown(worklet Bundle Mode;新增 react-native-enriched-markdown + remend;babel worklets:false + 显式 react-native-worklets/plugin {bundleMode, workletizableModules:['remend']};metro getBundleModeMetroConfig compose;enriched-markdown config plugin)。
  • 键盘 = react-native-keyboard-controllerKeyboardProvider_layoutKeyboardStickyViewChatScreen)。
  • origin = "mobile"——给后端 MessageOrigin 枚举新增 MOBILE 值,移植从此按选择触碰后端。
  • 渲染器契约 = Web 忠实MessageRenderer = {matches(packets), Component} + findRenderer(packets),放在 components/chat/renderers/registry.ts,而不是详细设计中的 reduce 草图。

发送体(最小){message, chat_session_id, parent_message_id, file_descriptors:[], deep_research:false, origin:"mobile"}

水合GET get-chat-session/{id} → PR 2 processRawChatHistory(防止覆盖进行中的流)。停止 = 客户端 abort + POST stop-chat-session/{id} + 立即翻转 UI。

源码佐证:唯一绕过 apiFetch 的地方

stream.ts 的注释点明了关键约束:"The one place that bypasses apiFetch: only expo/fetch exposes a readable response.body on RN."——这正是 PR 0 探针的结论落地:

  • streamChatMessage(body, signal)expo/fetch POST /chat/send-chat-message,Bearer + Content-Type: application/json,非 2xx 时 raiseForStatus(解析 detail 字段抛出 StreamHttpError)。
  • readNdjson(response, keepHeartbeats)response.body.getReader() + TextDecoderstream: true)→ 喂 createNdjsonBuffer → 默认丢弃 heartbeat(发送路径);finallyreader.cancel() 释放连接防泄漏。
  • 类型上区分三类流事件:isPacket(有 objplacement)、isMessageIdInfo(有 user_message_id,即根级对象)、isStreamError(有顶层 error 字符串)——正好应对 PR 0 发现的"混合 packet 形态"。

请求契约在 interfaces.ts 中体现:SendMessageBody 的可选字段 allowed_tool_ids/forced_tool_id/internal_search_filters/llm_override 缺省/为 null 时后端使用默认(允许所有工具、不强制任何工具、无源过滤);注释特别提醒 llm_override 是单数,llm_overrides 是多模型对比的独立字段,移动端不使用。

关键工程点:模块级 runChatStream

useChatController.ts 采用模块作用域runChatStream,使流在落地页卸载导航进 /chat/[id] 后仍能按 sessionId 继续写入(useChatSessionStore.getState() 直接读写 store)。约 50ms 批量 flush、-3→null 父节点守卫、persona_id=0 创建,全部在此实现。

验证tsc 干净、lint 干净、129 个 jest 通过(新增 10 个:store + controller 流程[增量 token / 新 session 创建 / stop 中止 / 水合] + 流判别器)。对抗性多代理审查 7 条发现,6 条误报(FlashList/memo/re-render "担忧"实为预期的流式行为),1 条 nit 已修(ChatHeaderpy-3py-12)。

仍欠的硬门控react-native-streamdown 真机渲染 + 干净的 expo prebuild --clean + run:ios/run:android dev-build(新增原生 enriched-markdown + keyboard-controller)。回退 react-native-marked(纯 JS,只换 StreamingMarkdown.tsx)。

PR 4:恢复进行中的 run + 自动命名

目标:中途后台化再重开会恢复进行中的 run;长历史翻页;session 自动命名。

范围收紧(2026-07-01,grill 后):grill 把"历史翻页"条目砍掉了。会话内消息翻页被放弃——Web 本就没有(一次拉全量 get-chat-session 快照,靠虚拟化处理长对话,没有可翻页的旧消息端点),移动端一次调用已拿到完整对话且 FlashList 已虚拟化,实现 onStartReached 反而会制造移动端独有分歧。因此 PR 4 = 恢复进行中的 run + 自动命名(约 190 源 LOC + 测试)。

恢复(忠实 Web 移植)

hooks/useChatSessionController.ts

  • 模块作用域的 runResumeStream(如 PR 3 的 runChatStream 般跨越重渲染存活)+ 模块级 resumingRuns: Set<number> 去重。
  • 该 hook 是 useQuery enabled:false 的只读观察者,观察 useChatController 已 fetch 的 get-chat-session 快照(同一 query key → React Query 去重,无第二次网络请求)。
  • 当快照的 current_run.run_id 映射到 assistant 节点(仅单模型——多模型 run_id 是用户消息,节点类型检查失败,与 Web 一致)时重连:resumeChatMessage(sessionId, cursor=0, signal)(全 buffer 回放 + 实时尾部)、约 50ms 批量 flush、stillCurrent = currentSessionId===sessionId && !aborted 守卫(用户切走后丢弃写入)。
  • 恢复路径上 heartbeat 透传(Web 经 resumeStream 同样处理,发送路径不保留)——安静期循环复查焦点,导航离开时及时释放连接;渲染层经 isHeartbeat 排除。
  • 结束/出错(404 = 无可恢复,被裸 catch 吞掉)时从 get-chat-session 落定(processRawChatHistoryhydrateSession)并 setQueryData 新快照,防止重挂载再次恢复已结束的 run。
  • 复用同一个 abortController(不加第二个字段):发送与恢复每 session 互斥(恢复只在冷启动水合时触发,受 data.abortController == null 守卫),所以 PR 3 的 stop() 免费中止恢复。
  • stillOurs() 所有权令牌:落定块中用恢复自己的 AbortController 对象作为持有到 settle await 的所有权令牌——即使发送在 get-chat-session 拉取期间竞速并完成,也不会被过期快照覆盖(完成的发送留下 null controller,≠ 恢复的令牌)。

自动命名

runChatStream 现在接收 AutoNameContext | null(仅当提交时 sessionId == null——全新 session——才设置)。在其 finally 中,当 run 产出了答案(!signal.aborted && sawStreaming && !hadError)时触发 nameNewSession:200ms 延迟(镜像 Web 的 handleNewSessionNaming——后端需要一拍来持久化行),然后 renameChatSessionPUT /chat/rename-chat-session {name:null}(后端 LLM 生成标题),随后使 chatSessions 失效,侧边栏 + 头部标题(从 useChatSessions() 读取)更新、新聊天出现。

源码侧:stream.ts 把 NDJSON reader 重构为共享的 readNdjson(发送与恢复共用),新增 resumeChatMessage(GET、Bearer)与 StreamHttpErrorsessions.ts 新增 renameChatSession

与 Web 的分歧(按 Web 对齐原则记录)

  1. 无会话内消息翻页(Web 也没有——对齐保持)。
  2. 自动命名仅在新 session 第一条答案后触发;Web 额外有第二条消息时的"补命名"恢复路径(newMessageHistory.length >= 2 && !description),移动端延后该次要恢复。
  3. 移动端恢复复用单个 abortController(Web 用专用实例)——安全,因为移动端两者每 session 互斥。

验证tsc 干净、lint 干净、141 个 jest 通过(新增 12 个)。两轮对抗性多代理审查:第 1 轮 19 条发现揪出 2 个真实 bug 并修复——(1) settle-refetch 可能覆盖 await 期间完成的发送 → stillOurs() 所有权令牌守卫;(2) 恢复路径过滤了 heartbeat、丢失 Web 的安静期活性复查 → keepHeartbeats 透传。第 2 轮确认两个修复正确(0 条修复正确性发现)。GitHub-bot 审查轮(Greptile + cubic,4 条 P2 nit)后跟进:无 body 的恢复 GET 不再发 Content-TypeFLUSH_INTERVAL_MS 提取到 chat/constants.ts;观察者 queryFnskipToken;恢复 catch 记录非 404 失败。143 个 jest 通过

PR 5:Agent 选择

目标:浏览可用 Agent、选一个开始聊天;用其 starter prompts。

范围内mobile/src/chat/contracts/agents.ts(移动端原生 MinimalAgent 子集——按 PR 2 最小共享决策,不共享);api/chat/agents.tsGET /api/persona);components/chat/AgentPicker.tsx(底部弹层:头像/名称/描述);选择时在 create 设置 persona_id;空屏展示 starter prompts。无创建/编辑。

As built(2026-07-01,"full web parity")——grill 中纠正了规格与代码的漂移:

  1. 头像不再用 icon_shape/icon_color——固定描边八边形(无按 id 哈希取色),4 分支回退:id 0 → Onyx logo · uploaded_image_id → 圆形 expo-image/persona/{id}/avatar 带 Bearer)· icon_name → 18 个映射 -Small 图标之一(各配 Web 主题色)· 单个 ASCII 字母 → 字母徽标 · 否则两行字形。颜色通过语义类(text-theme-blue-05…)由 vars() provider 解析,与 Web 精确一致。
  2. StarterMessage{name, message}
  3. GET /persona 无序、Web 不做客户端排序——移动端信任服务端顺序;各表面对应 pinned/featured(rail)与 featured/all-descending-id(gallery)。

owner 抉择(AskUserQuestion ×7):同时做全屏 gallery 路由(app)/agents.tsx,Featured+All 分区 + 搜索)侧边栏 pinned-agent rail(Web 精确,AgentSidebarSection);选中即自动 pinPATCH /user/pinned-assistants)让 rail 增长(手动 pin/unpin 延后);头像现在就全对齐(新增 expo-image——原生重建是 owner 门控);starter prompts 自动发送尊重 disable_default_assistant(新 GET /settings hook)。选择经 / 路由参数 agentId 传递;Agent 在 create-chat-session(personaId) 时绑定(切换 = 新 session),镜像 Web 的 liveAgent 优先级(resolveLiveAgent)。

与 Web 的分歧(已记录):picker 是屏+rail 而非 Web 的卡片弹窗(触屏、无 hover);id 0 的企业自定义 logo 未移植(无企业设置拉取);starters 渲染在键盘钉住的输入框上方而非下方;gallery 卡片点击直接开始聊天(无查看器弹窗);无每 Agent 编辑/分享/统计/pin 开关。约 1770 LOC。

验证tsc 干净、lint 干净、151 个 jest 通过(+22:纯 Agent 逻辑、头像分支、controller persona/starter 线程)。硬门控(owner 执行)expo prebuild 后真机 dev-build 编译新原生 expo-image 模块。回退:RN 核心 Image + source.headers(无需原生重建,只换 AgentImage.tsx)。

PR 6:项目——列表、选择、项目内聊天

目标:浏览项目、打开一个、看其聊天、开始/继续限定在该项目内的聊天。

范围内mobile/src/chat/contracts/projects.ts(移动端原生 ProjectProjectFileUserFileStatus);api/chat/projects.ts(list + detail/files,只读);app/(app)/projects/{index,[id]}.tsx;"在项目中新建聊天"给 create-chat-sessionproject_id;侧边栏展示项目。

As built(2026-07-01,web-faithful + 只读)——四个门控全部 Web 忠实:

  1. 导航 = 侧边栏 "Projects" 分区(Recents 之上)+ (app)/projects/[id].tsx 详情屏;无独立 projects/index.tsx 列表(Web 也没有)。
  2. 详情 = 完整只读对齐——文件夹标题 + 说明 + 带索引状态的文件 + 输入栏(启动项目限定聊天)+ 该项目的聊天。
  3. 聊天GET /user/projects/{id}/details 内嵌的 chat_sessions[] 读取(无独立分页拉取)。
  4. MMKV——所有项目查询键排除(内嵌聊天标题是 PII)。

关键实现点:useSegments() 区分 /chat/[id]/projects/[id] 共享的 id 参数;useChatController 新增 projectId 参数 → createChatSession(DEFAULT_PERSONA_ID, projectId),项目限定创建后使项目查询失效,并且从项目 push(而非 replace)进入 /chat/[id],让返回键能回到项目。约 528 源 LOC(在带内)。

验证tsc 干净、lint 干净、145 个 jest 通过(+16)。对抗性多代理审查 7 条发现,6 条确认并修复(timeAgo "0y ago" 年界缺口;项目 replacenavigate;NaN-id enabled 守卫;缺列表加载器;+1 重复)/ 1 条接受为 nit("File" 标签)。仍欠硬门控:真机跑真实后端验证。

Post-PR 6 重构:unify-chat-input(结构性,影响 PR 7–9 目标)

PR 6 后,分叉的 ChatConversation + ProjectView 被合并为一个持久化的 ChatSurface(挂载在 (app)/_layout,由 deriveFocus(pathname) 驱动;完整规格见 06-unified-chat-surface.md)。路由文件(indexchat/[id]projects/[id])现在渲染 null——聊天/项目 UI 由浮层绘制,composer 是 ChatSurface 中单个持久化的 InputBar。这改变了后续目标:

  • 项目详情 UI 在 components/chat/ProjectContextPanel.tsx(项目焦点下由 ChatSurface 渲染),不是 projects/[id].tsx(影响 PR 7)。
  • composer 只声明一次——附件 UI/状态接入 ChatSurface 的 composer,而非按屏(影响 PR 8)。
  • 注意ChatSurface 跨对话永不重挂载,因此任何按对话的草稿状态必须在 [sessionId, projectId] 变化时重置。输入草稿已如此(ChatSurfaceuseEffect);PR 8 附件必须遵守同一规则,否则会泄漏进错误对话。
  • PR 9 不受影响——rich 渲染器仍经 PR 3 注册表接入 MessageList;重构只改了屏壳,未改消息显示路径。

PR 7:项目文件管理

目标:向项目添加文档/照片、观察索引、移除它们。

范围内api/files/upload.tsexpo-file-system createUploadTask,MULTIPART,字段 files,Bearer,onProgress);state/uploadStore.ts + 3s 状态轮询;expo-document-picker + expo-image-picker 入口 + 资源归一化;link/unlink;带状态 chip 的文件列表 UI;app config plugin 条目(相册权限文案)。

As built(2026-07-06,"full web parity")——owner 在两个门控选了更大表面积(AskUserQuestion ×4):同时支持设备 picker recent/library 文件 picker(link 已有文件,如 Web 的 FilePickerPopover);普遍使用 createUploadTask仅 unlink 移除;从 GET /settings 新增客户端尺寸预检。

后端端点核对(对照 backend/onyx/server/features/projects/api.py):上传 POST /user/projects/file/upload(multipart files + Form project_id + temp_id_map;响应 {user_files, rejected_files},部分成功是常态);状态 POST /user/projects/file/statuses;unlink DELETE /user/projects/{id}/files/{fileId}(204);link POST 同路径;recent GET /user/files/recenttemp_id 回显的文件键为 ${size}|${name[:50]}build_hashed_file_key),移动端 buildFileKey 镜像;但对账用重新拉取(每文件一次请求,原生上传器单文件),不用回显。

枚举修复:后端 UserFileStatus 有移动端(和 Web)枚举遗漏的 INDEXING——补上它 + isProcessingStatus(),使索引中文件显示 spinner 而非"完成" chip。

新增文件api/files/{upload,files,pickers}.ts(SDK-56 对象 API 上的原生 new File(uri).upload(),遗留 createUploadTask 亦保留;手动 Bearer;非 2xx 也 resolve 以便检查状态)、state/uploadStore.ts(临时、按项目键控、永不持久化)、hooks/useProjectFiles.ts(编排:尺寸预检 → 乐观 → 重拉取对账 → 3s /statuses 轮询就地 patch 缓存的 ProjectDetails → link/unlink)、components/chat/FilePickerSheet.tsx(RN Modal 底部弹层)。依赖经 expo installexpo-file-system@56.0.8expo-document-picker@56.0.4expo-image-picker@56.0.19

与 Web 的分歧:无完整 UserFilesModal(搜索/选择/全局删除)——弹层只滚动 recent 文件;不暴露全局删除(后端在文件链接到项目时拒绝,从项目内删除会 no-op,unlink 是唯一 Web 忠实的移除);无图片缩略图(PR 8);错误内联展示(移动端无 toast 原语);picker 是底部弹层而非 Web 的 hover popover。约 900 源 LOC。

验证tsc 干净、lint 干净、228 个 jest 通过(+30)。对抗性多代理审查 10 条发现 → 3 条驳斥、7 条确认并修复。1 条中危(瞬时"文件同时出现在两个列表"竞态)接受为良性。硬门控仍欠expo prebuild --clean + run:ios/android 编译三个原生模块并真机验证 pick→upload→progress→indexing→unlink;真机风险点:Android content:// picker URI 过新 File() 上传器。

PR 8:输入栏附件(按消息)

目标:给单条消息附加文档/照片并发送。

范围内:从输入栏复用 PR 7 的 picker/上传器;components/chat/AttachmentChips.tsxmobile/src/chat/fileDescriptors.ts(移动端原生 projectFilesToFileDescriptors + 类型检测——按 PR 2 最小共享决策,不共享;Web 保留自己的 fileUtils.ts);发送时构建 file_descriptors[]索引完成前门控发送token_count != null);经 GET /api/chat/file/{file_id} 图片预览。相机延后。

Web 对齐(必需,owner 2026-06-30 强制):本 PR 必须把输入栏带到完整 Web 对齐。PR 3 交付的是刻意最小化的单行输入;PR 8 反正要重做以支持附件,所以把 InputBar.tsx 重排为 Web composer 的形态/布局——圆角、自动增长的多行容器 + 控制/工具行 + send/stop 控件位置与 Web 的 BaseInputBar/AppInputBar 一致,附件 chips 嵌入。移动输入必须看起来、用起来都像 Web 的 composer(允许小幅平台差异)。先重读 web/src/sections/input/{BaseInputBar,AppInputBar}.tsx;若缺移动原语,先问再移植。

As built(2026-07-07,"full web parity")——四个门控全部 Web 忠实路径:

  1. 发送门控 = 每个附件达到终态前硬禁用 Send;chips 下小状态行;FAILED 文件显示错误并保持可移除(移除解除发送阻塞)。
  2. 图片缩略图现在就做,用带 Bearer 的 expo-imageGET /chat/file/{file_id})。
  3. picker = 文档 + 照片 + recent 文件(复用 FilePickerSheet);无相机
  4. composer = 完整三行重排含多行自动增长

上传"阻塞点"由证据关闭而非 grill:按消息附件 POST 到同一个 /user/projects/file/upload省略 project_id——后端已声明 project_id: int | None = Form(None)projects/api.py),Web 正是这么做的(beginUpload(files, null))。PR 8 后端零改动。

复用映射(已验证):PR 7 上传器泛化——uploadProjectFileuploadUserFile(asset, projectId: number|null, …)(null 时省略 project_id Form 参数;即 Web beginUpload(files, projectId?) 的移动端对应物);picker(pickDocuments/pickImages)、FilePickerSheetgetUserFileStatuses 原样复用;乐观文件构建器 + 状态标签 + isFailedFile 提取到 lib/files.ts 共享助手。useProjectFiles 泛化——另写了更精简的兄弟 hook useMessageAttachments(项目 hook 耦合项目查询缓存 + link/unlink,均不适用于按消息草稿;Web 把两者同放在一个 ProjectsContext,移动端从未移植该 context)。

一张 FileCard 服务所有界面(Web 忠实):图片文件渲染为方形缩略图(Bearer expo-image),其余——含失败上传——渲染为带边框 pill;composer 条、已发消息附件、项目面板共用(镜像 Web 一个 FileCard 服务 AppInputBar + ProjectContextPanel + agent viewer)。移除统一受 !uploading 门控(Web 的 doneUploading)。

异步安全设计:composer 是单个持久实例,因此所有上传/轮询写入捕获 resetKeykeyRef)并经函数式 setState temp_id patch 乐观条目——用户已离开的对话的迟到上传/轮询被丢弃、永不复活(测试验证:上传中途切换对话 → 对账被丢弃)。

与 Web 的分歧(已记录):(1) FAILED 阻塞发送直到移除(Web 允许带失败文件发送)——owner 选择,更干净的 UX;(2) Enter = 换行、按钮发送(Web Enter = 发送)——移动端 composer 惯例;(3) recent 文件"附加"纯客户端(无服务端 link,匹配 Web 的 onPickRecent);(4) 错误为内联 banner(无 toast 原语);(5) 无相机;无 deep-research/actions/voice 工具栏控件(延后);(6) expo-image cachePolicy="none"(Bearer,按 AgentImage)。

验证tsc 干净、lint 干净、250 个 jest 通过。对抗性多代理审查 10 条发现 → 8 条驳斥 → 2 条确认(均为代码质量,已应用)——0 条正确性/竞态缺陷存活,验证了所有权键守卫。自查还修了:文档卡片卡在 INDEXING/FAILED 时无法移除(会卡死发送且无出路)、重复的 optimisticFile硬门控仍欠expo prebuild --clean + run:ios/run:android 真机验证 pick→upload→thumbnail→send-gating→send、多行自动增长键盘行为、Bearer 图片缩略图。

Post-PR 8 重构:rework-userfiles-hooks(计划中,owner 驱动)

动机:把用户文件处理从 sessions/projects 解耦。当前 PR 7 的 useProjectFiles按项目键控、PR 8 的 useMessageAttachments按对话本地草稿——两个分叉的临时文件层。目标(owner,2026-07-09):单一按文件键控的真相源(把 uploadStorebyProject 泛化为 Map<fileId, entry> + 一个共享状态轮询器),其上三个 hook——核心 useUserFiles(上传/删除/状态)+ 两个瘦透镜 useMessageAttachments(按对话草稿 = id 引用)与 useProjectFiles(link/unlink)。session/project 变成叠加的链接而非文件的身份,上传因此能跨导航存活。移动端保留 store 模式(非 Web 的 ProjectsContext):状态必须比形态可变的 ChatSurface 活得更久、从分离的异步回调写入,且 selector 避免每个进度 tick 重渲染所有文件消费者。

顺带要修的已知 bug(从 PR 8 的 GitHub-bot 审查延后,均为 P2、今日无 shipped-broken):

  1. 失败提交清空草稿ChatSurface.sendWithAttachmentsvoid submit(...) 后立即清空附件;若发送设置失败(如新 session 创建),草稿丢失且不可重试。需 submit 返回 accepted 信号,成功才清空。
  2. partitionBySize 把 null/0 上传尺寸设置当"无限"user_file_max_upload_size_mb 缺失/0/非法时客户端预检被跳过,超大文件只在服务端失败。需定有限回退(owner 拍板)还是继续交给服务端。
  3. 异步守卫按对话而非按 run 纪元:跨"离开 → 回到同一聊天"的上传被视为活跃,可能为用户已清空的草稿弹出过期错误 banner。store 中按文件/按 run 的身份 token 使迟到写入自失效(PR 8 修了选择中途的跨聊天泄漏,同对话场景仍存)。

清理 rider(非 bug):抽取共享 BearerImage 原语,AttachmentImageAgentImage 停止重复"认证图片 + cachePolicy="none""的缓存安全不变量。

PR 9a–9e:延期的丰富聊天(各自独立阶段)

目标:逐个独立特性丰富核心:9a 引用/来源 · 9b agentic 推理时间线(reasoning/search/tool 子步骤)· 9c 重新生成/编辑/反馈 · 9d 后续建议 · 9e 图像生成渲染。

范围(每个):把相关 rich packet 类型加进移动端 mobile/src/chat/contracts/ 的 packet 类型,向 PR 3 分发注册一个新 MessageRenderercomponents/chat/renderers/),并添加 RN UI。PR 3 的分发接缝意味着它们都不触碰核心显示路径。9b 额外构建 AgentTimeline 组合层(镜像 Web 的 AgentTimeline/TimelineRendererComponent);9b 的 reasoning/search/tool 子渲染器及后续时间线渲染器插入其中。Web 的 usePacketProcessor 保持 Web 独有。

Web 对齐(必需):每个 rich 渲染器以及 9b 的 AgentTimeline 组合必须匹配 Web 的外观与结构,不只是数据。移植 Web 的渲染器/时间线组件布局——步骤容器、头部、图标、缩进/连接线、间距、折叠/展开行为——让移动端 reasoning/search/tool 步骤读起来像 Web 对应物(web/src/app/app/message/messageComponents/**timeline/**)。镜像结构,不要发明新的移动端时间线形态。任何平台驱动的分歧写入 As-built 说明。

依赖:PR 3(+ 特性交互处 PR 5/6/7/8,如项目文档上的引用)。

测试:RN Testing Library + 含新 packet 类型的 mock packet 流。

建议优先级:很可能 9a 引用最先(产品价值最大);这些明确是核心之后、可独立排期。

测试与质量策略:分层验证体系

路线图对每个 PR 规定了一种主导测试类型,避免过度测试(详见 04-implementation-plan.md):

  • 纯助手(P2)→ jest 单元测试:NDJSON 缓冲(部分行、brace-recovery、heartbeat 透传、尾部 flush)+ upsertMessages/getLatestMessageChain/processRawChatHistory——价值最高的测试,守护移动端自有的解析器+树+历史。
  • 移动 hooks + 组件(P1、P3–P8)→ @testing-library/react-native流式传输 mock(喂脚本化 packet 序列):断言 token 增量渲染、stop 中止、发送门控阻塞到索引完成、Agent/项目选择线程 persona_id/project_id、附件 chips 反映上传状态。
  • 真实后端流式 → 每阶段真机手动验证(dev build;仓库内无 RN e2e 框架,Web Playwright 套件覆盖 Web)。
  • 无新后端测试——后端不变。

仓库中这些测试均已落地:mobile/src/chat/__tests__/ 现有 ndjson.test.tsmessageTree.test.tschatHistory.test.tsfileDescriptors.test.tscitations.test.tssources.test.tstools.test.tsmodels.test.ts 等 15 个测试文件,mobile/src/api/chat/__tests__/stream.test.tssessions.test.tsxprojects.test.tsx,与路线图按 PR 累计的 jest 数量(83 → 129 → 141 → 143 → 151 → 145 → 228 → 250)完全呼应。

此外,每个 PR 的 "As built" 都记录了对抗性多代理审查(每轮多维度 × 独立验证)与 GitHub-bot 审查(Greptile + cubic)结果,真实 bug 几乎都被这类审查+自查在合并前捕获并修复。

关键经验与当前状态

从这份路线图可以提炼几条对跨端移植有普遍价值的工程经验:

  1. 先用一次性 spike 消除外部不确定性expo/fetch 真机流式与 streamdown 在 RN 0.85 的构建,是 PR 3 之前的硬门控;spike 的产物(混合 packet 形态、MessageOrigin"mobile")直接改变了 PR 2/PR 3 的设计。
  2. 共享边界的决策要算清"活动部件":共享包机制的成本(重指向、jest module-mapper、dist 耦合)在 pre-production、协议稳定时高于约 200 行重复代码——于是移动端原生重实现成为正确选择,同时用"漂移后再抽取"作为低成本保险。
  3. 分发接缝先建、丰富渲染器后补:PR 3 的 MessageRenderer + findRenderer 注册表让 9a–9e 无需触碰核心路径即可逐个插入。
  4. 异步安全用所有权令牌stillOurs()(AbortController 对象作令牌)、resetKey + 按 temp_id 的函数式 setState,系统性消灭了"迟到写入污染已离开对话"的竞态类 bug。
  5. PII 默认排除:聊天 session/message/项目查询键在持久化前必须加入 dehydrateOptions 排除列表,聊天内容不落盘、启动时重取。

截至文档记录,PR 0–8 已全部实现并通过 jest 验证(PR 2/3 打 ✅,其余均有 As built 记录),两个结构性重构(unify-chat-input 已完成、rework-userfiles-hooks 计划中)已落地或规划,PR 9a–9e 待按产品优先级逐个排期。仍未关闭的硬门控集中在真机验证:dev-build 原生模块编译(enriched-markdownkeyboard-controllerexpo-image、文件 picker 三件套)与真实后端端到端手测——agent 无法运行设备构建,这些需要 owner 在真机上完成。

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

项目优选

收起
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