首页
/ 在 Danswer/Onyx 移动端实现 Chat Citations 与 Cited Sources:基于 mobile/ 的 PR 交付路线图全解

在 Danswer/Onyx 移动端实现 Chat Citations 与 Cited Sources:基于 mobile/ 的 PR 交付路线图全解

2026-09-09 18:20:18作者:龚格成

本文是 Danswer(Onyx)移动端 Chat 功能「9a — Citations & Cited Sources」子阶段交付路线图的深度解读。它聚焦于把 Web 端已经成熟的"行内引用 [N] 标记 + Sources 来源面板"体验移植到 React Native 应用(mobile/)中,并以 单个 PR 的方式落地。读完本文,你将掌握该 PR 的完整范围边界、内部构建顺序、涉及的 20 个文件变更明细、单元测试矩阵、质量门槛(typecheck / lint / jest)以及两个必须真机验证的平台未知点,并看到这些设计如何与仓库中已经落地的 messageProcessor.tscitations.tsopenSource.tsCitedSources.tsx 等源码一一对应。

一、为什么 9a 用"单个 PR"交付

9a 的完整实现大约在 1050–1250 行代码(生产代码 + 测试),超出了仓库常规的 500–700 行目标区间。按惯例,这种体量通常会被拆成两个 PR 分别评审。但在 GATE 3 的决策点上,负责人(owner)决定将其作为一个整体、一个垂直功能切片提交,理由是:

  • 它是一条自洽的垂直功能(从数据包类型 → 处理 → 行内跳转 → 来源 UI 全链路);
  • 拆开反而会破坏"可独立评审、可独立回滚"的完整性;
  • 一次合入可以为下一个阶段 9b(agent timeline)立好可复用的地基。
PR 标题 预估 LOC 依赖 核心交付物
1 feat(mobile): chat citations + cited sources ~1050–1250 定义并处理 citation/document 数据包;行内 [N](以及答案中的普通链接)点击后在应用内浏览器打开;"Sources" 按钮 + 底部弹层列出引用/检索到的文档;同时为 9b 建立可复用的数据处理与来源 UI 基础

该路线图的出处与上下文请见父级文档 docs/mobile-chat/9a-citations/00-index.md 及其规划链:研究(01-research.md)→ 高层设计(02-high-level-design.md)→ 详细设计(03-detailed-design.md)→ 实施计划(04-implementation-plan.md)。

二、PR 内部构建顺序(Build Order)

虽然对外只提交一个 PR,但内部必须按照依赖顺序分 7 步构建,先搭纯函数/地基层,再做消费它的 UI:

1. contracts/documents.ts + streamingModels.ts   (类型定义)
2. messageProcessor.ts                            (纯处理器 · 地基)
3. usePacketDisplay.ts + registry.ts              (宿主处理器,`processed` 通道 · 地基)
4. openSource.ts + StreamingMarkdown + MessageTextRenderer   (行内点击路由)
5. citations.ts + SourceIcon + SourceRow          (来源层 · 地基)
6. CitedSources.tsx + MessageRow 接线             (Sources 栏与弹层 · 9a UI)
7. fixtures + tests

这个顺序背后的原则是:先把无 React 依赖的纯逻辑层(contracts、processor、selectors、openSource)做扎实,再做依赖它们的 UI。9b(agent timeline)后续会在 messageProcessor 上扩展分组逻辑,并复用 SearchDoc / SourceRow / SourceIcon / openSource 这一整套来源层。

2.1 类型层:contracts/documents.tsstreamingModels.ts

  • 新增 mobile/src/chat/contracts/documents.ts,完整移植后端 SearchDoc(全字段集,9b 的 search/fetch 子渲染器会用到扩展字段),并定义 StreamingCitation(去重后的 {citation_num, document_id})与 CitationMapRecord<number, string>,即 citation_number → document_id)。
  • 修改 mobile/src/chat/streamingModels.ts:新增 PacketType 成员 CITATION_INFO / SEARCH_TOOL_DOCUMENTS_DELTA / OPEN_URL_DOCUMENTS,新增对应接口 CitationInfo / SearchToolDocumentsDelta / OpenUrlDocuments,给 MessageStart 增加 final_documents?: SearchDoc[] | null,并把三者并入 ObjTypes 联合类型。
  • 关键约束:绝对不要添加 citation_start / citation_end——Web 端枚举里有它们,但后端从不发送(代码证据见 backend/onyx/server/query_and_chat/streaming_models.py 中唯一的 citation 包 CitationInfo {type:"citation_info", citation_number:int, document_id:str})。

2.2 处理器层(地基):messageProcessor.ts

新增 mobile/src/chat/messageProcessor.ts,这是对 Web 端 packetProcessor 的忠实移动端移植,核心结构:

  • ProcessedMessageState:持有 nodeId、增量游标 nextPacketIndexcitationMap、去重后的 citations[]、去重守卫 seenCitationDocIdsdocumentMapMap<document_id, SearchDoc>)、isCompletestopReason
  • createInitialState(nodeId):生成初始状态。
  • processPackets(state, rawPackets):增量处理——只消费 [nextPacketIndex, len) 区间的新包;若发现数组被替换为更短的数组(regenarate / 历史重载),则重建初始状态,避免重复计数;原地变更 citationMap / documentMap / citations(Web 同款模式),返回同一对象。

obj.type 分发:

包类型 处理动作
CITATION_INFO citationMap[n] = document_id;若 document_id 未见过则追加 {citation_num, document_id}
SEARCH_TOOL_DOCUMENTS_DELTA / OPEN_URL_DOCUMENTS 逐个 documentMap.set(document_id, doc)
MESSAGE_START final_documents 非空,逐条 upsert 进 documentMap
MESSAGE_END / STOP isComplete = trueSTOP 额外记录 stopReason
其他 忽略(文本 / section_end / error 由别处处理)

仓库中该文件已经演进为同时承载 9b 的分组逻辑(groupedPacketsMaptoolGroups、合成 SECTION_END 等),但 9a 的核心字段——citationMap / citations / documentMap / isComplete——与文档设计的接口完全一致,handleCitationPacket / handleDocumentPacket / handleStopPacket 的行为与上表逐条对应。

2.3 宿主与渲染契约(地基):usePacketDisplay.ts + registry.ts

  • 修改 mobile/src/hooks/usePacketDisplay.ts:从"一个渲染器 + 原始 packets"升级为宿主处理器,返回值从 { renderer, packets, isComplete } 变为 { renderer, packets, processed }(去掉顶层 isComplete)。
  • 修改 mobile/src/components/chat/renderers/registry.tsMessageRendererProps{ packets, isComplete } 变为 { packets, processed },后续所有渲染器统一读取 processed

实现注记(与参考设计的偏离):移动端的 react-hooks/refs lint 规则禁止在渲染期间读写 ref.current(Web 的 usePacketProcessor 恰好这么干)。因此 9a 采用 useMemo(() => processPackets(createInitialState(nodeId), packets), [nodeId, packets])——每次 flush 全量重算(聊天规模下成本可忽略),而不是渲染期变更的增量 ref。messageProcessor 模块本身保留增量能力(游标 + shrink 重置),留给 9b 用 lint 兼容的增量模式宿主。

2.4 行内点击路由(Inline Tap-Routing):openSource.ts + StreamingMarkdown + MessageTextRenderer

这是整个特性最精妙的一个事实:行内标记的 URL 是后端预烘焙的backend/onyx/chat/citation_processor.py 第 496、506 行以 [[{num}]]({link}) 形式发出行内标记,其中 link = search_doc.link or ""。因此:

  • 对 Web/带链接的文档,标记 URL 就是文档链接 → onLinkPress(event.url) 可直接打开,点击无需查引用状态
  • 对文件/内部文档(无链接),标记退化为 [[n]]()(空括号)→ 可能根本渲染不成可点击链接,这是真正需要兜底的边界情况(见下文"平台未知点")。

实现上:

  • 新增 mobile/src/chat/openSource.tsdocumentTarget(doc) 三路判定(link 为 http(s) → browserfile_id 且无链接 → file;否则 none);openUrlexpo-web-browserWebBrowser.openBrowserAsync(应用内浏览器,SFSafariViewController / Chrome Custom Tabs,官方 Expo 推荐方案);openSource 统一入口——browseropenUrlfile 弹 toast "Preview isn't available on mobile yet.",none 静默。
  • 修改 mobile/src/components/chat/StreamingMarkdown.tsx:新增 onLinkPress?: (url: string) => void 透传,把 onLinkPress={(e) => onLinkPress?.(e.url)} 传给底层 StreamdownTextStreamdownText 继承 EnrichedMarkdownText 全部属性,包括 onLinkPress / onLinkLongPressevent.url)。
  • 修改 mobile/src/components/chat/renderers/MessageTextRenderer.tsx:读取 processed.isComplete,构造 onLinkPress = useCallback((url) => { if (url) openUrl(url); }, []) 传给 StreamingMarkdown

仓库中的 mobile/src/chat/openSource.ts 实现与文档设计完全一致:isHttpUrl^https?:\/\/ 正则守卫,openUrl 失败时通过全局 toast.error 兜底。

2.5 来源展示层(地基):citations.ts + SourceIcon + SourceRow

  • 新增 mobile/src/chat/citations.ts,纯 selector,无 React:selectSources(processed) 返回 { cited, more, files, iconDocs, count, hasSources }——cited 按首次引用顺序映射 documentMap(跳过缺失);files 抽出带 file_id 的文档;more 为既未引用也非文件的剩余文档;iconDocscited 前 ≤3 个(回退 more/files,保证纯文件答案的 bar 也有图标);count 为弹层内总条数;hasSources = count > 0按 count 而非原始 citations/docs 判定,避免"引用了但文档没到"渲染出空的 Sources · 0)。另有 domainOf(link)(剥离协议与 www. 前缀取主机名)与 faviconUrl(link)(公共 favicon 服务 URL,无主机返回 null)。仓库实现与设计逐字段吻合。
  • 新增 mobile/src/components/chat/SourceIcon.tsx:有 http 主机时用 expo-image 加载公共 favicon(onError 回退 file-text 图标);否则直接用 Icon as={SvgFileText}注意必须用普通 expo-image 而非 BearerImage(favicon 不带鉴权)。
  • 新增 mobile/src/components/chat/SourceRow.tsxCard variant="secondary" onPress 可点击行,布局为 [SourceIcon + 标题(semantic_identifier,单行截断)] + [domainOf(link) ?? source_type · timeAgo(updated_at)] + [(match_highlights[0] ?? blurb).slice(0, 200) 两行截断]timeAgo 复用 mobile/src/lib/time.ts

2.6 Sources 表面(9a UI):CitedSources.tsx + MessageRow 接线

新增 mobile/src/components/chat/CitedSources.tsx,导出两个组件:

  • CitedSourcesBar:一个 Pressable 药丸按钮,堆叠显示 ≤3 个 SourceIcon(重叠 marginLeft: -6)+ 文本 Sources · {count}accessibilityRole="button"accessibilityLabel="Sources, N"。仓库实现见其 20–49 行。
  • CitedSourcesSheet:底部弹层 Modal,镜像 FilePickerSheet 的样式范式(scrim Pressable、内层 rounded-t-24、安全区底部内边距、标题 "Sources" + 关闭按钮、ScrollView max-h)。正文 = selectSources(processed) → 最多三个分区(Cited Sources / More / User Files),每区为 Separator + 标题 + 若干 SourceRow,行点击 = openSource(doc)

mobile/src/components/chat/MessageRow.tsxAssistantMessage 中接线:从 usePacketDisplay 解构 processed,用 processed.isComplete 控制 AgentTimeline 的 loading 与 hasContent,并在渲染器之后、满足 processed.isComplete && hasSources 时渲染 CitedSourcesBar + CitedSourcesSheet(本地 sheetVisible 状态)。memo 比较器仍以 packets.length 为 key(新 citation/doc 包到达时长度会增长,天然触发重渲染)。

2.7 fixtures 与测试

修改 mobile/src/chat/tests/fixtures.ts,新增 makePacket(obj, placement?) / makeCitationPacket(n, docId) / makeSearchDoc(overrides) / makeSearchDocsPacket(docs, type?) 等工厂函数。

三、测试矩阵与质量门槛

9a 是纯客户端渲染特性,主测试类型为 unit tests(jest-expo + RN Testing Library),无后端/集成面。合入 PR 前需通过三道门槛:bun run typecheckbun run lintbunx jest

测试文件 覆盖点
mobile/src/chat/tests/messageProcessor.test.ts 引用去重 + 首次引用排序;citationMap 填充;两种文档包 + message_start.final_documentsdocumentMap 的 upsert;stopisComplete;增量游标(多次 flush 不重复计数);数组缩短时重置
mobile/src/chat/tests/citations.test.ts selectSources 三区切分与去重;hasSources / iconDocsdomainOf / faviconUrl 边界(无 host、无 link)
mobile/src/chat/tests/openSource.test.ts documentTarget 三分支:link → browser;file_id 且无 link → file;两者皆无 → none(mock expo-web-browser
mobile/src/components/chat/tests/SourceRow.test.tsx 标题/域名/摘要渲染;favicon 与回退图标;onPressopenSource
mobile/src/components/chat/tests/CitedSources.test.tsx bar 可见性门槛(isComplete && hasSources);弹层从 processed 状态渲染三个分区;行点击路由到 openSource

仓库中已落地的 messageProcessor.test.ts 逐条验证了上表行为,例如"引用去重 + 首次引用顺序"(d1 → d2 → 重复 d1 被去重)、"从两种文档包与 final_documents 填充 documentMap"、"stop 置完成"、"同数组多次 flush 不重复计数"(nextPacketIndex 停在 1)、"数组缩短时重置"。

单元套件不覆盖、需真机验证的部分:原生 onLinkPress 的事件结构;行内标记 → openUrl 经由 StreamdownText 的管线;MessageRow 完成答案 footer 的门控。这三项留待负责人合入后真机验证。

四、明确的范围边界

范围内(Scope In)

  • 包契约与类型:SearchDocCitationInfo、文档包、MessageStart.final_documents
  • 增量 messageProcessorcitationMap / citations[] / documentMap / 完成态),由 usePacketDisplay 宿主并通过 processed 暴露;MessageRendererProps 携带 processed
  • openSource / openUrl + StreamingMarkdownonLinkPress 透传,接入 MessageTextRenderer
  • selectSources selector;SourceIcon / SourceRowCitedSourcesBar + CitedSourcesSheetMessageRow footer 接线;
  • fixtures + 单元/组件测试。

范围外(延迟到后续,Out of Scope)

  • 行内 chip 组件 / hover 卡片(平台阻断:原生 markdown 渲染器无自定义节点钩子,仅暴露 markdownStyle + onLinkPress/onLinkLongPress);
  • turn/tab 数据包分组 + 时间线步骤(9b);
  • 一套按连接器区分的 source-logo 集合(9a 只做 favicon 或 file-text 最小版,完整 ~40 连接器图标映射留给后续);
  • 文件来源的应用内文档预览(9a 退化 toast / 无操作);
  • 流式进行中的实时引用计数器(行业常见做法,但 9a 刻意将 Sources 栏门控在 isComplete 之后,避免流式中途布局跳动,与 Web 保持一致;后续易重新审视)。

其他约束:不要触碰 /sources/[id] 路由(那是项目文件页面,见 mobile/src/app/(app)/sources/[id].tsx)——来源表面必须是 Modal 而非路由;移动端间距类用像素;所有文本走 @/components/ui/text;图标走 @/icons/* + Icon;只用语义色类,不用 dark:

五、两个必须真机验证的平台未知点(Drift Checkpoint)

在实现前/实现中,必须真机验证 react-native-enriched-markdownonLinkPress 事件结构(预期为 { url })——这是行内跳转路径依赖的唯一平台未知量(单元测试是 mock 的,dev build 才能确认)。同时确认 [[n]]() 空括号文件标记能否渲染为可点击链接;无论结果如何,Sources 弹层都是可靠的兜底路径(每个来源都能从弹层触达)。仓库的 openSource.ts 已经把 file 分支做成 toast 提示,正是对"移动端暂无文档预览"这一现实的既定行为。

六、与既有代码基座的无侵入集成

9a 刻意做到零控制器/零流层改动

  • mobile/src/hooks/useChatController.ts:不改动——它已把所有包装后的数据包追加到 node.packets(防抖 flush),不检查 obj.type,因此新的 citation/document 包零改动直达渲染器;
  • mobile/src/chat/chatHistory.ts:不改动——processRawChatHistory 已按 assistant turn 对齐历史 packets,历史引用在加载时经处理器重建;
  • mobile/src/api/chat/stream.ts:不改动——isPacket 已能路由包装后的 citation/document 包;
  • registry.tsRENDERERS 数组:不改动(仍为 [MessageTextRenderer]),9b 才追加新渲染器条目;
  • expo-web-browser:已是依赖(auth SSO 在用),新用法在 openSource

同时复用现有原语:Card / LineItemButton / Separator / Spinner / Text / Icon / ButtonFilePickerSheet 的底部弹层范式、timeAgouseToast

七、总结:一份可以照单执行的交付蓝图

这份 PR 路线图的价值在于它把 9a 的复杂性显式摊开:一个 ~1050–1250 行的单 PR、七步内部构建顺序、20 个文件的新增/修改清单、五张测试覆盖矩阵、明确的范围边界(含 9b 地基与延迟项),以及两个真机验证点。而仓库现状进一步证明了这份蓝图的可执行性——messageProcessor.ts 的 citation/document/stop 处理、citations.ts 的三区 selector、openSource.ts 的三路路由、CitedSources.tsx 的 bar 与 sheet、messageProcessor.test.ts 的去重/排序/重置用例均已落地。对任何要在移动端复刻 Web 级 RAG 引用体验的团队来说,这套"纯逻辑先行 → 地基层 → 消费 UI → 测试收尾"的 PR 内构建顺序,本身就是一份值得复用的工程模板;9b(agent timeline)将在这条地基上继续叠加 turn/tab 分组与时间线步骤,而不需要重构 9a 的任何一行核心状态模型。

热门项目推荐
相关项目推荐

项目优选

收起
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.15 K
2.77 K
kernelkernel
deepin linux kernel
C
34
18
docsdocs
暂无描述
Markdown
900
5.83 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
929
1.85 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
860
1.36 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.94 K
1.03 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.37 K
1.47 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
534
603
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
398
leetcodeleetcode
🔥LeetCode solutions in any programming language | 多种编程语言实现 LeetCode、《剑指 Offer(第 2 版)》、《程序员面试金典(第 6 版)》题解
Markdown
77
23