Onyx 移动端 Agentic 推理时间线(Agent Timeline)移植全解:从 Web 一比一复刻到 React Native 的零重构架构
导读
本文基于仓库 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.ts、mobile/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 ThinkingLabel 和 TimelineStep 列表原语,但 steps prop 恒为 [],没有任何东西填充它(<AgentTimeline agent isLoading={!hasContent && !processed.isComplete} /> 渲染在 MessageRow.AssistantMessage 的答案上方)。9b 阶段要做的,就是把 Web 这套完整的时间线外壳原样搬到移动端,并只接上 reasoning(推理) 一个步骤渲染器。
关键约束(来自 00-index.md 与 01-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/refslint 禁止在渲染期间访问ref.current,因此 Web 的 ref-heldusePacketProcessor模式在此非法。mobile/src/chat/streamingModels.ts——PacketType当前只有MESSAGE_START/DELTA/END、STOP、SECTION_END、ERROR、CITATION_INFO、SEARCH_TOOL_DOCUMENTS_DELTA、OPEN_URL_DOCUMENTS。Placement已携带全部 4 个字段(turn_index、tab_index、sub_turn_index、model_index),只需新增REASONING_START/DELTA/DONE(以及用于分组容错的TOP_LEVEL_BRANCHING)。
后端侧(已核实 llm_step.py):ReasoningStart(type="reasoning_start",无字段)、ReasoningDelta(type="reasoning_delta",含 reasoning: str)、ReasoningDone(type="reasoning_done"),均携带 Placement.turn_index/tab_index。线上协议里没有 message_end——整个 turn 只能通过 OverallStop(type="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;引入的契约字段(
alwaysCollapsible、timelineLayout)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 状态(isParallel、STREAMING_PARALLEL、showParallelTabs)与 sub_turn_index/model_index 容忍随外壳一起上线(与 Web 一致),使并行/嵌套/多模型成为后续纯 UI 跟进、接缝零改动。
高层设计:端到端流程与组件交互
02-high-level-design.md 将整个机制浓缩为五步:
- 分组(Group):纯 reducer 遍历 packet,按分组 key
"{turn_index}-{tab_index}"(取自每个 packet 的placement)分桶。新的turn_index会通过合成section_end关闭("完成")之前所有步骤;最终的stop包关闭剩余所有。随后步骤按 turn 组 组织(共享turn_index的步骤视为"并行",单个的是"顺序")。 - 节奏(Pace):有状态 hook 以 200ms 交错揭示步骤(第一个立即可见,其余每 200ms 一个;
stop一次性全部冲刷)。它还会扣住最终答案,直到工具步骤动画完成——答案不会抢在时间线之前弹出。历史重载(已完成)的消息绕过 pacing,立即全部显示。 - 推导 UI 状态(Derive):纯状态机把"流式中/已停止/已展开"归并为七种状态(EMPTY、DISPLAY_CONTENT_ONLY、STREAMING_SEQUENTIAL、STREAMING_PARALLEL、STOPPED、COMPLETED_COLLAPSED、COMPLETED_EXPANDED)外加一组"显示这个/圆角那个"布尔。另有小 hook 分别计算头部文本(取自当前步骤的首个 packet——"Thinking"、"Searching the web"…)、实时计时器(1 次/秒,后端上报时长后冻结)、步骤数、折叠/展开状态(默认折叠;答案开始时自动折叠,除非用户手动切换过)。
- 渲染(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渲染正文。 - 答案 + 来源:时间线下方,最终答案通过同一渲染器契约以
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):groupedPacketsMap、seenGroupKeys、groupKeysWithSectionEnd、expectedBranches、toolGroupKeys、displayGroupKeys、isGeneratingImage、generatedImageCount、finalAnswerComing、stopPacketSeen、toolProcessingDuration、toolGroups、potentialDisplayGroups。这些字段在仓库实现 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 除外,它只是思考而非真实工具调用);handleStreamingStatusPacket 从 MessageStart.pre_answer_processing_seconds 记录 toolProcessingDuration;TOP_LEVEL_BRANCHING 写入 expectedBranches(按 turn_index 记录期望并行分支数)。
3. 纯步骤 helpers:mobile/src/chat/timeline/(新目录)
transformers.ts——TransformedStep {key, turnIndex, tabIndex, packets}、TurnGroup {turnIndex, steps, isParallel}、transformPacketGroup(s)、groupStepsByTurn(isParallel = steps.length > 1;按 turn 再按 tab 排序)。实现已在 transformers.ts 核实。packetUtils.ts——isToolPacket、isActualToolCallPacket、isDisplayPacket、isSearchToolPacket、isStreamingComplete、isFinalAnswerComing、isFinalAnswerComplete、groupPacketsByTurnIndex、getTextContent、getCitations。packetHelpers.ts——COLLAPSED_STREAMING_PACKET_TYPES、CODING_AGENT_PACKET_TYPES、isReasoningPackets、isSearchToolPackets、isPythonToolPackets、isResearchAgentPackets、isCodingAgentPackets、isDeepResearchPlanPackets、isMemoryToolPackets、stepSupportsCollapsedStreaming、stepHasCollapsedStreamingContent。toolDisplay.ts——getToolKey、parseToolKey、getToolName(纯)、hasToolError、isToolComplete(对 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)",超长则视为正文而非标题;constructCurrentReasoningState中hasEnd同时认SECTION_END/ERROR/REASONING_DONE(section_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,并派生 showTintedBackground、showRoundedBottom、showDoneStep、showStoppedStep、hasDoneIndicator、showParallelTabs、showCollapsedCompact、showCollapsedParallel、isStreaming、isCompleted、isActivelyExecuting。
useStreamingDuration 已实现为 useStreamingDuration.ts:DURATION_TICK_MS = 250 的 setInterval 轮询(比 1s 快以保持整秒边界清晰,jest fake timers 下确定;RN 从 RAF 换 interval 无损失),只在整数秒变化时 setState;backendDuration 一旦存在立即冻结(返回该值、停跑计时器);新 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. ReasoningRenderer 与 ReasoningTextWindow 的已落地细节
ReasoningRenderer.tsx 已实现并验证:THINKING_MIN_DURATION_MS = 500、THINKING_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):完整推理(含标题)、字节数副标题、行数、Copy(expo-clipboard);Web 的 Download 被丢弃(移动端无对应物)。
唯一被接受的平台强制差异:两个 ref-during-render hook 的重构
这是整个设计中反复强调的一点(03-detailed-design.md 第 1 节、04-implementation-plan.md Important Notes):
- Web 的
usePacketProcessor在渲染期间改写stateRef.current;usePacedTurnGroups在渲染期间读取 pacing refs(shouldBypassPacing+ memos)——两者在移动端的react-hooks/refslint 下都非法(React 官方 purity 规则:渲染期读写ref.current破坏纯度/并发渲染)。 - 重构方式(行为保持):
usePacketProcessor→useMemo全量重算(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"。其内部
PacingState(revealedStepKeys/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门确认了方向,最终裁决"现在就把它们建出来"——ParallelTimelineTabs和ParallelStreamingHeader作为 LIVE 而非休眠交付,[03] 第 8(d) 条的 linearize 差异被废止。这要求一个新的移动端原语:components/ui/tabs.tsx(OpalpillTabs 的 RN 移植:Root context +List+Trigger),owner 批准用移植而非手搓一个近似品。 usePacketDisplay折叠进usePacketProcessor(暴露processed,删除原 hook 及其测试),而不是每个 flush 对每个包跑两遍。- 6 个 1:1 Opal 图标移植(
stop-circle、branch、user、code、book-open、slow-time);streamingStartedAt与processingDurationSeconds写入chat/interfaces.ts+chat/chatHistory.ts,由useChatController/useChatSessionController两个流启动点打戳;lib/time.ts新增formatDurationSeconds。 - 修复了一个生产级 bug:用户停止会先于后端
stop包中止 reader,stopPacketSeen永不翻转,turn 停留在 STREAMING 状态。修复方式:在runChatStream的finally中合成一个USER_CANCELLEDstop 包(buildUserCancelledStopPacket)。 - 修复了 FlashList 键回收 bug:
RenderStackManager.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 回退(FiTool → cpu、FiList → text-lines-small)。
计划挑战(Plan Challenge):六项全过
04-implementation-plan.md 记录了强制 6 点挑战的结果:
- 可扩展性/可伸缩性:PASS —— 全壳现在建好意味着 6 个后续工具渲染器 + 9c/9e 各只需"一个新文件 + 一条
findRenderer接线",零引擎/枚举/外壳改动;分组 key 已携带tab_index/sub_turn_index/model_index。唯一注意点(已文档化,非重写):lint-safe 的usePacketProcessor是 O(n)/flush(vs Web 增量游标),聊天规模下没问题,日后可在同一 API 后重新优化为增量。 - 脆弱性:CONCERN → 已加固 —— 两个脆点各有对策:(a)
usePacedTurnGroups(计时器 + effect 发布状态)是最易出 bug 的文件 → fake-timer 单测覆盖 first-immediate/200ms/stop-flush/history-bypass;(b) 1 秒计时器 + 200ms 揭示可能搅动FlatList/答案 → 计时器隔离到头部子树、React.memo行、稳定 key、不按 tick 制造新对象身份。 - 行业标准:VERIFIED —— render-props/children-as-function 仍是"wrapper 拥有树的无头渲染器"的公认标准(Downshift、React Aria、TanStack Table、Framer Motion);ref 重构与 React 官方 purity/refs lint 规则一致(Web 的渲染期 ref 才是反模式);RN FlatList 流式性能最佳实践已收入。
- 事实核查:PASS(一个诚实 nuance) —— "progressive disclosure = 行业默认"、"渲染期 ref 必须重构"、"[render-prop 使未来移植机械化](无头渲染器场景下成立)"均被验证。坦率陈述:render-props 并非新建逻辑共享的现代默认(hooks 才是)——移动端纯粹为了 web-parity / 机械化未来移植而采用,这是 owner 明确选择的偏离,而非疏漏。
- 可维护性:PASS(有条件满足) —— 镜像 Web 的
timeline/精确文件树(懂 Web 的开发者能找到相同结构)、纯逻辑隔离 + 单测、引擎/hooks/原语/渲染器边界清晰。约 35 个文件只为一个接线的渲染器买单,只有在后续渲染器真的被建(owner 已明确承诺立刻建)时才划算。 - 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 重置;hasContentPackets;model_index容忍;历史重载重置(数组收缩——processPackets里nextPacketIndex > rawPackets.length时重建初始状态,防重放的 turn 被重复计数)。 - Transformers:
groupStepsByTurn并行检测 + turn/tab 排序。 - Pacing(fake timers):首步立即、后续 200ms、
stop冲刷全部、历史旁路立即揭示、答案在 pacing 完成前被扣住。 - 状态 hooks:
useTimelineUIState全部 7 状态 + 每个派生布尔;useTimelineExpansion答案/stop 自动折叠 +userHasToggled抑制;useStreamingDuration每秒 tick + 后端时长冻结。 - Reasoning:
reasoningState标题提取(markdown 标题规则、60 字符上限)+ delta 累积;ReasoningRenderer500ms 门控(fake timers)+ 空/前置分支。 - 组合冒烟:mock reasoning 包流渲染 "Thinking" 步骤、流式 markdown、标记完成、折叠为 "Thought for Ns · 1 step";点按展开;
USER_CANCELLEDstop 显示 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 数据包发射逻辑,共同构成阅读本文时最直接的验证材料。
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