资讯动态

danswer 移动端 Chat 引用(Citations)与引用来源(Cited Sources)详细设计:从 NDJSON 流处理到底部弹层

发布时间:2026/9/10 10:05:45 来源:尧图企业网站定制
danswer 移动端 Chat 引用Citations与引用来源Cited Sources详细设计从 NDJSON 流处理到底部弹层【免费下载链接】danswerOpen Source AI Platform - AI Chat with advanced features that works with every LLM项目地址: https://gitcode.com/GitHub_Trending/da/danswer本文面向 danswer 移动端React Native开发者完整解读 Mobile Chat 9a 子阶段Citations Cited Sources的详细设计如何在不改动任何后端/数据库的前提下纯客户端地把流式 NDJSON 包折叠成引用状态再渲染出可点击的内联[N]标记与Sources底部弹层。读完你将掌握messageProcessor增量处理器、SearchDoc数据契约、openSource来源路由以及CitedSourcesBar/Sheet组件体系的完整设计决策与实现细节。功能定位纯客户端特性零后端改动9a 是 danswer 移动端 rich-chat 路线图见 docs/mobile-chat/05-pr-roadmap.md中的一个子阶段目标是把 Web 端已经成熟的答案引用 引用来源体验移植到 React Native 应用。设计文档第一部分直接给出结论N/A — client-only feature。这意味着没有后端改动没有 schema 变更没有 API 变更所有需要的包类型packet types已经在线上传输9a 只是在移动客户端消费它们数据在mobile/src/chat/contracts/documents.ts、处理逻辑在mobile/src/chat/messageProcessor.ts整套能力是叠加式的additive。后端在既有的 NDJSON 包流中已经携带了三个关键信息详见 docs/mobile-chat/9a-citations/02-high-level-design.md包类型载荷时序citation_info{citation_number, document_id}每条首次引用前一刻到达search_tool_documents_delta/open_url_documentsSearchDoc[]搜索/URL 工具在答案之前发出message_start.final_documentsSearchDoc[]该轮权威引用集答案开始前而内联标记本身已经以 markdown 链接[[N]](link)的形式烤进了答案文本其中link search_doc.link or ——所以普通网页/文档来源的标记 URL就是文档链接本身客户端无需再做引用号到链接的查表。从源码结构看移动端聊天控制器useChatController.ts早已把每一个流式包原样存放在 assistant 消息的node.packets上不检查类型usePacketDisplay也已把所有包交给匹配到的渲染器。因此 9a 不需要新增加接收通道核心工作量全部在处理process与渲染render两端。数据契约层SearchDoc/StreamingCitation/CitationMap设计文档要求在mobile/src/chat/contracts/documents.ts定义三个类型FOUNDATION 级纯类型、无逻辑仓库中的 实际实现 与设计完全一致export interface SearchDoc { document_id: string; semantic_identifier: string; link: string | null; blurb: string; source_type: string; score: number | null; updated_at: string | null; // ISO-8601 match_highlights: string[]; metadata: Recordstring, string | string[]; is_internet: boolean; chunk_ind: number; boost: number; hidden: boolean; primary_owners: string[] | null; secondary_owners: string[] | null; is_relevant: boolean | null; relevance_explanation: string | null; file_id: string | null; } export interface StreamingCitation { citation_num: number; document_id: string; } export type CitationMap Recordnumber, string;三个设计要点值得展开SearchDoc是后端同名模型的全字段移动端移植。刻意保留完整字段集score、boost、hidden、primary_owners、is_relevant、relevance_explanation、file_id等因为后续 9b 的搜索/抓取子渲染器需要这些额外字段——一次建模多处复用。StreamingCitation与线上的CitationInfo包刻意区分。wire 包用的是citation_number而处理后状态用的是citation_num避免两者混用导致字段错位。CitationMap即citation_number → document_id的纯映射配合处理器维护的citations[]有序数组使用。状态处理核心ProcessedMessageState与messageProcessor设计文档的核心资产是ProcessedMessageState接口与createInitialState/processPackets两个函数位于mobile/src/chat/messageProcessor.ts。状态结构9a 字段interface ProcessedMessageState { nodeId: number; nextPacketIndex: number; // 游标 —— 只处理新增包 citationMap: Recordnumber, string; // { 引用号: document_id } citations: StreamingCitation[]; // 去重、按首次引用顺序 seenCitationDocIds: Setstring; // 去重守卫 documentMap: Mapstring, SearchDoc; // document_id → doc isComplete: boolean; // 见到 MESSAGE_END 或 STOP stopReason?: StopReason; }设计上刻意无分组grouping-free这样 9b 可以在不重塑现有结构的前提下追加groupedPacketsMap/toolGroups/steps。值得注意仓库中实际的 messageProcessor.ts 已经走到了这一步之后——它包含了groupedPacketsMap、toolGroups、isGeneratingImage、toolProcessingDuration等 9b 字段证明该设计预留的扩展点已被后续阶段真实采用而 9a 的核心字段citationMap/citations/seenCitationDocIds/documentMap/isComplete/stopReason/nodeId/nextPacketIndex与设计文档逐字对应。逐包分发逻辑按obj.type判别processPackets的设计行为如下CITATION_INFO→citationMap[n] document_id若!seenCitationDocIds.has(document_id)则加入 set 并 push{ citation_num, document_id }到citations[]。这一套map 记录映射 set 去重 数组保序的组合保证了重复引用同一文档只算一次且顺序为首次引用顺序。SEARCH_TOOL_DOCUMENTS_DELTA/OPEN_URL_DOCUMENTS→ 对每个带document_id的 docdocumentMap.set(document_id, doc)。实际实现中handleDocumentPacket还额外处理了FETCH_TOOL_DOCUMENTS9b 抓取工具和MESSAGE_START.final_documents统一走upsertDocuments辅助函数。MESSAGE_START→ 若带final_documents逐条 upsert 进documentMap不覆盖已存在的同 id 文档因为后续 delta 可能带来更新。MESSAGE_END/STOP→isComplete trueSTOP同时捕获stopReason。实际实现中handleStopPacket还会为所有仍未闭合的工具组注入合成的SECTION_END供 9b 的 timeline 渲染读取。其他包→ 忽略文本/section_end/error 由其他路径处理。增量游标与 reset 语义关键边界processPackets的签名是纯函数式的(state, rawPackets) state但内部采用原地变更 返回同一对象的模式与 Web 端一致通过nextPacketIndex游标实现只处理新增包export function processPackets(state, rawPackets) { // 数组变短重新生成 / 历史替换→ 重建避免对重流的轮次重复计数 if (state.nextPacketIndex rawPackets.length) { state createInitialState(state.nodeId); } const prevProcessedIndex state.nextPacketIndex; for (let i state.nextPacketIndex; i rawPackets.length; i) { /* 分发 */ } state.nextPacketIndex rawPackets.length; return state; }设计文档明确指出了reset caveat重置陷阱这个数组变短检查只能捕获更短的替换等长或更长的重新生成/历史加载在增量宿主下会复用陈旧状态。9a 的策略是完全绕开——usePacketDisplay每次渲染都用useMemo传入全新的createInitialState(nodeId)见下一节因此不会有状态复用问题。文档同时给出对 9b 的告诫未来增量宿主必须基于包数组的**身份identity**变化而非长度变化来 reset。messageProcessor.test.ts中的测试见 mobile/src/chat/tests/messageProcessor.test.ts逐条验证了这些语义构建citationMap并保持首次引用顺序去重重复citation(1, d1)被丢弃从两类文档包 final_documentsupsertdocumentMap收到stop后isComplete true跨 flush 只处理新增包同一数组处理两次citations 长度不变数组变短时 reset两次引用后传入只含一条的短数组状态重建为只含citation 3。宿主接入usePacketDisplay与MessageRendererProps接口变更设计文档定义了两个被修改的接口// mobile/src/hooks/usePacketDisplay.ts interface PacketDisplay { renderer: MessageRenderer | null; packets: Packet[]; processed: ProcessedMessageState; // 取代旧的顶层 isComplete } // mobile/src/components/chat/renderers/registry.ts interface MessageRendererProps { packets: Packet[]; processed: ProcessedMessageState; // 原为 isComplete: boolean }usePacketDisplay是 Web 端usePacketProcessor的移动端对位负责宿主host处理器并返回processedrenderer仍通过findRenderer(packets)取得。这个processed对象就是贯穿本阶段 Sources UI 与下一阶段 9b timeline 渲染器的通道channel。关键实现偏差deviation from the ref design设计文档特别用 Implementation note 标注了与 Web 参考实现的偏差移动端react-hooks/refslint 规则禁止在渲染期间读写ref.current而 Web 的usePacketProcessor恰恰这么做了。因此 9a 用useMemo(() processPackets(createInitialState(nodeId), packets), [nodeId, packets])来宿主处理器——每次 flush 做一次全量遍历在聊天规模下开销可忽略而不是渲染期变更 ref。messageProcessor模块本身保持增量能力游标 缩短即 reset专为 9b 准备——9b 可以通过符合 lint 的增量模式来宿主它。文档还给出后续的性能注意MessageRow/AssistantMessage每次包 flush 都会重渲染memo 挂在packets.length上所以processed的读取始终是新鲜的不要对 Sources 子组件在processed身份上做React.memo——如需 memo应比较原始代理值citations.length、documentMap.size这是 9b 的性能路径。来源路由SourceTarget与openSourcemobile/src/chat/openSource.ts提供纯解析器 轻量执行器是内联标记点击与来源行点击的单一路由。仓库中 实际实现 与设计一致export type SourceTarget | { kind: browser; url: string } | { kind: file; fileId: string } | { kind: none }; export function documentTarget(doc: SearchDoc): SourceTarget { if (doc.link isHttpUrl(doc.link)) return { kind: browser, url: doc.link }; if (doc.file_id) return { kind: file, fileId: doc.file_id }; return { kind: none }; } export function openUrl(url: string): void { if (!isHttpUrl(url)) return; void WebBrowser.openBrowserAsync(url).catch(() { toast.error(Couldnt open this link.); }); } export function openSource(doc: SearchDoc): void { const target documentTarget(doc); switch (target.kind) { case browser: openUrl(target.url); break; case file: toast.info(Preview isnt available on mobile yet.); break; case none: break; } }设计要点documentTarget优先级有 http(s)link→ 浏览器否则有file_id→ 文件否则 → no-op。openUrl使用expo-web-browser的应用内浏览器iOS 的 SFSafariViewController / Android 的 Chrome Custom Tabs让用户停留在 App 内。expo-web-browser本来就是依赖auth SSO 已在使用无需新增依赖。文件来源在 9a 没有移动端文档预览器openSource内部直接触发全局toast提示 Preview isnt available on mobile yet.不需要调用方回调——所以SourceRow只需调用openSource(doc)。toast来自全局toasthelper/hooks/useToast。实际实现额外提供isHttpUrl辅助函数/^https?:\/\//iopenUrl对非 http URL 直接返回避免openBrowserAsync收到非法协议。来源选择器selectSources与 Cited/More/Files 分区mobile/src/chat/citations.ts提供纯选择器selectSources(processed)与两个字符串辅助函数domainOf/faviconUrl无 React 依赖。仓库中的 实际实现 与设计文档高度吻合并补充了count字段export interface SelectedSources { cited: SearchDoc[]; // 被答案引用按引用顺序非文件 more: SearchDoc[]; // 找到但未被引用非文件 files: SearchDoc[]; // 用户上传文件 iconDocs: SearchDoc[]; // 至多 3 个用于 Sources 按钮的图标堆叠 count: number; // 弹层中列出的总来源数 hasSources: boolean; }分区算法cited遍历state.citations首次引用顺序经documentMap映射出 doc跳过缺失项与文件项filesdocumentMap中带file_id的文档从 cited/more 中拆出独立成区moredocumentMap中既不在 cited 也不在 files 的剩余文档iconDocs取cited前 ≤3 个cited为空时回退到more再回退到files——保证纯文件回答时按钮上仍能显示文件图标hasSources与count基于文档数量而非原始 citations/文档数若引用的文档始终未到达绝不能渲染出一个空的 Sources · 0。辅助函数const HOST_RE /^https?:\/\/([^/?#])/i; export function domainOf(link: string | null): string | null { if (!link) return null; const match HOST_RE.exec(link); if (!match) return null; return match[1].replace(/^www\./i, ); } export function faviconUrl(link: string | null): string | null { const host domainOf(link); if (!host) return null; return https://www.google.com/s2/favicons?sz64domain${host}; }domainOf从链接提取主机名剥离www.前缀faviconUrl用公开的 Google favicon 服务生成图标 URL——该 URL 不需要鉴权所以后续SourceIcon用普通expo-image即可不要用BearerImage。UI 组件层SourceIcon/SourceRow/CitedSourcesSourceIcon.tsx新 · FOUNDATIONSourceIcon({ doc, size18 })若doc.link能解析出 http 主机 →Image source{faviconUrl(link)}expo-image公开源onError回退到file-text图标否则渲染Icon as{SvgFileText}。设计上刻意保持极简——完整的 per-connector logo 映射表不在 9a 范围内移动端没有逐连接器 logo 集移植约 40 个源的完整映射留给后续9b 可能扩展。SourceRow.tsx新 · FOUNDATIONSourceRow({ doc, onPress })Card variantsecondary onPress三行布局首行SourceIcon doc/Text fontmain-ui-action colortext-05 numberOfLines{1}{semantic_identifier}/Text次行Text fontsecondary-body colortext-02{domainOf(link) ?? source_type} · {timeAgo(updated_at)}/Text摘要行Text fontsecondary-body colortext-03 numberOfLines{2}{(match_highlights[0] ?? blurb).slice(0, 200)}/Text。其中timeAgo直接复用mobile/src/lib/time.ts项目文件已在使用的工具函数摘要优先取首个match_highlights否则回退blurb截断 200 字符、最多 2 行。CitedSources.tsx新 · 9a导出两个组件CitedSourcesBar({ iconDocs, count, onPress })—— 一个 pill 式按钮≤3 个SourceIcon交叠堆叠 文本 Sources · {count}accessibilityRolebutton保证无障碍语义。CitedSourcesSheet({ visible, onClose, processed })—— 底部弹层Modalchrome 完全对标既有的FilePickerSheetscrimPressable、内层rounded-t-24 … px-16 pt-16、底部安全区、头部 Sources 关闭SvgX、ScrollView max-h。正文 selectSources(processed)→ 最多三个带标签分区Cited Sources/More/User Files每个分区由SeparatorText头部 若干SourceRow组成行点击onPressopenSource(doc)。设计约束不要触碰/sources/[id]路由项目文件。引用来源的呈现面是 Modal不新增路由避免路由冲突。文件结构与改动点清单设计文档给出完整的文件结构树mobile/src/下整理为表格便于执行对照文件类型职责chat/streamingModels.ts修改3 包类型、MessageStart.final_documents、ObjTypeschat/messageProcessor.ts新增 · FOUNDATION纯增量包→ProcessedMessageState处理器chat/contracts/documents.ts新增 · FOUNDATIONSearchDoc/StreamingCitation/CitationMap类型chat/openSource.ts新增 · FOUNDATIONdocumentTargetopenSource/openUrlchat/citations.ts新增 · 9aselectSources(processed)分区 domainOf/faviconUrlhooks/usePacketDisplay.ts修改宿主处理器返回processedcomponents/chat/renderers/registry.ts修改MessageRendererProps→{packets, processed}components/chat/renderers/MessageTextRenderer.tsx修改processed.isComplete、onLinkPresscomponents/chat/StreamingMarkdown.tsx修改onLinkPress透传components/chat/MessageRow.tsx修改读processed、渲染 Sources footercomponents/chat/SourceIcon.tsx新增 · FOUNDATIONfavicon 或file-text回退components/chat/SourceRow.tsx新增 · FOUNDATION可点击来源行components/chat/CitedSources.tsx新增 · 9aCitedSourcesBarCitedSourcesSheetchat/__tests__/fixtures.ts修改makePacket/makeCitationPacket/makeSearchDoc等chat/__tests__/messageProcessor.test.ts等新增处理器/选择器/路由/组件测试各文件内容要点按设计文档逐文件展开streamingModels.ts新增PacketType.CITATION_INFOcitation_info、SEARCH_TOOL_DOCUMENTS_DELTAsearch_tool_documents_delta、OPEN_URL_DOCUMENTSopen_url_documents这三个值在仓库的 streamingModels.ts 中已确认存在接口CitationInfo {citation_number: number; document_id: string}、SearchToolDocumentsDelta {documents: SearchDoc[]}、OpenUrlDocuments {documents: SearchDoc[]}MessageStart增加final_documents?: SearchDoc[] | null三个新包并入ObjTypesunionimport { SearchDoc } from /chat/contracts/documents。注意不要添加citation_start/end——后端从不发射这两种包。messageProcessor.tscreateInitialState、processPackets及上述逐类型处理器纯逻辑、无 React按obj.type判别。contracts/documents.ts三个类型无逻辑。openSource.tsdocumentTarget、openUrlexpo-web-browser、openSource。citations.ts如上文的selectSources分区算法。SourceIcon.tsx/SourceRow.tsx/CitedSources.tsx如上文的 UI 规格。usePacketDisplay.ts文档给出参考实现——const stateRef useRef(createInitialState(node.nodeId))若stateRef.current.nodeId ! node.nodeId或数组缩短则 reseedstateRef.current processPackets(stateRef.current, node.packets)renderer useMemo(() findRenderer(node.packets), [node.packets])返回{ renderer, packets: node.packets, processed: stateRef.current }。注设计文档同时声明最终以 lint 友好的useMemo全量遍历方式实现见宿主接入一节。registry.tsMessageRendererProps改为{ packets; processed }。MessageTextRenderer.tsx改读processed.isComplete原顶层isComplete构造onLinkPress useCallback((url) { if (url) openUrl(url); }, [])传给StreamingMarkdownmatches逻辑不变。StreamingMarkdown.tsx增加onLinkPress?: (url: string) void透传给StreamdownText onLinkPress{(e) onLinkPress?.(e.url)}。MessageRow.tsxAssistantMessage从usePacketDisplay解构processed用processed.isComplete驱动hasContent/AgentTimeline isLoadingRenderer之后当processed.isComplete hasSources时渲染CitedSourcesBarCitedSourcesSheet本地sheetVisiblestate。memo 比较器仅在必要时更新仍以packets.length为 key它随新的引用/文档包推进。fixtures.tsmakePacket(obj, placement?)、makeCitationPacket(n, docId)、makeSearchDoc(overrides)、makeSearchDocsPacket(docs, type?)等测试工厂函数。集成点为什么这些既有模块零改动设计文档逐一论证了与既有代码的接缝seamuseChatController.ts—— 不动。它已经把所有包装后的包存放在node.packets上。chatHistory.ts—— 不动。processRawChatHistory已在加载历史时为每个 assistant turn 装载历史packets因此处理器在历史加载时会自动重建引用状态——无需额外的历史持久化。api/chat/stream.ts—— 不动。isPacket已能把包装后的引用/文档包路由进来。registry.ts的RENDERERS—— 不动仍为[MessageTextRenderer]。引用/文档包搭乘同一数组流动MessageTextRenderer.matches照常触发9b 再追加新渲染器条目。expo-web-browser—— 已是依赖auth SSO 使用openSource只是新增使用点。timeAgo—— 复用mobile/src/lib/time.ts。实现前必读的注意事项边界情况全集设计文档在 Important notes before implementation 中给出了实现前必须确认的六类边界问题这是本项目最容易被忽略的实战细节空括号[[n]]()文件标记是头号边界情况。对文件/内部来源link 该标记在 enriched-markdown 中可能无法渲染为可点击链接。9a 的行为内联点击是尽力而为的无 URL 则 no-op文件来源可靠地在Sources 弹层的 User Files 分区中触达。需在真机构建上验证[[n]]()是渲染为文本还是链接若想要可点但为空的链接可加 1 行归一化]]()→ 哨兵 href作为回退——但保持其在默认路径之外。onLinkPress对所有链接触发不限于引用答案中的普通 markdown 链接也一样。这是有意为之——都在应用内浏览器打开。需在真机确认事件载荷形状{ url }。Favicon 是公开资源用普通expo-image不要用BearerImagefavicon URL 不按 auth key 鉴权onError必须回退到通用图标保证缺失 favicon 不破坏行渲染。来源图标覆盖范围刻意最小化favicon 或file-text移动端没有 per-connector logo 集移植完整约 40 源映射超出 9a 范围标记为后续事项9b 可能扩展映射表。Bar 可见性镜像 Web仅在processed.isComplete hasSources时显示——避免流中途的布局跳动到答案完成时来源早已填充完毕。原地变更 稳定 ref处理器原地变更状态usePacketDisplay每次渲染返回同一 ref。MessageRow/AssistantMessage随每次包 flush 重渲染memo 于packets.length所以processed读取始终新鲜不要对 Sources 子组件在processed身份上React.memo。Reset 语义处理器在nodeId变化新消息与包数组缩短重新生成 / 历史替换时重置——与 Web 镜像缺少它重新生成会导致重复计数。路由冲突不要触碰/sources/[id]项目文件。引用来源呈现面是 Modal无路由。测试策略处理器、选择器、documentTarget均为纯函数 → 单元测试覆盖去重、排序、final_documents播种、缩短即重置、file/link/none 三类目标SourceRow/CitedSourcesSheet→ RN Testing Library渲染分区、onPress → openSource。Mockexpo-web-browser与expo-imagejest 全局从jest/globals导入断言使用/components/ui/text。测试验证设计语义在仓库中的落地9a 的纯逻辑部分在仓库中已有完整测试佐证可作为设计与实现的对照基准见 messageProcessor.test.ts 与 openSource.test.ts去重与排序citationMap正确构建重复citation_info只保留首条citations[]按首次引用顺序。文档播种search_tool_documents_delta、open_url_documents与message_start.final_documents三类来源统一进入documentMap。完成态收到stop包后isComplete翻转。增量与重置同一数组重复处理不重复计数游标生效数组缩短时状态重建reset 生效。来源目标路由documentTarget的 browser/file/none 三分支各有断言。结语一套为 9b 铺路的最小可复用地基9a 的详细设计可以概括为三个层次契约层SearchDoc全字段类型一次建模多处复用、处理层纯函数式增量处理器messageProcessor游标 缩短即重置刻意无分组、呈现层openSource单一来源路由 SourceIcon/SourceRow可复用行组件 9a 专属的CitedSourcesBar/Sheet。每个层次都通过具体文件与既有集成点锚定确保纯客户端、零后端改动。其中 FOUNDATION 级的部分——messageProcessor、contracts/documents.ts、usePacketDisplay的processed通道、openSource、SourceIcon/SourceRow——每一项都能在下一个阶段 9bagent timeline找到具体消费者而分组grouping本身被刻意推迟到 9b。这种现在只建最小接缝、不提前造分组的取舍正是本设计最值得借鉴的工程决策9a 交付可用的引用体验同时为 9b 留出无需重写的扩展轨道。【免费下载链接】danswerOpen Source AI Platform - AI Chat with advanced features that works with every LLM项目地址: https://gitcode.com/GitHub_Trending/da/danswer创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价