Onyx 移动端 Chat 引用(Citations & Cited Sources)体验移植:数据包规范、渲染约束与方案选型
导读
本文整理自 docs/mobile-chat/9a-citations/01-research.md(Onyx 移动端 Chat 子阶段 9a — Citations & Cited Sources 的研究文档),并对照当前仓库中已落地的实现与测试进行佐证。文章聚焦三个问题:后端以怎样的 NDJSON 数据包把"引用编号 → 文档"关系送达客户端;在 React Native 原生 markdown 渲染器没有自定义节点 hook 的约束下,inline [N] 标记如何被样式化、可点按;以及移动端"Sources"按钮 + 底部弹层(bottom sheet)这一引用来源界面该如何设计与选型。读完本文,你将掌握 Onyx 移动端引用的完整数据流、后端权威包规范、三个实现方案(A/B/C)的取舍,以及最终为何选择"可复用基础 + inline 简化"的组合。
需求与背景:把 Web 的引用体验搬到移动端
Onyx 的 Web 端在回答中会把检索到的文档以 [N] 引用标记嵌入答案,并提供可点击的引用来源面板。子阶段 9a 的目标,是把这套体验完整移植到移动端 React Native 应用(mobile/):消费并处理引用/文档的流式数据包,在流式答案中渲染可点按的 [N] 标记,以及一个 "Sources" 来源列表界面——行为与 Web 对齐,结构上尽可能对齐。这是被延期的"丰富聊天(rich-chat)"工作(docs/mobile-chat/05-pr-roadmap.md 中的 PR 9a–9e)的第一个子阶段,Web 端是设计的唯一事实来源。
一个贯穿全文的背景事实:移动端聊天层采用原生方案(非 WebView),因此 Web 端 react-markdown 那种"把任意节点替换为自定义组件"的做法在移动端行不通,这直接决定了 9a 几乎所有关键决策。
三个关键澄清:子阶段、流程与最核心的渲染约束
研究文档以 Q&A 形式锁定了三个前提:
- Q1 — 属于 PR 9 的哪个子阶段? 9a(citations/sources)。路线图推荐它最先做;而 9b(agent 时间线,即 agent-timeline)是延期的下一阶段。
- Q2 — 启动流程? 完整的特性流程(研究 → 方案 → 高层设计 → 详细设计 → 实施计划),在 docs/mobile-chat/ 下以门禁文档(gated docs)形式推进,写任何代码之前先被评审。
- Q3(承重问题)— 移动端 markdown 渲染器没有自定义节点 hook,inline
[N]标记怎么处理? 结论:做成带样式、可点按的[N]链接(点按通过onLinkPress打开来源);忠实于 Web 的富 UI 放在 Sources 按钮 + 来源弹层里(那里没有渲染器约束)。上标样式可选。而 Web 那种 inlineSourceTag芯片形态需要重度文本分段,会与流式 markdown 渲染器打架——被否决;完全隐藏标记也被否决(会丢掉"论断 → 来源"的绑定关系)。
约束为什么存在(记录下来以免被重新争议)
- Web:用
react-markdown渲染答案(JS 元素树),Onyx 重写a节点(MemoizedAnchor),把每个[[N]](url)链接替换成自定义SourceTag芯片 + 悬停卡片。因为每个节点都是可替换的 React 元素。 - 移动端:用
react-native-streamdown→react-native-enriched-markdown,这是原生渲染器(md4c + 原生文本原语),选择它是为了避免 WebView 开销和流式卡顿。它全部的公开定制面就是markdownStyle(每个节点的颜色/字重/下划线)+onLinkPress/onLinkLongPress点按回调,没有自定义节点渲染器 / 渲染覆盖。所以 inline 位置无法用自定义组件替换[N]链接,只能样式化 + 可点按。悬停卡片无论如何也无法移植(触屏没有 hover,点按打开就是等价物)。
这一约束在 mobile/src/components/chat/StreamingMarkdown.tsx 的实现中得到印证:组件只暴露 markdownStyle(通过 buildMarkdownStyle 基于 Onyx 设计 token 解析具体颜色/字重)与 onLinkPress(第 141 行把原生回调的 event.url 转发出去),没有任何自定义节点接口。
现状盘点:移动端 Chat 渲染路径与分发接缝
研究文档对代码库做了逐文件扫描,明确了 9a 要"嫁接"在哪些既有机制上:
渲染路径与分发接缝
- mobile/src/components/chat/MessageList.tsx:每条消息渲染一个
MessageRow,本身没有按包(packet)分发的逻辑。 - mobile/src/components/chat/MessageRow.tsx:
AssistantMessage从会话状态取得processed状态(文档研究时点为usePacketDisplay(node),其定位是"单组 stub",注释写明 "Core = one group; PR 9 adds real grouping"),先渲染<AgentTimeline>(外壳),再在px-12缩进内渲染匹配到的 Renderer。 - mobile/src/components/chat/renderers/:分发接缝。
MessageRenderer { matches(packets); Component },findRenderer按"首个匹配"分发,最初只注册了MessageTextRenderer(研究文档注明 "PR 9 adds rich renderers")。 - mobile/src/components/chat/renderers/MessageTextRenderer.tsx:
matches判定"存在 chat 类包";accumulateContent(packets)把message_start/message_delta的content拼接起来 → 交给useTypewriter→<StreamingMarkdown>。当前实现中,它已经给StreamingMarkdown传入了onLinkPress={openUrl}(见 MessageTextRenderer.tsx),即 9a 的 inline 点按路径已经落地。
Markdown 渲染器与打字机揭示
- mobile/src/components/chat/StreamingMarkdown.tsx:包装
<StreamdownText markdown markdownStyle flavor="github" selectable>。StreamdownText继承EnrichedMarkdownText的全部 props(含onLinkPress/onLinkLongPress),另有remendConfig。markdownStyle.link为--action-selection-05+ 下划线(全局样式,无法按链接单独定制)。 - mobile/src/hooks/useTypewriter.ts:按字符前缀切片逐步揭示
target(流中约 180 字符/秒)。这带来一个重要的实现约束:标记文本会作为字符逐个通过,中途可能短暂出现残缺的[1——所以引用解析必须跑在完整累积内容上,而不是"已显示内容"上。当前实现还包含一个细节:流结束时进入自适应追赶(CATCHUP_FRAMES)模式,以及回到前台时直接跳到全文。
数据包契约与摄取通道
- mobile/src/chat/streamingModels.ts:
PacketType枚举(message_start/delta/end、stop、section_end、error等)、Packet {placement, obj}、Placement {turn_index, tab_index?, sub_turn_index?, model_index?}、ObjTypes联合类型。这是新增包类型的扩展点。文档还强调了两级判别约定:外层包用"obj" in e && "placement" in e判别(见 mobile/src/api/chat/stream.ts),根级对象(如message_id_info、流式错误)按字段存在性判别,不要看obj.type。 - mobile/src/chat/contracts/projects.ts:项目读模型,是
SearchDoc/文档契约(mobile/src/chat/contracts/)的候选归属地。 - mobile/src/hooks/useChatController.ts:通过防抖 flush 把每一个包裹包追加到
node.packets({...node, packets:[...node.packets, ...pending]}),不检查obj.type——因此新增的引用/文档包类型到达渲染器时控制器零改动。 - mobile/src/chat/chatHistory.ts:
processRawChatHistory按助手轮次对齐packets[agentIdx],历史会话的引用通过这里加载。
可复用的 UI 原语
9a 的来源界面可以直接复用仓库里已有的组件原语:
- mobile/src/components/ui/card.tsx:
Card(variant: primary/secondary/tertiary/borderless;rounded-12 p-16;可选onPress)。 - mobile/src/components/ui/line-item-button.tsx:
LineItemButton(全宽可点按行:图标 + 标题 + 描述 +rightChildren)。 - mobile/src/components/chat/FilePickerSheet.tsx:底部弹层 Modal 模式的现成范例(RN
Modal透明滑入、scrimPressable、内层rounded-t-24、安全区底部 inset)。 - mobile/src/components/ui/BearerImage.tsx:带鉴权的
expo-image(用于 API 图片)。注意:Web 公开 favicon 不需要鉴权,要直接用普通expo-image,不要用BearerImage。 - mobile/src/components/ui/ 下的
spinner、text(所有文本必须用它)、icon、button、separator、content。 - mobile/src/hooks/useToast.ts:
useToasts()提示宿主(用于"预览不可用"等)。 - 打开链接:
expo-web-browser的WebBrowser.openBrowserAsync(url)(已是依赖;当前移动端没有任何地方打开内容链接,只有 SSO 鉴权用浏览器)。
已确认的缺口与冲突
研究扫描确认了三个必须处理的问题:
- 来源图标缺口:移动端没有任何
SourceIcon/WebResultIcon/ favicon 组件(grep 确认)。Web 用SourceIcon(source_type → 图标)+WebResultIcon(URL → favicon)。9a 必须补一个最小版本,完整的约 40 个连接器 logo 集不在范围内。 - 没有文档预览面:移动端没有文档预览(Web 在
PreviewModal中打开 File/UserFile 文档)。 - 路由
/sources/[id]已被占用:它是 PROJECT-FILES 屏幕(mobile/src/app/(app)/sources/[id].tsx,把id当 projectId,复用useProjectFiles)。deriveFocus(mobile/src/chat/chatFocus.ts)只匹配/、/chat/…、/projects/…。所以聊天引用面必须是底部弹层(或一条全新的独立路由),不能是/sources。 - 测试缺失:原先没有 renderer /
usePacketDisplay测试,mobile/src/chat/tests/fixtures.ts 只有makeProjectFile,事实上的包 fixture 内联在ndjson.test.ts里。需要补makePacket/makeCitationPacket/makeSearchDoc(Packet)辅助函数——这一点在后续实现中已经兑现(见下文"测试覆盖")。
后端数据包规范(权威 wire 形状)
这是 9a 的"接缝"所在,也是移动端必须完全对齐的事实层。格式为 NDJSON,每行一个 Packet:
{ placement: { turn_index, tab_index?, sub_turn_index?, model_index? }, obj: { type, ... } }
没有 data: SSE 前缀。移动端的 isPacket = "obj" in e && "placement" in e 正是据此判别。
CitationInfo:唯一的引用包
- CitationInfo
{ type: "citation_info", citation_number: int, document_id: str },定义于 backend/onyx/server/query_and_chat/streaming_models.py。注释明确写道:citation_number是 LLM 提供的编号,document_id是连接器侧的真实文档 ID(非数据库 int 主键)。 - Web 的枚举还声明了
citation_start/citation_end——后端从不发送它们,忽略即可。移动端 mobile/src/chat/streamingModels.ts 也以注释形式记录了这一点:"Only citation_info is emitted; citation_start/end are declared for parity but never sent."
文档包(每个 { type, documents: SearchDoc[] })
SearchToolDocumentsDelta—type: "search_tool_documents_delta"(内部检索 + Web 检索)。OpenUrlDocuments—type: "open_url_documents"(URL 抓取工具;Web 端叫FetchToolDocuments,wire 字符串相同)。
message_start.final_documents:权威被引集合
message_start 携带 final_documents: SearchDoc[] | null(本轮被引用文档的权威集合)和 pre_answer_processing_seconds。注意后端 AgentResponseStart 不发送 Web 端那个 id/content 字段(移动端 MessageStart 也只建模了 content、pre_answer_processing_seconds、final_documents)。
SearchDoc 全字段
后端定义于 backend/onyx/context/search/models.py:
| 字段 | 类型 | 说明 |
|---|---|---|
document_id |
str | 连接器侧文档 ID |
chunk_ind |
int | 块索引 |
semantic_identifier |
str | 语义标题(列表展示用) |
link |
str | None | 文档外链;无链接文档为 null |
blurb |
str | 摘要 |
source_type |
DocumentSource | 如 web、google_drive、confluence |
boost |
int | 检索加权 |
hidden |
bool | 是否隐藏文档(仅 admin 检索才可能为 True) |
metadata |
dict[str, str | list[str]] | 元数据键值 |
score |
float | None | 相关分 |
is_relevant |
bool | None | 相关标记 |
relevance_explanation |
str | None | 相关性解释 |
match_highlights |
list[str] | Vespa 高亮片段(如 <hi>the</hi> <hi>answer</hi> is 42) |
updated_at |
datetime | None | 最后更新 |
primary_owners / secondary_owners |
list[str] | None | 属主 |
is_internet |
bool | 是否互联网文档 |
file_id |
str | None | 文件型文档的文件 ID(populate_file_ids_on_sections 之后才有) |
注意:线上没有 db_doc_id(它是 SearchDoc,不是 SavedSearchDoc)。移动端在 mobile/src/chat/contracts/documents.ts 里做了全字段移植(注释明确说明"Full field set — 9b's search/fetch sub-renderers consume the extras, so model it once here")。
终止包是 stop 而非 message_end
终止包是 stop({type:"stop", stop_reason}),没有 message_end。移动端 isComplete 已经以 STOP 为准。
关键事实:inline 标记 [[n]](url) 的链接已烘焙
[backend/onyx/chat/citation_processor.py](https://gitcode.com/GitHub_Trending/da/danswer/blob/2a5439d86a849cdbb543d4034533edc5166806d1/backend/onyx/chat/citation_processor.py?utm_source=gitcode_repo_files#L496-L508) 是 9a 整个 inline 路径的支点:处理器解析出引用编号后,取 link = search_doc.link or "",然后在 HYPERLINK 模式下把标记格式化为 [[{num}]]({link})。由此推出两条结论:
- Web/有链接文档 → 标记的 URL 就是文档链接 →
onLinkPress(event.url)可以直接打开,点按不需要任何引用状态解析。 - 文件/内部文档(无链接) → 标记是
[[n]]()(空括号)→ 可能根本渲染不成可点按链接(这才是真正的边缘 case)。
同文件还展示了去重逻辑:同一 document_id 只有在首次被引时才会生成 CitationInfo(recent_cited_documents / cited_document_ids 两个集合共同把关),cited_documents_in_order 维护首次引用顺序——这正是移动端 citations[] 有序数组的后端来源。
单轮回答的包序与去重
研究文档给出的每轮标准顺序:
- 文档包先到(搜索工具的轮次内):
search_tool_documents_delta/open_url_documents; message_start(携带final_documents);- 反复出现:
citation_info紧随其后就是携带对应[[n]](url)文本的message_delta; section_end;stop。
去重规则:一个 document_id 只在首次被引时发 citation_info。这对移动端处理器意味着:citations[] 天然按首次引用顺序去重排列,citationMap 是"编号 → 文档 ID"的覆盖映射。
Web 端结构对齐目标(parity target)
Web 的 packetProcessor.ts 通过 handleCitationPacket/handleDocumentPacket 就地、增量地维护三份状态(nextPacketIndex 游标保证只处理新增,数组收缩时重置):citationMap({[n]: document_id})、citations[](经 seen-set 去重、按首次引用顺序)、documentMap(Map<document_id, doc>)。三个表面都从这份状态取数:
- (A) inline 标记:答案 markdown 中的
[N]→MemoizedAnchor解析 →citationMap→ 文档 →SourceTag芯片 + 悬停卡片。(移动端:改为带样式的可点按链接。) - (B) "Sources" 工具栏按钮(
MessageToolbar→SourceTag variant="button"):最多 3 个来源图标的 IconStack + "Sources" 标签,当citations.length>0 || documentMap.size>0时显示,点按打开 (C)。 - (C) 引用来源列表(
DocumentsSidebar):移动 Web 上已经是底部Modal,标题就是 "Sources"。分节:Cited Sources(按引用顺序)/ More(或 "Found Sources")/ User Files。每行 =ChatDocumentDisplay:[来源类型图标或 favicon + 标题(semantic_identifier)截断] / [元数据:更新时间徽标 + ≤3 个元数据 chip] / [来自match_highlights || blurb的片段]。行点按 →openDocument。 openDocument(web/src/lib/search/utils.ts):有link→ 浏览器打开;File/UserFile(无链接)→ 预览弹窗;否则 no-op。
移动端 9a 的三种状态名与三个表面,正是对这一结构的逐一移植。
移动端引用 UX 行业最佳实践
研究文档调研并沉淀了以下移动端引用体验的最佳实践(这也是 9a 设计的 UX 依据):
- 保留数字
[N]标记(RAG 级模式;Perplexity、Claude 都在用)——工作是样式 + 点按目标,而不是改成内联超链接。 - 标记必须是真正可聚焦/可点按的元素,具备"点按即开"行为(触屏没有 hover 兜底)。
- 点按目标 ≥ 44×44 pt(Apple HIG)/ WCAG 2.5.8 24px 下限——用
hitSlop/padding 扩大命中区,而不是只给一个小小的字形。 - 来源 = 可折叠的 "Sources (N)" / 底部弹层 / 横向卡片滚动,而不是桌面侧栏;卡片展示 favicon + 标题 + 域名 + ≤200 字符片段。
- 点按默认用应用内浏览器打开(
expo-web-browseropenBrowserAsync→ SFSafariViewController / Chrome Custom Tabs),而不是外部 Safari/Chrome;无 URL 的内部文档用预览弹层。 - 流式:从累积答案文本解析标记(
[12]可能跨 delta 被切开);容忍前向引用(标记先于其来源到达),未解析前渲染为惰性。 - 无障碍:每个标记要有描述性
accessibilityLabel("Source N: {title}"),而不是裸数字;accessibilityRole="link"。 - NN/g 研究:用户很少点击引用,但引用的存在会驱动(过度)信任——来源卡片要以标题 + 域名开头(有意义的标签),标记放在论断旁边。
三个实现方案与选型
方案 A — Simplicity-First:扩展 MessageTextRenderer,不引入新框架
引用数据沿用现有 node.packets;usePacketDisplay 已经把所有包交给唯一匹配的 MessageTextRenderer,所以 registry / usePacketDisplay / controller / history 全部零改动。只需扩展 MessageTextRenderer:(a) 把 onLinkPress 透传给 StreamingMarkdown;(b) 用一个纯函数 deriveCitations(packets)(每次 flush 用 useMemo 重跑,和 accumulateContent 一样)驱动 Sources 栏 + 底部弹层。inline 路由不需要引用状态——标记 URL 是预烘焙的,onLinkPress(event.url) 直接打开;空 URL 的文件标记路由到打开 Sources 弹层。新增约 4 个文件、4 处小改动。
- 代价:活动部件最少、最快上线、风险最低;但引用逻辑被困在
MessageTextRenderer里(9b 分组时要搬家),deriveCitations每次 flush 全量重扫(聊天规模下可接受),且没有为 9b 建共享来源层。 - 风险:空括号
[[n]]()文件标记可能解析不成链接(缓解:一行归一化]]()→]](#cited));来源图标缺口用公共 faviconexpo-image+ 通用兜底补齐。
方案 B — Streaming-Robustness / Performance-First:增量处理器 + 标记变换
核心是一个纯增量 citationProcessor.ts(ref 型 useCitationProcessor,nextPacketIndex 只处理新增、就地变更、用 citationCount/docCount/resolveVersion 做变更代理),让 2000 token / 30 引用的答案永不重解析;Sources 栏/弹层是 memoized 兄弟组件,只在计数变化时重渲染,绝不逐 token 渲染。纯函数 transformCitationMarkers(displayed, resolve) 把已解析的 [[n]](url) 重写为受控的 onyxcite://n href,裁剪残缺的尾部标记,把前向引用降级为惰性纯文本;点按路由解析 n → doc → openDocument。
- 代价:确定性的流式边界 + O(新增包) 处理 + memoized UI;但这套鲁棒性机器在很大程度上被"URL 预烘焙"这一事实削弱(文档包先于答案到达,且标记自带 URL,前向引用门控收益很小),
onyxcite://重写还引入了脆弱的逐帧变换。 - 风险:渲染器可能过滤自定义
onyxcite://scheme;部分标记正则的边缘 case([not a cite]、真正的[[x]](y)链接);上标可能无法表达。
方案 C — Flexibility / Reusable-Foundation:一个处理器 + processed 通道 + 共享来源层
引入 9b–9e 都会复用的最小持久接缝:(a) 纯增量 messageProcessor.ts(Web 单个 packetProcessor 的移动移植:游标 + 收缩重置),9a 填充 citationMap/citations[]/documentMap,9b 在此基础上扩展分组/steps;(b) 扩展 usePacketDisplay 来宿主处理器,MessageRendererProps 携带 processed 状态(不只是原始包)——即 9b 的时间线/搜索/工具渲染器要读的通道;(c) 共享来源层——SearchDoc 契约、SourceIcon/WebResultIcon、SourceRow、openSource 路由——9a 的弹层和 9b 的搜索/抓取子渲染器共用。分组本身现在不建(那是 9b);9a 状态保持扁平/无分组,让 9b 的分组设计不受约束。inline 走同一条 onLinkPress → resolveCitationHref → openSource。
- 代价:9b 接入时渲染器管道零重写(Web 证明了单处理器路径),
SourceRow/SourceIcon/SearchDoc/openSource在 9a–9b 各被复用 3–4 次;前置成本是一个小的纯模块 + 一个 hook 改动 + 一个 prop 增加 + 几个组件。每个接缝都由具体、临近的 9b 消费者背书(不是臆测)。 - 风险:对 9b 分组形态的猜测错误(已最小化——9a 不承诺任何 turn/tab 分组);
SearchDoc建模不足(缓解:现在就移植全字段);resolveCitationHref是唯一脆弱映射(隔离 + 单测)。
跨方案比较
- inline 点按路径:A 的洞察(预烘焙标记 URL + 文档包先于答案)让常见 Web 文档场景变得平凡,同时削弱了 B 的前向引用 /
onyxcite://机器——B 真正有用的内核(增量游标)在 C 里本来就有(Web 里也有)。所以 B 的额外部分在聊天规模下大多不划算。 - 与路线图的对齐:9b 紧接着要做,且明确需要一个可扩展的包处理器(分组)+ 渲染
SearchDoc来源行的搜索/抓取渲染器——也就是 C 的共享层 +processed通道。A 是把引用逻辑临时塞进MessageTextRenderer,偏离 Web 的单处理器结构,9b 落地时被迫搬家。 - 抽取策略张力:移动端策略是"证明可复用才抽取,而非提前抽取"。C 的复用对象都是具名且临近的(9b),不是臆测——所以 C 不算过度工程;但如果 9b 很远,A 才是诚实的选择。
- 失败模式:三者共享空括号
[[n]]()文件标记风险和来源图标缺口;C 额外带一个(已最小化的)分组形态猜测;B 额外带自定义 scheme + 正则风险。
选定方案:C + A 的 inline 简化(GATE 1 通过)
Approach C(可复用基础),采纳 A 的 inline 简化。 理由:C 匹配 Web 的真实结构(一个增量包处理器 + 给渲染器的 processed 状态通道),而 9b(agent 时间线)就是下一个阶段,它需要的正是这些:一个可扩展分组/步骤的处理器,以及 9b 搜索/抓取子渲染器复用的共享来源层(SearchDoc + SourceIcon/WebResultIcon/SourceRow/openSource)。A 会在 9b 落地时被迫把引用逻辑搬家;B 建的流式鲁棒性机器被"标记 URL 预烘焙"事实基本架空。
从 A 嫁接的(inline 简化):inline 点按路径不解析引用状态——标记 URL 预烘焙([[n]](link),link = search_doc.link or ""),onLinkPress(event.url) 直接打开 Web/有链接文档;空 URL 的文件标记降级为打开 Sources 弹层。因此完全砍掉 B 的前向引用门控和 onyxcite:// 标记重写变换。
净形态:C 的可复用接缝(处理器 + processed 通道 + 共享来源层)+ A 的轻量 inline 路径 + 零 B 的重写/门控复杂度。
明确推迟到 9b(现在不建):turn/tab 包分组、时间线步骤、推理/搜索/工具子渲染器。9a 的处理器状态保持扁平(citationMap、citations[]、documentMap、完成态),让 9b 的分组设计不受约束。
当前仓库中的落地印证(源码与测试佐证)
研究文档标注为 draft,但其结论在仓库中已基本实现为代码,可作为选型的实证:
- 纯增量处理器:mobile/src/chat/messageProcessor.ts 就是 Web
packetProcessor的移动移植:ProcessedMessageState携带nextPacketIndex游标、citationMap、citations[]+seenCitationDocIds去重集合、documentMap、isComplete/stopReason。processPackets在数组收缩时重建状态("regenerate / reload"),只处理游标之后的新包,并保持数组引用稳定以服务 memoized 消费者。handleCitationPacket与handleDocumentPacket严格对应后端三类包(citation_info、两种文档包、message_start.final_documents),其中upsertDocuments对document_id做 upsert 覆盖。 - 契约与路由:mobile/src/chat/contracts/documents.ts 移植了
SearchDoc全字段(含file_id、primary_owners等),并定义了StreamingCitation(注意与 wire 包的citation_number字段名差异)和CitationMap;mobile/src/chat/openSource.ts 实现openUrl(expo-web-browser应用内浏览器,仅接受 http/https)与openSource(有链接 → 浏览器;有file_id→ toast "Preview isn't available on mobile yet.";否则 no-op),正是选型文档里"openSource 路由"的落地。 - 纯选择器:mobile/src/chat/citations.ts 提供
selectSources(把文档拆成 Cited / More / Files 三组,iconDocs取前 3 个用于按钮图标栈,count驱动 "Sources · N")以及domainOf/faviconUrl(Google 公共 favicon 服务,无需鉴权)。 - UI 落地:mobile/src/components/chat/CitedSources.tsx 实现了
CitedSourcesBar(Pressable,accessibilityRole="button"、accessibilityLabel形如Sources, ${count},图标栈 + "Sources · N")与CitedSourcesSheet(Sheet标题 "Sources",三个分区:Cited Sources / More(无被引时显示 Found Sources)/ User Files,行点击openSource);SourceRow.tsx 展示图标 + 标题 +domainOf(link) ?? source_type · 相对时间+ ≤200 字符摘要(并剥离 Vespa<hi>高亮标记);SourceIcon.tsx 用普通expo-image加载 favicon、失败回退通用文件图标。MessageRow.tsx 在processed.isComplete后通过selectSources计算并渲染 Sources 脚注(见 MessageRow.tsx 与CitedSourcesBar/CitedSourcesSheet的挂载处)。 - 测试覆盖:mobile/src/chat/tests/messageProcessor.test.ts 验证了 citationMap 构建与首次引用顺序去重、两种文档包 +
final_documents的 upsert、stop置完成、跨 flush 只处理新增、数组收缩重置等关键行为;mobile/src/chat/tests/fixtures.ts 提供了研究文档点名要的makePacket/makePlacedPacket/makeCitationPacket/makeSearchDoc/makeSearchDocsPacket辅助函数;mobile/src/components/chat/tests/CitedSources.test.tsx 用createInitialState + processPackets构造真实状态,断言弹层渲染 "Cited Sources" 分区与 "Sources · N" 按钮计数及onPress回调。
结论与后续(9b)
9a 的最终形态可以一句话概括:后端零改动(引用/文档数据早已存在于既有 NDJSON 流中),移动端以"一个纯增量处理器 + processed 状态通道 + 一组共享来源组件"为骨架,inline 标记借预烘焙 URL 走 onLinkPress 直接打开,富 UI 全部收敛到 Sources 按钮 + 底部弹层。这既完成了 Web 引用体验的移动端移植,又为下一阶段 9b(agent 时间线) 铺好了处理器与来源层的可扩展接缝。9b 将在此之上增加 turn/tab 分组、时间线步骤以及搜索/抓取子渲染器——而 9a 已确保这些扩展不需要推翻任何渲染器管道。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0631
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
video-shotcraftAI宣传片skill,使用 Remotion 制作电影级产品视频:提供106 张镜头配方卡和可复用的视频魔板。适用于 Claude Code 与 Codex以及所有其他智能体Markdown00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python09
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00