首页
/ Onyx 移动端 Agentic 推理时间线(Agent Timeline)移植全解:从 Web 一比一复刻到 React Native 的零重构架构

Onyx 移动端 Agentic 推理时间线(Agent Timeline)移植全解:从 Web 一比一复刻到 React Native 的零重构架构

2026-09-09 18:24:09作者:咎竹峻Karen

导读

本文基于仓库 docs/mobile-chat/9b-timeline/ 下六份完整的设计与实施文档,系统讲解 Onyx 将 Web 端"智能体推理时间线"(Agent Timeline)以 1:1 忠实移植(Faithful Shell First,Approach C) 的方式落地到 React Native + Expo 移动端(mobile/)的完整过程:包括需求边界、三种候选方案与取舍、端到端数据流、分组引擎与 section_end 合成算法、渲染 prop 契约、七状态机、200ms 节奏控制、7 个分层 PR 的交付路线,以及唯一的平台强制差异。读完本文,你将掌握如何在既有流式聊天管道上增量搭建一套"零重构可扩展"的智能体步骤时间线,并能直接对照仓库源码(移动端实现已落地,mobile/src/chat/messageProcessor.tsmobile/src/hooks/timeline/* 等均可直接阅读)验证每一处设计。

背景:移动端聊天对"智能体思考过程"的可见性缺口

在 Web 端(web/src/app/app/message/messageComponents/),当助手在作答前"思考"或运行工具时,答案上方会显示一条 agent timeline——一条垂直的步骤轨道(rail):每个步骤(reasoning "Thinking" 步骤、搜索/工具步骤等)都带图标、状态标题、连接线和可折叠/展开的正文;流式输出期间显示一个 shimmer 效果的 "Thinking… (12s)" 头部,一旦答案开始输出就自动折叠成一个 "Thought for 12s · 3 steps" 的胶囊。

移动端当时的情况是:mobile/src/components/chat/AgentTimeline.tsx 只是一个占位 stub——有 36px 轨道、24px 的 AgentAvatar、reanimated 的 shimmer ThinkingLabelTimelineStep 列表原语,但 steps prop 恒为 [],没有任何东西填充它<AgentTimeline agent isLoading={!hasContent && !processed.isComplete} /> 渲染在 MessageRow.AssistantMessage 的答案上方)。9b 阶段要做的,就是把 Web 这套完整的时间线外壳原样搬到移动端,并只接上 reasoning(推理) 一个步骤渲染器。

关键约束(来自 00-index.md01-research.md):

  • Web parity 是硬性要求:匹配 Web 的"外观 AND 结构",移植 step-container/header/icon/connector/collapse 形态,而不是发明一套新的移动端时间线;任何平台驱动的差异都要文档化。
  • 基础优先(FOUNDATION-ONLY FIRST):9b 只做三件事——turn/tab 分组引擎AgentTimeline 组合层(真实步骤 + 折叠/展开)、reasoning 步骤渲染器reasoning_start/delta/done)。所有工具渲染器(搜索/fetch/python/custom-tool/deep-research/memory)以及 9c–9e 都是在这个"零重构接缝"上的后续 PR。
  • 后端零改动:reasoning 数据包已经存在,由 backend/onyx/chat/llm_step.py 发出。
  • 聊天层是原生实现:位于 mobile/src/chat/(而非 @onyx-ai/shared),Web 不受影响。

现状盘点:9b 的接缝已经存在且被明确预留

研究文档通过直接阅读源码确认了以下事实(均已在本仓库核对):

  • 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 将用 turn/tab 分组 + 时间线步骤扩展它,因此这里刻意保持扁平、无分组。)
  • mobile/src/components/chat/AgentTimeline.tsx —— 上文提到的 stub。
  • mobile/src/components/chat/renderers/registry.ts —— 简单的 MessageRenderer = {matches(packets), Component} 契约、findRenderer 首个匹配分发;比 Web 的 render-prop 契约简单得多,且只注册了 MessageTextRenderer
  • mobile/src/hooks/usePacketDisplay.ts —— processed = useMemo(() => processPackets(createInitialState(nodeId), packets), [nodeId, packets]),每次 flush 全量重算。刻意不是渲染期改写 ref——移动端的 react-hooks/refs lint 禁止在渲染期间访问 ref.current,因此 Web 的 ref-held usePacketProcessor 模式在此非法
  • mobile/src/chat/streamingModels.ts —— PacketType 当前只有 MESSAGE_START/DELTA/ENDSTOPSECTION_ENDERRORCITATION_INFOSEARCH_TOOL_DOCUMENTS_DELTAOPEN_URL_DOCUMENTSPlacement 已携带全部 4 个字段turn_indextab_indexsub_turn_indexmodel_index),只需新增 REASONING_START/DELTA/DONE(以及用于分组容错的 TOP_LEVEL_BRANCHING)。

后端侧(已核实 llm_step.py):ReasoningStarttype="reasoning_start",无字段)、ReasoningDeltatype="reasoning_delta",含 reasoning: str)、ReasoningDonetype="reasoning_done"),均携带 Placement.turn_index/tab_index线上协议里没有 message_end——整个 turn 只能通过 OverallStoptype="stop")完成;SECTION_END 通常由客户端合成(Web 在遇到新的 turn_index 时向先前的组注入、在 STOP 时向所有打开的组注入——这正是步骤被标记为"完成"的方式)。TopLevelBranching {num_parallel_branches} 是并行前的元数据。

三种候选方案:从"最简步进"到"完整外壳"

01-research.md 给出了三条路线及精确的 LOC/PR 估算:

Approach A — Simplicity-First:"Lean Steps"

把分组放进已经是纯函数的 messageProcessor(在 ProcessedMessageState 上新增 steps: TimelineStep[]),喂给现有 stub 真实的 reasoning 步骤,新增一个 ReasoningStep 叶子。不移植任何 Web 机制(无 render-prop 契约、无七状态机、无 pacing)。

  • 规模约 360–420 LOC,1 个 PR;表面最小、对 9a 零风险、纯分组完全可单测。
  • 代价:没有 pacing / 七状态头部 / 逐步骤 render-prop 契约;第二个渲染器只能通过新 kind + 一个 case 插槽(而非 findRenderer),即当真实工具需要 tint 表面/逐步骤折叠时,AgentTimeline 要做一次有界重构。

Approach B — Flexibility-First:"The Extensible Step Seam"

在扁平 processPackets 旁边新增一个纯 turn/tab 分组模块(chat/timeline/grouping.ts),产出 TurnGroup[];步骤由优先级有序的步骤注册表解析为 Web RendererResult 的移动端等价物——纯数据对象而非 JSX;单个 StepContainer 拥有全部 chrome。增加精简版 useTimelineExpansion + useTimelineUIState,以及全新的 Collapsible 原语 + reasoning 图标(均需 owner 确认)。

  • 规模约 1,810 LOC,2 个 PR(9b-1 纯引擎、9b-2 UI+接线)。
  • 代价:约 A 的 2 倍 LOC;引入的契约字段(alwaysCollapsibletimelineLayout)reasoning 几乎用不到(部分属推测性)。

Approach C — Full-Parity:"Faithful Shell First"

把 Web 的整个时间线外壳现在、1:1 移植过来:分组 + transformers + usePacedTurnGroups(200ms 交错)+ 完整 useTimelineUIState(7 状态)+ useTimelineExpansion + useTimelineHeader + useStreamingDuration(实时 "Ns"/"Thought for {duration}")+ useTimelineMetrics + render-prop 渲染器契约 + StepContainer + TimelineRendererComponent + Streaming/Stopped/Completed 头部 + Done/Stopped 终止步骤——只接 reasoning 渲染器的主体

  • 规模约 3,700–4,300 LOC,诚实地说 5–6 个 PR
  • 代价:为只接 reasoning 的阶段付出最大的 LOC/审查面;七状态机的大部分会作为死路径上线(直到并行存在);pacing + 自动折叠高度动画带来流式性能/卡顿风险。
  • 收益:day one 就与 Web 忠实一致(头部/计时器/自动折叠/交错/Done-Stopped/tint 表面),每个后续渲染器都是纯增量、零外壳返工、零漂移。

方案对比与最终选择

三者共用同一个分组 key${turn_index}-${tab_index ?? 0}model_index 忽略),差异只在"现在建多少外壳/契约"。react-hooks/refs 约束同时约束三者——谁都不能移植 Web 渲染期改写 ref 的 usePacketProcessor;分组必须是纯 useMemo。C 在此风险最大(pacing hook 重构)。

最终选择:Approach C(2026-07-16 GATE 1 由 owner 拍板)。Owner 原话(文档逐字记录):"I want something that matches web. I am going to build the renderers immediately… I don't want any refactor later. I'm fine with any number of lines — I want everything, whatever way we need it. Just don't drift away from web."(我要与 Web 一致的东西,我会立刻构建渲染器;我不要以后重构;行数多少都行,我全要;只是别偏离 Web。)

这意味着:render-prop MessageRenderer<T,S> 契约 + RendererResult/RenderType(HIGHLIGHT/FULL/COMPACT/INLINE)原样采用(而非 B 的简化数据对象),让未来的每个 Web 渲染器近乎机械化地移植;并行 tab 状态(isParallelSTREAMING_PARALLELshowParallelTabs)与 sub_turn_index/model_index 容忍随外壳一起上线(与 Web 一致),使并行/嵌套/多模型成为后续纯 UI 跟进、接缝零改动。

高层设计:端到端流程与组件交互

02-high-level-design.md 将整个机制浓缩为五步:

  1. 分组(Group):纯 reducer 遍历 packet,按分组 key "{turn_index}-{tab_index}"(取自每个 packet 的 placement)分桶。新的 turn_index 会通过合成 section_end 关闭("完成")之前所有步骤;最终的 stop 包关闭剩余所有。随后步骤按 turn 组 组织(共享 turn_index 的步骤视为"并行",单个的是"顺序")。
  2. 节奏(Pace):有状态 hook 以 200ms 交错揭示步骤(第一个立即可见,其余每 200ms 一个;stop 一次性全部冲刷)。它还会扣住最终答案,直到工具步骤动画完成——答案不会抢在时间线之前弹出。历史重载(已完成)的消息绕过 pacing,立即全部显示。
  3. 推导 UI 状态(Derive):纯状态机把"流式中/已停止/已展开"归并为七种状态(EMPTY、DISPLAY_CONTENT_ONLY、STREAMING_SEQUENTIAL、STREAMING_PARALLEL、STOPPED、COMPLETED_COLLAPSED、COMPLETED_EXPANDED)外加一组"显示这个/圆角那个"布尔。另有小 hook 分别计算头部文本(取自当前步骤的首个 packet——"Thinking"、"Searching the web"…)、实时计时器(1 次/秒,后端上报时长后冻结)、步骤数折叠/展开状态(默认折叠;答案开始时自动折叠,除非用户手动切换过)。
  4. 渲染(Render):时间线外壳在 36px 轨道中画 agent 头像、头部(流式中是 shimmer 的 "Thinking…",完成时是 "Thought for X · N steps" 折叠按钮)、展开时画完整步骤列表。每个步骤由 StepContainer 绘制(图标轨道 + 连接线 + tint 表面 + 头部 + 可折叠正文)。TimelineRendererComponent 掌管每个步骤的展开/折叠状态,并通过 findRenderer(Web 的优先级有序分发,reasoning 垫底)挑选渲染器。渲染器是 render-prop 组件:计算一个小 RendererResult{icon, status, content, …})交给容器,容器拥有视觉框架。9b 唯一接线的渲染器是 ReasoningRenderer:累积流式 reasoning markdown、提取标题作为步骤标题、强制执行 500ms 最短 "Thinking" 显示、用移动端 StreamingMarkdown 渲染正文。
  5. 答案 + 来源:时间线下方,最终答案通过同一渲染器契约以 FULL 渲染(移动端现有 MessageTextRenderer 迁移到 render-prop 契约),9a 的 Sources 栏在其下方——两者行为不变。

文档还给出组件交互图,其核心脉络为:

assistant node.packets[](扁平 Packet[],每次流式 flush 增长)
  → MessageRow.AssistantMessage(Web AgentMessage 的移动端对应)
      → usePacketProcessor: messageProcessor.processPackets(纯 reducer:分组 + section_end 注入 + citations/docs + finalAnswerComing)
          → transformers.groupStepsByTurn → toolTurnGroups: TurnGroup[]
      → usePacedTurnGroups(200ms 交错揭示)→ pacedTurnGroups / pacedDisplayGroups / pacedFinalAnswerComing
      → <AgentTimeline turnGroups=pacedTurnGroups …/>(上方)
          → useTimelineUIState(7 状态) · useTimelineExpansion · useTimelineHeader · useStreamingDuration · useMetrics
          → 头部 switch → StreamingHeader / CompletedHeader / StoppedHeader
          → ExpandedTimelineContent → 每步: TimelineStep → TimelineRendererComponent → findRenderer → ReasoningRenderer
             → children([RendererResult]) → StepContainer 包裹
             + Done / Stopped 终止步骤
      → pacedDisplayGroups → <RendererComponent renderType=FULL/>(下方,答案)
      → <CitedSources/>(9a,不变)

详细设计:契约、算法与新文件

03-detailed-design.md 是本文的技术核心,以下按模块展开(均可在仓库中找到对应实现)。

1. Packet 契约:mobile/src/chat/streamingModels.ts

一次性加入完整 Web PacketType 枚举值(让移植的分组引擎 + packetHelpers + toolDisplay 零未来枚举改动即可编译),外加引擎/外壳现在就要解引用的接口。已核实的实现中,streamingModels.ts 已包含 REASONING_START = "reasoning_start"REASONING_DELTA = "reasoning_delta"REASONING_DONE = "reasoning_done",以及完整的搜索/抓取/python/自定义工具/文件读取/记忆/图像生成/深度研究/coding-agent/bash 等枚举值(SEARCH_TOOL_START 等,L17-L67)。

关键新增 obj 接口(设计文档原文):

interface ReasoningStart  extends BaseObj { type: PacketType.REASONING_START }
interface ReasoningDelta  extends BaseObj { type: PacketType.REASONING_DELTA; reasoning: string }
interface ReasoningDone   extends BaseObj { type: PacketType.REASONING_DONE }
interface TopLevelBranching extends BaseObj { type: PacketType.TOP_LEVEL_BRANCHING; num_parallel_branches: number }
interface ToolCallArgumentDelta extends BaseObj { type: PacketType.TOOL_CALL_ARGUMENT_DELTA; tool_type: string; argument_deltas: Record<string, unknown> }
interface SearchToolStart extends BaseObj { type: PacketType.SEARCH_TOOL_START; is_internet_search?: boolean }
interface CustomToolStart extends BaseObj { type: PacketType.CUSTOM_TOOL_START; tool_name?: string; tool_id?: number | null }
interface ImageGenerationToolDelta extends BaseObj { type: PacketType.IMAGE_GENERATION_TOOL_DELTA; images?: { file_id: string }[] }
// 扩展现有 MessageStart:pre_answer_processing_seconds?: number | null
export const CODE_INTERPRETER_TOOL_TYPES = { PYTHON: "python" } as const;

策略是"枚举全量现在、按工具接口以后":引擎/helpers/Sets 一次编译、永不改枚举;每个工具渲染器阶段只加自己的 obj 接口 + 一条 findRenderer predicate 接线——一行改动,无引擎/枚举翻动。这就是"零重构"保证。

2. 分组引擎:mobile/src/chat/messageProcessor.ts

ProcessedMessageState 新增 Web 的分组字段(9a 字段保留给 CitedSources):groupedPacketsMapseenGroupKeysgroupKeysWithSectionEndexpectedBranchestoolGroupKeysdisplayGroupKeysisGeneratingImagegeneratedImageCountfinalAnswerComingstopPacketSeentoolProcessingDurationtoolGroupspotentialDisplayGroups。这些字段在仓库实现 messageProcessor.ts 中原样存在。

section_end 合成是正确性的核心,必须逐字移植。实现中的 injectSectionEnd

  • 幂等(已结束则跳过);
  • 从 key 解析 {turn,tab}
  • 向组内推入合成的 {placement:{turn,tab}, obj:{type:SECTION_END}}不带 sub_turn_index——这样 research/coding 的父级完成检查才能工作);
  • 标记该 key 已完成。

三个触发条件(与 Web packetProcessor.ts:116-133, 190-208, 270-287 对齐):

  • 触发 1(真实包):真实的 SECTION_END/ERROR 包。
  • 触发 2(turn 转换):新 turn_index 的首个包向每个尚未结束的 seenGroupKey 注入。同一 seen turn 内的新 tab_index 不触发(见 handleTurnTransition)。
  • 触发 3(stop):首个 stop 向所有打开组注入(handleStopPacket)。

其他已落地的细节:buildGroupsFromKeys 展开 packets 成新数组以触发变更检测、只保留有内容包(hasContentPackets,其中 TOOL_CALL_ARGUMENT_DELTA 仅当 tool_type === "python" 才算内容)、按 turn 再按 tab 排序(messageProcessor.ts);handleToolAfterMessagePacket 处理"Claude 可能先发消息再发真实工具"的情形——此时 finalAnswerComing 被重置为 false(reasoning 除外,它只是思考而非真实工具调用);handleStreamingStatusPacketMessageStart.pre_answer_processing_seconds 记录 toolProcessingDurationTOP_LEVEL_BRANCHING 写入 expectedBranches(按 turn_index 记录期望并行分支数)。

3. 纯步骤 helpers:mobile/src/chat/timeline/(新目录)

  • transformers.ts —— TransformedStep {key, turnIndex, tabIndex, packets}TurnGroup {turnIndex, steps, isParallel}transformPacketGroup(s)groupStepsByTurnisParallel = steps.length > 1;按 turn 再按 tab 排序)。实现已在 transformers.ts 核实。
  • packetUtils.ts —— isToolPacketisActualToolCallPacketisDisplayPacketisSearchToolPacketisStreamingCompleteisFinalAnswerComingisFinalAnswerCompletegroupPacketsByTurnIndexgetTextContentgetCitations
  • packetHelpers.ts —— COLLAPSED_STREAMING_PACKET_TYPESCODING_AGENT_PACKET_TYPESisReasoningPacketsisSearchToolPacketsisPythonToolPacketsisResearchAgentPacketsisCodingAgentPacketsisDeepResearchPlanPacketsisMemoryToolPacketsstepSupportsCollapsedStreamingstepHasCollapsedStreamingContent
  • toolDisplay.ts —— getToolKeyparseToolKeygetToolName(纯)、hasToolErrorisToolComplete(对 research/coding 感知 sub_turn_index)。图标工厂拆到 components/chat/timeline/toolIcons.ts(返回移动端图标组件,而非 Web react-icons 的 JSX)。
  • reasoningState.ts —— extractFirstParagraph + constructCurrentReasoningState(纯)。实现已核实:reasoningState.ts 中标题规则为"首行必须是 #+ 空格 开头的 markdown 标题、清洗后长度 ≤ 60 字符(MAX_TITLE_LENGTH = 60)",超长则视为正文而非标题;constructCurrentReasoningStatehasEnd 同时认 SECTION_END/ERROR/REASONING_DONEsection_end 多为客户端合成,reasoning_done 是后端自己的)。

4. 渲染器契约:mobile/src/components/chat/renderers/timelineContract.ts

从 Web interfaces.ts 逐字移植(类型级;JSX.Element = RN 元素):

export enum RenderType { HIGHLIGHT="highlight", FULL="full", COMPACT="compact", INLINE="inline" }
export type TimelineLayout = "timeline" | "content";
export type TimelineSurfaceBackground = "tint" | "transparent" | "error";
export interface RendererResult {
  icon: IconFunctionComponent | null;          // 移动端图标类型
  status: string | React.ReactElement | null;
  content: React.ReactElement;
  supportsCollapsible?: boolean; alwaysCollapsible?: boolean;
  timelineLayout?: TimelineLayout; noPaddingRight?: boolean;
  surfaceBackground?: TimelineSurfaceBackground;
  // 注:Web 的 `expandedText?` 已死(0 个读者)——移植时 DROPPED。
}
export type RendererOutput = RendererResult[];
export interface FullChatState {               // 移动端子集
  agent: MinimalAgent | null;
  citations?: CitationMap; documentMap?: Map<string, SearchDoc>;
  openSource?: (doc: SearchDoc) => void;       // 9a
}
export type MessageRenderer<T extends Packet, S extends Partial<FullChatState>> = React.ComponentType<{
  packets: T[]; state: S; messageNodeId?: number; hasTimelineThinking?: boolean;
  onComplete: () => void; renderType: RenderType; animate: boolean;
  stopPacketSeen: boolean; stopReason?: StopReason; isLastStep?: boolean;
  children: (result: RendererOutput) => React.ReactElement;   // isHover DROPPED(RN 无 hover)
}>;
export interface TimelineRendererResult extends RendererResult {
  isExpanded: boolean; onToggle: () => void; renderType: RenderType;
  isLastStep: boolean; timelineLayout: TimelineLayout;
}

5. 时间线状态 hooks:mobile/src/hooks/timeline/(新目录)

签名(除标注 RESTRUCTURED 的两个外全部 1:1 移植):

usePacketProcessor(packets: Packet[], nodeId: number): UsePacketProcessorResult    // RESTRUCTURED
usePacedTurnGroups(toolTurnGroups, displayGroups, stopPacketSeen, nodeId, finalAnswerComing):
  { pacedTurnGroups: TurnGroup[]; pacedDisplayGroups: GroupedPacket[]; pacedFinalAnswerComing: boolean }  // RESTRUCTURED
useTimelineUIState(input: TimelineUIStateInput): TimelineUIStateResult              // 纯,1:1
useTimelineExpansion(stopPacketSeen, lastTurnGroup, hasDisplayContent): TimelineExpansionState   // 1:1
useTimelineHeader(turnGroups, stopReason?, isGeneratingImage?): TimelineHeaderResult  // 1:1
useStreamingDuration(isStreaming, startTime?, backendDuration?): number               // 1:1(RAF→interval 可)
useTimelineMetrics(turnGroups, userStopped): TimelineMetrics                          // 1:1
useTimelineStepState(turnGroups): MemoryStepState                                     // 1:1(休眠/最小化)

七状态机已在 useTimelineUIState.ts 实现,优先级分支为:EMPTY → DISPLAY_CONTENT_ONLY → STREAMING_PARALLEL/SEQUENTIAL → STOPPED → COMPLETED_EXPANDED → COMPLETED_COLLAPSED,并派生 showTintedBackgroundshowRoundedBottomshowDoneStepshowStoppedStephasDoneIndicatorshowParallelTabsshowCollapsedCompactshowCollapsedParallelisStreamingisCompletedisActivelyExecuting

useStreamingDuration 已实现为 useStreamingDuration.tsDURATION_TICK_MS = 250setInterval 轮询(比 1s 快以保持整秒边界清晰,jest fake timers 下确定;RN 从 RAF 换 interval 无损失),只在整数秒变化时 setStatebackendDuration 一旦存在立即冻结(返回该值、停跑计时器);新 startTime 表示新流,立即重置。

6. 新文件清单与职责(摘录)

文件 职责
mobile/src/chat/timeline/{transformers,packetUtils,packetHelpers,toolDisplay,reasoningState}.ts 纯分组/分类/谓词/工具名/推理状态推导(全部 reanimated-free、可单测)
mobile/src/components/chat/renderers/{timelineContract,findRenderer,RendererComponent,ReasoningRenderer}.tsx 契约、优先级分发、终答分发、唯一接线的步骤渲染器
mobile/src/hooks/timeline/*(8 个) 处理器/节奏/七状态/展开/头部/时长/度量/步骤状态
mobile/src/components/chat/timeline/primitives/* timelineTokens(px 常量:rail 36、icon 12…)、Root/HeaderRow/Row/IconColumn/Surface/StepContent
mobile/src/components/chat/timeline/StepContainer.tsx 组合原语成步骤框架
mobile/src/components/chat/timeline/{TimelineRendererComponent,TimelineStep,ExpandedTimelineContent,CollapsedStreamingContent}.tsx 逐步骤展开 + renderType、步骤编排、展开/折叠内容
mobile/src/components/chat/timeline/headers/{Streaming,Completed,Stopped,ParallelStreaming}Header.tsx 四种头部
mobile/src/components/chat/timeline/ReasoningTextWindow.tsx maxHeight markdown 窗口
mobile/src/icons/{circle,fold,expand,check-circle}.tsx 新图标

7. ReasoningRendererReasoningTextWindow 的已落地细节

ReasoningRenderer.tsx 已实现并验证:THINKING_MIN_DURATION_MS = 500THINKING_STATUS = "Thinking"constructCurrentReasoningState(hasStart/hasEnd/content=delta 拼接)+ extractFirstParagraph(markdown 标题 → 步骤标题,≤60 字符);500ms 最短思考门控——历史消息以 animate=false 回放、跳过地板(minimumThinkingDuration = animate ? 500 : 0);标题为空时 displayStatus|| 回退(空标题 "" 必须回退到 "Thinking");supportsCollapsible 刻意不设——严格 Web parity:Web 的 ReasoningRenderer 不设它,StepContainer 从不按 isExpanded 门控其正文,且 Web 的 hideHeader = isSingleStep && !supportsCollapsible 会为 reasoning-only turn 有意去掉步骤头部。渲染器是 headless 的:通过 children([RendererResult]) 交出结果,绝不自己返回视图树。

ReasoningTextWindow.tsx 已实现:REASONING_WINDOW_MAX_HEIGHT = 8 × 24 = 192px(对齐 Web 的盒子而非移动端 8 行文本);必须是 ScrollView 而非裁剪 View——markdown 是原生测量叶子,双平台都把它钳制到 Yoga 的 AtMost 约束(iOS ENRMClampMeasuredSize、Android MeasurementStore AT_MOST),永不溢出,任何 justifyContent 技巧都挪不动它;流式中 onContentSizeChange → scrollToEnd({animated:false}) 跟随最新行,scrollEnabled 仅在 overflows && !isStreaming 时开启(避免 iOS 橡皮筋抢手势),nestedScrollEnabled 保证嵌套在聊天列表中的 Android 手势;溢出且可展开时显示 "View full text" 按钮(prominence="tertiary" + SvgExpand)。全文本阅读器 ReasoningTextSheet 以 9a CitedSourcesSheet 形态实现(RN Modal + ScrollView):完整推理(含标题)、字节数副标题、行数、Copyexpo-clipboard);Web 的 Download 被丢弃(移动端无对应物)。

唯一被接受的平台强制差异:两个 ref-during-render hook 的重构

这是整个设计中反复强调的一点(03-detailed-design.md 第 1 节、04-implementation-plan.md Important Notes):

  • Web 的 usePacketProcessor渲染期间改写 stateRef.currentusePacedTurnGroups 在渲染期间读取 pacing refs(shouldBypassPacing + memos)——两者在移动端的 react-hooks/refs lint 下都非法(React 官方 purity 规则:渲染期读写 ref.current 破坏纯度/并发渲染)。
  • 重构方式(行为保持):
    • usePacketProcessoruseMemo 全量重算(9a 已验证的模式;O(n)/flush,聊天规模下无压力)。
    • usePacedTurnGroups → 把 revealedStepKeys/toolPacingComplete 提升为 useState,由 effect 写入shouldBypassPacing 从 props 同步计算;定时器句柄留在 ref(仅 effect 内读写)。
  • 已落地实现证实了这一点:usePacedTurnGroups.ts 头部注释明确写了"Restructured from web (which reads pacing refs during render)… Behavior-preserving; web's prevPacedRef stabilization is dropped"。其内部 PacingStaterevealedStepKeys/lastRevealedPacketType/pendingSteps/pacingTimer/toolPacingComplete/stopPacketSeen/nodeId)全部收在 ref 中、只在 effect/callback 内触碰;消息切换时渲染期同步重置已发布的镜像(React 在 commit 前重渲染,过渡帧不会闪现上一个节点的已揭示步骤或答案);第一个步骤立即揭示、其余 200ms(PACING_DELAY_MS = 200)排队、stop 一次性冲刷、历史重载完全绕过、finalAnswerComing 从 true→false(tool-after-message)时立即把 toolPacingComplete 置 false 防止答案穿透 pacing 间隙。

这是实现层面的必要性,不是外观/结构漂移——渲染出的时间线与 Web 忠实一致,且这在 React 规则意义上反而"更正确"(Web 的渲染期 ref 正是 React 现在 lint 反对的反模式)。

实施路线:7 个分层 PR

05-pr-roadmap.md 把约 3.7–4.3k LOC 切成 7 个可独立合并的 PR。生产前无特性开关:各层以"暗"状态落地(编译 + 单测通过,但不上屏),直到组合 PR(9b.7)点亮时间线;唯一的序列中行为变化是 9b.4(终答路径迁移到共享契约,行为完全一致)。每个 PR 都保证 main 可构建、tsc/lint/jest 绿、app 可用。

PR 标题 估算 LOC 依赖 关键交付
9b.1 timeline 分组引擎 + packet 契约 ~700 纯分组 reducer(turn/tab + section_end 合成)+ 完整 PacketType 枚举 + 纯步骤 helpers;单测。暗。
9b.2 timeline 处理器 + pacing hooks ~590 9b.1 usePacketProcessor(lint-safe 重算)+ usePacedTurnGroups(200ms 揭示),两个重构 hook;单测。暗。
9b.3 timeline 状态 hooks ~550 9b.1 七状态/展开/头部/时长/度量/步骤状态六 hook;单测。暗。
9b.4 render-prop 契约 + 终答迁移 ~510 9b.1 RendererResult/MessageRenderer + findRenderer + RendererComponent;迁移终答 MessageTextRenderer上线(答案路径)。
9b.5 timeline 原语 + StepContainer ~650 9b.4 轨道/表面/内容原语 + StepContainer + TimelineRendererComponent + 新图标/Button(ASK)+ StreamingMarkdown muted。暗。
9b.6 reasoning 渲染器 ~450 9b.4, 9b.5 ReasoningRenderer + reasoningState + ReasoningTextWindow;注册进 findRenderer;单测。暗。
9b.7 agent timeline 组合 ~700 9b.2, 9b.3, 9b.5, 9b.6 AgentTimeline 外壳 + 头部 + Expanded/Collapsed 内容 + MessageRow 接线 + streamingStartedAt点亮时间线。设备门禁

依赖关系:9b.1 是根;9b.2/9b.3/9b.4 从它并行展开;9b.5→9b.6 链在契约上;9b.7 依赖 hooks(9b.2/9b.3)、UI 原语(9b.5)和 reasoning 渲染器(9b.6)——它是唯一改变屏上时间线的 PR。

9b.7 的关键实战细节(文档如实记录,含"as built"修正)

  • 并行 tab 从休眠改为直接上线:编码前 owner 用四个 AskUserQuestion 门确认了方向,最终裁决"现在就把它们建出来"——ParallelTimelineTabsParallelStreamingHeader 作为 LIVE 而非休眠交付,[03] 第 8(d) 条的 linearize 差异被废止。这要求一个新的移动端原语:components/ui/tabs.tsx(Opal pill Tabs 的 RN 移植:Root context + List + Trigger),owner 批准用移植而非手搓一个近似品。
  • usePacketDisplay 折叠进 usePacketProcessor(暴露 processed,删除原 hook 及其测试),而不是每个 flush 对每个包跑两遍。
  • 6 个 1:1 Opal 图标移植(stop-circlebranchusercodebook-openslow-time);streamingStartedAtprocessingDurationSeconds 写入 chat/interfaces.ts + chat/chatHistory.ts,由 useChatController / useChatSessionController 两个流启动点打戳;lib/time.ts 新增 formatDurationSeconds
  • 修复了一个生产级 bug:用户停止会先于后端 stop 包中止 reader,stopPacketSeen 永不翻转,turn 停留在 STREAMING 状态。修复方式:在 runChatStreamfinally 中合成一个 USER_CANCELLED stop 包(buildUserCancelledStopPacket)。
  • 修复了 FlashList 键回收 bugRenderStackManager.syncItem 会把池化 key 交给不同的 stableId,导致滚出视野的 turn 的组件状态(时间线展开、活动分支 tab、打开的 sources sheet)被无关消息继承——且 userHasToggled 锁存后该 turn 再也无法自动折叠。修复:在 renderItem 内以 nodeId 作为 MessageRow 的 React key(keyExtractor 只提供 stableId,不提供 React key)。
  • hydration 时长修复:重开的会话此前没有任何时长,头部会显示 "Thought for some time"——processing_duration_seconds(后端响应已带,models.py:232)现在通过 chatHistory 映射进来。
  • 实测结果:tsc 干净、lint 干净(2 个既有 _layout 警告)、prettier 干净、76 个套件 596 个 jest 通过(新增 40 个,含强制组合冒烟测试:reasoning 流 → "Thinking" 步骤 → 自动折叠为 "Thought for Xs · 1 step" → 点按展开 → Done;USER_CANCELLED → Interrupted Thinking → Stopped;并行 turn → 每个分支一个 tab,含流式头部与展开正文内的分支切换)。

9b.7 相对 Web 的自身差异(叠加在 [03] §8 之上)

(a) 任何地方都无 hover——Web 的 headerIsInteractive 只切换 hover 类,无移动端对应物,各头部自带 Pressable;(b) useTimelineStepState 不被外壳调用(其唯一 Web 消费者是 memory 标签和那个 hover 标志),CompletedHeader 丢弃 Web 的 memory 标签/气泡/MemoriesModal——memory 渲染器是后续阶段;(c) shimmer = opacity 脉冲ShimmerText,RN Animated,保持 jest 可渲染),非 Web 的 background-clip:text 渐变;(d) 展开正文无入场动画;(e) streamingStartTime每节点 prop(非 Web 的每会话 store 读),resumed 运行从恢复点重启计时器;(f) 移植的 Tabs 只带 pill 变体、丢弃滚动箭头;(g) TimelineStep 拆为独立模块;(h) getToolIcon 用 Onyx 字形替代 Web 的 react-icons 回退(FiToolcpuFiListtext-lines-small)。

计划挑战(Plan Challenge):六项全过

04-implementation-plan.md 记录了强制 6 点挑战的结果:

  1. 可扩展性/可伸缩性:PASS —— 全壳现在建好意味着 6 个后续工具渲染器 + 9c/9e 各只需"一个新文件 + 一条 findRenderer 接线",零引擎/枚举/外壳改动;分组 key 已携带 tab_index/sub_turn_index/model_index。唯一注意点(已文档化,非重写):lint-safe 的 usePacketProcessor 是 O(n)/flush(vs Web 增量游标),聊天规模下没问题,日后可在同一 API 后重新优化为增量。
  2. 脆弱性:CONCERN → 已加固 —— 两个脆点各有对策:(a) usePacedTurnGroups(计时器 + effect 发布状态)是最易出 bug 的文件 → fake-timer 单测覆盖 first-immediate/200ms/stop-flush/history-bypass;(b) 1 秒计时器 + 200ms 揭示可能搅动 FlatList/答案 → 计时器隔离到头部子树、React.memo 行、稳定 key、不按 tick 制造新对象身份。
  3. 行业标准:VERIFIED —— render-props/children-as-function 仍是"wrapper 拥有树的无头渲染器"的公认标准(Downshift、React Aria、TanStack Table、Framer Motion);ref 重构与 React 官方 purity/refs lint 规则一致(Web 的渲染期 ref 才是反模式);RN FlatList 流式性能最佳实践已收入。
  4. 事实核查:PASS(一个诚实 nuance) —— "progressive disclosure = 行业默认"、"渲染期 ref 必须重构"、"[render-prop 使未来移植机械化](无头渲染器场景下成立)"均被验证。坦率陈述:render-props 并非新建逻辑共享的现代默认(hooks 才是)——移动端纯粹为了 web-parity / 机械化未来移植而采用,这是 owner 明确选择的偏离,而非疏漏。
  5. 可维护性:PASS(有条件满足) —— 镜像 Web 的 timeline/ 精确文件树(懂 Web 的开发者能找到相同结构)、纯逻辑隔离 + 单测、引擎/hooks/原语/渲染器边界清晰。约 35 个文件只为一个接线的渲染器买单,只有在后续渲染器真的被建(owner 已明确承诺立刻建)时才划算。
  6. Patch vs Fix:PROPER FIX(无需升级) —— render-prop 迁移已发布的 PR-3/9a 代码是根因修复(现在统一到 Web 契约,以后零重构);两个 hook 重构是修复(对齐 React purity 规则)而非 lint 压制;各推迟项是文档化的范围边界,不是症状补丁。

结论:六项全过(2 个 concern 已在计划内加固),无 patch-vs-fix 升级,放行 Phase 5。

测试策略:纯核心先行,设备门禁收尾

主测试类型:RN Testing Library + Jest 单元(纯核心 + hooks 承担几乎所有风险;无后端表面——reasoning 包已存在)。覆盖面(来自 04-implementation-plan.md Tests 节):

  • 分组/引擎:分组 key "{turn}-{tab}";三个 section_end 触发;tool-vs-display 分类;finalAnswerComing + tool-after-message 重置;hasContentPacketsmodel_index 容忍;历史重载重置(数组收缩——processPacketsnextPacketIndex > rawPackets.length 时重建初始状态,防重放的 turn 被重复计数)。
  • TransformersgroupStepsByTurn 并行检测 + turn/tab 排序。
  • Pacing(fake timers):首步立即、后续 200ms、stop 冲刷全部、历史旁路立即揭示、答案在 pacing 完成前被扣住。
  • 状态 hooksuseTimelineUIState 全部 7 状态 + 每个派生布尔;useTimelineExpansion 答案/stop 自动折叠 + userHasToggled 抑制;useStreamingDuration 每秒 tick + 后端时长冻结。
  • ReasoningreasoningState 标题提取(markdown 标题规则、60 字符上限)+ delta 累积;ReasoningRenderer 500ms 门控(fake timers)+ 空/前置分支。
  • 组合冒烟:mock reasoning 包流渲染 "Thinking" 步骤、流式 markdown、标记完成、折叠为 "Thought for Ns · 1 step";点按展开;USER_CANCELLED stop 显示 Stopped 步骤。

HARD 设备门禁(owner 运行,不可自动化):dev build 上驱动 reasoning 模型,确认流式 shimmer + 实时计时器、答案开始自动折叠、点按展开、Done 终止步骤、hydrated(历史重载)渲染——以及迁移后的终答路径 + 9a Sources 依然正常。并行 tab 另需一个多分支运行来在真机上看到 pill strip 与滑动指示器。

9b 之后的后续渲染器阶段

每个都是零重构接缝上独立、先评审后编码的 PR:加工具的 obj 接口 + 一条 findRenderer predicate 接线 + 一个返回 RendererResult 的 render-prop 渲染器(search/fetch 复用 9a 的 SourceRow)。按产品价值排序(owner 定):search → fetch → python/code → custom-tool → deep-research(+嵌套)→ memory,外加 并行 tab(UI-only,已上线)与 9c/9d/9e 独立推进。

结语

9b 阶段的价值主张清晰:先用一次性成本把 Web 的时间线外壳 1:1 搬到移动端,让"智能体思考过程可见"这一交互从第一天起与 Web 一致,同时把渲染器契约、分组引擎、状态机这些最昂贵、最难返工的部分一次性做对;其后每个工具渲染器都是"新文件 + 一行接线"的纯增量,彻底兑现 owner"zero refactor later"的要求。过程中唯一的技术让步(两个渲染期 ref hook 的重构)反而是对 React 纯度规则的回归,而不是妥协。仓库中 mobile/src/chat/timeline/mobile/src/hooks/timeline/mobile/src/components/chat/timeline/mobile/src/components/chat/renderers/ 下已落地的实现,连同 backend/onyx/chat/llm_step.py 中的 reasoning 数据包发射逻辑,共同构成阅读本文时最直接的验证材料。

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

项目优选

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