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_documents | SearchDoc[] | 搜索/URL 工具在答案之前发出 |
message_start.final_documents | SearchDoc[](该轮权威引用集) | 答案开始前 |
而内联标记本身已经以 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: Record<string, 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 = Record<number, 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: Record<number, string>; // { 引用号: document_id } citations: StreamingCitation[]; // 去重、按首次引用顺序 seenCitationDocIds: Set<string>; // 去重守卫 documentMap: Map<string, 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的 doc,documentMap.set(document_id, doc)。实际实现中handleDocumentPacket还额外处理了FETCH_TOOL_DOCUMENTS(9b 抓取工具)和MESSAGE_START.final_documents,统一走upsertDocuments辅助函数。MESSAGE_START→ 若带final_documents,逐条 upsert 进documentMap(不覆盖已存在的同 id 文档,因为后续 delta 可能带来更新)。MESSAGE_END/STOP→isComplete = true;STOP同时捕获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)处理器并返回processed;renderer仍通过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与openSource
mobile/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("Couldn't 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 isn't 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 isn't available on mobile yet.",不需要调用方回调——所以SourceRow只需调用openSource(doc)。toast来自全局toasthelper(@/hooks/useToast)。 - 实际实现额外提供
isHttpUrl辅助函数(/^https?:\/\//i),openUrl对非 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,跳过缺失项与文件项;files:documentMap中带file_id的文档,从 cited/more 中拆出独立成区;more:documentMap中既不在 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?sz=64&domain=${host}`; }domainOf从链接提取主机名(剥离www.前缀),faviconUrl用公开的 Google favicon 服务生成图标 URL——该 URL 不需要鉴权,所以后续SourceIcon用普通expo-image即可(不要用BearerImage)。
UI 组件层:SourceIcon/SourceRow/CitedSources
SourceIcon.tsx(新 · FOUNDATION)
SourceIcon({ doc, size=18 }):若doc.link能解析出 http 主机 →<Image source={faviconUrl(link)}>(expo-image,公开源,onError回退到file-text图标);否则渲染<Icon as={SvgFileText}>。设计上刻意保持极简——完整的 per-connector logo 映射表不在 9a 范围内(移动端没有逐连接器 logo 集,移植约 40 个源的完整映射留给后续,9b 可能扩展)。
SourceRow.tsx(新 · FOUNDATION)
SourceRow({ doc, onPress }):Card variant="secondary" onPress,三行布局:
- 首行:
<SourceIcon doc/>+<Text font="main-ui-action" color="text-05" numberOfLines={1}>{semantic_identifier}</Text>; - 次行:
<Text font="secondary-body" color="text-02">{domainOf(link) ?? source_type} · {timeAgo(updated_at)}</Text>; - 摘要行:
<Text font="secondary-body" color="text-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}",accessibilityRole="button"保证无障碍语义。CitedSourcesSheet({ visible, onClose, processed })—— 底部弹层Modal,chrome 完全对标既有的FilePickerSheet:scrimPressable、内层rounded-t-24 … px-16 pt-16、底部安全区、头部 "Sources" + 关闭SvgX、ScrollView max-h。正文 =selectSources(processed)→ 最多三个带标签分区(Cited Sources/More/User Files),每个分区由Separator+Text头部 + 若干SourceRow组成;行点击onPress=openSource(doc)。
设计约束:不要触碰/sources/[id]路由(项目文件)。引用来源的呈现面是 Modal,不新增路由,避免路由冲突。
文件结构与改动点清单
设计文档给出完整的文件结构树(mobile/src/下),整理为表格便于执行对照:
| 文件 | 类型 | 职责 |
|---|---|---|
chat/streamingModels.ts | 修改 | +3 包类型、+MessageStart.final_documents、+ObjTypes |
chat/messageProcessor.ts | 新增 · FOUNDATION | 纯增量包→ProcessedMessageState处理器 |
chat/contracts/documents.ts | 新增 · FOUNDATION | SearchDoc/StreamingCitation/CitationMap类型 |
chat/openSource.ts | 新增 · FOUNDATION | documentTarget+openSource/openUrl |
chat/citations.ts | 新增 · 9a | selectSources(processed)分区 +domainOf/faviconUrl |
hooks/usePacketDisplay.ts | 修改 | 宿主处理器,返回processed |
components/chat/renderers/registry.ts | 修改 | MessageRendererProps→{packets, processed} |
components/chat/renderers/MessageTextRenderer.tsx | 修改 | processed.isComplete、onLinkPress |
components/chat/StreamingMarkdown.tsx | 修改 | +onLinkPress透传 |
components/chat/MessageRow.tsx | 修改 | 读processed、渲染 Sources footer |
components/chat/SourceIcon.tsx | 新增 · FOUNDATION | favicon 或file-text回退 |
components/chat/SourceRow.tsx | 新增 · FOUNDATION | 可点击来源行 |
components/chat/CitedSources.tsx | 新增 · 9a | CitedSourcesBar+CitedSourcesSheet |
chat/__tests__/fixtures.ts | 修改 | +makePacket/makeCitationPacket/makeSearchDoc等 |
chat/__tests__/messageProcessor.test.ts等 | 新增 | 处理器/选择器/路由/组件测试 |
各文件内容要点(按设计文档逐文件展开)
streamingModels.ts:新增PacketType.CITATION_INFO="citation_info"、SEARCH_TOOL_DOCUMENTS_DELTA="search_tool_documents_delta"、OPEN_URL_DOCUMENTS="open_url_documents"(这三个值在仓库的 streamingModels.ts 中已确认存在);接口CitationInfo {citation_number: number; document_id: string}、SearchToolDocumentsDelta {documents: SearchDoc[]}、OpenUrlDocuments {documents: SearchDoc[]};MessageStart增加final_documents?: SearchDoc[] | null;三个新包并入ObjTypesunion;import { SearchDoc } from "@/chat/contracts/documents"。注意:不要添加citation_start/end——后端从不发射这两种包。messageProcessor.ts:createInitialState、processPackets及上述逐类型处理器;纯逻辑、无 React,按obj.type判别。contracts/documents.ts:三个类型,无逻辑。openSource.ts:documentTarget、openUrl(expo-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或数组缩短则 reseed;stateRef.current = processPackets(stateRef.current, node.packets);renderer = useMemo(() => findRenderer(node.packets), [node.packets]);返回{ renderer, packets: node.packets, processed: stateRef.current }。(注:设计文档同时声明最终以 lint 友好的useMemo全量遍历方式实现,见"宿主接入"一节。)registry.ts:MessageRendererProps改为{ packets; processed }。MessageTextRenderer.tsx:改读processed.isComplete(原顶层isComplete);构造onLinkPress = useCallback((url) => { if (url) openUrl(url); }, [])传给StreamingMarkdown;matches逻辑不变。StreamingMarkdown.tsx:增加onLinkPress?: (url: string) => void,透传给<StreamdownText onLinkPress={(e) => onLinkPress?.(e.url)}>。MessageRow.tsx:AssistantMessage从usePacketDisplay解构processed;用processed.isComplete驱动hasContent/AgentTimeline isLoading;<Renderer>之后,当processed.isComplete && hasSources时渲染<CitedSourcesBar>+<CitedSourcesSheet>(本地sheetVisiblestate)。memo 比较器仅在必要时更新(仍以packets.length为 key,它随新的引用/文档包推进)。fixtures.ts:makePacket(obj, placement?)、makeCitationPacket(n, docId)、makeSearchDoc(overrides)、makeSearchDocsPacket(docs, type?)等测试工厂函数。
集成点:为什么这些既有模块"零改动"
设计文档逐一论证了与既有代码的接缝(seam):
useChatController.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,不要用BearerImage(favicon 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-image;jest 全局从@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——每一项都能在下一个阶段 9b(agent 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),仅供参考