danswer 移动端 Chat 引用(Citations)与引用来源(Cited Sources)详细设计:从 NDJSON 流处理到底部弹层
2026/9/10 10:05:41 网站建设 项目流程

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: 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>;

三个设计要点值得展开:

  1. SearchDoc是后端同名模型的全字段移动端移植。刻意保留完整字段集(scoreboosthiddenprimary_ownersis_relevantrelevance_explanationfile_id等),因为后续 9b 的搜索/抓取子渲染器需要这些额外字段——"一次建模,多处复用"。
  2. StreamingCitation与线上的CitationInfo包刻意区分。wire 包用的是citation_number,而处理后状态用的是citation_num,避免两者混用导致字段错位。
  3. CitationMapcitation_number → document_id的纯映射,配合处理器维护的citations[]有序数组使用。

状态处理核心:ProcessedMessageStatemessageProcessor

设计文档的核心资产是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 已经走到了这一步之后——它包含了groupedPacketsMaptoolGroupsisGeneratingImagetoolProcessingDuration等 9b 字段,证明该设计预留的扩展点已被后续阶段真实采用,而 9a 的核心字段(citationMap/citations/seenCitationDocIds/documentMap/isComplete/stopReason/nodeId/nextPacketIndex)与设计文档逐字对应。

逐包分发逻辑(按obj.type判别)

processPackets的设计行为如下:

  • CITATION_INFOcitationMap[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/STOPisComplete = 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
  • 收到stopisComplete === true
  • 跨 flush 只处理新增包(同一数组处理两次,citations 长度不变);
  • 数组变短时 reset(两次引用后传入只含一条的短数组,状态重建为只含citation 3)。

宿主接入:usePacketDisplayMessageRendererProps

接口变更

设计文档定义了两个被修改的接口:

// 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.lengthdocumentMap.size),这是 9b 的性能路径。

来源路由:SourceTargetopenSource

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,跳过缺失项与文件项;
  • filesdocumentMap中带file_id的文档,从 cited/more 中拆出独立成区;
  • moredocumentMap中既不在 cited 也不在 files 的剩余文档;
  • iconDocs:取cited前 ≤3 个,cited为空时回退到more,再回退到files——保证纯文件回答时按钮上仍能显示(文件)图标
  • hasSourcescount基于文档数量而非原始 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)

导出两个组件:

  1. CitedSourcesBar({ iconDocs, count, onPress })—— 一个 pill 式按钮:≤3 个SourceIcon交叠堆叠 + 文本 "Sources · {count}",accessibilityRole="button"保证无障碍语义。
  2. CitedSourcesSheet({ visible, onClose, processed })—— 底部弹层Modal,chrome 完全对标既有的FilePickerSheet:scrimPressable、内层rounded-t-24 … px-16 pt-16、底部安全区、头部 "Sources" + 关闭SvgXScrollView 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新增 · FOUNDATIONSearchDoc/StreamingCitation/CitationMap类型
chat/openSource.ts新增 · FOUNDATIONdocumentTarget+openSource/openUrl
chat/citations.ts新增 · 9aselectSources(processed)分区 +domainOf/faviconUrl
hooks/usePacketDisplay.ts修改宿主处理器,返回processed
components/chat/renderers/registry.ts修改MessageRendererProps{packets, processed}
components/chat/renderers/MessageTextRenderer.tsx修改processed.isCompleteonLinkPress
components/chat/StreamingMarkdown.tsx修改+onLinkPress透传
components/chat/MessageRow.tsx修改processed、渲染 Sources footer
components/chat/SourceIcon.tsx新增 · FOUNDATIONfavicon 或file-text回退
components/chat/SourceRow.tsx新增 · FOUNDATION可点击来源行
components/chat/CitedSources.tsx新增 · 9aCitedSourcesBar+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.tscreateInitialStateprocessPackets及上述逐类型处理器;纯逻辑、无 React,按obj.type判别。
  • contracts/documents.ts:三个类型,无逻辑。
  • openSource.tsdocumentTargetopenUrlexpo-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.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.tsxAssistantMessageusePacketDisplay解构processed;用processed.isComplete驱动hasContent/AgentTimeline isLoading<Renderer>之后,当processed.isComplete && hasSources时渲染<CitedSourcesBar>+<CitedSourcesSheet>(本地sheetVisiblestate)。memo 比较器仅在必要时更新(仍以packets.length为 key,它随新的引用/文档包推进)。
  • fixtures.tsmakePacket(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.tsRENDERERS—— 不动(仍为[MessageTextRenderer])。引用/文档包搭乘同一数组流动,MessageTextRenderer.matches照常触发;9b 再追加新渲染器条目。
  • expo-web-browser—— 已是依赖(auth SSO 使用),openSource只是新增使用点。
  • timeAgo—— 复用mobile/src/lib/time.ts

实现前必读的注意事项(边界情况全集)

设计文档在 "Important notes before implementation" 中给出了实现前必须确认的六类边界问题,这是本项目最容易被忽略的实战细节:

  1. 空括号[[n]]()文件标记是头号边界情况。对文件/内部来源(link == ""),该标记在 enriched-markdown 中可能无法渲染为可点击链接。9a 的行为:内联点击是尽力而为的(无 URL 则 no-op),文件来源可靠地在Sources 弹层的 "User Files" 分区中触达。需在真机构建上验证[[n]]()是渲染为文本还是链接;若想要"可点但为空"的链接,可加 1 行归一化(]]()→ 哨兵 href)作为回退——但保持其在默认路径之外。
  2. onLinkPress所有链接触发,不限于引用(答案中的普通 markdown 链接也一样)。这是有意为之——都在应用内浏览器打开。需在真机确认事件载荷形状({ url })。
  3. Favicon 是公开资源:用普通expo-image不要用BearerImage(favicon URL 不按 auth key 鉴权);onError必须回退到通用图标,保证缺失 favicon 不破坏行渲染。
  4. 来源图标覆盖范围刻意最小化(favicon 或file-text):移动端没有 per-connector logo 集;移植完整约 40 源映射超出 9a 范围(标记为后续事项,9b 可能扩展映射表)。
  5. Bar 可见性镜像 Web:仅在processed.isComplete && hasSources时显示——避免流中途的布局跳动;到答案完成时来源早已填充完毕。
  6. 原地变更 + 稳定 ref:处理器原地变更状态,usePacketDisplay每次渲染返回同一 ref。MessageRow/AssistantMessage随每次包 flush 重渲染(memo 于packets.length),所以processed读取始终新鲜;不要对 Sources 子组件在processed身份上React.memo
  7. Reset 语义:处理器在nodeId变化(新消息)与包数组缩短(重新生成 / 历史替换)时重置——与 Web 镜像;缺少它,重新生成会导致重复计数。
  8. 路由冲突:不要触碰/sources/[id](项目文件)。引用来源呈现面是 Modal,无路由。
  9. 测试策略:处理器、选择器、documentTarget均为纯函数 → 单元测试覆盖(去重、排序、final_documents播种、缩短即重置、file/link/none 三类目标);SourceRow/CitedSourcesSheet→ RN Testing Library(渲染分区、onPress → openSource)。Mockexpo-web-browserexpo-image;jest 全局从@jest/globals导入;断言使用@/components/ui/text

测试验证:设计语义在仓库中的落地

9a 的纯逻辑部分在仓库中已有完整测试佐证,可作为设计与实现的对照基准(见 messageProcessor.test.ts 与 openSource.test.ts):

  • 去重与排序citationMap正确构建;重复citation_info只保留首条;citations[]按首次引用顺序。
  • 文档播种search_tool_documents_deltaopen_url_documentsmessage_start.final_documents三类来源统一进入documentMap
  • 完成态:收到stop包后isComplete翻转。
  • 增量与重置:同一数组重复处理不重复计数(游标生效);数组缩短时状态重建(reset 生效)。
  • 来源目标路由documentTarget的 browser/file/none 三分支各有断言。

结语:一套为 9b 铺路的最小可复用地基

9a 的详细设计可以概括为三个层次:契约层SearchDoc全字段类型,一次建模多处复用)、处理层(纯函数式增量处理器messageProcessor,游标 + 缩短即重置,刻意无分组)、呈现层openSource单一来源路由 +SourceIcon/SourceRow可复用行组件 + 9a 专属的CitedSourcesBar/Sheet)。每个层次都通过具体文件与既有集成点锚定,确保"纯客户端、零后端改动"。

其中 FOUNDATION 级的部分——messageProcessorcontracts/documents.tsusePacketDisplayprocessed通道、openSourceSourceIcon/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),仅供参考

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

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

立即咨询