在 Danswer/Onyx 移动端实现 Chat Citations 与 Cited Sources:基于 mobile/ 的 PR 交付路线图全解
2026/9/10 18:11:22 网站建设 项目流程

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

【免费下载链接】danswerOpen Source AI Platform - AI Chat with advanced features that works with every LLM项目地址: https://gitcode.com/GitHub_Trending/da/danswer

本文是 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依赖核心交付物
1feat(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_INFOcitationMap[n] = document_id;若document_id未见过则追加{citation_num, document_id}
SEARCH_TOOL_DOCUMENTS_DELTA/OPEN_URL_DOCUMENTS逐个documentMap.set(document_id, doc)
MESSAGE_STARTfinal_documents非空,逐条 upsert 进documentMap
MESSAGE_END/STOPisComplete = 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.ts:MessageRendererProps{ packets, isComplete }变为{ packets, processed },后续所有渲染器统一读取processed

实现注记(与参考设计的偏离):移动端的react-hooks/refslint 规则禁止在渲染期间读写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.ts:documentTarget(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.tsx:Card 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的样式范式(scrimPressable、内层rounded-t-24、安全区底部内边距、标题 "Sources" + 关闭按钮、ScrollView max-h)。正文 =selectSources(processed)→ 最多三个分区(Cited Sources/More/User Files),每区为Separator+ 标题 + 若干SourceRow,行点击 =openSource(doc)

在 mobile/src/components/chat/MessageRow.tsx 的AssistantMessage中接线:从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.tsselectSources三区切分与去重;hasSources/iconDocsdomainOf/faviconUrl边界(无 host、无 link)
mobile/src/chat/tests/openSource.test.tsdocumentTarget三分支:link → browser;file_id 且无 link → file;两者皆无 → none(mockexpo-web-browser
mobile/src/components/chat/tests/SourceRow.test.tsx标题/域名/摘要渲染;favicon 与回退图标;onPressopenSource
mobile/src/components/chat/tests/CitedSources.test.tsxbar 可见性门槛(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
  • selectSourcesselector;SourceIcon/SourceRowCitedSourcesBar+CitedSourcesSheetMessageRowfooter 接线;
  • 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 的任何一行核心状态模型。

【免费下载链接】danswerOpen Source AI Platform - AI Chat with advanced features that works with every LLM项目地址: https://gitcode.com/GitHub_Trending/da/danswer

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询