# 选区引用追问技术方案 > 状态:可行,已确认(D1–D13,待实施) > 日期:2026-10-27 > 上游提案:[docs/proposals/2026-10-27-selection-quote.md](../proposals/2026-10-27-selection-quote.md)(P3 终端选区引用已驳回;P2 按「标记外附行号 + 片段」) > 覆盖端:desktop;标题预处理一处在 backend-ts;admin / contracts / agent-cli / 安卓原生壳零改动 > 迁移:无(不占号) > > 2026-10-27 可行性评审:需求与方案主体可行。D10 的结论不变,比较信号改为 §5.4 的修订号。D11–D13 是评审补上的缺口。 在助手回复里选中一段,或在文件预览 / Diff 里选中若干行,引用进当前输入框再追问。发出去的仍是普通用户消息文本:回复引用是开头的 Markdown 引用块,文件引用保持现有 `@{路径}@` 标记,行号和片段写在标记外面。 --- ## 0. 决策记录 2026-10-27 评审确认: | # | 决策 | 结论 | |---|---|---| | D1 | 原 P3 终端选区引用 | **驳回**。`TerminalPanel.vue` 不改。终端使用率不作为本方案的前置数据 | | D2 | 文件行号放哪 | **标记外**。序列化仍是 `@{路径}@`,后面接「第 N-M 行」和可选代码块。不改成 `@{path#L120-145}@`,现有解析正则一行不动 | | D3 | 片段上限 | **200 行或 8KB(UTF-8 字节),先到先停**。超出只留行号,不内联片段。常量集中,不进设置 | | D4 | 回复引用上限 | **单条 2000 字**(JavaScript 字符串长度)。超出截断,并在引用块末行写 `…(已截断)` | | D5 | 编辑已发送的消息 | **编辑框里是序列化后的纯文本**,不把引用还原成引用条或文件标签。不写反向解析器 | | D6 | 安卓 | **只保留回复操作栏的「引用」**。不做选区浮钮,不注册文件 / Diff 的「引用到对话」。判定用现成 `isAndroidCapacitor()` | | D7 | 后端 | **只改标题预处理**。提示词、飞书引用注入、全文检索、反馈预览都不改 | | D8 | 空选区 | 浮钮只在选区非空时出现。文件 / Diff 右键在选区为空时引用光标所在的一整行 | | D9 | Diff 范围 | **只做 SNAPSHOT 的修改后一侧**。PATCH、原始一侧、Markdown 渲染态、CSV / 表格 / 压缩包 / PDF / 图片 / 音视频都不提供入口 | | D10 | 过期提示 | 引用捕获之后,同一路径又出现新的文件变更,输入框标签加「内容可能已变化」。发给模型的文本不含这句提示。信号是 §5.4 的修订号,不是变更条数 | | D11 | Git Diff | **不提供引用**。Tab id 前缀是 `git-diff:`。多仓库时路径被加上仓库前缀,不是工作区里能直接读的文件 | | D12 | 发出去之后怎么显示 | 存储、复制、编辑重发仍是全文。气泡、待发送队列、轮次摘要、分叉标签把开头的回复引用折成一行,不把 `> ` 铺开 | | D13 | 标题剥哪些围栏 | **只剥文件行区间自带的那一段围栏**。用户自己写的代码块留在标题素材里 | --- ## 1. 背景与目标 今天 `@` 文件只有路径。模型要自己在整份文件里找用户说的「那段」。从回复里复制出来的文字没有「这是哪一条回复的哪一段」。文件预览和 Diff 能选中、能复制,没有「引用到对话」。 目标: 1. 助手回复里选中文字,选区上方出现「引用」。点击后输入框上方多一条可删除的引用。整条回复的操作栏也有「引用」,引用这条回复里用户能看见的正文。 2. 文本预览和工具变更 Diff(修改后一侧)里选中行,右键和选区浮钮都能「引用到对话」。输入框里是一枚 `文件名:120-145` 标签。发送时标记格式不变,行号和片段跟在标记后面,模型直接看到片段,不必先读整文件。Git Diff 不提供这个入口。 3. 引用条和带行号的文件标签随现有草稿走,切换会话不丢、不串。 不做: - 终端选区引用。Git Diff 也不引用。 - 改 `@{路径}@` 的语法,或改任何一处现有解析正则。 - 跨会话引用、图片区域框选、改系统提示词、改飞书「引用回复」注入。 - 把已发送消息编辑回引用条。 - 新接口、新表、新契约类型。 --- ## 2. 现状代码事实(实施前已核对) 行号为 2026-10-27 工作区读到的位置,实施时以符号为准,行号会漂。 ### 2.1 提案里三处和代码对不上的地方 实施按本节,不按提案原文的类名和文件名。 | 提案写法 | 代码事实 | |---|---| | 选区落在 `.markdown-content` | 没有这个类。助手正文是 `MarkdownContent`,`body-class="assistant-text markdown-body"`(`MessageBubble.vue` 约 96–102、112–118 行) | | 复用 `workspace-file-provider.ts` 的 mtime,和「打开预览的时间」比 | provider 的 `mtime` 只出现在目录条目上,预览打开时不记 mtime。`FileViewer` 的 300ms 重载看的是 `matchingChangeCount`(约 154–160、384–395 行),而 `appendFileChange` 对同一 `path` 是原地合并,条数对同一路径字符串不会再增加。同文件第二次修改时,这个 watch **不会**重载。本方案要改这个触发条件,见 §5.4 | | 六处解析点含「飞书入站」 | 飞书入站是**写入** `@{路径}@`(`quoted-injection.ts`、`merge-forward.ts`),不解析桌面用户打出来的引用。真正按 `/@\{([^}]+)\}@/` 取出花括号内路径的调用点见 §2.3 | ### 2.2 输入与展示 | 事项 | 现状 | 位置 | |---|---|---| | 文件标签节点 | 原子 inline 节点,唯一属性 `filePath`。`renderText` 输出 `@{${filePath}}@`,标签只显示文件名 | `tiptap/FileReferenceNode.ts` | | 编辑器能力 | `StarterKit` 关掉了 `blockquote`、列表、代码。粘贴普通文字(含从回复复制的内容)变成纯段落,不会变成引用块 | `ChatInput.vue` 约 524–541 行 | | 发送取文本 | `editor.getText({ blockSeparator: '\n' })`。原子节点走 `renderText`,其中的换行会进消息正文 | `ChatInput.vue` `handleSend` 约 1094 行 | | 发送后路径 | `resolveFileRefPaths` 把标记内的相对路径拼成工作区绝对路径;已是绝对路径(含 Windows 盘符)的不动。乐观气泡和实际发送用同一份文本 | `useChat.ts` `resolveFileRefPaths` | | 能否发送 | `canSend` 要求编辑器有文字或有待发附件。只有引用、输入框为空时今天发不出去 | `ChatInput.vue` `canSend` 约 284 行 | | 草稿 | Pinia 内存,不落 localStorage。`DraftEntry` 是 `html + text + files`。`html` 来自 `getHTML()`,自定义节点属性只要写进 `parseHTML` / `renderHTML` 就能随草稿往返。空草稿(无文字、无附件、html 为空壳)会删条目 | `stores/draft.ts`;`ChatInput.vue` `buildCurrentDraft` 约 724 行 | | 「添加到聊天」路由 | `TaskView.handleAddFileToChat`:当前 Tab 的输入框有注册就插入;文件 / Diff Tab 没有输入框,切回主会话 `chat` 再插入。主会话 key 是 `'chat'`,边路是 tabId(如 `side:123`)。KeepAlive 下多个 `ChatInput` 同时挂载,不能用单例注册 | `TaskView.vue` 约 298–330 行 | | 插入实现 | `insertFileReference` 在光标处插入节点,并在节点后加一个空格 | `ChatInput.vue` 约 1212–1227 行 | | 用户气泡 | 不走 Markdown。`parseQuickCommandSegments` 把 `@{路径}@` 切成 file 段,其余是纯文本。`FileReferenceTag` 只显示文件名,悬停才是完整路径 | `quick-command-parser.ts`;`FileReferenceTag.vue` | | 助手操作栏 | 复制、点踩、Fork、分享。流式输出中整条栏不渲染(`!isAssistantRunning`) | `MessageBubble.vue` 约 167 行 | | 编辑重发 | 用户消息编辑态是 `