Onyx 移动端聊天移植详细设计:客户端数据模型、NDJSON 流式解析与消息渲染器注册表架构
【免费下载链接】danswerOpen Source AI Platform - AI Chat with advanced features that works with every LLM项目地址: https://gitcode.com/GitHub_Trending/da/danswer
本文是 Onyx 移动端聊天移植(Mobile Chat Port)系列的详细设计文档,承接 高层设计 的端到端流程,聚焦于客户端数据模型(不涉及任何后端改动)、纯 TypeScript 聊天数据层(NDJSON 解析器、消息树、历史重建)以及可扩展的消息渲染器注册表。读者将掌握:为什么聊天状态必须拆成"临时 zustand + 持久化 TanStack Query"两层、
createNdjsonBuffer与expo/fetch流式传输如何协作、渲染器注册表如何让后续富聊天功能以"增量注册"而非"核心重写"的方式落地,以及整个移植对 web 端和@onyx-ai/shared共享包的"零触碰"边界。
设计定位与一条关键决策覆盖
设计文档的"地图"定位
按项目所有者要求,本文档刻意保持高层级(high-level)——它是移植工作的地图(map)而非逐行的实现规格(line-by-line spec)。每个 PR 阶段(见 05-pr-roadmap.md)在编码前都会针对该阶段进行独立的详细分析会话,与所有者逐项确认。因此,文中列出的文件清单与形态是预期结构,会在各阶段实施时被确认或细化。
⚠️ 决策覆盖(2026-06-29):聊天逻辑不共享
本设计最重要的前提是 2026-06-29 的决策覆盖(DECISION OVERRIDE):项目所有者推翻了 Approach C 中"共享聊天代码接缝"的规划——任何与聊天相关的内容都不会被抽取到@onyx-ai/shared共享包。具体来说:
- 本文档中凡是出现
web/lib/shared/src/contracts/*或web/lib/shared/src/utils/*(如ndjson.ts、messageTree.ts、chatHistory.ts、streaming.ts、chat.ts、files.ts、agents.ts、projects.ts、fileDescriptors.ts)之处,均应读作mobile/src/chat/*——移动端自有,web 端不重指向、无 shim; - "Shared message-tree fns"、"Shared
createNdjsonBuffer()" 以及共享dist/watch.mjs集成说明均被取代:移动端拥有这些副本,web 端保持原样; @onyx-ai/shared继续只接收跨平台的设计原语(design primitives)。
这一决策已在 PR 2 中落地实现,文件位于mobile/src/chat/下(streamingModels.ts、interfaces.ts、ndjson.ts、messageTree.ts、__tests__/等)。其动机与完整推理记录在 05-pr-roadmap.md 的 PR 2 章节:共享包机制(工具函数 + web 重指向 + jest mapper + dist 构建耦合)比它消除的约 200 行重复代码更复杂;且产品尚未投产(pre-production),后端协议稳定,漂移风险低且后续重新抽取成本低。
数据库设计:零后端改动
本移植不涉及任何后端或数据库变更:
N/A —— 移动端客户端与现有 Onyx 后端及其现有 schema 通信。所有"数据模型"工作都在客户端完成(内存中的 zustand + TanStack Query 缓存,外加已配置好的 MMKV 持久化)。
相关后端表(chat_session.project_id、user_file、persona、project、Project__UserFile)均已存在,其结构说明见 01-research.md。这意味着本移植是一个纯客户端项目,可以独立验证、独立合并,不触碰服务端任何代码。
客户端数据模型:三层状态架构
客户端状态被刻意拆分为三个各司其职的部分,这也是 高层设计 中"两层状态"决策的具体化。
临时聊天状态 ——chatSessionStore(zustand,绝不持久化)
chatSessionStore镜像 web 端useChatSessionStore的形态,但裁剪到锁定核心范围(无多模型、无重新生成、无排队消息、无文档侧栏字段)。其字段设计如下:
| 字段 | 形态 | 设计理由 |
|---|---|---|
currentSessionId | string \| null | 指明当前打开屏幕渲染的是哪个会话 |
sessions | Map<sessionId, SessionData> | 按会话隔离,保证一个流不会写进另一个会话 |
SessionData.messageTree | Map<nodeId, Message>(移动端原生Message类型) | 会话的对话内容;只能通过移动端原生的upsertMessages变更 |
SessionData.chatState | 'input' \| 'loading' \| 'streaming' \| 'uploading' | 驱动输入栏、loading 指示器与发送按钮的门控 |
SessionData.abortController | AbortController | 停止/卸载时取消流;不可序列化 → store 绝不能持久化 |
SessionData.submittedMessage | string | 用户节点落地前的乐观回显 |
端口并裁剪后的 actions:setCurrentSession、createSession、updateSessionAndMessageTree、updateChatState、setAbortController、abortSession。
在仓库中,该 store 已实现于 mobile/src/state/chatSessionStore.ts:SessionData接口与文档定义一致(messageTree、chatState、abortController、submittedMessage),并通过ensureSession/hydrateSession/updateSessionTree/patchNode等 action 操作。文件头注释明确写着"Ephemeral per-session chat state. NEVER persisted: holds live AbortControllers. The Map-per-session is what stops one stream writing into another."——与本文档的"绝不持久化"约束完全对应。
服务器态列表 —— TanStack Query(持久化到 MMKV,PII 排除)
列表类服务器状态走 TanStack Query,复用移动端已有的 MMKV 持久化与失效机制。查询键在 mobile/src/api/query-keys.ts 中扩展,全部以serverUrl为键前缀,切换后端实例时不会串数据:
chatSessions(serverUrl)chatSession(serverUrl, id)agents(serverUrl)projects(serverUrl)projectFiles(serverUrl, projectId)
仓库实现与此一致,例如chatSessions: (serverUrl) => ["chat-sessions", serverUrl]、chatSession: (serverUrl, sessionId) => ["chat-session", serverUrl, sessionId]。
附件上传进度 ——uploadStore(zustand,临时态,独立)
附件上传进度单独存放在一个独立的临时 zustand store中,键为客户端临时文件 id,值为{ uri, name, mimeType, size, status, bytesSent, totalBytes }。它被输入栏与项目文件页共同消费,与聊天状态解耦,避免上传进度污染消息树的重渲染。
接口设计:函数 + 类型 + Hooks(无类)
整个新面是函数 + 类型 + Hooks,不引入任何 class。关键接缝(seams)如下。
移动端原生createNdjsonBuffer<T>()
定义于 mobile/src/chat/ndjson.ts,是流式解析的核心纯函数:
- 接口为
pushChunk(text: string): T[]与flush(): T[],泛型参数为调用方的包类型; - 内部持有跨 chunk 的部分行(partial-line)字符串,按
\n切分,保留行尾残缺部分留待下次拼接,逐行对完整行执行JSON.parse; - 内置 web 端的花括号恢复(brace-recovery)回退:单行解析失败时,尝试用
/\{[^{}]*\}/g提取扁平(非嵌套)JSON 对象以挽救可恢复数据(见parseLine实现); - 不含任何
fetch/TextDecoder——传输层职责留在外部(transport 负责提供已解码文本); - 镜像 web 端
handleSSEStream的解析核心(去掉了 reader); - 不共享(PR 2 Decision,2026-06-26)。
export function createNdjsonBuffer<T = unknown>(): NdjsonBuffer<T> { let buffer = ""; return { pushChunk(text: string): T[] { buffer += text; const lines = buffer.split("\n"); buffer = lines.pop() ?? ""; // 保留行尾残缺部分 const out: T[] = []; for (const line of lines) { if (line.trim() === "") continue; parseLine(line, out); } return out; }, flush(): T[] { /* 解析流末尾遗留的残缺行 */ }, }; }配套单元测试位于 mobile/src/chat/tests/ndjson.test.ts。
移动端streamChatMessage(body, signal): AsyncGenerator<Packet>
定义于 mobile/src/api/chat/stream.ts,负责流式传输:
- 使用
expo/fetch发起 POST(整个应用中唯一不走apiFetch的 HTTP 调用,因为它需要可读的字节流response.body); - 持有
getReader()+TextDecoder解码循环,逐 chunk 喂给移动端原生的 NDJSON buffer; - 过滤
chat_heartbeat心跳包; - 在 abort /
finally中调用reader.cancel()释放连接,防止泄漏(见readNdjson的 finally 块:"Release the reader on early-return/abort, or the connection leaks.")。
同一文件还实现了resumeChatMessage(sessionId, cursor, signal),用于恢复进行中的 run:先从cursor位置重放缓冲,再尾随(tail)实时事件;resume 场景下保留心跳作为静默期 liveness 信号(keepHeartbeats = true)。另外,StreamHttpError携带 HTTP status,让 resume 调用方在预期的"无内容可恢复(404)"场景下保持静默。
SendMessageBody的字段在源码中也有详细注释,其中值得注意的约定:
parent_message_id: number | null——null表示首条消息,否则为最后一条助手消息 id;allowed_tool_ids/forced_tool_id/internal_search_filters省略或为 null 时使用后端默认(允许所有工具、不强制任何工具、无来源过滤);- 模型覆盖字段是单数名
llm_override——llm_overrides(多模型对比用)是另一个字段,移动端不使用。
移动端包渲染器注册表(renderer registry)
这是本文档强调的可扩展性基石(在 PR 3 中构建,即使当时只随核心发布一个渲染器)。它镜像 web 端的renderMessageComponent.tsx+MessageRenderer<TPacket,TState>契约:
MessageRenderer<TPacket, TState>(移动端契约)——{ matches(packetType): boolean; reduce(state, packet): TState; render(state): ReactNode }。因为返回 RN 节点(React Native 耦合),所以移动端自有、不共享;其中的*包分组(packet-grouping)*步骤未来若证明有复用价值,可以再共享;findRenderer(packetType): MessageRenderer | null——分发函数(web 端findRenderer的移动端等价物);- 核心(PR 3)只注册一个渲染器:
MessageTextRenderer(MESSAGE_START/DELTA/END→ markdown 字符串 +isComplete/error)。它的代码量约等于一个扁平拼接器,不会扩大核心范围; usePacketDisplay(node)——对某个节点的包分组后遍历注册表产出渲染输出。延后的富聊天 PR(9a–9e)只需向注册表添加渲染器(agentic 步骤则再加一个时间线组合层)——无需重写核心。web 端 React 耦合的usePacketProcessor保持 web-only。
移动端原生消息树函数
从 web 端messageTree.ts移植,针对最小化移动端Message编写(依 PR 2 最小共享决策,不共享,web 保留自己的副本):
upsertMessages、getLatestMessageChain、getMessageByMessageId、buildImmediateMessages、buildEmptyMessage- 常量
SYSTEM_NODE_ID与类型MessageTreeState
在仓库中,mobile/src/chat/messageTree.ts 的upsertMessages完整实现了"树为以合成 system 节点为根、通过latestChildNodeId分支的Map<nodeId, Message>"这一结构:首次插入时若不存在根节点,会创建一个SYSTEM_MESSAGE_ID = -3的 dummy system 节点(见 mobile/src/chat/messageTree.ts),并通过updateParentInMap维护父节点的childrenNodeIds与latestChildNodeId语义(makeLatest强制、唯一子节点或新添加子节点时成为 latest)。
processRawChatHistory则位于 mobile/src/chat/chatHistory.ts:将后端的messages[]+packets[][](按助手消息序数对齐)结构性地重建为消息树——只做结构转换,不涉及渲染。值得注意的细节:
packets按助手消息序数对齐,每个助手轮次一个列表;- 出错轮次的文本在
error字段中,渲染为消息文本(web 对齐),消息type置为"error"; nodeId直接复用message_id(只要求唯一性);processing_duration_seconds从后端回填,作为"Thought for Xs"头部展示;而流式进行中的轮次则用streamingStartedAt(epoch ms)驱动时间线计时器。
配套单元测试位于 mobile/src/chat/tests/chatHistory.test.ts 与 mobile/src/chat/tests/messageTree.test.ts。
新文件清单
共享包(web/lib/shared/src/):无新增
依 PR 2 决策,聊天纯层为移动端原生,任何聊天相关内容都不会进入@onyx-ai/shared。
移动端原生聊天层(mobile/src/chat/)
整个聊天纯层都在移动端,不共享:
| 文件 | 职责 |
|---|---|
ndjson.ts | createNdjsonBuffer<T>()——纯行缓冲 NDJSON 解析器(镜像 webhandleSSEStream解析核心) |
contracts/streaming.ts | 最小化Packet包装、Placement、PacketType(核心子集)、MessageStart/Delta/End、Stop/StopReason、PacketError、ChatHeartbeat、MessageResponseIDInfo。MessageStart.final_documents类型为unknown[] \| null(不带OnyxDocument)。富包类型按各自阶段后续添加 |
contracts/chat.ts | Message(最小化)、ChatState、ChatSession/BackendChatSession/BackendMessage、发送消息请求体类型、创建会话请求/响应 |
contracts/files.ts | FileDescriptor、ChatFileType、UserFileStatus |
contracts/agents.ts | MinimalAgent(选择子集)、AgentStarterMessage |
contracts/projects.ts | Project、ProjectFile、CategorizedFiles、RejectedFile |
messageTree.ts | 树 upsert/遍历/构建器——从 web 移植并针对最小化Message |
chatHistory.ts | processRawChatHistory——后端 messages+packets → 树(仅结构) |
fileDescriptors.ts | projectFilesToFileDescriptors+ 类型检测(PR 8) |
仓库实际落地时,契约层位于 mobile/src/chat/contracts/(含documents.ts、projects.ts),另有streamingModels.ts、interfaces.ts、agents.ts、fileDescriptors.ts、constants.ts等文件,以及__tests__/目录下的ndjson.test.ts、messageTree.test.ts、chatHistory.test.ts、fileDescriptors.test.ts、agents.test.ts等单元测试。
移动端应用层(mobile/src/)
| 文件 | 职责 |
|---|---|
app/(app)/_layout.tsx | AuthGate下的 Authed Stack,承载聊天组 |
app/(app)/index.tsx | 新聊天 / 聊天首页(空态、starter prompts) |
app/(app)/chat/[id].tsx | 聊天会话页(消息列表 + 输入栏) |
app/(app)/history.tsx | 会话/历史列表 |
app/(app)/projects/index.tsx、projects/[id].tsx | 项目列表 + 项目详情(聊天 + 文件) |
state/chatSessionStore.ts | 临时逐会话 zustand store(见上) |
state/uploadStore.ts | 附件上传进度(见上) |
api/chat/stream.ts | expo/fetch流式生成器(见上) |
api/chat/sessions.ts | TanStack Query hooks:创建/获取/列表/重命名会话 |
api/chat/agents.ts | GET /api/personahook |
api/chat/projects.ts | 项目列表/详情/文件 + 关联/取消关联 hooks |
api/files/upload.ts | expo-file-systemcreateUploadTaskmultipart 上传器 |
hooks/useChatController.ts | onSubmit、驱动流、~50ms 批量 flush、停止 |
hooks/useChatSessionController.ts | 加载会话 → 水合消息树;恢复进行中的 run |
hooks/usePacketDisplay.ts | 分组节点包 + 遍历渲染器注册表产出渲染输出 |
components/chat/renderers/registry.ts | MessageRenderer契约 +findRenderer分发(镜像 webrenderMessageComponent)。核心只注册MessageTextRenderer |
components/chat/renderers/MessageTextRenderer.tsx | 唯一核心渲染器:MESSAGE_*→StreamingMarkdown。富渲染器(9a–9e)稍后在其旁注册 |
components/chat/MessageList.tsx | FlashList v2 非反转、maintainVisibleContentPosition、onStartReached分页、memoized 行 |
components/chat/MessageRow.tsx | 用户 vs 助手气泡;按(nodeId, packetCount)memoize;经usePacketDisplay渲染 |
components/chat/AgentTimeline.tsx | (延后——在 PR 9b 首次构建)agentic 时间线渲染器的组合层;镜像 webAgentTimeline/TimelineRendererComponent |
components/chat/StreamingMarkdown.tsx | RN markdown(接口后面是 streamdown;marked 作为回退);块级 memoize |
components/chat/InputBar.tsx | KeyboardStickyView增长式输入 + 发送/停止;后续:附件 chips |
components/chat/AgentPicker.tsx | Bottom-sheet 智能体列表(avatar/name/description/starters) |
components/chat/AttachmentChips.tsx | 已选文件 chips + 状态 + 移除 |
icons/* | 任何新图标(回形针、停止等) |
仓库中,编排 hook 已实现于 mobile/src/hooks/useChatController.ts,其开头注释说明了一个关键工程细节:runChatStream是模块级作用域的,因此当页面跳转进入/chat/[id]导致首页卸载后,流仍能按sessionId继续写入。FLUSH_INTERVAL_MS(约 50ms 批量 flush 间隔)来自 mobile/src/chat/constants.ts。
文件结构(目录树)
web/lib/shared/src/ (无聊天改动——聊天相关内容绝不进入 shared) web/src/ (原样不动——streamingUtils.ts / messageTree.ts / fileUtils.ts 保持 web 自有) mobile/src/ ├── app/ │ ├── _layout.tsx (修改:挂载 (app) 组) │ └── (app)/ (新增) _layout · index · chat/[id] · history · projects/* ├── chat/ (新增,移动端原生纯层) │ ├── ndjson.ts (新增) contracts/ (新增) streaming · chat · files · agents · projects │ ├── messageTree.ts (新增) chatHistory.ts (新增) fileDescriptors.ts (新增) │ └── *.test.ts (新增) ndjson + tree + history 单元测试 ├── state/ (新增) chatSessionStore.ts · uploadStore.ts ├── api/ │ ├── query-keys.ts (修改:新增 chat/agents/projects 键) │ ├── chat/ (新增) stream.ts · sessions.ts · agents.ts · projects.ts │ └── files/ (新增) upload.ts ├── hooks/ (新增) useChatController · useChatSessionController · usePacketDisplay └── components/chat/ (新增) MessageList · MessageRow · StreamingMarkdown · InputBar · AgentPicker · AttachmentChips集成点
Auth / HTTP
- 列表类调用复用 mobile/src/api/client.ts 的
apiFetch(注入 bearer +ApiError归一化); - 流式调用(
api/chat/stream.ts)复用getBaseUrl()(mobile/src/api/config.ts)+tokenStore.ts的 token,但直接使用expo/fetch——这是唯一例外路径,因为apiFetch的 JSON 语义无法承载可读字节流。
导航
(app)组挂载在 mobile/src/app/_layout.tsx 现有的AuthGate之下;侧边栏(mobile/src/components/sidebar)呈现会话/项目入口。
查询缓存(PII 排除,必做而非可选)
扩展 mobile/src/api/query-keys.ts 并复用 mobile/src/query/client.ts 的持久化客户端。由于聊天内容天然敏感,聊天会话列表与会话详情/消息的查询键必须在 PR 1 中加入dehydrateOptions的 PII 排除列表(与现有me排除项并列),在任何聊天历史被持久化到 MMKV 之前完成。
后果是:聊天历史不会缓存到磁盘,启动时重新拉取——这是正确的 PII 安全默认值(镜像me排除模式)。仓库中该机制已存在:mobile/src/query/client.ts 的dehydrateOptions.shouldDehydrateQuery首先检查isNonPersistedKey(query.queryKey);mobile/src/query/tests/client.test.ts 中的测试断言了chat-session相关键与me、项目查询、最近文件查询一样绝不持久化到未加密磁盘缓存,而auth-type等非 PII 成功查询可以持久化。
无 web 改动
移动端聊天移植不触碰任何 web 文件。web/src/lib/search/streamingUtils.ts、web/src/app/app/services/messageTree.ts、web/src/app/app/services/fileUtils.ts保持 web 自有;移动端在mobile/src/chat/中原生重实现了解析器、消息树与文件描述符逻辑。
无共享构建耦合
聊天相关内容不进入@onyx-ai/shared,因此本移植不依赖共享包的 dist 重建 /file:重新链接。(@onyx-ai/shared继续保留其现有的 design-token、interactive/typography、numbers/format面。)
实施前的重要注意事项
先做降险 spike(Phase 0 / 早期)
expo/fetchspike:确认在设备 dev build(RN 0.85 / SDK 56)上response.body.getReader()可用——回退方案是 XHR 进度事件喂入同一个NDJSON buffer(该 spike 已通过:getReader()在 iOS 模拟器上工作正常,记录于 05-pr-roadmap.md);react-native-streamdownspike:确认能在 RN 0.85 / Reanimated v4 上构建——回退方案为react-native-marked(两者都保留块级 memoize)。
两者都需要dev client(已有expo-dev-client),不能用 Expo Go。
绝不持久化chatSessionStore
它持有AbortController与实时流,必须与持久化的 TanStack 缓存严格分离;重启后通过GET get-chat-session+ 移动端原生processRawChatHistory重新水合会话。
流式性能杠杆
- ~50ms 批量 flush,行按
packetCount(而非数组身份)memoize——这是最大的流式性能杠杆; - 移植 web 的
stillCurrent/abort 守卫,保证后台化的流不会写入错误的会话(源码层面由chatSessionStore的按会话Map与模块级runChatStream协同实现)。
发送门控
阻塞发送直到附件文件完成索引(token_count != null);对FAILED/卡住状态给出可见提示而不是无限阻塞(3s 状态轮询,镜像 webProjectsContext的模式)。
保持移动端contracts/streaming.ts最小化
现在只放核心包类型;富类型留在各自的延后阶段添加。在引文(citations)落地之前,避免把 web 端完整的OnyxDocument形态拖进来。
渲染器基础在 PR 3,渲染器作为后续
在 PR 3 构建MessageRenderer契约 +findRenderer分发(只注册MessageTextRenderer),使富聊天功能成为增量注册而非核心重写。Agentic 时间线组合层(AgentTimeline)本身也延后——第一个时间线渲染器 PR(9b)才构建它;它插入的分发接缝已在 PR 3 存在。不要在核心中构建富渲染器,只构建接缝。
智能体/项目选择是隐式的
由会话创建时的persona_id/project_id携带,没有逐消息的 agent 参数。后端不支持为已有会话选择智能体(与 web 一致)——需要新开会话。
错误处理
ERROR/PacketError包将助手节点置为错误状态;OnyxError风格的消息只在后端抛出(客户端呈现ApiError消息)。这是纯客户端工程,不涉及HTTPException考量。
expires=/ Celery:不适用
本移植未引入任何后端任务。
小结:一张可以照着实施的"地图"
本详细设计文档的价值在于给出了无后端改动前提下的完整客户端蓝图:三层状态架构(临时 zustand / 持久化 Query / 独立上传进度)、移动端原生的纯聊天数据层(NDJSON 解析器 + 消息树 + 历史重建)、以及一个让未来 9a–9e 富聊天功能全部以"加一个渲染器"方式落地的渲染器注册表。配合仓库中已落地的 ndjson.ts、stream.ts、messageTree.ts、chatHistory.ts、chatSessionStore.ts 与 PII 排除测试 client.test.ts,无论是继续阅读 实施计划 与 PR 路线图,还是对照 统一聊天面设计,本设计都能为每个阶段提供稳定的接缝与验收基准。
【免费下载链接】danswerOpen Source AI Platform - AI Chat with advanced features that works with every LLM项目地址: https://gitcode.com/GitHub_Trending/da/danswer
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考