
Onyx 移动端 Agentic Reasoning Timeline 高层设计1:1 移植 Web 智能体时间线外壳【免费下载链接】danswerOpen Source AI Platform - AI Chat with advanced features that works with every LLM项目地址: https://gitcode.com/GitHub_Trending/da/danswer导读本文是 Onyxdanswer移动端富聊天改造子阶段9b — Agentic Reasoning Timeline的高层设计解读。Web 端在助手思考或调用工具时会在答案上方展示一条垂直步骤时间线推理 Thinking 步骤、搜索/工具步骤等每个步骤含图标、状态头、连接线和可折叠正文并在流式输出期间显示带微光的 Thinking… (12s) 头部答案开始后自动折叠为 Thought for 12s · 3 steps 胶囊。9b 的目标是把这条时间线完整外壳 1:1 移植到 React Native Expo 移动端并只接线reasoning推理步骤渲染器为后续 search/fetch/python/custom-tool/deep-research/memory 等工具渲染器铺好零重构的接入缝隙。读完本文你将掌握移动端时间线的端到端数据流、分组引擎与section_end合成规则、200ms 节拍揭示、七状态机、render-prop 渲染契约以及 Web/移动两端唯一被平台强制的实现差异。背景为什么移动端需要 9b在 9b 之前移动端聊天虽然已经能渲染流式 Markdown 答案和 9a 阶段引入的 Sources 引用条但对助手的推理过程与工具调用过程完全没有可见性——AgentTimeline组件只是一个steps属性永远为空的桩stub。这可以从移动端源码得到印证mobile/src/components/chat/AgentTimeline.tsx已有 36px 的 rail、24px 的AgentAvatar、reanimated 微光ThinkingLabel和TimelineStep列表原语但没有任何数据填充它mobile/src/chat/messageProcessor.ts是一个扁平的、游标增量式nextPacketIndex的 packet→state reducer其文件头注释明确写着9b extends it with turn/tab grouping timeline steps, so the shape here stays deliberately flat (grouping-free)——即 9b 的缝隙是预先预留的。而 Web 端早已拥有完整的 agent timeline 体系全部位于web/src/app/app/message/messageComponents/下。9b 的使命就是把这套体系忠实移植到移动端。总体方案Approach C — Faithful Shell First全量外壳先行9b 在需求研究阶段01-research.md评估过三个方案A — Lean Steps约 400 LOC1 个 PR分组逻辑放进已有的扁平 reducer只加一个推理叶子不移植任何 Web 机制B — Extensible Step Seam约 1,800 LOC2 个 PR纯分组模块 优先级步骤注册表 数据契约但不含 pacing 与完整状态机C — Faithful Shell First约 3,700–4,300 LOC5–6 个 PR把 Web 的整个时间线外壳 1:1 移植包括 render-prop 渲染契约、分组引擎、usePacedTurnGroups的 200ms 节拍、完整的七状态机、StepContainer 与 Done/Stopped 终止步骤只接线 reasoning 渲染器。最终选定Approach CGATE 12026-07-16 所有者决策。所有者的原话意图是我要和 Web 一致的东西。我马上就会去写渲染器——当前 PR 先合入然后渲染器跟上。我不想要任何后续重构。行数多少我都不在乎——我要全部的东西用任何必要的方式。只是别偏离 Web。 这意味着移动端要精确移植Web 的 render-propMessageRendererT,S契约而不是简化成数据对象使后续每个 Web 渲染器都能近乎机械地移植过来。端到端数据流从原始 Packet 到屏幕上的时间线移动端聊天流本质上是一个随模型流式输出不断增长的扁平Packet[]列表每个 packet 形如{placement, obj}。9b 的核心做法是在原始 packets与屏幕上的时间线之间插入 Web 的分组grouping、节拍pacing与状态机state-machine三层忠实镜像 Web 的AgentMessage。完整流程源自高层设计如下分组Group一个纯 reducer 遍历 packets按分组键{turn_index}-{tab_index}取自每个 packet 的placement把它们装入steps。每当出现新的turn_index就把之前所有未结束的 step完成——方法是向其中合成synthesize一个section_end最终的stoppacket 关闭剩余的一切。这正是 Web 判定一个 step 结束的方式——后端很少主动发送section_end。随后 steps 被组织成turn groups共享同一turn_index的 steps 视为并行单独一个的视为串行。节拍Pace一个有状态 hook 以200ms 交错逐步揭示 steps第一步立即显示其余每隔 200ms 显示一个stop一次性全部刷出。它还扣住最终答案直到工具步骤动画播完——保证答案永远不会先于时间线弹出。历史重载已完成的消息绕过节拍瞬间显示全部内容。推导 UI 状态Derive UI state一个纯状态机把正在流式 / 已停止 / 已展开推导为七种状态EMPTY、DISPLAY_CONTENT_ONLY、STREAMING_SEQUENTIAL、STREAMING_PARALLEL、STOPPED、COMPLETED_COLLAPSED、COMPLETED_EXPANDED外加一组显示什么/圆角哪里布尔量。若干独立小 hook 分别计算头部文本取自当前 step 的第一个 packetThinking、Searching the web…、实时计时器每秒 1 次一旦后端上报时长即冻结、step 计数以及展开/折叠状态默认折叠答案开始后自动折叠除非用户手动切换过。渲染Render时间线外壳在 36px rail 中绘制 agent 头像、头部流式时是微光 Thinking…完成时是 Thought for X · N steps 折叠按钮展开时显示完整 step 列表。每个 step 由StepContainer绘制图标 rail 连接线 着色表面 头部 可折叠正文。TimelineRendererComponent拥有每个 step 的展开/折叠状态并通过findRenderer沿用 Web 的优先级调度reasoning 排最后选择渲染器。渲染器是一个render-prop 组件它计算一个小型RendererResult{icon, status, content, …}交给容器由容器拥有视觉外框。9b 接线的唯一渲染器是ReasoningRenderer它累积流式推理 Markdown抽取标题作为 step 标题强制 500ms 最短 Thinking 展示时长并通过移动端的StreamingMarkdown渲染正文。答案与引用Answer sources时间线下方最终答案通过同一渲染契约以FULL级别渲染移动端已有的MessageTextRenderer迁移到 render-prop 契约9a 的Sources条渲染在其下——两者行为均保持不变。组件交互图以下结构图源自高层设计展示了移动端 9b 完成后各组件的关系assistant node.packets[] (flat Packet[], grows each stream flush) │ ▼ ┌───────────────────────────────────────────────────────────────────────┐ │ MessageRow.AssistantMessage (mobile analog of web AgentMessage) │ │ │ │ usePacketProcessor(packets, nodeId) │ │ └─ messageProcessor.processPackets (PURE reducer: grouping │ │ section_end injection citations/docs finalAnswerComing) │ │ → toolGroups, displayGroups, citations, stopPacketSeen, … │ │ └─ transformers.groupStepsByTurn → toolTurnGroups: TurnGroup[] │ │ │ │ usePacedTurnGroups(toolTurnGroups, displayGroups, stop, node, final) │ │ └─ 200ms staggered reveal (timer) │ │ → pacedTurnGroups, pacedDisplayGroups, pacedFinalAnswerComing │ │ │ │ ┌── AgentTimeline turnGroupspacedTurnGroups … / ── (ABOVE) ──┐ │ │ │ useTimelineUIState (7 states) · useTimelineExpansion │ │ │ │ useTimelineHeader · useStreamingDuration · useMetrics │ │ │ │ header switch → StreamingHeader / CompletedHeader / Stopped│ │ │ │ ExpandedTimelineContent → per step: │ │ │ │ TimelineStep → TimelineRendererComponent (owns expand) │ │ │ │ → findRenderer(step.packets) → ReasoningRenderer │ │ │ │ → children([RendererResult]) → StepContainer wraps │ │ │ │ Done / Stopped terminal step │ │ │ └─────────────────────────────────────────────────────────────── ┘ │ │ │ │ pacedDisplayGroups → RendererComponent renderTypeFULL/ (BELOW) │ │ → findRenderer → MessageTextRenderer → StreamingMarkdown answer │ │ │ │ CitedSources/ (9a, unchanged) │ └───────────────────────────────────────────────────────────────────────┘分组引擎section_end合成是正确性的核心Web 端的分组引擎是web/src/app/app/message/messageComponents/timeline/hooks/packetProcessor.ts9b 将其逐字移植为移动端的messageProcessor.ts扩展。仓库源码可以印证文档中的关键算法分组键getGroupKey从 packet 的placement中取turn_index与tab_index ?? 0拼成${turnIndex}-${tabIndex}见 packetProcessor.ts。model_index被忽略移动端单模型但分组键与数据模型不排除并行/嵌套/多模型为后续阶段预留section_end合成injectSectionEnd是幂等的key 已结束时直接返回解析 key 中的{turn, tab}向该组 push 一个合成 packet{placement: {turn_index, tab_index}, obj: {type: SECTION_END}}注意不带sub_turn_index——这样 research/coding 父级完成判定才能工作并把 key 标记为已结束见 packetProcessor.ts。触发时机共有三个触发 1真实的SECTION_END/ERRORpacket触发 2turn 切换新的turn_index的第一个 packet 会向所有未结束的seenGroupKey注入section_end触发 3stop第一个stop向所有打开的组注入。 一个关键细节在已见过的 turn 内出现新的tab_index不会触发触发 2——这正是并行 tab 语义得以保留的原因。内容判定CONTENT_PACKET_TYPES_SET与FINAL_ANSWER_PACKET_TYPES_SET两个集合决定一个组是否有可显示内容、最终答案是否即将到来见 packetProcessor.ts。例如REASONING_START在内容集合中而MESSAGE_START/MESSAGE_DELTA/IMAGE_GENERATION_*在最终答案集合中。为什么这一步如此重要因为在 Web 与移动端一个 step 是否完成几乎完全靠客户端合成的section_end判定——后端很少主动发送。把它逐字移植是保证推理/工具步骤正确闭合的承重性正确性规则Web 端代码位于web/.../timeline/hooks/packetProcessor.ts。节拍层200ms 交错揭示与答案闸门usePacedTurnGroups是移动端新引入的 hook对应 Web 同名实现负责第一步立即显示其余 steps 每隔PACING_DELAY_MS 200毫秒逐一显示stop到来时一次性刷出所有步骤扣住最终答案pacedDisplayGroups/pacedFinalAnswerComing直到工具步骤动画完成避免答案先于时间线弹出历史重载已完成的消息绕过节拍全部瞬间呈现Web 的prevPacedRef引用稳定性逻辑在移植时被舍弃。七状态机与派生态 hook移动端按 1:1 移植了 Web 的派生 hook 族全部位于mobile/src/hooks/timeline/Hook职责useTimelineUIState纯状态机六分支优先级 EMPTY → DISPLAY_CONTENT_ONLY → STREAMING_PARALLEL/SEQUENTIAL → STOPPED → COMPLETED_EXPANDED → COMPLETED_COLLAPSED外加showTintedBackground、showRoundedBottom、showDoneStep、showStoppedStep、showParallelTabs等布尔量useTimelineExpansion折叠状态默认折叠、userHasToggled锁存、在stopPacketSeen \|\| hasDisplayContent时自动折叠、并行 tab 同步useTimelineHeaderpacket 类型 → 头部文本映射reasoning → Thinkingstop_reason USER_CANCELLED→ StoppedHeader默认 → Thinking…useStreamingDuration实时计时器每秒刷新一次整数秒变化时才更新后端时长message_start.pre_answer_processing_seconds存在时冻结useTimelineMetricsstep 计数与 last-step 标志useTimelineStepStatememory 专属提取休眠后续阶段启用这些 hook 需要的streamingStartedAt时间戳由 PR-3 流控制器在流启动发送与恢复时打在每个助手节点上——没有它实时计时标签永远不会显示。Render-prop 渲染契约零重构的未来渲染器缝隙9b 最关键的架构决策是原样采用 Web 的 render-prop 契约移植自 Webinterfaces.ts见详细设计RenderTypeHIGHLIGHT / FULL / COMPACT / INLINERendererResult{icon, status, content, supportsCollapsible?, alwaysCollapsible?, timelineLayout?, noPaddingRight?, surfaceBackground?}Web 中无人读取的expandedText?死字段在移植时被丢弃MessageRendererT,Srender-prop 类型渲染器接收{packets, state, renderType, animate, stopPacketSeen, …}并调用children(results)把RendererResult交给容器——渲染器绝不返回自己的 View 树否则StepContainer无法包裹它findRenderer完整的 13 槽优先级链chat → deep-research → research-agent → coding-agent → web-search → internal-search → image → python → file-reader → custom-tool → fetch → memory →reasoning 最后。9b 只接线 chat→MessageTextRenderer与 reasoning→ReasoningRenderer其余 11 个谓词存在但返回null每个都标注// PR 9x: tool——后续阶段只需一行接线上。这样做的回报是每个未来工具渲染器搜索/抓取/Python/自定义工具/深度研究/记忆都只是一个 obj 接口 一个findRenderer谓词接线 一个返回RendererResult的 render-prop 渲染器零引擎/枚举/外壳改动。这也要求把移动端 PR-3 已有的简单{matches, Component}注册表mobile/src/components/chat/renderers/registry.ts现在就迁移到该契约包括最终答案的MessageTextRenderer——推迟迁移本身就是所有者明令避免的日后重构。ReasoningRenderer唯一接线的步骤渲染器Web 端实现位于web/src/app/app/message/messageComponents/timeline/renderers/reasoning/ReasoningRenderer.tsx移动端逐字移植。从源码可看到三个核心点500ms 最短 Thinking 展示const THINKING_MIN_DURATION_MS 500;见 ReasoningRenderer.tsx——推理流结束后至少保持 500ms 的 Thinking 状态避免闪烁标题抽取extractFirstParagraph只把真正的 Markdown 标题以#开头且长度不超过 60 字符的第一段抽取为 step 标题否则回退为 Thinking见 ReasoningRenderer.tsx状态累积constructCurrentReasoningState判定hasStartREASONING_START与hasEndSECTION_END/ERROR/REASONING_DONE并把所有REASONING_DELTA的reasoning字段 join 成正文。正文渲染用移动端的ReasoningTextWindow192px 高、8×24 的ScrollView包裹StreamingMarkdown流式时自动滚到最新行关闭后可手动滚动溢出时显示 View full text 按钮以及ReasoningTextSheet模态完整文本 字节数 行数 expo-clipboard复制Web 的 Download 因无移动端对应物而被移除。端到端场景用户向推理模型提问源自高层设计的完整场景用户发送问题流开始AgentTimeline显示头像 微光Thinking…状态 EMPTY。reasoning_start随后reasoning_deltapackets 到达turn 0。分组引擎打开 step0-0头部切到STREAMING_SEQUENTIAL并显示微光Thinking折叠态流式预览显示推理 Markdown 的最新几行滚动进来计时器逐秒跳动3s… 4s…。模型思考结束reasoning_done或新 turn /message_start。推理 step 被标记完成合成section_endmessage_start置位finalAnswerComing时间线自动折叠为Thought for 6s · 1 step。message_deltapackets 通过MessageTextRenderer在已折叠的时间线下方流式渲染答案若答案引用了文档9aSources条出现。stop结束本轮。点击 Thought for 6s 胶囊展开时间线显示完整推理 step图标 rail Thinking 头部 推理 Markdown与终止的Donestep。稍后重新打开聊天会水合已保存的 packets、绕过节拍并瞬间渲染折叠时间线 答案。关键决策与原因精确采用 Web 的 render-propMessageRendererT,S契约而非简化的数据对象所有者会立即以后续 PR 构建工具渲染器要求零重构——每个 Web 渲染器的移植必须近乎机械这要求完全相同的{packets, state, renderType, children(results)}形状与完全相同的RendererResult字段。忠实的section_end合成 {turn}-{tab}分组step 是否完成几乎全部由客户端合成判定这是承重规则必须逐字移植。唯一被接受的、平台强制的分歧两个 render 期间读 ref 的 hook 被重构Web 的usePacketProcessor在render 期间修改状态 refusePacedTurnGroups在 render 期间读取 pacing refs——两者在移动端的react-hooks/refslint 下都非法。它们被重构为useMemo全量重算分组 effect 驱动状态pacing 定时器行为保持等价同样的分组输出、同样的 200ms 节奏。这是实现层面的必要不是外观/结构漂移。顺带一提这个重构反而更符合 React 官方 purity 规则react.dev 明确禁止 render 期间读写ref.current。现在交付完整外壳只接线 reasoning并行 tabParallelTimelineTabs与嵌套/记忆路径以休眠状态随外壳发布与 Web 一致使并行性、深度研究嵌套、多模型都成为后续纯 UI跟进无需缝隙变更——这既匹配 Web 外壳已支持的设计也兑现所有者要全部、不要重构的要求。渐进式披露与行业默认一致默认折叠、流式 Thinking… (Ns) 摘要、答案到来时自动折叠、点击而非悬停展开——这是主流模式也正是 Web 已有的行为所以对齐与最佳实践在这里重合。移动端与 Web 的既定分歧清单除上述两个 hook 重构外详细设计还记录了以下有意的、不影响 reasoning 路径外观/结构的分歧微光 reanimated不透明度脉冲复用 9a 的ThinkingLabel而非 Web 的background-clip:text渐变RN 无对应物推理正文窗口 固定高度的自动跟随 ScrollView而非 Web 的像素级translateY自动滚动RN 的 native measure 叶子在 YogaAtMost约束下无法溢出裁剪 View 方案不可行无悬停所有isHover分支删除用静止色bg-border-01、stroke-text-02、bg-background-tint-00与显式Pressable触达搜索头部子标签Reading vs Searching the web在搜索阶段前退化为通用标签expandedText死字段丢弃memory 提示/模态在记忆阶段前不实现并行 tab 在 9b.7 实际按所有者决策提前交付为 live含 Opal pillTabs的 RN 移植见下文。后端与数据契约零改动9b 是纯前端阶段无后端、DB、API 变更。推理数据包已经存在于后端ReasoningStart typereasoning_start无字段、ReasoningDelta typereasoning_delta {reasoning: str}、ReasoningDone typereasoning_done由backend/onyx/chat/llm_step.py发出携带Placement.turn_index/tab_index线上没有message_end——整个 turn 只能通过OverallStop (typestop)完成TopLevelBranching {num_parallel_branches}是并行前的元数据。移动端mobile/src/chat/streamingModels.ts需要补充的是完整的 WebPacketType枚举值当前文件已含REASONING_START/DELTA/DONE、TOP_LEVEL_BRANCHING、SEARCH_TOOL_*、FETCH_TOOL_*、TOOL_CALL_ARGUMENT_DELTA等以及引擎/外壳需要解引用的 obj 接口ReasoningStart/Delta/Done、TopLevelBranching、ToolCallArgumentDelta、MessageStart.pre_answer_processing_seconds等。策略是枚举现在就补全、各工具的 obj 接口按阶段后补——引擎/助手/集合一次性编译通过未来永不再改枚举。交付路线7 个分层 PR由于全量外壳移植规模可观约 3.7–4.3k LOCPR 路线图将其切成 7 个可独立合并的 PR前 6 个以 dark已编译 单元测试、未上屏方式合入直到 9b.7 组合 PR 点亮时间线PR关键交付物状态9b.1分组引擎 完整 PacketType 枚举 纯步骤助手单元测试dark9b.2usePacketProcessorusePacedTurnGroups两个重构 hookdark9b.3六个状态/派生 hook7 状态机、展开、头部、计时、指标dark9b.4render-prop 契约 findRendererRendererComponent最终答案迁移live答案路径9b.5rail/surface/content 原语 StepContainerTimelineRendererComponent 新图标 StreamingMarkdownmuted 变体dark9b.6ReasoningRendererreasoningStateReasoningTextWindowdark9b.7AgentTimeline外壳 头部 Expanded/Collapsed 内容 MessageRow接线 streamingStartedAt点亮 设备门禁值得注意9b.7 在正式编码前经历了所有者的四个AskUserQuestion门禁其中最大的一个决策是并行 tab 提前交付为 live而非休眠这需要一个新原语components/ui/tabs.tsxOpal pillTabs的 RN 移植Root context ListTrigger以及 6 个 1:1 的 Opal 图标移植stop-circle、branch、user、code、book-open、slow-time。9b.7 还修复了三个对抗性评审发现的高危问题其中最重要的是Stopped 路径在线上不可达——用户停止会先于后端stoppacket 中止读取器导致stopPacketSeen永不置位、turn 停留在 STREAMING 状态修复方式是runChatStream的finally在 abort 时合成一个USER_CANCELLED停止 packetbuildUserCancelledStopPacket。另一处修复是FlashList回收 React key 导致滚动后无关消息继承了上一 turn 的展开状态修复为在renderItem内以nodeId作为MessageRow的 key。同时水合 turn 此前完全没有时长任何重开的聊天头部都会显示 Thought for some time——后端响应中已有的processing_duration_secondsbackend/onyx/chat/llm_step.py模型侧被映射进chatHistory解决。测试策略纯逻辑承载风险9b 的全部风险集中在纯核心与 hooks后端零改动因此采用RN Testing Library Jest 单元测试为主覆盖详见实施计划分组/引擎分组键{turn}-{tab}三个section_end触发真实 packet、turn 切换关闭先前组、stop 关闭全部打开组工具 vs 显示分类finalAnswerComing与 tool-after-message 重置hasContentPacketsmodel_index容忍历史重载重置数组收缩TransformersgroupStepsByTurn并行检测 turn/tab 排序节拍fake timers第一步立即、后续 200ms 间隔、stop全刷、历史绕过瞬间显示、答案在节拍完成前被扣住状态 hooks7 状态全部 每个派生布尔量自动折叠 userHasToggled抑制计时器 tick 后端时长冻结Reasoning标题抽取Markdown 标题规则、60 字符上限 delta 累积ReasoningRenderer500ms 门禁fake timers 空/未开始分支组件冒烟测试模拟推理 packet 流渲染 Thinking step、流式 Markdown、标记 done、折叠为 Thought for Ns · 1 step点击展开USER_CANCELLED停止显示 Stopped step硬性设备门禁所有者执行不可自动化开发构建上驱动推理模型确认流式微光 实时计时、答案开始时自动折叠、点击展开、Done 终止步骤、水合重开历史渲染显示真实 Thought for Xs以及迁移后的答案路径与 9a Sources 完好、流式重渲染保持流畅。现有行为的变化涉及思考/工具的助手消息现在会在答案上方显示时间线此前只有静态 Thinking… 微光、没有任何步骤普通无工具答案外观不变EMPTY → 直接出答案渲染路径被重构为 Web 的分组/节拍/调度三层9a 的citations/Sources行为与流式 Markdown 答案保持不变最终答案迁移到同一契约但渲染效果一致无后端、DB 或 API 变更非聊天界面零改动新增两处小的时序状态依赖每条消息一个streamingStartedAt时间戳实时计时用runChatStream启动时打点以及折叠/展开 reasoning 的 muted Markdown 变体新 UI。参考文档本阶段的完整设计文档链从索引进入01-research.md — 需求、锁定范围、代码库/后端/Web/行业调研、三方案对比与选定C02-high-level-design.md — 端到端流程、组件交互图、关键决策本文主体03-detailed-design.md — 精确契约、新文件清单、文件树、逐文件职责、集成点、两个 ref 重构、既定分歧04-implementation-plan.md — CLAUDE.md 格式实施计划 六项计划挑战结果全部通过05-pr-roadmap.md — 7-PR 交付序列与 9b.7 的 as-built 记录。对应的仓库源码锚点Web 端权威实现位于web/src/app/app/message/messageComponents/timeline/hooks/packetProcessor.ts、AgentTimeline.tsx、StepContainer.tsx、TimelineRendererComponent.tsx、renderers/reasoning/ReasoningRenderer.tsx组合根在web/src/app/app/message/messageComponents/AgentMessage.tsx移动端接入点包括mobile/src/chat/streamingModels.ts、mobile/src/chat/messageProcessor.ts、mobile/src/components/chat/AgentTimeline.tsx、mobile/src/components/chat/MessageRow.tsx、mobile/src/components/chat/renderers/registry.ts、mobile/src/hooks/usePacketDisplay.ts后端推理数据包来自backend/onyx/chat/llm_step.py。【免费下载链接】danswerOpen Source AI Platform - AI Chat with advanced features that works with every LLM项目地址: https://gitcode.com/GitHub_Trending/da/danswer创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考