在 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.ts、citations.ts、openSource.ts、CitedSources.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.ts与streamingModels.ts
- 新增 mobile/src/chat/contracts/documents.ts,完整移植后端
SearchDoc(全字段集,9b 的 search/fetch 子渲染器会用到扩展字段),并定义StreamingCitation(去重后的{citation_num, document_id})与CitationMap(Record<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、增量游标nextPacketIndex、citationMap、去重后的citations[]、去重守卫seenCitationDocIds、documentMap(Map<document_id, SearchDoc>)、isComplete与stopReason。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 = true;STOP额外记录stopReason |
| 其他 | 忽略(文本 /section_end/error由别处处理) |
仓库中该文件已经演进为同时承载 9b 的分组逻辑(groupedPacketsMap、toolGroups、合成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) →browser;file_id且无链接 →file;否则none);openUrl用expo-web-browser的WebBrowser.openBrowserAsync(应用内浏览器,SFSafariViewController / Chrome Custom Tabs,官方 Expo 推荐方案);openSource统一入口——browser走openUrl,file弹 toast "Preview isn't available on mobile yet.",none静默。 - 修改 mobile/src/components/chat/StreamingMarkdown.tsx:新增
onLinkPress?: (url: string) => void透传,把onLinkPress={(e) => onLinkPress?.(e.url)}传给底层StreamdownText(StreamdownText继承EnrichedMarkdownText全部属性,包括onLinkPress/onLinkLongPress→event.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为既未引用也非文件的剩余文档;iconDocs取cited前 ≤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 typecheck、bun run lint、bunx jest。
| 测试文件 | 覆盖点 |
|---|---|
| mobile/src/chat/tests/messageProcessor.test.ts | 引用去重 + 首次引用排序;citationMap填充;两种文档包 +message_start.final_documents对documentMap的 upsert;stop置isComplete;增量游标(多次 flush 不重复计数);数组缩短时重置 |
| mobile/src/chat/tests/citations.test.ts | selectSources三区切分与去重;hasSources/iconDocs;domainOf/faviconUrl边界(无 host、无 link) |
| mobile/src/chat/tests/openSource.test.ts | documentTarget三分支:link → browser;file_id 且无 link → file;两者皆无 → none(mockexpo-web-browser) |
| mobile/src/components/chat/tests/SourceRow.test.tsx | 标题/域名/摘要渲染;favicon 与回退图标;onPress→openSource |
| 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):
- 包契约与类型:
SearchDoc、CitationInfo、文档包、MessageStart.final_documents; - 增量
messageProcessor(citationMap/citations[]/documentMap/ 完成态),由usePacketDisplay宿主并通过processed暴露;MessageRendererProps携带processed; openSource/openUrl+StreamingMarkdown的onLinkPress透传,接入MessageTextRenderer;selectSourcesselector;SourceIcon/SourceRow;CitedSourcesBar+CitedSourcesSheet;MessageRowfooter 接线;- 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-markdown的onLinkPress事件结构(预期为{ 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.ts的RENDERERS数组:不改动(仍为[MessageTextRenderer]),9b 才追加新渲染器条目;expo-web-browser:已是依赖(auth SSO 在用),新用法在openSource。
同时复用现有原语:Card/LineItemButton/Separator/Spinner/Text/Icon/Button、FilePickerSheet的底部弹层范式、timeAgo、useToast。
七、总结:一份可以照单执行的交付蓝图
这份 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),仅供参考