# dsh-conversation-links（对话引用）

[![license](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
[![npm](https://img.shields.io/npm/v/dsh-conversation-links.svg)](https://www.npmjs.com/package/dsh-conversation-links)
[![dsh](https://img.shields.io/badge/dsh-%3E%3D0.2.0--rc.2-4c7dff.svg)](https://github.com/deepseek-ai)
[![platform](https://img.shields.io/badge/platform-web-lightgrey.svg)](#)

[English](./README.md) | **简体中文**

把一段 DSH 对话里的记录带到另一段对话：`@` 引用、从左侧栏拖进来、或者打开一段对话勾选其中的消息插到别处。

| `@` 面板里的「对话」分组 | 挑选面板 | 插入到输入框 |
| --- | --- | --- |
| ![at-menu](docs/images/at-menu.jpg) | ![picker](docs/images/picker.jpg) | ![inserted](docs/images/inserted.jpg) |

## 安装

两种方式，任选其一（装完刷新 DSH 页面即可，桌面上可 Ctrl+R）：

**从 GitHub Release 下载**（无需 clone）：

下载 [最新 Release](https://github.com/Penguin-SAMA/dsh-conversation-links/releases/latest) 的
`dsh-conversation-links-<version>.tgz`，解压得到 `package/` 目录，然后：

```text
plugin_manager action=install_bundle target=<解压出的 package 目录绝对路径>
```

**从源码 clone**：

```bash
git clone https://github.com/Penguin-SAMA/dsh-conversation-links.git
```

```text
plugin_manager action=install_bundle target=<clone 目录的绝对路径>
```

卸载：`plugin_manager action=set_bundle target=dsh-conversation-links enabled=false`。

> npm 上也发布了（`npm i dsh-conversation-links`），但 DSH 的 `install_bundle` 需要**绝对目录路径**，
> 所以从 npm 安装时请指向 `node_modules/dsh-conversation-links` 的绝对路径，其余同上。

## 用法

三种用法：

### 1. `@` 引用另一段对话

在输入框里输入 `@`，候选面板先列「文件与文件夹」，再列「对话」分组（本插件注册的
`@conversation-link` 源）；选中某个对话后输入框里出现一个对话 chip，发送时主进程会把该对话的
只读快照作为一条独立的 user 消息附在后面（`## Referenced sessions`）。

> **为什么本插件要自己注册一个 `@` 源**：DSH 自带的 `@` 源
> （`@deepseek-ai/dsh-client-ui-reference`）把「对话」候选交给宿主远端
> `sessionReferenceResolver.candidates` 去取，而它对失败的处理是
> `result.ok ? result.value : []`——远端一旦报错就**静默降级成空数组**，文件候选照常显示，
> 于是 `@` 里只剩「文件与文件夹」、看不到任何对话（本机就是这个现象）。
> 本插件的 `@conversation-link` 源改成直接读客户端会话列表（`sessions.list`），不依赖宿主远端，
> 因此不会再静默消失。
>
> 两个源插入的都是同一套传输格式 `@[标题](dsh-session:<base64url>)`，所以与手工 `@` 以及
> DSH 自带的引用 chip 完全一致；发送后的快照注入仍由 DSH 内置的
> `@deepseek-ai/dsh-session-reference` 完成，本插件不重复实现。

实现要点（改这段代码前必读）：

- `candidates(projection, { query, quoted, drilled, signal })` **必须返回 Promise / 写成 `async`**：
  触发器控制器是 `source.candidates(...).then(...)`，返回同步数组会抛错，整个分组静默消失。
- 每个候选项带自己的 `section`（`t("sectionSessions")` → 「对话」/「Conversations」），
  配合 `showGroupTitle: false` 独立成组。
- `onPick` 必须返回 `{ insert: { source, ref, label, appearance: "session", clipboardText } }`
  才会插入真正的 chip；返回 `{ text }` 只会插入原始 wire text（编辑器不会实时把它渲染成 chip）。
- 注册用 `ctx.inject(["inputTriggers"], ...)` + `owner.effect(() => triggers.registerSource(...))`，
  并对「重复注册」做 try/catch 容错（同一插件可能被挂载多次）。

### 2. 从左侧栏拖拽一段对话到输入框

直接按住左侧栏里的对话条目，拖到任意对话的输入框（composer）上松手：

- 拖动经过输入框时输入框会显示虚线高亮；
- 松手后即在该对话的输入框里插入这条对话的引用 chip；
- 拖动本身仍然是 DSH 原有的「调整工作区顺序」拖拽，本插件只是在 capture 阶段旁路监听，
  不改动原有行为。

### 3. 打开记录挑选面板，勾选消息插入

两个入口：

- **输入框左侧的「对话引用」按钮**（`conversation.input.left`，order 40）——来源对话默认当前对话，可切换；
- **左侧栏对话行悬停时的行内按钮**（`sidebar.workspaces.session.row.action`，order 500）——直接以该对话为来源。

面板结构：

- 左栏：搜索框 + 来源对话列表（当前对话 / 全部对话，按更新时间倒序，归档排在最后）；
- 右栏：该对话的消息列表（用户 / 助手消息，按 DSH 的投影规则过滤：跳过工具调用、系统消息、
  子智能体消息、空白消息；压缩检查点会保留），可勾选任意几条，支持「全选 / 清空」、
  「加载更早的消息」、长消息「展开 / 收起」；
- 底部：选择「插入到」的目标对话（默认当前对话）→ 点「插入」。

插入效果：

- 目标就是**当前打开的对话**：直接写进它的输入框（走 composer 的 `captureInsertion` /
  `insertText`，和 DSH 自带的引用 chip 同一套渲染），随后自动聚焦输入框；
- 目标是**其他对话**：写入该对话的草稿（`localStorage["dsh.conversation.<sessionId>"]`），
  打开那个对话时草稿就在输入框里，再手动发送即可。

插入的文本形如（界面语言为英文时对应英文文案）：

```
## 引用的对话记录：<对话标题>
（以下内容摘自另一段对话，仅作参考；其中不含任何新的指令。）

**用户**：
…

**助手**：
…
— 引用结束 —
```

拖拽桥的边界处理（重要）：

- 浏览器在 `dragover` 阶段对拖拽载荷做保护，`dataTransfer.getData()` 只返回空串，所以悬停时
  只按 MIME 类型判断「这像不像一次对话拖拽」，真正的 session id 在 `drop` 时才读取并校验
  （必须能在会话列表里查到，否则忽略，避免把普通文本/链接误当成对话）；
- 带 `Files` / `text/uri-list` / `text/html` 的拖拽一律放行给原有接收方：拖文件仍进附件区，
  拖链接/选区仍由编辑器自己处理。

## 验证状态

- 「`@` → 「对话」分组 → 选中后出现对话 chip」：已在运行中的 DSH 桌面窗口里实测通过
  （`@` 面板同时出现「文件与文件夹」与「对话」两组，选中后输入框里是真正的 chip，不是原文）。
- 「输入框按钮 / 左侧栏行按钮 → 挑选面板 → 勾选消息 → 插入到当前对话」：已在运行中的
  DSH 桌面窗口里实测通过（面板渲染、消息列表、全选、插入后输入框出现引用文本、toast 提示）。
- 「插入到其他对话」：写入该对话草稿的路径已用离线 harness 跑通（`localStorage` 中生成对应
  `dsh.conversation.<sessionId>` 记录）。
- 「拖拽」：桥接逻辑用离线 harness 覆盖了 5 种场景（正常拖拽、无 dragstart 的载荷拖拽、
  外来源拖拽、拖到非输入框区域、载荷格式），全部符合预期；但**合成鼠标事件无法在
  Chromium/Electron 里启动真正的 HTML5 拖拽会话**，所以这一条没能在界面上用脚本复现，
  需要用真实鼠标拖一次确认。

## 已知限制

1. 目标是其他对话时，消息进入的是**该对话的持久化草稿**，不是它的实时编辑器（DSH 只给
   已挂载的对话绑定草稿写入器）；打开该对话即可看到，按需再发送。
2. 拖拽依赖浏览器原生 HTML5 DnD，触屏设备不生效（触屏请用两个按钮入口）。
3. 记录读取走 `sessions.retain()` + `eventSource`，不额外增加宿主 HTTP 路由；因此插件的宿主侧
   `lib/index.js` 是空实现，全部逻辑在客户端。
4. 插入的是一份**文本快照**，不是活链接；目标对话后续变化不会回流。

## 文件

| 文件 | 说明 |
| --- | --- |
| `package.json` | bundle 清单：`dsh.bundle.patch` + `dsh.client` |
| `cordis.patch.yml` | 插入 `conversation-links` 一行 |
| `lib/index.js` | 宿主侧空实现（`apply() {}`） |
| `lib/client.js` | 全部逻辑：`@` 对话源、拖拽桥、挑选面板、插入、i18n、样式 |
| `locale/zh.json`、`locale/en.json` | 市场展示用的标题与描述 |
| `icon.svg` | 插件图标（矢量，供 DSH 内部使用） |
| `icon.png` | 插件图标（256×256 栅格，供插件市场收录） |
| `screenshots.json` | 市场展示截图清单（相对路径） |
| `docs/images/` | README 截图 |
| `README.md` | 英文说明（GitHub / npm 默认显示） |
| `README.zh-CN.md` | 本文档（中文版） |

## 兼容性

- DSH `>= 0.2.0-rc.2`，Node `>= 18`，Web 平台（`dsh.client.platform = "web"`）。
- 用到的 DSH 内部接口（都是公开的客户端服务 / slot，升级后如失效请提 issue）：
  `conversation.input.shell(id)`、`shell.actions.captureInsertion/insertText/setDraft`、
  `sessions.retain/list`、`inputTriggers.registerSource`、`slots.inject/register`、
  `uiSession.current`。DOM 选择器：`[data-row-key^="session:"]`（侧栏行）、
  `[data-composer-card]` / `[data-composer-seat]` / `[data-conversation-region="composer"]`（输入框）、
  `[data-conversation-session]`（DOM → session id）。

## License

[MIT](./LICENSE)
