dsh-select-quote 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 agan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,162 @@
1
+ # dsh-select-quote
2
+
3
+ [DeepSeek Harness](https://deepseek-harness.github.io/deepseek-harness/guide/quickstart) 的 Web 客户端插件:在对话里划词,把选中的文本作为**引用**带进下一条消息。
4
+
5
+ - **划词工具条**:选中对话区任意文本,浮出「复制 / 添加到任务」
6
+ - **引用卡片**:添加到任务后,输入框内出现引用卡片(可多张),**草稿里不会出现 Markdown**
7
+ - **随消息发送**:按 Enter 或点原来的发送按钮,引用块按卡片顺序折进消息,卡片消失
8
+ - **对话记录**:同一条消息里的引用渲染为卡片,用户气泡里只剩你打的问题
9
+
10
+ ---
11
+
12
+ ## 兼容性
13
+
14
+ | 项 | 值 |
15
+ |---|---|
16
+ | dsh | `0.1.5-alpha.2`(同 minor 的 `0.1.5-alpha.x` 应该可用) |
17
+ | profile | web(`dsh web`) |
18
+ | 运行时 | 只有浏览器半有业务;Node 半仅用于让包被 Loader 挂载 |
19
+
20
+ 实现依赖若干产品内部约定(见[已知限制](#已知限制)),升级 dsh 后请先跑 `npm run check` 再人工回归。
21
+
22
+ ## 快速开始
23
+
24
+ ### 方式一:本地开发加载(`--patch`)
25
+
26
+ ```bash
27
+ git clone <repo> && cd dsh-select-quote
28
+ npm install
29
+ npm run build
30
+
31
+ dsh web --patch "$PWD/dev.patch.yml"
32
+ ```
33
+
34
+ 打开 `http://127.0.0.1:3080`,在对话消息里划词即可看到工具条。
35
+
36
+ > `dev.patch.yml` 里的插件路径必须是**绝对路径**:checkout 不在 profile 的 `node_modules` 里,Loader 没有包名可解析。仓库内该文件按本机路径写死,换机器/换目录时改这一行;该文件不随 npm 包发布。
37
+
38
+ ### 方式二:从 npm 安装(bundle)
39
+
40
+ ```bash
41
+ dsh plugin --profile web add dsh-select-quote # 或本地目录的绝对路径
42
+ dsh --profile web
43
+ ```
44
+
45
+ ## 开发
46
+
47
+ ```bash
48
+ npm run typecheck # tsc --noEmit,应无输出
49
+ npm run build # 产出 lib/index.js 与 lib/client.js
50
+ npm run watch # 增量重建
51
+ npm run verify # 无头执行 lib/client.js,断言 5 项贡献都注册上了
52
+ npm run check # typecheck + build + verify(提交前跑这个)
53
+ ```
54
+
55
+ **为什么有 `verify`**:`tsdown` 不做类型检查,一个语法坏掉的 bundle 也能构建"成功"。最典型的一次事故是 CSS 模板字符串里出现了反引号(`` `.nyYjTG_file` ``),字符串被提前截断,产出的 JS 无法执行,但 build 输出一切正常。`verify` 把 `lib/client.js` 放进假的 `window.__ModuleLoader__` 里跑一遍,用 mock ctx 调 `apply()`,逐项核对注册结果——这是唯一能在浏览器之外发现这类错误的手段。改动 `src/client/index.tsx` 的注册项时,记得同步 `scripts/verify-bundle.mjs` 里的 `EXPECTED`。
56
+
57
+ ## 工作原理
58
+
59
+ ### 数据契约:消息里的引用块
60
+
61
+ 引用在消息里就是一段带标记的 Markdown 引用块,正文在最前面:
62
+
63
+ ```
64
+ > [选中文本]
65
+ > 用户选中的第一段
66
+
67
+ > [选中文本]
68
+ > 用户选中的第二段
69
+
70
+ 用户自己输入的问题
71
+ ```
72
+
73
+ `> [选中文本]` 这个标记是插件的识别依据:客户端据此把这段从用户气泡里剥掉、改渲染成卡片。**改动这个标记要同时改三处**:`formatQuoteBlock()`(写)、`parseQuoteMessage()`(读)、`displayTextWithoutQuote()`(剥离)。
74
+
75
+ ### 客户端贡献
76
+
77
+ | 位置 | 类型 | 说明 |
78
+ |---|---|---|
79
+ | `conversation.input.overlay` | list slot | `select-quote-toolbar`(划词工具条)、`select-quote-card`(引用卡片堆) |
80
+ | `conversation.chat.node` | keyed slot | `select-quote`(引用卡片)、`user`(替换用户气泡) |
81
+ | Conversation Definition | `uiConversation.events.register` | `select-quote`:把带引用的 `user/message` 变成一个 Chat 节点 |
82
+
83
+ ### 关键机制
84
+
85
+ **1. 引用不进草稿,只在发送瞬间注入**
86
+
87
+ 卡片不是草稿的"可视化",而是唯一的表示。草稿保持用户真正输入的内容,引用块在**发送动作的捕获阶段**才折进去:`document` 上的 `keydown`(无修饰键的 Enter、非 IME 组字)或 `click`(composer 内 DOM 顺序上的最后一个 `button`,且不是停止按钮——停止按钮渲染的是 `svg rect`)。捕获阶段早于 composer 自己的处理器,因此它读到的草稿已经包含引用块。见 `src/client/QuoteCard.tsx`。
88
+
89
+ **2. 不可见字符 U+200B 让发送按钮保持可用**
90
+
91
+ 草稿为空时 composer 会把发送按钮置灰(`empty = draft.trim() === "" && attachments.length === 0`),而 U+200B 不是 JS 的 WhiteSpace,`trim()` 不会去掉它,于是"只有引用、没打字"也能直接发送。它只在**新增/移除引用卡片**时写入或清除一次,发送前由 `stripDraftMarker()` 剔除,绝不进消息。
92
+
93
+ > 这条规则有个坑:**永远不要因为草稿变化就重写编辑器**。`inputActions.setDraft()` 的实现是 `root.clear()` + 逐行重建 + 光标移到末尾。早期版本在 `useEffect` 里依赖 `draft`,一旦发现标记缺失就补——用户按 Backspace 删到标记时会被立刻补回(表现为"删除失效"),输入法组字期间甚至会把正在组字的编辑器内容整段清掉(表现为"打完中文按 Backspace 没反应")。
94
+
95
+ **3. 输入框卡片布局**
96
+
97
+ `conversation.input.overlay` 的锚点是 `height: 0; position: absolute`,卡片本身也是绝对定位,因此**不会挤开下面的编辑器**,默认会盖住附件栏。做法是给 `[data-composer-card]` 加一个 `.dsq_cardPad` 类,用 `ResizeObserver` 实测卡片堆高度写进 `--dsq-quote-pad`:
98
+
99
+ ```css
100
+ .dsq_cardPad { padding-top: var(--dsq-quote-pad, 88px) !important; }
101
+ ```
102
+
103
+ 绝对定位子元素相对**padding box** 定位,所以卡片仍在顶部,而附件栏与编辑器一起被推到它下方——这也顺带修掉了"传图时图片被卡片盖住"。
104
+
105
+ **4. 对话记录:Definition + 节点 + 气泡替换**
106
+
107
+ - Definition(`transcript-node.ts`)匹配 `type === "user/message"` 且文本含引用块的消息,产出一个 `select-quote` 节点;
108
+ - `TranscriptQuoteCard.tsx` 渲染该节点(每段引用一张卡片,纵向堆叠、与气泡同侧);
109
+ - `UserMessageDisplay.tsx` 以 `priority: -10` 替换内置的 `user` 节点,把引用块从气泡里剥掉,**同时自己渲染消息携带的图片**——只引用、没有正文的消息,气泡文本为空,若不接管图片渲染,整条消息(含图片)会消失。
110
+
111
+ **5. 样式与主题**
112
+
113
+ 卡片几何对齐产品自带的文件卡片(`ui-deliverables` 的 `.nyYjTG_file`):`.5px` 发丝边框、18px 圆角、64px 固定高、10px 内边距、hover 背景过渡;删除按钮绝对定位右上角,仅 hover / 键盘聚焦时淡入。
114
+
115
+ - 深色模式用产品自己的 `body[data-ds-dark-theme]`(由 `dsh-client-ui-layout` 挂在 body 上),**不是** `prefers-color-scheme`;
116
+ - 颜色一律用会随主题翻转的 `--dsw-alias-*` / `--dsw-static-*` token,只自己定义 `--dsq-card-fill` / `--dsq-card-hover` 两个填充变量;
117
+ - 样式通过 `ensureToolbarStyles()` 注入一个 `<style data-plugin-css="dsh-select-quote/toolbar.css">`,**改完 CSS 必须刷新页面**(函数只插入一次)。
118
+
119
+ ## 目录结构
120
+
121
+ ```
122
+ src/index.ts # Node 半:占位,让包被 Loader 挂载
123
+ src/client/index.tsx # Client 半入口:注册 Slot 与 Conversation Definition
124
+ src/client/SelectionToolbar.tsx # 划词浮动条(复制 / 添加到任务)
125
+ src/client/selection.ts # 选区快照、工具条定位、复制、聚焦输入框
126
+ src/client/QuoteCard.tsx # 输入框内引用卡片堆 + 发送动作拦截
127
+ src/client/quote-store.ts # 每会话引用状态、消息块拼装、U+200B 标记
128
+ src/client/TranscriptQuoteCard.tsx # 对话记录里的引用卡片
129
+ src/client/UserMessageDisplay.tsx # 替换用户气泡(隐藏引用块、保留图片)
130
+ src/client/transcript-node.ts # select-quote Conversation Definition
131
+ src/client/transcript-parse.ts # 引用块解析 / 气泡文本剥离
132
+ src/client/styles.ts # 全部 CSS(模板字符串注入)
133
+ src/client/context.d.ts # 客户端 Context 服务的类型补充
134
+ scripts/build.mjs # tsdown 构建 + ModuleLoader 包装
135
+ scripts/verify-bundle.mjs # 无头 bundle 校验
136
+ cordis.patch.yml # bundle 加载层(按包名,随 npm 包发布)
137
+ dev.patch.yml # 本地 --patch 开发层(绝对路径,不发布)
138
+ lib/ # 构建产物(已提交,运行时直接读它)
139
+ ```
140
+
141
+ ## 已知限制
142
+
143
+ - **引用状态在内存里**:刷新页面或换会话后待发引用会丢,卡片不会持久化。
144
+ - **输入框卡片最宽为输入框的 1/4**(`max-width: calc(25% - 5px)`),超长标题走省略号;同一行可放 4 张,多了换行。正因为窄,输入框内的图标是 36px,而对话记录里是 40px。
145
+ - **替换了 `user` 节点**(`priority: -10`,最低优先级胜出)。若其它插件也替换同一个键(例如 `dsh-easyrewrite` 用 `-1`),本插件会把对方屏蔽掉。
146
+ - **只注入一个会话一份待发引用**,按 `sessionId` 在内存 Map 里存。
147
+ - 依赖的产品内部约定:`[data-composer-card]` 属性、主发送按钮是卡片内 DOM 顺序上最后一个 `button`、停止按钮渲染 `svg rect`、`user/message` 事件的内容在 `data.content`(不是 `data.message.content`)、深色属性 `data-ds-dark-theme`。产品升级后这些都需要复核。
148
+
149
+ ## 排错
150
+
151
+ | 现象 | 先查 |
152
+ |---|---|
153
+ | 划词没有工具条 / 卡片不出现 | 跑 `npm run check`;确认页面已刷新;Host 的 boot 图里应包含 `dsh-select-quote`(`/plugins` 路由由 `ctx.clientModules` 提供) |
154
+ | 输入框里出现 `> [选中文本]` 原文 | 说明发送前的注入没生效:确认草稿里存在 U+200B(发送按钮应是可用的),以及 composer 结构未变(`[data-composer-card]`) |
155
+ | 对话记录里没有卡片 | 该 `user/message` 事件的 `data.content` 是否含引用块;`conversation.chat.node` 的 `select-quote` 单元是否被注册 |
156
+ | 带图片的消息在记录里只剩卡片、图片没了 | `UserMessageDisplay` 是否拿到了 `renderMessageImages` |
157
+ | 改了 CSS 不生效 | `<style>` 只注入一次,刷新页面 |
158
+ | 按 Backspace 删不掉 / 中文输入被打断 | 检查是否有代码在响应草稿变化时调用 `setDraft()`(见[关键机制 2](#关键机制)) |
159
+
160
+ ## License
161
+
162
+ [MIT](LICENSE) © 2026 agan
@@ -0,0 +1,18 @@
1
+ # dsh-select-quote bundle patch: inserts this plugin into a profile's roster.
2
+ #
3
+ # Applied as a profile bundle layer (the `dsh.bundle.patch` manifest field) over
4
+ # dsh-base. Activate with:
5
+ #
6
+ # dsh plugin --profile web add dsh-select-quote
7
+ #
8
+ # The row addresses the plugin by PACKAGE NAME: the node half (exports ".") runs
9
+ # in the host process, and the `dsh.client` declaration in package.json makes the
10
+ # browser half (exports "./client", served at /plugins/<id>/client.js) load in
11
+ # the web GUI.
12
+ #
13
+ # Local development from a checkout cannot use this file — the checkout is not
14
+ # in the profile's node_modules, so the loader has no name to resolve. Use
15
+ # `dev.patch.yml` (absolute path) with `dsh web --patch` instead.
16
+ - insert:
17
+ - id: select-quote
18
+ name: 'dsh-select-quote'
package/lib/client.js ADDED
@@ -0,0 +1,1126 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "dsh-select-quote",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ let react = require("react");
8
+ let react_jsx_runtime = require("react/jsx-runtime");
9
+ let react_dom = require("react-dom");
10
+ //#region src/client/quote-store.ts
11
+ const EMPTY_QUOTES = [];
12
+ const bySession = /* @__PURE__ */ new Map();
13
+ const listeners = /* @__PURE__ */ new Set();
14
+ /** Notify every mounted card that the store changed. */
15
+ function notify() {
16
+ for (const listener of [...listeners]) listener();
17
+ }
18
+ /** Observe store changes (save / remove / clear). Returns the unsubscribe callback. */
19
+ function subscribeQuotes(listener) {
20
+ listeners.add(listener);
21
+ return () => {
22
+ listeners.delete(listener);
23
+ };
24
+ }
25
+ function preview(text, max = 48) {
26
+ const oneLine = text.replace(/\s+/g, " ").trim();
27
+ return oneLine.length > max ? `${oneLine.slice(0, max)}…` : oneLine;
28
+ }
29
+ /**
30
+ * Markdown blockquote of the selection — what the model should read.
31
+ * `[选中文本]` marks the block as plugin-injected (not a hand-typed quote).
32
+ */
33
+ function formatQuoteBlock(text) {
34
+ return `> [选中文本]\n${text.split("\n").map((line) => line.length > 0 ? `> ${line}` : ">").join("\n")}`;
35
+ }
36
+ /** Ensure the draft carries the invisible marker, without disturbing its text. */
37
+ function withDraftMarker(draft) {
38
+ return draft.includes("​") ? draft : `${draft}​`;
39
+ }
40
+ /** Remove every invisible marker from a draft. */
41
+ function stripDraftMarker(draft) {
42
+ return draft.split("​").join("");
43
+ }
44
+ /**
45
+ * Fold every pending quote block into the draft. Called only at the send
46
+ * gesture, so the composer never displays the markdown while the user types.
47
+ */
48
+ function composeSubmission(draft, quoteBlocks) {
49
+ const rest = stripDraftMarker(draft).replace(/^\s+/, "");
50
+ const prefix = quoteBlocks.filter((block) => block.length > 0).join("\n\n");
51
+ if (prefix === "") return rest;
52
+ if (quoteBlocks.every((block) => rest.includes(block))) return rest;
53
+ return rest ? `${prefix}\n\n${rest}` : `${prefix}\n\n`;
54
+ }
55
+ /** Append one selection as a quote card; an identical pending quote is reused. */
56
+ function saveQuote(sessionId, text) {
57
+ const draftBlock = formatQuoteBlock(text);
58
+ const current = bySession.get(sessionId) ?? EMPTY_QUOTES;
59
+ const duplicate = current.find((quote) => quote.draftBlock === draftBlock);
60
+ if (duplicate) return duplicate;
61
+ const quote = {
62
+ id: `sq_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
63
+ text,
64
+ title: preview(text),
65
+ draftBlock
66
+ };
67
+ bySession.set(sessionId, [...current, quote]);
68
+ notify();
69
+ return quote;
70
+ }
71
+ function getQuotes(sessionId) {
72
+ return bySession.get(sessionId) ?? EMPTY_QUOTES;
73
+ }
74
+ function removeQuote(sessionId, id) {
75
+ const current = bySession.get(sessionId);
76
+ if (!current) return;
77
+ const next = current.filter((quote) => quote.id !== id);
78
+ if (next.length === current.length) return;
79
+ if (next.length === 0) bySession.delete(sessionId);
80
+ else bySession.set(sessionId, next);
81
+ notify();
82
+ }
83
+ function clearQuotes(sessionId) {
84
+ if (bySession.delete(sessionId)) notify();
85
+ }
86
+ //#endregion
87
+ //#region src/client/styles.ts
88
+ const CSS = `
89
+ .dsq_toolbar {
90
+ position: fixed;
91
+ z-index: 10000;
92
+ transform: translate(-50%, -100%);
93
+ display: flex;
94
+ align-items: center;
95
+ gap: 2px;
96
+ padding: 4px;
97
+ border: 1px solid var(--dsw-alias-border-l4, rgba(0, 0, 0, 0.12));
98
+ border-radius: 999px;
99
+ background: var(--dsw-alias-bg-layer-2, #fff);
100
+ box-shadow:
101
+ 0 8px 24px rgba(0, 0, 0, 0.12),
102
+ 0 2px 8px rgba(0, 0, 0, 0.08);
103
+ pointer-events: auto;
104
+ }
105
+
106
+ .dsq_button {
107
+ appearance: none;
108
+ border: none;
109
+ background: transparent;
110
+ color: var(--dsw-alias-label-primary, rgba(0, 0, 0, 0.88));
111
+ font: inherit;
112
+ font-size: 13px;
113
+ line-height: 20px;
114
+ cursor: pointer;
115
+ border-radius: 999px;
116
+ padding: 4px 10px;
117
+ display: inline-flex;
118
+ align-items: center;
119
+ gap: 6px;
120
+ white-space: nowrap;
121
+ }
122
+
123
+ .dsq_button:hover {
124
+ background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.06));
125
+ }
126
+
127
+ .dsq_button:focus-visible {
128
+ outline: 2px solid var(--dsw-alias-button-primary-fill, #1677ff);
129
+ outline-offset: 1px;
130
+ }
131
+
132
+ .dsq_divider {
133
+ width: 1px;
134
+ height: 14px;
135
+ background: var(--dsw-alias-border-l4, rgba(0, 0, 0, 0.12));
136
+ margin: 0 2px;
137
+ }
138
+
139
+ .dsq_status {
140
+ position: fixed;
141
+ z-index: 10001;
142
+ transform: translate(-50%, 8px);
143
+ padding: 4px 10px;
144
+ border-radius: 999px;
145
+ background: var(--dsw-alias-bg-layer-3, rgba(0, 0, 0, 0.78));
146
+ color: var(--dsw-alias-label-primary-inverted, #fff);
147
+ font-size: 12px;
148
+ line-height: 18px;
149
+ pointer-events: none;
150
+ white-space: nowrap;
151
+ }
152
+
153
+ /* Quote card row — floats inside the composer card (input.overlay layer).
154
+ Cards fill left to right and wrap onto the next line. */
155
+ .dsq_cardStack {
156
+ position: absolute;
157
+ top: 8px;
158
+ left: 8px;
159
+ right: 8px;
160
+ z-index: 40;
161
+ display: flex;
162
+ flex-direction: row;
163
+ flex-wrap: wrap;
164
+ align-items: flex-start;
165
+ gap: 6px;
166
+ pointer-events: none;
167
+ }
168
+
169
+ /* One quote card. Geometry mirrors the product's own file card
170
+ (.nyYjTG_file in ui-deliverables): hairline border, neutral fill,
171
+ 18px radius, fixed 72px row, centered content, background transition. */
172
+ .dsq_card {
173
+ --dsq-card-fill: var(--dsw-static-neutral-50, #fafafa);
174
+ --dsq-card-hover: var(--dsw-static-neutral-100, #f5f5f5);
175
+ box-sizing: border-box;
176
+ flex: 0 0 auto;
177
+ display: flex;
178
+ align-items: center;
179
+ gap: 10px;
180
+ width: fit-content;
181
+ max-width: calc(25% - 5px);
182
+ min-width: 0;
183
+ height: 64px;
184
+ margin: 0;
185
+ padding: 10px;
186
+ border: 0.5px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, 0.04));
187
+ border-radius: 18px;
188
+ background: var(--dsq-card-fill);
189
+ color: var(--dsw-alias-label-primary, rgba(0, 0, 0, 0.88));
190
+ transition: background-color 0.12s;
191
+ position: relative;
192
+ overflow: hidden;
193
+ user-select: none;
194
+ pointer-events: auto;
195
+ }
196
+
197
+ .dsq_card:hover {
198
+ background: var(--dsq-card-hover);
199
+ }
200
+
201
+ /* Scaled down from the reference 48px: the composer card is width-capped at a
202
+ quarter of the input, so 48px would leave no room for the quote text. */
203
+ .dsq_cardIcon {
204
+ z-index: 2;
205
+ flex: none;
206
+ width: 36px;
207
+ height: 36px;
208
+ border: 0.5px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.1));
209
+ border-radius: 12px;
210
+ display: grid;
211
+ place-items: center;
212
+ background: var(--dsq-card-fill);
213
+ color: var(--dsw-alias-link, rgba(0, 0, 0, 0.65));
214
+ font-size: 12px;
215
+ font-weight: 600;
216
+ letter-spacing: -0.02em;
217
+ }
218
+
219
+ .dsq_cardBody {
220
+ z-index: 2;
221
+ flex: 1;
222
+ min-width: 0;
223
+ display: flex;
224
+ flex-direction: column;
225
+ justify-content: center;
226
+ gap: 2px;
227
+ /* Keeps the ellipsised title clear of the corner remove button. */
228
+ padding-right: 14px;
229
+ }
230
+
231
+ .dsq_cardTitle {
232
+ font-size: 14px;
233
+ font-weight: 500;
234
+ line-height: 22px;
235
+ color: var(--dsw-alias-label-primary, rgba(0, 0, 0, 0.88));
236
+ overflow: hidden;
237
+ text-overflow: ellipsis;
238
+ white-space: nowrap;
239
+ }
240
+
241
+ .dsq_cardSubtitle {
242
+ font-size: 12px;
243
+ line-height: 18px;
244
+ color: var(--dsw-alias-label-tertiary, rgba(0, 0, 0, 0.45));
245
+ overflow: hidden;
246
+ text-overflow: ellipsis;
247
+ white-space: nowrap;
248
+ }
249
+
250
+ /* Corner remove button: hidden until the card is hovered or focused. */
251
+ .dsq_cardClose {
252
+ position: absolute;
253
+ top: 4px;
254
+ right: 4px;
255
+ z-index: 3;
256
+ appearance: none;
257
+ border: none;
258
+ background: transparent;
259
+ width: 20px;
260
+ height: 20px;
261
+ border-radius: 6px;
262
+ cursor: pointer;
263
+ color: var(--dsw-alias-label-tertiary, rgba(0, 0, 0, 0.45));
264
+ display: grid;
265
+ place-items: center;
266
+ font-size: 14px;
267
+ line-height: 1;
268
+ opacity: 0;
269
+ pointer-events: none;
270
+ transition: opacity 0.12s, background-color 0.12s;
271
+ }
272
+
273
+ .dsq_card:hover .dsq_cardClose,
274
+ .dsq_card:focus-within .dsq_cardClose {
275
+ opacity: 1;
276
+ pointer-events: auto;
277
+ }
278
+
279
+ .dsq_cardClose:hover {
280
+ background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.06));
281
+ color: var(--dsw-alias-label-primary, rgba(0, 0, 0, 0.88));
282
+ }
283
+
284
+ /* Transcript cards — in-flow Chat node (conversation history), stacked. */
285
+ .dsq_tCardStack {
286
+ display: flex;
287
+ flex-direction: column;
288
+ align-items: flex-end;
289
+ gap: 6px;
290
+ margin: 4px 0 8px;
291
+ }
292
+
293
+ .dsq_tCard {
294
+ --dsq-card-fill: var(--dsw-static-neutral-50, #fafafa);
295
+ --dsq-card-hover: var(--dsw-static-neutral-100, #f5f5f5);
296
+ box-sizing: border-box;
297
+ display: flex;
298
+ align-items: center;
299
+ gap: 10px;
300
+ width: fit-content;
301
+ max-width: min(calc(var(--dsh-chat-content-width, 748px) * 0.72), 520px);
302
+ min-width: 0;
303
+ height: 64px;
304
+ margin: 0 0 0 auto;
305
+ padding: 10px;
306
+ border: 0.5px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, 0.04));
307
+ border-radius: 18px;
308
+ background: var(--dsq-card-fill);
309
+ color: var(--dsw-alias-label-primary, rgba(0, 0, 0, 0.88));
310
+ transition: background-color 0.12s;
311
+ position: relative;
312
+ overflow: hidden;
313
+ user-select: none;
314
+ }
315
+
316
+ .dsq_tCard:hover {
317
+ background: var(--dsq-card-hover);
318
+ }
319
+
320
+ .dsq_tCardIcon {
321
+ z-index: 2;
322
+ flex: none;
323
+ width: 40px;
324
+ height: 40px;
325
+ border: 0.5px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.1));
326
+ border-radius: 12px;
327
+ display: grid;
328
+ place-items: center;
329
+ background: var(--dsq-card-fill);
330
+ color: var(--dsw-alias-link, rgba(0, 0, 0, 0.65));
331
+ font-size: 14px;
332
+ font-weight: 600;
333
+ letter-spacing: -0.02em;
334
+ }
335
+
336
+ .dsq_tCardBody {
337
+ z-index: 2;
338
+ flex: 1;
339
+ min-width: 0;
340
+ display: flex;
341
+ flex-direction: column;
342
+ justify-content: center;
343
+ gap: 2px;
344
+ }
345
+
346
+ .dsq_tCardTitle {
347
+ font-size: 14px;
348
+ font-weight: 500;
349
+ line-height: 22px;
350
+ color: var(--dsw-alias-label-primary, rgba(0, 0, 0, 0.88));
351
+ overflow: hidden;
352
+ text-overflow: ellipsis;
353
+ white-space: nowrap;
354
+ }
355
+
356
+ .dsq_tCardSubtitle {
357
+ font-size: 12px;
358
+ line-height: 18px;
359
+ color: var(--dsw-alias-label-tertiary, rgba(0, 0, 0, 0.45));
360
+ overflow: hidden;
361
+ text-overflow: ellipsis;
362
+ white-space: nowrap;
363
+ }
364
+
365
+ /* Replacement user bubble (quote block stripped from display). */
366
+ .dsq_userRow {
367
+ display: flex;
368
+ flex-direction: column;
369
+ align-items: flex-end;
370
+ gap: 6px;
371
+ }
372
+
373
+ .dsq_userStack {
374
+ min-width: 0;
375
+ max-width: min(calc(var(--dsh-chat-content-width, 748px) * 0.702), 82%);
376
+ display: flex;
377
+ flex-direction: column;
378
+ align-items: flex-end;
379
+ gap: 8px;
380
+ }
381
+
382
+ .dsq_userBubble {
383
+ max-width: 100%;
384
+ padding: 10px 16px;
385
+ border-radius: 22px;
386
+ background: var(--dsw-specific-bubble, rgba(0, 0, 0, 0.05));
387
+ color: var(--dsw-alias-label-primary, rgba(0, 0, 0, 0.88));
388
+ font-size: var(--dsh-content-font-size, 14px);
389
+ line-height: calc(22px + var(--dsh-content-font-delta, 0px));
390
+ white-space: pre-wrap;
391
+ word-break: break-word;
392
+ }
393
+
394
+ /* Reserve room for the floating quote cards inside the composer card, so the
395
+ attachment rail and the editor flow below them instead of being covered. */
396
+ .dsq_cardPad {
397
+ padding-top: var(--dsq-quote-pad, 88px) !important;
398
+ }
399
+
400
+ /* Durable message images of a replacement user bubble. */
401
+ .dsq_userImages {
402
+ display: flex;
403
+ flex-direction: column;
404
+ align-items: flex-end;
405
+ gap: 8px;
406
+ max-width: 100%;
407
+ }
408
+
409
+ /* Dark theme: the product flips its palette on body[data-ds-dark-theme], and
410
+ every --dsw-alias-* token follows it. Only the card fill is ours. */
411
+ body[data-ds-dark-theme] .dsq_card,
412
+ body[data-ds-dark-theme] .dsq_tCard {
413
+ --dsq-card-fill: var(--dsw-static-neutral-850, #212123);
414
+ --dsq-card-hover: var(--dsw-static-neutral-800, #292929);
415
+ }
416
+ `;
417
+ const TAG_ID = "dsh-select-quote/toolbar.css";
418
+ function ensureToolbarStyles() {
419
+ if (typeof document === "undefined") return;
420
+ if (document.querySelector(`style[data-plugin-css="${TAG_ID}"]`)) return;
421
+ const tag = document.createElement("style");
422
+ tag.dataset.plugin = "dsh-select-quote";
423
+ tag.dataset.pluginCss = TAG_ID;
424
+ tag.textContent = CSS;
425
+ document.head.appendChild(tag);
426
+ }
427
+ const styles = {
428
+ toolbar: "dsq_toolbar",
429
+ button: "dsq_button",
430
+ divider: "dsq_divider",
431
+ status: "dsq_status",
432
+ cardStack: "dsq_cardStack",
433
+ card: "dsq_card",
434
+ cardIcon: "dsq_cardIcon",
435
+ cardBody: "dsq_cardBody",
436
+ cardTitle: "dsq_cardTitle",
437
+ cardSubtitle: "dsq_cardSubtitle",
438
+ cardClose: "dsq_cardClose",
439
+ cardPad: "dsq_cardPad",
440
+ tCardStack: "dsq_tCardStack",
441
+ tCard: "dsq_tCard",
442
+ tCardIcon: "dsq_tCardIcon",
443
+ tCardBody: "dsq_tCardBody",
444
+ tCardTitle: "dsq_tCardTitle",
445
+ tCardSubtitle: "dsq_tCardSubtitle",
446
+ userRow: "dsq_userRow",
447
+ userStack: "dsq_userStack",
448
+ userImages: "dsq_userImages",
449
+ userBubble: "dsq_userBubble"
450
+ };
451
+ //#endregion
452
+ //#region src/client/QuoteCard.tsx
453
+ /** The resident composer card that owns the draft surface. */
454
+ const COMPOSER_CARD = "[data-composer-card]";
455
+ const NO_QUOTES = [];
456
+ function composerCardOf(node) {
457
+ const card = node.closest(COMPOSER_CARD);
458
+ return card instanceof HTMLElement ? card : null;
459
+ }
460
+ /**
461
+ * A composer card is non-empty when it holds visible draft text or a draft
462
+ * attachment — the two states in which the composer's own send gesture
463
+ * actually submits.
464
+ */
465
+ function willSubmit(card, draft) {
466
+ return draft.trim().length > 0 || card.querySelector("img") !== null;
467
+ }
468
+ /**
469
+ * The quote card stack inside the composer card (`conversation.input.overlay`).
470
+ *
471
+ * Selections never enter the editor as text: the cards are the only thing the
472
+ * user sees, and the markdown blocks are folded into the draft one capture-phase
473
+ * step before the composer's own send handler reads it.
474
+ */
475
+ function QuoteCard({ useInput, inputActions, sessionId }) {
476
+ const draft = useInput((state) => state.draft);
477
+ const [quotes, setQuotes] = (0, react.useState)(NO_QUOTES);
478
+ const stackRef = (0, react.useRef)(null);
479
+ const draftRef = (0, react.useRef)(draft);
480
+ const actionsRef = (0, react.useRef)(inputActions);
481
+ const sessionRef = (0, react.useRef)(sessionId);
482
+ draftRef.current = draft;
483
+ actionsRef.current = inputActions;
484
+ sessionRef.current = sessionId;
485
+ const sync = (0, react.useCallback)(() => {
486
+ const id = sessionRef.current;
487
+ setQuotes(id ? getQuotes(id) : NO_QUOTES);
488
+ }, []);
489
+ (0, react.useEffect)(() => {
490
+ ensureToolbarStyles();
491
+ sync();
492
+ return subscribeQuotes(sync);
493
+ }, [sync, sessionId]);
494
+ (0, react.useEffect)(() => {
495
+ const element = stackRef.current;
496
+ if (!element) return;
497
+ const card = composerCardOf(element);
498
+ if (!card) return;
499
+ const apply = () => {
500
+ card.style.setProperty("--dsq-quote-pad", `${element.offsetHeight + 20}px`);
501
+ };
502
+ card.classList.add(styles.cardPad);
503
+ apply();
504
+ const observer = new ResizeObserver(apply);
505
+ observer.observe(element);
506
+ return () => {
507
+ observer.disconnect();
508
+ card.classList.remove(styles.cardPad);
509
+ card.style.removeProperty("--dsq-quote-pad");
510
+ };
511
+ }, [quotes]);
512
+ (0, react.useEffect)(() => {
513
+ if (quotes.length === 0 || !sessionId) return;
514
+ const current = draftRef.current;
515
+ if (current.includes("​")) return;
516
+ if (current.trim() !== "") return;
517
+ actionsRef.current.setDraft(withDraftMarker(current));
518
+ }, [quotes, sessionId]);
519
+ /**
520
+ * Fold every quote into the draft at the send gesture: Enter (without
521
+ * modifiers, outside IME composition) inside the composer editor, or the
522
+ * composer's own primary action. Both are intercepted on `document` in the
523
+ * capture phase, so the draft already carries the blocks when the composer
524
+ * submits it, and the cards disappear at the same moment.
525
+ */
526
+ (0, react.useEffect)(() => {
527
+ const id = sessionId;
528
+ if (quotes.length === 0 || !id) return;
529
+ const inject = () => {
530
+ const current = getQuotes(id);
531
+ if (current.length === 0) return;
532
+ const blocks = current.map((quote) => quote.draftBlock);
533
+ actionsRef.current.setDraft(composeSubmission(draftRef.current, blocks));
534
+ clearQuotes(id);
535
+ setQuotes(NO_QUOTES);
536
+ };
537
+ const onKeyDown = (event) => {
538
+ if (event.key !== "Enter" || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return;
539
+ if (event.isComposing) return;
540
+ const target = event.target;
541
+ if (!(target instanceof HTMLElement) || !target.isContentEditable) return;
542
+ const card = composerCardOf(target);
543
+ if (!card || !willSubmit(card, draftRef.current)) return;
544
+ inject();
545
+ };
546
+ const onClick = (event) => {
547
+ const target = event.target;
548
+ if (!(target instanceof Element)) return;
549
+ const card = composerCardOf(target);
550
+ if (!card) return;
551
+ const button = target.closest("button");
552
+ if (!button || button.disabled) return;
553
+ const buttons = card.querySelectorAll("button");
554
+ if (buttons.length === 0 || buttons[buttons.length - 1] !== button) return;
555
+ if (button.querySelector("svg rect") !== null) return;
556
+ inject();
557
+ };
558
+ document.addEventListener("keydown", onKeyDown, true);
559
+ document.addEventListener("click", onClick, true);
560
+ return () => {
561
+ document.removeEventListener("keydown", onKeyDown, true);
562
+ document.removeEventListener("click", onClick, true);
563
+ };
564
+ }, [quotes, sessionId]);
565
+ const onRemove = (0, react.useCallback)((id) => {
566
+ const active = sessionRef.current;
567
+ if (!active) return;
568
+ removeQuote(active, id);
569
+ const rest = getQuotes(active);
570
+ setQuotes(rest);
571
+ if (rest.length === 0) actionsRef.current.setDraft(stripDraftMarker(draftRef.current));
572
+ }, []);
573
+ if (!sessionId || quotes.length === 0) return null;
574
+ return (0, react_jsx_runtime.jsx)("div", {
575
+ ref: stackRef,
576
+ className: styles.cardStack,
577
+ children: quotes.map((quote) => (0, react_jsx_runtime.jsxs)("div", {
578
+ className: styles.card,
579
+ role: "group",
580
+ "aria-label": "选中的文本",
581
+ children: [
582
+ (0, react_jsx_runtime.jsx)("div", {
583
+ className: styles.cardIcon,
584
+ "aria-hidden": true,
585
+ children: "AI"
586
+ }),
587
+ (0, react_jsx_runtime.jsxs)("div", {
588
+ className: styles.cardBody,
589
+ children: [(0, react_jsx_runtime.jsx)("div", {
590
+ className: styles.cardTitle,
591
+ title: quote.text,
592
+ children: quote.title
593
+ }), (0, react_jsx_runtime.jsx)("div", {
594
+ className: styles.cardSubtitle,
595
+ children: "选中的文本"
596
+ })]
597
+ }),
598
+ (0, react_jsx_runtime.jsx)("button", {
599
+ type: "button",
600
+ className: styles.cardClose,
601
+ "aria-label": "移除引用",
602
+ onClick: () => onRemove(quote.id),
603
+ children: "×"
604
+ })
605
+ ]
606
+ }, quote.id))
607
+ });
608
+ }
609
+ //#endregion
610
+ //#region src/client/selection.ts
611
+ const TOOLBAR_OFFSET = 12;
612
+ const VIEWPORT_MARGIN = 12;
613
+ const LINE_TOP_TOLERANCE = 3;
614
+ /** Contenteditable roots that must never host the selection toolbar. */
615
+ function isInsideEditable(node) {
616
+ let current = node;
617
+ while (current) {
618
+ if (current instanceof HTMLElement) {
619
+ if (current.isContentEditable) return true;
620
+ if (current.getAttribute("role") === "textbox") return true;
621
+ }
622
+ current = current.parentNode;
623
+ }
624
+ return false;
625
+ }
626
+ function mergeRects(rects) {
627
+ const left = Math.min(...rects.map((rect) => rect.left));
628
+ const right = Math.max(...rects.map((rect) => rect.right));
629
+ const top = Math.min(...rects.map((rect) => rect.top));
630
+ const bottom = Math.max(...rects.map((rect) => rect.bottom));
631
+ return DOMRect.fromRect({
632
+ height: bottom - top,
633
+ width: right - left,
634
+ x: left,
635
+ y: top
636
+ });
637
+ }
638
+ function getRangeFirstLineRect(range) {
639
+ const rects = [...range.getClientRects()].filter((rect) => rect.width > 0 && rect.height > 0);
640
+ if (rects.length === 0) {
641
+ const rect = range.getBoundingClientRect();
642
+ return rect.width > 0 || rect.height > 0 ? rect : void 0;
643
+ }
644
+ const firstTop = Math.min(...rects.map((rect) => rect.top));
645
+ return mergeRects(rects.filter((rect) => Math.abs(rect.top - firstTop) <= LINE_TOP_TOLERANCE));
646
+ }
647
+ function toolbarPosition(rect) {
648
+ const center = rect.left + rect.width / 2;
649
+ return {
650
+ left: Math.min(Math.max(center, VIEWPORT_MARGIN), window.innerWidth - VIEWPORT_MARGIN),
651
+ top: Math.max(rect.top - TOOLBAR_OFFSET, VIEWPORT_MARGIN)
652
+ };
653
+ }
654
+ /**
655
+ * Read the live selection when it is a non-empty, non-editable range.
656
+ * Conversation-area coverage (3B): any selectable text outside the composer.
657
+ */
658
+ function readSelectionSnapshot(toolbarRoot) {
659
+ const selection = window.getSelection();
660
+ if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return null;
661
+ const { anchorNode, focusNode } = selection;
662
+ if (!anchorNode || !focusNode) return null;
663
+ if (isInsideEditable(anchorNode) || isInsideEditable(focusNode)) return null;
664
+ if (toolbarRoot?.contains(anchorNode) || toolbarRoot?.contains(focusNode)) return null;
665
+ const text = selection.toString().replace(/\r\n/g, "\n").trim();
666
+ if (!text) return null;
667
+ const rect = getRangeFirstLineRect(selection.getRangeAt(0));
668
+ if (!rect) return null;
669
+ const { left, top } = toolbarPosition(rect);
670
+ return {
671
+ text,
672
+ left,
673
+ top
674
+ };
675
+ }
676
+ /** Focus the resident composer editor and park the caret at the end. */
677
+ function focusComposer() {
678
+ const editor = document.querySelector("[data-lexical-editor=\"true\"]") ?? document.querySelector("[contenteditable=\"true\"][data-lexical-editor]") ?? lastEditable();
679
+ if (!editor) return;
680
+ editor.focus({ preventScroll: false });
681
+ const selection = window.getSelection();
682
+ if (!selection) return;
683
+ const range = document.createRange();
684
+ range.selectNodeContents(editor);
685
+ range.collapse(false);
686
+ selection.removeAllRanges();
687
+ selection.addRange(range);
688
+ }
689
+ function lastEditable() {
690
+ const nodes = document.querySelectorAll("[contenteditable=\"true\"]");
691
+ for (let i = nodes.length - 1; i >= 0; i -= 1) {
692
+ const node = nodes[i];
693
+ if (node instanceof HTMLElement && !node.closest("[data-dsq-toolbar]")) return node;
694
+ }
695
+ return null;
696
+ }
697
+ function clearNativeSelection() {
698
+ window.getSelection()?.removeAllRanges();
699
+ }
700
+ async function copyText(text) {
701
+ if (navigator.clipboard?.writeText) {
702
+ await navigator.clipboard.writeText(text);
703
+ return;
704
+ }
705
+ const area = document.createElement("textarea");
706
+ area.value = text;
707
+ area.setAttribute("readonly", "");
708
+ area.style.position = "fixed";
709
+ area.style.left = "-9999px";
710
+ document.body.appendChild(area);
711
+ area.select();
712
+ document.execCommand("copy");
713
+ area.remove();
714
+ }
715
+ //#endregion
716
+ //#region src/client/SelectionToolbar.tsx
717
+ function SelectionToolbar({ sessionId }) {
718
+ const toolbarRef = (0, react.useRef)(null);
719
+ const [active, setActive] = (0, react.useState)(null);
720
+ const [status, setStatus] = (0, react.useState)(null);
721
+ const sessionRef = (0, react.useRef)(sessionId);
722
+ sessionRef.current = sessionId;
723
+ (0, react.useEffect)(() => {
724
+ ensureToolbarStyles();
725
+ }, []);
726
+ const hide = (0, react.useCallback)(() => {
727
+ setActive(null);
728
+ setStatus(null);
729
+ }, []);
730
+ const refresh = (0, react.useCallback)(() => {
731
+ const next = readSelectionSnapshot(toolbarRef.current);
732
+ setActive((prev) => {
733
+ if (!next) return null;
734
+ if (prev && prev.text === next.text && prev.left === next.left && prev.top === next.top) return prev;
735
+ return next;
736
+ });
737
+ }, []);
738
+ (0, react.useEffect)(() => {
739
+ const schedule = () => {
740
+ window.setTimeout(refresh, 0);
741
+ };
742
+ const onPointerDown = (event) => {
743
+ const target = event.target;
744
+ if (!(target instanceof Node)) return;
745
+ if (toolbarRef.current?.contains(target)) return;
746
+ const selection = window.getSelection();
747
+ if (selection && !selection.isCollapsed && selection.rangeCount > 0) {
748
+ schedule();
749
+ return;
750
+ }
751
+ hide();
752
+ };
753
+ document.addEventListener("pointerdown", onPointerDown, true);
754
+ document.addEventListener("pointerup", schedule, true);
755
+ document.addEventListener("keyup", schedule, true);
756
+ window.addEventListener("resize", hide);
757
+ document.addEventListener("scroll", hide, true);
758
+ return () => {
759
+ document.removeEventListener("pointerdown", onPointerDown, true);
760
+ document.removeEventListener("pointerup", schedule, true);
761
+ document.removeEventListener("keyup", schedule, true);
762
+ window.removeEventListener("resize", hide);
763
+ document.removeEventListener("scroll", hide, true);
764
+ };
765
+ }, [hide, refresh]);
766
+ const handleToolbarPointerDown = (0, react.useCallback)((event) => {
767
+ event.preventDefault();
768
+ event.stopPropagation();
769
+ }, []);
770
+ const handleCopy = (0, react.useCallback)(async () => {
771
+ const snapshot = active;
772
+ if (!snapshot) return;
773
+ try {
774
+ await copyText(snapshot.text);
775
+ clearNativeSelection();
776
+ setActive(null);
777
+ setStatus(null);
778
+ } catch {
779
+ setStatus("复制失败");
780
+ }
781
+ }, [active]);
782
+ const handleAddToTask = (0, react.useCallback)(() => {
783
+ const snapshot = active;
784
+ if (!snapshot) return;
785
+ const sid = sessionRef.current;
786
+ if (!sid) {
787
+ setStatus("会话未就绪");
788
+ return;
789
+ }
790
+ saveQuote(sid, snapshot.text);
791
+ clearNativeSelection();
792
+ setActive(null);
793
+ setStatus(null);
794
+ window.setTimeout(focusComposer, 16);
795
+ }, [active]);
796
+ if (!active) return null;
797
+ const toolbarStyle = {
798
+ left: active.left,
799
+ top: active.top
800
+ };
801
+ return (0, react_dom.createPortal)((0, react_jsx_runtime.jsxs)(react.Fragment, { children: [(0, react_jsx_runtime.jsx)("div", {
802
+ ref: toolbarRef,
803
+ className: styles.toolbar,
804
+ style: toolbarStyle,
805
+ role: "toolbar",
806
+ "aria-label": "划词操作",
807
+ "data-dsq-toolbar": "true",
808
+ onPointerDown: handleToolbarPointerDown,
809
+ children: (0, react_jsx_runtime.jsxs)("div", {
810
+ style: {
811
+ display: "inline-flex",
812
+ alignItems: "center",
813
+ gap: 2
814
+ },
815
+ children: [
816
+ (0, react_jsx_runtime.jsx)("button", {
817
+ type: "button",
818
+ className: styles.button,
819
+ onClick: () => void handleCopy(),
820
+ children: "复制"
821
+ }),
822
+ (0, react_jsx_runtime.jsx)("span", {
823
+ className: styles.divider,
824
+ "aria-hidden": true
825
+ }),
826
+ (0, react_jsx_runtime.jsx)("button", {
827
+ type: "button",
828
+ className: styles.button,
829
+ onClick: handleAddToTask,
830
+ children: "添加到任务"
831
+ })
832
+ ]
833
+ })
834
+ }), status ? (0, react_jsx_runtime.jsx)("div", {
835
+ className: styles.status,
836
+ style: toolbarStyle,
837
+ role: "status",
838
+ children: status
839
+ }) : null] }), document.body);
840
+ }
841
+ //#endregion
842
+ //#region src/client/TranscriptQuoteCard.tsx
843
+ /**
844
+ * Transcript card for the plugin-injected quotes of one user message.
845
+ * Renders in the Chat node list; the same text remains in the user bubble so
846
+ * the model still receives the full selection.
847
+ */
848
+ function TranscriptQuoteCard({ node }) {
849
+ ensureToolbarStyles();
850
+ const quotes = node.data?.quotes ?? [];
851
+ if (quotes.length === 0) return null;
852
+ return (0, react_jsx_runtime.jsx)("div", {
853
+ className: styles.tCardStack,
854
+ children: quotes.map((quote, index) => (0, react_jsx_runtime.jsxs)("div", {
855
+ className: styles.tCard,
856
+ role: "group",
857
+ "aria-label": "选中的文本",
858
+ children: [(0, react_jsx_runtime.jsx)("div", {
859
+ className: styles.tCardIcon,
860
+ "aria-hidden": true,
861
+ children: "AI"
862
+ }), (0, react_jsx_runtime.jsxs)("div", {
863
+ className: styles.tCardBody,
864
+ children: [(0, react_jsx_runtime.jsx)("div", {
865
+ className: styles.tCardTitle,
866
+ title: quote.body,
867
+ children: quote.title
868
+ }), (0, react_jsx_runtime.jsx)("div", {
869
+ className: styles.tCardSubtitle,
870
+ children: "选中的文本"
871
+ })]
872
+ })]
873
+ }, `${index}`))
874
+ });
875
+ }
876
+ /** The composer's invisible send marker is never part of message text. */
877
+ const DRAFT_MARKER_RE = /\u200B/g;
878
+ /** Plain text from Chat user-node content blocks (string or block array). */
879
+ function contentBlocksToText(content) {
880
+ if (typeof content === "string") return content;
881
+ if (!Array.isArray(content)) return "";
882
+ return content.map((block) => {
883
+ if (block && typeof block === "object" && "text" in block) {
884
+ const text = block.text;
885
+ return typeof text === "string" ? text : "";
886
+ }
887
+ return "";
888
+ }).join("");
889
+ }
890
+ /**
891
+ * Best-effort plain text from a durable `user/message` event. A surface event
892
+ * carries its blocks directly on `data.content`; `data.message.content` is only
893
+ * a fallback for the older shape.
894
+ */
895
+ function extractUserText(event) {
896
+ const data = event?.data;
897
+ if (!data) return "";
898
+ return contentBlocksToText(data.content ?? data.message?.content);
899
+ }
900
+ function toQuote(body) {
901
+ const text = body.join("\n").trim();
902
+ if (!text) return null;
903
+ const oneLine = text.replace(/\s+/g, " ");
904
+ return {
905
+ title: oneLine.length > 48 ? `${oneLine.slice(0, 48)}…` : oneLine,
906
+ body: text
907
+ };
908
+ }
909
+ /**
910
+ * Detect every `> [选中文本]` + following `>` block written by the composer.
911
+ * One message may carry several quotes; an empty array means none.
912
+ */
913
+ function parseQuoteMessage(text) {
914
+ if (!text.includes("> [选中文本]")) return [];
915
+ const quotes = [];
916
+ let body = null;
917
+ for (const line of text.split("\n")) {
918
+ const trimmed = line.trimEnd();
919
+ if (body === null) {
920
+ if (trimmed === "> [选中文本]" || trimmed.startsWith("> [选中文本]")) body = [];
921
+ continue;
922
+ }
923
+ if (trimmed.startsWith(">")) {
924
+ body.push(trimmed.replace(/^>\s?/, ""));
925
+ continue;
926
+ }
927
+ const quote = toQuote(body);
928
+ if (quote) quotes.push(quote);
929
+ body = null;
930
+ if (trimmed === "> [选中文本]" || trimmed.startsWith("> [选中文本]")) body = [];
931
+ }
932
+ if (body !== null) {
933
+ const quote = toQuote(body);
934
+ if (quote) quotes.push(quote);
935
+ }
936
+ return quotes;
937
+ }
938
+ /**
939
+ * Display text for a user bubble: drop every plugin quote block so the
940
+ * transcript shows only the question. The durable message (and model payload)
941
+ * still contains the full `> [选中文本]` text.
942
+ */
943
+ function displayTextWithoutQuote(text) {
944
+ const clean = text.replace(DRAFT_MARKER_RE, "");
945
+ if (!clean.includes("> [选中文本]")) return clean;
946
+ const out = [];
947
+ let skipping = false;
948
+ for (const line of clean.split("\n")) {
949
+ const trimmed = line.trimEnd();
950
+ if (!skipping) {
951
+ if (trimmed === "> [选中文本]" || trimmed.startsWith("> [选中文本]")) {
952
+ skipping = true;
953
+ continue;
954
+ }
955
+ out.push(line);
956
+ continue;
957
+ }
958
+ if (trimmed.startsWith(">")) continue;
959
+ skipping = false;
960
+ out.push(line);
961
+ }
962
+ return out.join("\n").replace(/^\n+/, "").trim();
963
+ }
964
+ //#endregion
965
+ //#region src/client/UserMessageDisplay.tsx
966
+ /**
967
+ * Image blocks carried by one durable user message, in the shape the
968
+ * attachment presentation slot renders (`{ attachment }`).
969
+ */
970
+ function contentImages(content) {
971
+ if (!Array.isArray(content)) return [];
972
+ const images = [];
973
+ for (const block of content) {
974
+ if (block === null || typeof block !== "object") continue;
975
+ const candidate = block;
976
+ if (candidate.type !== "image" || candidate.attachment === void 0) continue;
977
+ images.push({ attachment: candidate.attachment });
978
+ }
979
+ return images;
980
+ }
981
+ /**
982
+ * Replacement for the built-in `user` Chat node view.
983
+ *
984
+ * Hides the plugin `> [选中文本]` block in the bubble (the transcript card
985
+ * already shows it) while still rendering the message's durable images — a
986
+ * quote-only message has no visible text at all, so the images are the only
987
+ * thing left to show.
988
+ */
989
+ function UserMessageDisplay({ node, renderMessageImages }) {
990
+ ensureToolbarStyles();
991
+ const text = displayTextWithoutQuote(contentBlocksToText(node.data?.content)).trim();
992
+ const images = contentImages(node.data?.content);
993
+ if (!text && images.length === 0) return null;
994
+ return (0, react_jsx_runtime.jsx)("div", {
995
+ className: styles.userRow,
996
+ children: (0, react_jsx_runtime.jsxs)("div", {
997
+ className: styles.userStack,
998
+ children: [images.length > 0 && renderMessageImages !== void 0 ? (0, react_jsx_runtime.jsx)("div", {
999
+ className: styles.userImages,
1000
+ "data-message-attachments": true,
1001
+ children: renderMessageImages({
1002
+ images,
1003
+ align: "end",
1004
+ compact: images.length > 1
1005
+ })
1006
+ }, "images") : null, text ? (0, react_jsx_runtime.jsx)("div", {
1007
+ className: styles.userBubble,
1008
+ children: text
1009
+ }, "bubble") : null]
1010
+ })
1011
+ });
1012
+ }
1013
+ //#endregion
1014
+ //#region src/client/runtime.ts
1015
+ const runtime = {};
1016
+ /**
1017
+ * Bind root services we declared in `inject`. Never touch undeclared
1018
+ * properties — Cordis getters throw without inject.
1019
+ */
1020
+ function bindRuntime(ctx) {
1021
+ runtime.sessions = ctx.sessions;
1022
+ }
1023
+ //#endregion
1024
+ //#region src/client/transcript-node.ts
1025
+ const KIND = "select-quote";
1026
+ /**
1027
+ * Conversation Definition: one durable `user/message` that carries plugin quote
1028
+ * blocks becomes a `select-quote` Chat node (rendered as a card per quote).
1029
+ */
1030
+ function createSelectQuoteDefinition() {
1031
+ return {
1032
+ kind: KIND,
1033
+ target: "chat",
1034
+ match(event) {
1035
+ if (event.type !== "user/message") return null;
1036
+ if (parseQuoteMessage(extractUserText(event)).length === 0) return null;
1037
+ return {
1038
+ id: `${KIND}-${event.seq ?? 0}`,
1039
+ role: "start"
1040
+ };
1041
+ },
1042
+ start(_context, match) {
1043
+ const quotes = parseQuoteMessage(extractUserText(match.event));
1044
+ if (quotes.length === 0) throw new Error("select-quote: missing quote payload on start");
1045
+ return {
1046
+ quotes,
1047
+ seq: match.event.seq ?? 0
1048
+ };
1049
+ },
1050
+ update(context) {
1051
+ return context.state;
1052
+ },
1053
+ buildViewNode(context) {
1054
+ if (context.state === void 0) return null;
1055
+ const anchor = context.start?.event.seq ?? context.matches?.[0]?.event.seq ?? context.state.seq ?? 0;
1056
+ const location = context.start?.location ?? context.matches?.[0]?.location ?? { kind: "unresolved" };
1057
+ return {
1058
+ key: context.key,
1059
+ kind: KIND,
1060
+ id: context.id,
1061
+ target: "chat",
1062
+ anchorSeq: anchor,
1063
+ location,
1064
+ visibility: "visible",
1065
+ data: context.state
1066
+ };
1067
+ }
1068
+ };
1069
+ }
1070
+ function registerSelectQuoteNode(ctx) {
1071
+ const uiConversation = ctx.uiConversation;
1072
+ if (!uiConversation) {
1073
+ console.warn("[dsh-select-quote] uiConversation unavailable; transcript card disabled");
1074
+ return;
1075
+ }
1076
+ ctx.effect(() => uiConversation.events.register(createSelectQuoteDefinition()), "dsh-select-quote: conversation node definition");
1077
+ }
1078
+ //#endregion
1079
+ //#region src/client/index.tsx
1080
+ /**
1081
+ * Browser half of dsh-select-quote.
1082
+ *
1083
+ * - Floating selection toolbar (copy / add-to-task)
1084
+ * - Composer quote card + `>` draft block (model payload)
1085
+ * - Transcript Chat node card for messages that carry `[选中文本]`
1086
+ */
1087
+ const inject = [
1088
+ "slots",
1089
+ "sessions",
1090
+ "uiConversation"
1091
+ ];
1092
+ function apply(ctx) {
1093
+ ensureToolbarStyles();
1094
+ bindRuntime(ctx);
1095
+ registerSelectQuoteNode(ctx);
1096
+ ctx.slots.inject("conversation.input.overlay", () => {
1097
+ ctx.slots.register({
1098
+ name: "conversation.input.overlay",
1099
+ id: "select-quote-toolbar",
1100
+ order: 100
1101
+ }, SelectionToolbar);
1102
+ ctx.slots.register({
1103
+ name: "conversation.input.overlay",
1104
+ id: "select-quote-card",
1105
+ order: 20
1106
+ }, QuoteCard);
1107
+ });
1108
+ ctx.slots.inject("conversation.chat.node", () => {
1109
+ ctx.slots.register({
1110
+ name: "conversation.chat.node",
1111
+ key: "select-quote"
1112
+ }, TranscriptQuoteCard);
1113
+ ctx.slots.register({
1114
+ name: "conversation.chat.node",
1115
+ key: "user",
1116
+ priority: -10
1117
+ }, UserMessageDisplay);
1118
+ });
1119
+ }
1120
+ //#endregion
1121
+ exports.apply = apply;
1122
+ exports.inject = inject;
1123
+
1124
+ return module.exports;
1125
+ }
1126
+ });
package/lib/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export const name = "dsh-select-quote"
2
+ export function apply() {}
package/package.json ADDED
@@ -0,0 +1,72 @@
1
+ {
2
+ "name": "dsh-select-quote",
3
+ "version": "0.1.0",
4
+ "description": "DeepSeek Harness plugin: select text in the conversation, then quote it into the next message.",
5
+ "keywords": [
6
+ "deepseek",
7
+ "deepseek-harness",
8
+ "dsh",
9
+ "dsh-plugin",
10
+ "web",
11
+ "quote",
12
+ "selection"
13
+ ],
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/latte03/dsh-select-quote.git"
17
+ },
18
+ "homepage": "https://github.com/latte03/dsh-select-quote#readme",
19
+ "bugs": {
20
+ "url": "https://github.com/latte03/dsh-select-quote/issues"
21
+ },
22
+ "type": "module",
23
+ "license": "MIT",
24
+ "main": "./lib/index.js",
25
+ "exports": {
26
+ ".": "./lib/index.js",
27
+ "./client": "./lib/client.js",
28
+ "./package.json": "./package.json"
29
+ },
30
+ "files": [
31
+ "lib/index.js",
32
+ "lib/client.js",
33
+ "cordis.patch.yml",
34
+ "LICENSE"
35
+ ],
36
+ "scripts": {
37
+ "build": "node scripts/build.mjs",
38
+ "watch": "node scripts/build.mjs --watch",
39
+ "typecheck": "tsc -p tsconfig.json --noEmit",
40
+ "verify": "node scripts/verify-bundle.mjs",
41
+ "check": "npm run typecheck && npm run build && npm run verify"
42
+ },
43
+ "dsh": {
44
+ "client": {
45
+ "inject": [
46
+ "@deepseek-ai/dsh-api-session-controller",
47
+ "@deepseek-ai/dsh-client-ui-chat",
48
+ "@deepseek-ai/dsh-client-ui-conversation",
49
+ "@deepseek-ai/dsh-client-ui-renderer",
50
+ "@deepseek-ai/dsh-client-ui-session"
51
+ ],
52
+ "platform": "web"
53
+ },
54
+ "bundle": {
55
+ "patch": "./cordis.patch.yml"
56
+ }
57
+ },
58
+ "publishConfig": {
59
+ "registry": "https://registry.npmjs.org/",
60
+ "access": "public"
61
+ },
62
+ "peerDependencies": {
63
+ "@deepseek-ai/cordis": "^4.0.2"
64
+ },
65
+ "devDependencies": {
66
+ "@types/react": "~18.3.1",
67
+ "@types/react-dom": "~18.3",
68
+ "react": "^18.2.0",
69
+ "react-dom": "^18.2.0",
70
+ "typescript": "^5.8.0"
71
+ }
72
+ }