dsh-superinput 0.7.0 → 0.12.1
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/CHANGELOG.md +265 -0
- package/README.md +18 -7
- package/client.js +2356 -251
- package/modules/compaction.js +413 -0
- package/modules/cost.js +571 -3
- package/modules/prompts.client.js +1068 -57
- package/modules/prompts.js +65 -0
- package/package.json +2 -3
- package/scripts/build-suite.mjs +14 -4
- package/scripts/smoke.mjs +54 -3
- package/server.js +1057 -3
|
@@ -7,6 +7,17 @@ function createPromptsClient(require) {
|
|
|
7
7
|
const STYLE_ID = 'dsh-superinput-prompts-style'
|
|
8
8
|
const ENDPOINT = '/api/dsh-superinput/prompts'
|
|
9
9
|
|
|
10
|
+
/* 划选引用:官方 reference chip + Trae 规格。
|
|
11
|
+
* CHIP_MARK 是不可见分隔符(U+2063),作为标签前缀用于 CSS 精确选中
|
|
12
|
+
* 「我们插入的 chip」而不波及官方 @file chip;视觉与文本内容无影响。 */
|
|
13
|
+
const SELECTION_SOURCE = 'superinput-selection'
|
|
14
|
+
const SELECTION_RULE = 'dsh-superinput-selection'
|
|
15
|
+
const CHIP_MARK = '\u2063'
|
|
16
|
+
const CONTEXT_MAX = 8
|
|
17
|
+
const CONTEXT_TEXT_MAX = 4000
|
|
18
|
+
const CONTEXT_COMMENT_MAX = 600
|
|
19
|
+
const STORAGE_KEY = 'dsh-superinput-selection-items'
|
|
20
|
+
|
|
10
21
|
const CSS = `
|
|
11
22
|
.dsh-cp-btn {
|
|
12
23
|
display: inline-flex;
|
|
@@ -107,17 +118,154 @@ function createPromptsClient(require) {
|
|
|
107
118
|
.dsh-cp-action:hover { color: var(--dsw-alias-label-primary); }
|
|
108
119
|
.dsh-cp-action.primary { color: #fff; background: #3a83f7; border-color: transparent; }
|
|
109
120
|
.dsh-cp-action.primary:hover { background: #4f8ef9; }
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
121
|
+
/* 四选项横条:完全官方样式——令牌与几何照抄官方 QuoteSelection 按钮
|
|
122
|
+
(.U4oBDW_action:floating-fill 底 / 999px 胶囊 / panel 阴影 / xs-13 字), */
|
|
123
|
+
.dsh-cp-selmenu { display: flex; flex-direction: row; align-items: center; gap: 2px; padding: 3px; border-radius: 999px;
|
|
124
|
+
background: var(--dsw-alias-button-floating-fill, rgba(116, 126, 148, .24));
|
|
125
|
+
--dsw-elevation-stroke-color: var(--dsw-alias-border-l3, rgba(255, 255, 255, .1));
|
|
126
|
+
box-shadow: var(--dsw-elevation-panel, 0 0 0 .5px var(--dsw-elevation-stroke-color), 0 2px 6px rgba(0, 0, 0, .12));
|
|
115
127
|
}
|
|
116
|
-
.dsh-cp-selitem { border: none; background: transparent; text-align:
|
|
117
|
-
color: var(--dsw-alias-label-primary
|
|
118
|
-
|
|
128
|
+
.dsh-cp-selitem { border: none; background: transparent; text-align: center; padding: 5px 10px; font-size: 13px; line-height: 18px;
|
|
129
|
+
color: var(--dsw-alias-label-primary); border-radius: 999px; cursor: pointer; white-space: nowrap;
|
|
130
|
+
font-family: var(--dsw-font-family, inherit); letter-spacing: -.02em; }
|
|
131
|
+
.dsh-cp-selitem:hover, .dsh-cp-selitem:focus-visible { background: var(--dsw-alias-button-floating-hover, rgba(116, 126, 148, .36)); }
|
|
132
|
+
/* 官方浮动「引用文本」pill 由横条里的同名功能取代(同一 insertQuote API),隐藏避免双浮层 */
|
|
133
|
+
[class*="U4oBDW_action"] { display: none !important; }
|
|
119
134
|
.dsh-cp-toast { padding: 5px 12px; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-primary, #fff);
|
|
120
135
|
background: rgba(50, 50, 54, 0.95); border-radius: 8px; box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25); }
|
|
136
|
+
/* ---- 划选引用 chip:Trae comment-mention 规格(ai-modules-chat/index.css)----
|
|
137
|
+
规格:高 22px / 圆角 --dsw-radius-sm(8) / business 浅色底 + business 主色文字 /
|
|
138
|
+
12px 500 / max-width 240;左图标在 hover 时换成 ×(Trae remove-mention-icon
|
|
139
|
+
同义)。颜色全部走 dsh 设计令牌,明暗主题各自生效,不再写死 Trae 的灰/蓝。 */
|
|
140
|
+
[contenteditable] span[title^="${CHIP_MARK}"] {
|
|
141
|
+
box-sizing: border-box;
|
|
142
|
+
display: inline-flex;
|
|
143
|
+
align-items: baseline;
|
|
144
|
+
gap: 4px;
|
|
145
|
+
max-width: min(80%, 300px);
|
|
146
|
+
padding: 0;
|
|
147
|
+
border: none;
|
|
148
|
+
border-radius: 0;
|
|
149
|
+
background: none;
|
|
150
|
+
color: var(--dsw-alias-state-business-primary, #4176e6);
|
|
151
|
+
font-family: var(--dsw-font-family, inherit);
|
|
152
|
+
font-size: 14px;
|
|
153
|
+
line-height: 20px;
|
|
154
|
+
vertical-align: baseline;
|
|
155
|
+
cursor: pointer;
|
|
156
|
+
overflow: hidden;
|
|
157
|
+
transition: background .14s ease;
|
|
158
|
+
}
|
|
159
|
+
[contenteditable] span[title^="${CHIP_MARK}"]:hover {
|
|
160
|
+
color: var(--dsw-alias-state-business-primary, #4176e6);
|
|
161
|
+
}
|
|
162
|
+
/* 隐藏宿主默认前缀(@ / file 图标),换官方同款 ❝ 引用标记(hover 变 ×) */
|
|
163
|
+
[contenteditable] span[title^="${CHIP_MARK}"] > span:first-child { display: none; }
|
|
164
|
+
[contenteditable] span[title^="${CHIP_MARK}"]::before {
|
|
165
|
+
content: '';
|
|
166
|
+
flex: 0 0 14px;
|
|
167
|
+
width: 14px;
|
|
168
|
+
height: 14px;
|
|
169
|
+
align-self: center;
|
|
170
|
+
margin-right: 2px;
|
|
171
|
+
background: currentColor;
|
|
172
|
+
-webkit-mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><path d='M6.6 3.2C4.5 4.3 3 6.3 3 8.6c0 2.3 1.3 3.7 3.2 3.7 1.6 0 2.7-1 2.7-2.5 0-1.4-.9-2.3-2.2-2.3-.4 0-.7 0-1 .2.3-1 .9-1.8 1.7-2.4L6.6 3.2Zm7 0c-2.1 1.1-3.6 3.1-3.6 5.4 0 2.3 1.3 3.7 3.2 3.7 1.6 0 2.7-1 2.7-2.5 0-1.4-.9-2.3-2.2-2.3-.4 0-.7 0-1 .2.3-1 .9-1.8 1.7-2.4l-.8-1.9Z' fill='black'/></svg>") center / 14px 14px no-repeat;
|
|
173
|
+
mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><path d='M6.6 3.2C4.5 4.3 3 6.3 3 8.6c0 2.3 1.3 3.7 3.2 3.7 1.6 0 2.7-1 2.7-2.5 0-1.4-.9-2.3-2.2-2.3-.4 0-.7 0-1 .2.3-1 .9-1.8 1.7-2.4L6.6 3.2Zm7 0c-2.1 1.1-3.6 3.1-3.6 5.4 0 2.3 1.3 3.7 3.2 3.7 1.6 0 2.7-1 2.7-2.5 0-1.4-.9-2.3-2.2-2.3-.4 0-.7 0-1 .2.3-1 .9-1.8 1.7-2.4l-.8-1.9Z' fill='black'/></svg>") center / 14px 14px no-repeat;
|
|
174
|
+
}
|
|
175
|
+
[contenteditable] span[title^="${CHIP_MARK}"]:hover::before {
|
|
176
|
+
content: '×';
|
|
177
|
+
background: none;
|
|
178
|
+
-webkit-mask: none;
|
|
179
|
+
mask: none;
|
|
180
|
+
color: var(--dsw-alias-label-primary, currentColor);
|
|
181
|
+
font-size: 14px;
|
|
182
|
+
line-height: 14px;
|
|
183
|
+
text-align: center;
|
|
184
|
+
}
|
|
185
|
+
/* 消息正文里的同款 chip(user-text 规则节点;不在 contenteditable 里) */
|
|
186
|
+
.dsh-si-msgchip { box-sizing: border-box; display: inline-flex; align-items: baseline; gap: 4px; max-width: min(80%, 300px);
|
|
187
|
+
padding: 0; border: none; border-radius: 0; background: none;
|
|
188
|
+
color: var(--dsw-alias-state-business-primary, #4176e6);
|
|
189
|
+
font-family: var(--dsw-font-family, inherit); font-size: 14px; line-height: 20px;
|
|
190
|
+
vertical-align: baseline; overflow: hidden; user-select: none; }
|
|
191
|
+
.dsh-si-msgchip.interactive { cursor: pointer; }
|
|
192
|
+
.dsh-si-msgchip-icon { flex: 0 0 14px; width: 14px; height: 14px; margin-right: 2px; opacity: .8; background: currentColor;
|
|
193
|
+
-webkit-mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><path d='M6.6 3.2C4.5 4.3 3 6.3 3 8.6c0 2.3 1.3 3.7 3.2 3.7 1.6 0 2.7-1 2.7-2.5 0-1.4-.9-2.3-2.2-2.3-.4 0-.7 0-1 .2.3-1 .9-1.8 1.7-2.4L6.6 3.2Zm7 0c-2.1 1.1-3.6 3.1-3.6 5.4 0 2.3 1.3 3.7 3.2 3.7 1.6 0 2.7-1 2.7-2.5 0-1.4-.9-2.3-2.2-2.3-.4 0-.7 0-1 .2.3-1 .9-1.8 1.7-2.4l-.8-1.9Z' fill='black'/></svg>") center / 14px 14px no-repeat;
|
|
194
|
+
mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><path d='M6.6 3.2C4.5 4.3 3 6.3 3 8.6c0 2.3 1.3 3.7 3.2 3.7 1.6 0 2.7-1 2.7-2.5 0-1.4-.9-2.3-2.2-2.3-.4 0-.7 0-1 .2.3-1 .9-1.8 1.7-2.4L6.6 3.2Zm7 0c-2.1 1.1-3.6 3.1-3.6 5.4 0 2.3 1.3 3.7 3.2 3.7 1.6 0 2.7-1 2.7-2.5 0-1.4-.9-2.3-2.2-2.3-.4 0-.7 0-1 .2.3-1 .9-1.8 1.7-2.4l-.8-1.9Z' fill='black'/></svg>") center / 14px 14px no-repeat; }
|
|
195
|
+
.dsh-si-msgchip-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
196
|
+
/* 详情弹窗:dsh 原生 Dialog 观感(layer-1 表面 / 16 圆角 / 突出投影 / 标准按钮) */
|
|
197
|
+
.dsh-si-mask { position: fixed; inset: 0; z-index: 1200; display: flex; align-items: center; justify-content: center;
|
|
198
|
+
padding: 24px; background: var(--dsw-alias-bg-mask-1, rgba(0, 0, 0, .24)); }
|
|
199
|
+
.dsh-si-dialog { box-sizing: border-box; width: min(480px, 100%); max-height: min(600px, 82vh); display: flex; flex-direction: column; gap: 12px;
|
|
200
|
+
padding: 16px; border-radius: var(--dsw-radius-lg, 16px); color: var(--dsw-alias-label-primary, #e8eefa); overflow-y: auto;
|
|
201
|
+
font-family: var(--dsw-font-family, inherit);
|
|
202
|
+
background: var(--dsw-alias-bg-layer-1, #232324);
|
|
203
|
+
border: .5px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, .08));
|
|
204
|
+
--dsw-elevation-stroke-color: var(--dsw-alias-border-l4, rgba(255, 255, 255, .2));
|
|
205
|
+
box-shadow: var(--dsw-elevation-prominent, 0 0 0 .5px var(--dsw-elevation-stroke-color), 0 8px 28px rgba(0, 0, 0, .28)); }
|
|
206
|
+
.dsh-si-dialog-head { display: flex; align-items: center; gap: 8px; }
|
|
207
|
+
.dsh-si-dialog-title { flex: 1 1 auto; min-width: 0; font-size: 16px; font-weight: 500; line-height: 24px;
|
|
208
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
209
|
+
.dsh-si-dialog-x { flex: 0 0 auto; width: 24px; height: 24px; padding: 0; border: 0; border-radius: var(--dsw-radius-xs, 4px); cursor: pointer;
|
|
210
|
+
background: transparent; color: var(--dsw-alias-label-tertiary, #8a9096); font-size: 15px; line-height: 1; }
|
|
211
|
+
.dsh-si-dialog-x:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover, rgba(255, 255, 255, .08)); }
|
|
212
|
+
.dsh-si-dialog-quote { box-sizing: border-box; flex: 1 1 auto; min-height: 40px; max-height: 240px; overflow: auto; padding: 10px 12px; border-radius: var(--dsw-radius-sm, 8px);
|
|
213
|
+
background: var(--dsw-alias-bg-module-platform, rgba(255, 255, 255, .06));
|
|
214
|
+
border-left: 2px solid var(--dsw-alias-state-business-primary, #3a83f7);
|
|
215
|
+
font-size: 14px; line-height: 22px; color: var(--dsw-alias-label-secondary); white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
216
|
+
.dsh-si-dialog-label { font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-tertiary, #8a9096); }
|
|
217
|
+
.dsh-si-dialog-detail { font-size: 14px; line-height: 22px; color: var(--dsw-alias-label-secondary); white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
218
|
+
.dsh-si-dialog-comment { box-sizing: border-box; width: 100%; min-height: 64px; max-height: 160px; resize: vertical; padding: 8px 10px;
|
|
219
|
+
font-size: 14px; line-height: 22px; font-family: inherit; color: var(--dsw-alias-label-primary);
|
|
220
|
+
background: var(--dsw-alias-bg-layer-2, rgba(255, 255, 255, .06)); border: .5px solid var(--dsw-alias-border-l2, rgba(255, 255, 255, .12));
|
|
221
|
+
border-radius: var(--dsw-radius-sm, 8px); outline: none; }
|
|
222
|
+
.dsh-si-dialog-comment:focus {
|
|
223
|
+
border-color: var(--dsw-alias-state-business-primary, #3a83f7);
|
|
224
|
+
box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary, #3a83f7) 16%, transparent);
|
|
225
|
+
}
|
|
226
|
+
.dsh-si-dialog-foot { display: flex; align-items: center; gap: 8px; }
|
|
227
|
+
.dsh-si-dialog-spacer { flex: 1 1 auto; }
|
|
228
|
+
.dsh-si-dialog-btn { height: 30px; padding: 0 14px; font-size: 13px; font-weight: 500; line-height: 20px; cursor: pointer;
|
|
229
|
+
color: var(--dsw-alias-label-primary); background: var(--dsw-alias-button-elevated-fill, transparent);
|
|
230
|
+
border: .5px solid var(--dsw-alias-border-l2, rgba(255, 255, 255, .12)); border-radius: var(--dsw-radius-sm, 8px); }
|
|
231
|
+
.dsh-si-dialog-btn:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(255, 255, 255, .08)); }
|
|
232
|
+
.dsh-si-dialog-btn.ghost { background: transparent; border-color: transparent; color: var(--dsw-alias-label-secondary); }
|
|
233
|
+
.dsh-si-dialog-btn.ghost:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover, rgba(255, 255, 255, .08)); }
|
|
234
|
+
.dsh-si-dialog-btn.primary { color: var(--dsw-alias-label-primary-foreground, #fff);
|
|
235
|
+
background: var(--dsw-alias-button-primary-fill, #3a83f7); border-color: transparent; }
|
|
236
|
+
.dsh-si-dialog-btn.primary:hover { background: var(--dsw-alias-button-primary-hover, #4f8ef9); }
|
|
237
|
+
.dsh-si-dialog-btn.danger { background: transparent; border-color: transparent; color: var(--dsw-alias-state-error-primary, #f87171); }
|
|
238
|
+
.dsh-si-dialog-btn.danger:hover { background: var(--dsw-alias-interactive-bg-hover-danger, rgba(248, 113, 113, .12)); }
|
|
239
|
+
/* 划选评论编辑器:照抄 Trae CommentEditorCard(solo-lite 7920 包源码)——
|
|
240
|
+
previewCommentCardContainer(菜单底/1px 中性描边/radius 8/双层阴影)内嵌
|
|
241
|
+
commentEditorCard(320px/padding 8/gap 4 列布局);无标题无预览无取消钮,
|
|
242
|
+
单行 = 透明自增 textarea(13px/20px,max-height 140)+ ✓ 主按钮;Enter 提交、
|
|
243
|
+
Shift+Enter 换行、Esc/点外取消。 */
|
|
244
|
+
.dsh-si-comment-editor { position: fixed; z-index: 1300; box-sizing: border-box; width: 320px; padding: 8px;
|
|
245
|
+
display: flex; flex-direction: column; gap: 4px;
|
|
246
|
+
border-radius: var(--dsw-radius-sm, 8px); color: var(--dsw-alias-label-primary);
|
|
247
|
+
font-family: var(--dsw-font-family, inherit); background: var(--dsw-specific-menu, #202124);
|
|
248
|
+
border: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, .1));
|
|
249
|
+
/* specific-menu 是半透明底:必须配官方毛玻璃,否则透出下层文字(官方弹卡成对规则) */
|
|
250
|
+
backdrop-filter: var(--dsw-menu-backdrop-filter, blur(40px) saturate(150%));
|
|
251
|
+
-webkit-backdrop-filter: var(--dsw-menu-backdrop-filter, blur(40px) saturate(150%));
|
|
252
|
+
box-shadow: 0 2px 6px rgba(0, 0, 0, .19), 0 .5px rgba(255, 255, 255, .06), inset 0 .5px rgba(255, 255, 255, .06);
|
|
253
|
+
overflow: hidden;
|
|
254
|
+
transition: width .25s cubic-bezier(.4, 0, .2, 1), height .25s cubic-bezier(.4, 0, .2, 1);
|
|
255
|
+
visibility: hidden; }
|
|
256
|
+
.dsh-si-comment-row { display: flex; align-items: center; gap: 8px; }
|
|
257
|
+
.dsh-si-comment-input { flex: 1; min-width: 0; box-sizing: border-box; background: none; border: none; outline: none; resize: none;
|
|
258
|
+
color: var(--dsw-alias-label-primary); font-family: var(--dsw-font-family, inherit);
|
|
259
|
+
font-size: 13px; font-weight: 400; line-height: 20px; max-height: 140px;
|
|
260
|
+
padding: 0 8px; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--dsw-alias-border-l2, rgba(255, 255, 255, .16)) transparent; }
|
|
261
|
+
.dsh-si-comment-input::placeholder { color: var(--dsw-alias-label-tertiary); }
|
|
262
|
+
.dsh-si-comment-submit { flex: 0 0 auto; width: 24px; height: 24px; padding: 0; display: inline-flex; align-items: center; justify-content: center;
|
|
263
|
+
border: none; border-radius: 6px; cursor: pointer;
|
|
264
|
+
background: var(--dsw-alias-bg-layer-2, rgba(255, 255, 255, .12)); color: var(--dsw-alias-label-primary); }
|
|
265
|
+
.dsh-si-comment-submit:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover, rgba(255, 255, 255, .2)); }
|
|
266
|
+
.dsh-si-comment-submit:disabled { opacity: .5; cursor: default; }
|
|
267
|
+
/* 点击标签跳源时对源元素的闪烁高亮。 */
|
|
268
|
+
.dsh-cp-flash { outline: 2px solid var(--dsw-alias-state-business-primary, #3a83f7) !important; outline-offset: 1px; border-radius: 3px; transition: outline-color .3s ease; }
|
|
121
269
|
`
|
|
122
270
|
|
|
123
271
|
function ensureStyle() {
|
|
@@ -149,6 +297,195 @@ function createPromptsClient(require) {
|
|
|
149
297
|
} catch { /* ignore */ }
|
|
150
298
|
}
|
|
151
299
|
|
|
300
|
+
/* ------------------------------------------------------------------ */
|
|
301
|
+
/* 划选引用:输入框内联 chip(官方 reference source)+ /context 同步 */
|
|
302
|
+
/* ------------------------------------------------------------------ */
|
|
303
|
+
|
|
304
|
+
const selectionItems = new Map() // id -> item(发送后归档,供消息正文的 chip 渲染)
|
|
305
|
+
const selectionSyncTimers = new Map()
|
|
306
|
+
const selectionActions = new Map() // sessionId -> InputActions
|
|
307
|
+
const selectionDetailSubs = new Set()
|
|
308
|
+
let selectionDetailRequest = null
|
|
309
|
+
let selectionSessions = null // ClientSessions 服务(取每会话 binding.ctx 发输入事件)
|
|
310
|
+
let selectionIdSeq = 0
|
|
311
|
+
let selectionStorageTimer = null
|
|
312
|
+
|
|
313
|
+
const tokenOf = (id) => `[选段#${id}]`
|
|
314
|
+
const previewOf = (text, max = 220) => String(text ?? '').replace(/\s+/g, ' ').trim().slice(0, max)
|
|
315
|
+
|
|
316
|
+
/** 选区矩形兜底:菜单给的 rect 失效时退回锚点元素/默认位置。 */
|
|
317
|
+
const normalizeSelectionRect = (rect, anchorEl) => {
|
|
318
|
+
const finite = (n) => typeof n === 'number' && Number.isFinite(n)
|
|
319
|
+
if (rect && finite(rect.left) && finite(rect.top) && finite(rect.right) && finite(rect.bottom)) return rect
|
|
320
|
+
const el = anchorEl instanceof Element ? anchorEl : null
|
|
321
|
+
if (el) {
|
|
322
|
+
const r = el.getBoundingClientRect()
|
|
323
|
+
return { left: r.left, top: r.top, right: r.right, bottom: r.bottom, width: r.width, height: r.height }
|
|
324
|
+
}
|
|
325
|
+
return { left: 16, top: 80, right: 16, bottom: 80, width: 0, height: 0 }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** chip 标签:Trae 是 `N. 选段文本 · 评论`(评论内联在标签里)。
|
|
329
|
+
* 有评论时把选段压到 ~14 字(Trae 的 text-content 也只占 50%),
|
|
330
|
+
* 否则 240px 的 chip 会把评论整段挤没。 */
|
|
331
|
+
const chipTextOf = (seq, text, comment) => {
|
|
332
|
+
const quote = String(text ?? '').replace(/\s+/g, ' ').trim()
|
|
333
|
+
const note = String(comment ?? '').replace(/\s+/g, ' ').trim()
|
|
334
|
+
if (!note) return `${seq}. ${quote}`
|
|
335
|
+
const head = quote.length > 14 ? `${quote.slice(0, 14)}…` : quote
|
|
336
|
+
return `${seq}. ${head} · ${note.slice(0, 40)}`
|
|
337
|
+
}
|
|
338
|
+
const chipLabelOf = (item) => `${CHIP_MARK}${chipTextOf(item.seq, item.text, item.comment)}`
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* 某条 chip 在 detect 坐标里的跨度。occurrences 的 offset 是 clipboard
|
|
342
|
+
* 坐标(chip 展开成 clipboardText),detect 坐标里每条 chip 只占 1 个
|
|
343
|
+
* U+FFFC——把前面所有 chip 的 (length-1) 扣掉即得。
|
|
344
|
+
*/
|
|
345
|
+
function chipSpanOf(occurrences, ref, draftRev) {
|
|
346
|
+
const own = (occurrences ?? []).find((occ) => occ.source === SELECTION_SOURCE && occ.ref === ref)
|
|
347
|
+
if (!own) return null
|
|
348
|
+
let start = own.offset
|
|
349
|
+
for (const occ of occurrences) if (occ.offset < own.offset) start -= occ.length - 1
|
|
350
|
+
return { start, end: start + 1, draftRev }
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** 当前草稿里真实存在的条目,按草稿里的先后顺序(Trae 的编号顺序)。 */
|
|
354
|
+
function liveSelectionItems(sessionId, occurrences) {
|
|
355
|
+
if (!sessionId) return []
|
|
356
|
+
const seen = new Set()
|
|
357
|
+
const live = []
|
|
358
|
+
for (const occ of occurrences ?? []) {
|
|
359
|
+
if (occ.source !== SELECTION_SOURCE || seen.has(occ.ref)) continue
|
|
360
|
+
seen.add(occ.ref)
|
|
361
|
+
const item = selectionItems.get(occ.ref)
|
|
362
|
+
if (item && item.sessionId === sessionId) live.push(item)
|
|
363
|
+
}
|
|
364
|
+
return live
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function postContext(sessionId, items) {
|
|
368
|
+
if (!sessionId) return
|
|
369
|
+
try {
|
|
370
|
+
await fetch(`${ENDPOINT}/context`, {
|
|
371
|
+
method: 'POST',
|
|
372
|
+
headers: { 'content-type': 'application/json' },
|
|
373
|
+
body: JSON.stringify({
|
|
374
|
+
sessionId,
|
|
375
|
+
items: items.map(({ text, comment }) => ({
|
|
376
|
+
text: String(text ?? '').slice(0, CONTEXT_TEXT_MAX),
|
|
377
|
+
comment: String(comment ?? '').slice(0, CONTEXT_COMMENT_MAX),
|
|
378
|
+
})),
|
|
379
|
+
}),
|
|
380
|
+
keepalive: true,
|
|
381
|
+
})
|
|
382
|
+
} catch { /* 网络失败时服务端保留上一份,不打断输入 */ }
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** 服务端 /context 同步:150ms 防抖;发送意图用 immediate flush。 */
|
|
386
|
+
function syncSelectionContext(sessionId, items, immediate) {
|
|
387
|
+
if (!sessionId) return
|
|
388
|
+
const previous = selectionSyncTimers.get(sessionId)
|
|
389
|
+
if (previous) clearTimeout(previous)
|
|
390
|
+
const send = () => {
|
|
391
|
+
selectionSyncTimers.delete(sessionId)
|
|
392
|
+
void postContext(sessionId, items)
|
|
393
|
+
}
|
|
394
|
+
if (immediate) send()
|
|
395
|
+
else selectionSyncTimers.set(sessionId, setTimeout(send, 150))
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** 落 sessionStorage:消息正文的 chip 在刷新后仍有文本/评论可渲染。 */
|
|
399
|
+
function persistSelectionItems() {
|
|
400
|
+
if (typeof sessionStorage === 'undefined' || selectionStorageTimer) return
|
|
401
|
+
selectionStorageTimer = setTimeout(() => {
|
|
402
|
+
selectionStorageTimer = null
|
|
403
|
+
try {
|
|
404
|
+
const snapshot = [...selectionItems.values()].slice(-120).map((item) => ({
|
|
405
|
+
id: item.id,
|
|
406
|
+
sessionId: item.sessionId,
|
|
407
|
+
seq: item.seq,
|
|
408
|
+
text: item.text,
|
|
409
|
+
comment: item.comment,
|
|
410
|
+
sent: item.sent === true,
|
|
411
|
+
at: item.at,
|
|
412
|
+
}))
|
|
413
|
+
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot))
|
|
414
|
+
} catch { /* 配额/隐私模式:忽略 */ }
|
|
415
|
+
}, 400)
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function loadSelectionItems() {
|
|
419
|
+
if (typeof sessionStorage === 'undefined') return
|
|
420
|
+
try {
|
|
421
|
+
const saved = JSON.parse(sessionStorage.getItem(STORAGE_KEY) ?? '[]')
|
|
422
|
+
if (!Array.isArray(saved)) return
|
|
423
|
+
for (const raw of saved) {
|
|
424
|
+
if (!raw?.id || !raw?.text) continue
|
|
425
|
+
const id = String(raw.id)
|
|
426
|
+
if (selectionItems.has(id)) continue
|
|
427
|
+
const sessionId = String(raw.sessionId ?? '')
|
|
428
|
+
const seq = Number(raw.seq) || 0
|
|
429
|
+
selectionItems.set(id, {
|
|
430
|
+
id, sessionId, seq,
|
|
431
|
+
text: String(raw.text),
|
|
432
|
+
comment: String(raw.comment ?? ''),
|
|
433
|
+
anchor: null,
|
|
434
|
+
sent: raw.sent === true,
|
|
435
|
+
archived: true, // 历史条目:chip 已不在草稿里,只用于消息正文展示
|
|
436
|
+
at: Number(raw.at) || Date.now(),
|
|
437
|
+
})
|
|
438
|
+
}
|
|
439
|
+
} catch { /* 存档损坏:忽略 */ }
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** 走会话作用域的 slash/input-insert-reference 事件把 chip 插进官方编辑器。 */
|
|
443
|
+
function applySelectionInsert(sessionId, item, span) {
|
|
444
|
+
if (!span) return false
|
|
445
|
+
const binding = selectionSessions?.binding?.(sessionId)
|
|
446
|
+
const actx = binding?.ctx
|
|
447
|
+
if (!actx) return false
|
|
448
|
+
const request = {
|
|
449
|
+
reference: {
|
|
450
|
+
source: SELECTION_SOURCE,
|
|
451
|
+
ref: item.id,
|
|
452
|
+
label: chipLabelOf(item),
|
|
453
|
+
clipboardText: tokenOf(item.id),
|
|
454
|
+
},
|
|
455
|
+
span,
|
|
456
|
+
}
|
|
457
|
+
try {
|
|
458
|
+
if (typeof actx.bail === 'function') {
|
|
459
|
+
try { return actx.bail(actx, 'slash/input-insert-reference', request) === true }
|
|
460
|
+
catch { /* 有些版本只接受 (name, payload) */ }
|
|
461
|
+
try { return actx.bail('slash/input-insert-reference', request) === true }
|
|
462
|
+
catch { return false }
|
|
463
|
+
}
|
|
464
|
+
if (typeof actx.emit === 'function') {
|
|
465
|
+
actx.emit(actx, 'slash/input-insert-reference', request)
|
|
466
|
+
return true
|
|
467
|
+
}
|
|
468
|
+
} catch { return false }
|
|
469
|
+
return false
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** 删除一条 chip:用 insertText('') 替换它的 detect 跨度(空文本即删除)。 */
|
|
473
|
+
function applySelectionRemove(sessionId, item, span) {
|
|
474
|
+
if (!span) return false
|
|
475
|
+
const actions = selectionActions.get(sessionId)
|
|
476
|
+
if (typeof actions?.insertText !== 'function') return false
|
|
477
|
+
try { return actions.insertText('', span) === true } catch { return false }
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function requestSelectionDetail(sessionId, id, focus) {
|
|
481
|
+
selectionDetailRequest = { sessionId, id, focus: focus === true }
|
|
482
|
+
for (const listener of selectionDetailSubs) {
|
|
483
|
+
try { listener() } catch { /* ignore */ }
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
loadSelectionItems()
|
|
488
|
+
|
|
152
489
|
/** 找出正文里的 {{变量}},去重保序。 */
|
|
153
490
|
function varsOf(text) {
|
|
154
491
|
const found = []
|
|
@@ -237,84 +574,53 @@ function createPromptsClient(require) {
|
|
|
237
574
|
if (hideTimer) clearTimeout(hideTimer)
|
|
238
575
|
hideTimer = setTimeout(hide, 1800)
|
|
239
576
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
try {
|
|
243
|
-
await navigator.clipboard?.writeText(text)
|
|
244
|
-
toast('已复制')
|
|
245
|
-
} catch {
|
|
246
|
-
toast('复制失败')
|
|
247
|
-
}
|
|
248
|
-
}
|
|
577
|
+
// 评论编辑器在 React 侧保存后要提示,复用这个文档级 toast。
|
|
578
|
+
window.__dshSuperinputToast = toast
|
|
249
579
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
const set = window.__dshSuperinputSetDraft
|
|
255
|
-
if (!set) { toast('草稿桥未就绪,刷新后重试'); return }
|
|
256
|
-
const current = String(window.__dshSuperinputDraft?.() ?? '')
|
|
257
|
-
set(current ? `${current}\n${text}` : text)
|
|
258
|
-
toast('已添加到输入框')
|
|
259
|
-
} catch {
|
|
260
|
-
toast('添加失败')
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
const save = async (text) => {
|
|
265
|
-
const title = text.replace(/\s+/g, ' ').trim().slice(0, 40)
|
|
266
|
-
hide()
|
|
267
|
-
try {
|
|
268
|
-
const res = await fetch('/api/dsh-superinput/prompts/save', {
|
|
269
|
-
method: 'POST',
|
|
270
|
-
headers: { 'content-type': 'application/json' },
|
|
271
|
-
body: JSON.stringify({ title, body: text }),
|
|
272
|
-
})
|
|
273
|
-
const data = res.ok ? await res.json() : null
|
|
274
|
-
toast(data?.ok ? '已存为模板' : '保存失败')
|
|
275
|
-
} catch {
|
|
276
|
-
toast('保存失败')
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
// 选区变化驱动(dblclick 选词在 mouseup 之后才成形,selectionchange 才是可靠信号)
|
|
580
|
+
/* 四选项横条(用户拍板恢复 0.8.x 形态):复制 / 引用文本(官方
|
|
581
|
+
* insertQuote 机制)/ 添加评论(Trae 评论卡 → chip)/ 存为模板。
|
|
582
|
+
* 官方自己的浮动「引用文本」pill 用 CSS 隐藏——横条里的「引用文本」
|
|
583
|
+
* 调同一个官方 API,避免双浮层。 */
|
|
281
584
|
let showTimer = 0
|
|
282
585
|
document.addEventListener('selectionchange', () => {
|
|
283
586
|
try {
|
|
284
587
|
if (showTimer) { clearTimeout(showTimer); showTimer = 0 }
|
|
285
588
|
const selection = window.getSelection?.()
|
|
286
589
|
const text = String(selection ?? '').trim()
|
|
287
|
-
if (!text ||
|
|
590
|
+
if (!text || !selection.rangeCount) { hide(); return }
|
|
288
591
|
showTimer = setTimeout(() => {
|
|
289
592
|
try {
|
|
290
593
|
const sel = window.getSelection?.()
|
|
291
594
|
const current = String(sel ?? '').trim()
|
|
292
|
-
if (!current ||
|
|
595
|
+
if (!current || !sel.rangeCount) { hide(); return }
|
|
293
596
|
const node = sel.anchorNode
|
|
294
597
|
const el = node instanceof Element ? node : node?.parentElement
|
|
295
|
-
//
|
|
598
|
+
// 只收对话正文:跳过输入区、我们自己的弹层,以及侧栏/设置等非对话区域
|
|
296
599
|
if (!el || el.closest('[data-dsh-superinput-portal]') || el.closest('[contenteditable="true"], textarea, input')) { hide(); return }
|
|
600
|
+
if (!el.closest('[data-chat-flow], [data-slot="conversation.chat.flow"]')) { hide(); return }
|
|
297
601
|
const rect = sel.getRangeAt(0).getBoundingClientRect()
|
|
298
602
|
if (!rect || (rect.width === 0 && rect.height === 0)) { hide(); return }
|
|
299
603
|
const container = ensureHost()
|
|
300
|
-
//
|
|
301
|
-
container.style.left = `${Math.min(Math.max(8, rect.left), Math.max(8, window.innerWidth -
|
|
604
|
+
// 横排四项 ≈320px:左缘对齐选区,右侧防溢出
|
|
605
|
+
container.style.left = `${Math.min(Math.max(8, rect.left), Math.max(8, window.innerWidth - 330))}px`
|
|
302
606
|
container.style.top = `${Math.max(8, rect.top - 44)}px`
|
|
303
607
|
container.style.transform = ''
|
|
304
608
|
container.style.display = 'block'
|
|
305
609
|
const menu = document.createElement('div')
|
|
306
610
|
menu.className = 'dsh-cp-selmenu'
|
|
307
|
-
|
|
611
|
+
// 阻止默认聚焦/清选区:否则 mousedown 触发 selectionchange,菜单会在 click 前被隐藏
|
|
612
|
+
menu.addEventListener('mousedown', (e) => { e.stopPropagation(); e.preventDefault() })
|
|
308
613
|
const mkItem = (label, action) => {
|
|
309
614
|
const b = document.createElement('button')
|
|
310
615
|
b.className = 'dsh-cp-selitem'
|
|
311
616
|
b.textContent = label
|
|
312
|
-
b.addEventListener('click', () => { void action(current) })
|
|
617
|
+
b.addEventListener('click', () => { void action(current, el, rect) })
|
|
313
618
|
return b
|
|
314
619
|
}
|
|
315
620
|
menu.append(
|
|
316
621
|
mkItem('复制', copySel),
|
|
317
|
-
mkItem('
|
|
622
|
+
mkItem('引用文本', quoteToComposer),
|
|
623
|
+
mkItem('添加评论', (text2) => openComment(text2, el, rect)),
|
|
318
624
|
mkItem('存为模板', save))
|
|
319
625
|
container.replaceChildren(menu)
|
|
320
626
|
} catch { /* ignore */ }
|
|
@@ -328,6 +634,128 @@ function createPromptsClient(require) {
|
|
|
328
634
|
document.addEventListener('selectionchange', () => {
|
|
329
635
|
if (!String(window.getSelection?.() ?? '').trim()) hide()
|
|
330
636
|
})
|
|
637
|
+
|
|
638
|
+
const copySel = async (text) => {
|
|
639
|
+
hide()
|
|
640
|
+
if (!navigator.clipboard?.writeText) { toast('当前环境不支持复制'); return }
|
|
641
|
+
try {
|
|
642
|
+
await navigator.clipboard.writeText(text)
|
|
643
|
+
toast('已复制')
|
|
644
|
+
} catch {
|
|
645
|
+
toast('复制失败')
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** 官方「引用文本」同款:inputActions.insertQuote 插 Markdown blockquote。
|
|
650
|
+
* plain 模式限制下返回 false 时,退回草稿桥手写 `> ` 前缀(发送后官方
|
|
651
|
+
* quoteRule 一样渲染成引用样式)。 */
|
|
652
|
+
const quoteToComposer = (text) => {
|
|
653
|
+
hide()
|
|
654
|
+
const insert = window.__dshSuperinputInsertQuote
|
|
655
|
+
if (typeof insert === 'function' && insert(text) === true) { toast('已引用到输入框'); return }
|
|
656
|
+
try {
|
|
657
|
+
const set = window.__dshSuperinputSetDraft
|
|
658
|
+
if (!set) { toast('输入框桥未就绪,刷新后重试'); return }
|
|
659
|
+
const block = text.split('\n').map((line) => `> ${line}`).join('\n')
|
|
660
|
+
const current = String(window.__dshSuperinputDraft?.() ?? '')
|
|
661
|
+
set(current ? `${current}\n${block}` : block)
|
|
662
|
+
toast('已引用到输入框')
|
|
663
|
+
} catch {
|
|
664
|
+
toast('引用失败')
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** Trae 的「添加评论」:在选区旁弹评论卡,保存后才插 chip(带评论)。
|
|
669
|
+
* rect 是选区矩形,React 侧用它把卡片锚在选区附近。 */
|
|
670
|
+
const openComment = (text, anchorEl, rect) => {
|
|
671
|
+
hide()
|
|
672
|
+
const open = window.__dshSuperinputCommentContext
|
|
673
|
+
if (typeof open !== 'function') { toast('评论编辑器未就绪,刷新后重试'); return }
|
|
674
|
+
let status = 'failed'
|
|
675
|
+
try {
|
|
676
|
+
status = open(text, anchorEl, {
|
|
677
|
+
left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom,
|
|
678
|
+
width: rect.width, height: rect.height,
|
|
679
|
+
})
|
|
680
|
+
} catch { status = 'failed' }
|
|
681
|
+
if (status === 'full') toast(`最多添加 ${CONTEXT_MAX} 段对话内容`)
|
|
682
|
+
else if (status === 'empty') toast('选段为空')
|
|
683
|
+
else if (status === 'failed') toast('打开评论编辑器失败')
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const save = async (text) => {
|
|
687
|
+
const title = text.replace(/\s+/g, ' ').trim().slice(0, 40)
|
|
688
|
+
hide()
|
|
689
|
+
try {
|
|
690
|
+
const res = await fetch('/api/dsh-superinput/prompts/save', {
|
|
691
|
+
method: 'POST',
|
|
692
|
+
headers: { 'content-type': 'application/json' },
|
|
693
|
+
body: JSON.stringify({ title, body: text }),
|
|
694
|
+
})
|
|
695
|
+
const data = res.ok ? await res.json() : null
|
|
696
|
+
toast(data?.ok ? '已存为模板' : '保存失败')
|
|
697
|
+
} catch {
|
|
698
|
+
toast('保存失败')
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/* Trae dragDropMention 等价:把对话里划选的文字拖进输入框,落成引用 chip。
|
|
703
|
+
* dragstart 时对选区打自定义 dataTransfer 标记 + stash 文本;dragover 放行
|
|
704
|
+
* composer;drop 拦截宿主的纯文本插入,走 __dshSuperinputDropContext 插 chip。 */
|
|
705
|
+
let dragSelection = null // { text, anchorEl }
|
|
706
|
+
document.addEventListener('dragstart', (event) => {
|
|
707
|
+
try {
|
|
708
|
+
const selection = window.getSelection?.()
|
|
709
|
+
const text = String(selection ?? '').trim()
|
|
710
|
+
const node = selection?.anchorNode
|
|
711
|
+
const el = node instanceof Element ? node : node?.parentElement
|
|
712
|
+
if (!text || !el?.closest?.('[data-chat-flow], [data-slot="conversation.chat.flow"]')
|
|
713
|
+
|| el.closest?.('[contenteditable="true"], textarea, input')) { dragSelection = null; return }
|
|
714
|
+
dragSelection = { text: text.slice(0, CONTEXT_TEXT_MAX), anchorEl: el }
|
|
715
|
+
try { event.dataTransfer?.setData('application/x-dsh-selection', '1') } catch { /* 标记失败只影响识别 */ }
|
|
716
|
+
} catch { dragSelection = null }
|
|
717
|
+
}, true)
|
|
718
|
+
document.addEventListener('dragover', (event) => {
|
|
719
|
+
try {
|
|
720
|
+
if (!dragSelection) return
|
|
721
|
+
const target = event.target
|
|
722
|
+
if (!(target instanceof Element) || !target.closest?.('[contenteditable="true"]')) return
|
|
723
|
+
event.preventDefault()
|
|
724
|
+
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'
|
|
725
|
+
} catch { /* ignore */ }
|
|
726
|
+
}, true)
|
|
727
|
+
document.addEventListener('drop', (event) => {
|
|
728
|
+
try {
|
|
729
|
+
if (!dragSelection) return
|
|
730
|
+
const target = event.target
|
|
731
|
+
if (!(target instanceof Element) || !target.closest?.('[contenteditable="true"]')) return
|
|
732
|
+
event.preventDefault()
|
|
733
|
+
event.stopPropagation()
|
|
734
|
+
const { text, anchorEl } = dragSelection
|
|
735
|
+
dragSelection = null
|
|
736
|
+
// 尽力把光标挪到落点:宿主 captureInsertion 读到哪就是哪。
|
|
737
|
+
try {
|
|
738
|
+
const range = typeof document.caretRangeFromPoint === 'function'
|
|
739
|
+
? document.caretRangeFromPoint(event.clientX, event.clientY)
|
|
740
|
+
: null
|
|
741
|
+
if (range) {
|
|
742
|
+
window.getSelection()?.removeAllRanges()
|
|
743
|
+
window.getSelection()?.addRange(range)
|
|
744
|
+
}
|
|
745
|
+
} catch { /* 定位失败就落原 caret */ }
|
|
746
|
+
const insert = window.__dshSuperinputDropContext ?? window.__dshSuperinputAddContext
|
|
747
|
+
if (typeof insert === 'function') {
|
|
748
|
+
const status = insert(text, anchorEl)
|
|
749
|
+
const toast = window.__dshSuperinputToast
|
|
750
|
+
if (typeof toast === 'function') {
|
|
751
|
+
if (status === 'added') toast('已添加到对话')
|
|
752
|
+
else if (status === 'dup') toast('该选段已在列表中')
|
|
753
|
+
else if (status === 'full') toast(`最多添加 ${CONTEXT_MAX} 段对话内容`)
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
} catch { /* ignore */ }
|
|
757
|
+
}, true)
|
|
758
|
+
document.addEventListener('dragend', () => { dragSelection = null }, true)
|
|
331
759
|
}
|
|
332
760
|
|
|
333
761
|
function PromptsPanel({ anchor, onClose, onSyncSlash }) {
|
|
@@ -623,6 +1051,525 @@ function createPromptsClient(require) {
|
|
|
623
1051
|
render() { return this.state.crashed ? null : this.props.children }
|
|
624
1052
|
}
|
|
625
1053
|
|
|
1054
|
+
/** 详情弹窗:Trae 点 chip 打开的**只读** Dialog(引用全文 + 评论 + 跳转原文)。
|
|
1055
|
+
* 评论不在这个弹窗里编辑——Trae 的评论在选区旁的 previewCommentCard 输入;
|
|
1056
|
+
* 这里只展示结果(与 ai-modules-chat 的 mention-info-modal 同构)。 */
|
|
1057
|
+
function SelectionDetail({ title, text, comment, onNavigate, onClose }) {
|
|
1058
|
+
const maskRef = React.useRef(null)
|
|
1059
|
+
|
|
1060
|
+
/* 桌面壳(Electron)里 body 会变成 0×0 的 fixed 包含块:`inset: 0` 的遮罩
|
|
1061
|
+
* 缩在左下角,弹窗被裁掉。打开后量一次遮罩实际矩形,若没有铺满视口,
|
|
1062
|
+
* 就按测量偏移把遮罩校正回视口原点并铺满——不依赖宿主沿用哪种 portal。 */
|
|
1063
|
+
React.useEffect(() => {
|
|
1064
|
+
const mask = maskRef.current
|
|
1065
|
+
if (!mask || typeof window === 'undefined') return undefined
|
|
1066
|
+
let corrected = false
|
|
1067
|
+
const fix = () => {
|
|
1068
|
+
const vw = window.innerWidth
|
|
1069
|
+
const vh = window.innerHeight
|
|
1070
|
+
if (!corrected) {
|
|
1071
|
+
const rect = mask.getBoundingClientRect()
|
|
1072
|
+
if (rect.width >= vw * 0.8 && rect.height >= vh * 0.8) return
|
|
1073
|
+
corrected = true
|
|
1074
|
+
mask.style.position = 'fixed'
|
|
1075
|
+
mask.style.inset = 'auto'
|
|
1076
|
+
mask.style.left = `${-rect.left}px`
|
|
1077
|
+
mask.style.top = `${-rect.top}px`
|
|
1078
|
+
mask.style.display = 'flex'
|
|
1079
|
+
mask.style.alignItems = 'center'
|
|
1080
|
+
mask.style.justifyContent = 'center'
|
|
1081
|
+
report(`selection dialog: corrected mask ${Math.round(rect.width)}x${Math.round(rect.height)}@${Math.round(rect.left)},${Math.round(rect.top)} to viewport ${vw}x${vh}`)
|
|
1082
|
+
}
|
|
1083
|
+
mask.style.width = `${vw}px`
|
|
1084
|
+
mask.style.height = `${vh}px`
|
|
1085
|
+
mask.style.boxSizing = 'border-box'
|
|
1086
|
+
}
|
|
1087
|
+
fix()
|
|
1088
|
+
window.addEventListener('resize', fix)
|
|
1089
|
+
return () => window.removeEventListener('resize', fix)
|
|
1090
|
+
}, [])
|
|
1091
|
+
|
|
1092
|
+
return h('div', {
|
|
1093
|
+
ref: maskRef,
|
|
1094
|
+
className: 'dsh-si-mask',
|
|
1095
|
+
onMouseDown: (event) => { event.stopPropagation(); onClose?.() },
|
|
1096
|
+
},
|
|
1097
|
+
h('div', {
|
|
1098
|
+
className: 'dsh-si-dialog',
|
|
1099
|
+
onMouseDown: (event) => event.stopPropagation(),
|
|
1100
|
+
onClick: (event) => event.stopPropagation(),
|
|
1101
|
+
},
|
|
1102
|
+
h('div', { className: 'dsh-si-dialog-head' },
|
|
1103
|
+
h('span', { className: 'dsh-si-dialog-title' }, title),
|
|
1104
|
+
h('button', { className: 'dsh-si-dialog-x', 'aria-label': '关闭', onClick: () => onClose?.() }, '×')),
|
|
1105
|
+
h('div', { className: 'dsh-si-dialog-quote' }, text),
|
|
1106
|
+
comment ? h(React.Fragment, null,
|
|
1107
|
+
h('div', { className: 'dsh-si-dialog-label' }, '评论'),
|
|
1108
|
+
h('div', { className: 'dsh-si-dialog-detail' }, comment)) : null,
|
|
1109
|
+
h('div', { className: 'dsh-si-dialog-foot' },
|
|
1110
|
+
onNavigate ? h('button', { className: 'dsh-si-dialog-btn', onClick: onNavigate }, '跳转原文') : null,
|
|
1111
|
+
h('span', { className: 'dsh-si-dialog-spacer' }),
|
|
1112
|
+
h('button', { className: 'dsh-si-dialog-btn ghost', onClick: () => onClose?.() }, '关闭')))
|
|
1113
|
+
)
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/** 划选评论编辑器:照抄 Trae CommentEditorCard——无标题/无预览/无取消钮,
|
|
1117
|
+
* 单行 = 透明自增 textarea + ✓ 按钮;Enter 提交、Shift+Enter 换行、
|
|
1118
|
+
* Esc/点外取消;IME 组合期不提交。锚在选区旁,不遮罩。 */
|
|
1119
|
+
function SelectionCommentEditor({ draft, onSave, onCancel }) {
|
|
1120
|
+
const [value, setValue] = React.useState(draft.comment ?? '')
|
|
1121
|
+
const cardRef = React.useRef(null)
|
|
1122
|
+
const inputRef = React.useRef(null)
|
|
1123
|
+
const valueRef = React.useRef(value)
|
|
1124
|
+
React.useEffect(() => { valueRef.current = value }, [value])
|
|
1125
|
+
|
|
1126
|
+
// textarea 自增高(Trae 同款:height auto → scrollHeight,上限 140)
|
|
1127
|
+
React.useEffect(() => {
|
|
1128
|
+
const el = inputRef.current
|
|
1129
|
+
if (!el) return
|
|
1130
|
+
el.style.height = 'auto'
|
|
1131
|
+
el.style.height = `${Math.min(el.scrollHeight, 140)}px`
|
|
1132
|
+
}, [value])
|
|
1133
|
+
|
|
1134
|
+
React.useEffect(() => {
|
|
1135
|
+
const card = cardRef.current
|
|
1136
|
+
if (!card || typeof window === 'undefined') return
|
|
1137
|
+
const vw = window.innerWidth
|
|
1138
|
+
const vh = window.innerHeight
|
|
1139
|
+
const margin = 8
|
|
1140
|
+
const width = Math.min(320, vw - margin * 2)
|
|
1141
|
+
card.style.width = `${width}px`
|
|
1142
|
+
const height = card.getBoundingClientRect().height || 44
|
|
1143
|
+
const rect = draft.rect ?? {}
|
|
1144
|
+
let left = Math.min(Math.max(margin, (rect.left ?? 16) + 20), Math.max(margin, vw - width - margin))
|
|
1145
|
+
let top = (rect.bottom ?? 80) + 8
|
|
1146
|
+
if (top + height > vh - margin) top = Math.max(margin, (rect.top ?? 80) - height - 8)
|
|
1147
|
+
// 先放到期望位置;桌面壳若把 fixed 包含块变成 0×0,再按实际偏差校正。
|
|
1148
|
+
card.style.visibility = 'hidden'
|
|
1149
|
+
card.style.left = `${left}px`
|
|
1150
|
+
card.style.top = `${top}px`
|
|
1151
|
+
const actual = card.getBoundingClientRect()
|
|
1152
|
+
const dx = actual.left - left
|
|
1153
|
+
const dy = actual.top - top
|
|
1154
|
+
if (Math.abs(dx) > 1) card.style.left = `${left - dx}px`
|
|
1155
|
+
if (Math.abs(dy) > 1) card.style.top = `${top - dy}px`
|
|
1156
|
+
card.style.visibility = 'visible'
|
|
1157
|
+
const textarea = card.querySelector('textarea')
|
|
1158
|
+
if (textarea) {
|
|
1159
|
+
textarea.focus()
|
|
1160
|
+
textarea.setSelectionRange?.(textarea.value.length, textarea.value.length)
|
|
1161
|
+
}
|
|
1162
|
+
}, [draft])
|
|
1163
|
+
|
|
1164
|
+
React.useEffect(() => {
|
|
1165
|
+
const onDown = (event) => {
|
|
1166
|
+
if (cardRef.current?.contains(event.target)) return
|
|
1167
|
+
onCancel?.()
|
|
1168
|
+
}
|
|
1169
|
+
document.addEventListener('mousedown', onDown, true)
|
|
1170
|
+
return () => document.removeEventListener('mousedown', onDown, true)
|
|
1171
|
+
}, [onCancel])
|
|
1172
|
+
|
|
1173
|
+
const submit = () => {
|
|
1174
|
+
if (!value.trim()) return
|
|
1175
|
+
onSave?.(value)
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
return createPortal(h('div', {
|
|
1179
|
+
ref: cardRef,
|
|
1180
|
+
className: 'dsh-si-comment-editor',
|
|
1181
|
+
onMouseDown: (event) => event.stopPropagation(),
|
|
1182
|
+
onClick: (event) => event.stopPropagation(),
|
|
1183
|
+
},
|
|
1184
|
+
h('div', { className: 'dsh-si-comment-row' },
|
|
1185
|
+
h('textarea', {
|
|
1186
|
+
ref: inputRef,
|
|
1187
|
+
className: 'dsh-si-comment-input',
|
|
1188
|
+
rows: 1,
|
|
1189
|
+
value,
|
|
1190
|
+
placeholder: '输入你的评论...',
|
|
1191
|
+
'aria-label': '输入你的评论...',
|
|
1192
|
+
onInput: (event) => setValue(event.target.value),
|
|
1193
|
+
onKeyDown: (event) => {
|
|
1194
|
+
if (event.nativeEvent?.isComposing || event.nativeEvent?.keyCode === 229) return
|
|
1195
|
+
event.stopPropagation()
|
|
1196
|
+
if (event.key === 'Escape') { event.preventDefault(); onCancel?.() }
|
|
1197
|
+
else if (event.key === 'Enter' && !event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
|
1198
|
+
event.preventDefault()
|
|
1199
|
+
submit()
|
|
1200
|
+
}
|
|
1201
|
+
},
|
|
1202
|
+
}),
|
|
1203
|
+
h('button', {
|
|
1204
|
+
className: 'dsh-si-comment-submit',
|
|
1205
|
+
title: draft.comment ? '保存' : '评论到对话',
|
|
1206
|
+
'aria-label': draft.comment ? '保存' : '评论到对话',
|
|
1207
|
+
disabled: !value.trim(),
|
|
1208
|
+
onClick: submit,
|
|
1209
|
+
},
|
|
1210
|
+
h('svg', { width: 14, height: 14, viewBox: '0 0 16 16', 'aria-hidden': true },
|
|
1211
|
+
h('path', {
|
|
1212
|
+
d: 'M13.5 4.5L6.5 11.5L2.5 7.5',
|
|
1213
|
+
fill: 'none', stroke: 'currentColor', 'stroke-width': '1.8',
|
|
1214
|
+
'stroke-linecap': 'round', 'stroke-linejoin': 'round',
|
|
1215
|
+
}))))
|
|
1216
|
+
), document.body)
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
/** 划选引用桥:把「添加到对话」插成输入框内的官方 reference chip。
|
|
1220
|
+
* Trae 的引用是编辑器里的内联节点(conversation-selection-mention-symbol),
|
|
1221
|
+
* dsh 对外只开放 reference chip 这一条内联通道——这里注册自定义 source,
|
|
1222
|
+
* 用 slash/input-insert-reference 落 chip,并按 Trae 规格重着色。
|
|
1223
|
+
* 输入框上方不再有独立 pill 行。 */
|
|
1224
|
+
function SelectionContextBridge({ session, useInput, inputActions }) {
|
|
1225
|
+
const sessionId = session?.sessionId === undefined ? '' : String(session.sessionId)
|
|
1226
|
+
const draftRev = useInput((state) => state?.draftRev ?? 0)
|
|
1227
|
+
const occurrences = useInput((state) => state?.occurrences)
|
|
1228
|
+
const phase = useInput((state) => state?.phase)
|
|
1229
|
+
const snapshotRef = React.useRef({ draftRev, occurrences })
|
|
1230
|
+
const actionsRef = React.useRef(inputActions)
|
|
1231
|
+
const previousPhase = React.useRef(phase)
|
|
1232
|
+
const [, setVersion] = React.useState(0)
|
|
1233
|
+
const [dialog, setDialog] = React.useState(null)
|
|
1234
|
+
const [editor, setEditor] = React.useState(null)
|
|
1235
|
+
const bump = () => setVersion((value) => value + 1)
|
|
1236
|
+
|
|
1237
|
+
React.useEffect(() => {
|
|
1238
|
+
snapshotRef.current = { draftRev, occurrences }
|
|
1239
|
+
actionsRef.current = inputActions
|
|
1240
|
+
}, [draftRev, occurrences, inputActions])
|
|
1241
|
+
|
|
1242
|
+
React.useEffect(() => {
|
|
1243
|
+
if (!sessionId || !inputActions) return undefined
|
|
1244
|
+
selectionActions.set(sessionId, inputActions)
|
|
1245
|
+
return () => {
|
|
1246
|
+
if (selectionActions.get(sessionId) === inputActions) selectionActions.delete(sessionId)
|
|
1247
|
+
}
|
|
1248
|
+
}, [sessionId, inputActions])
|
|
1249
|
+
|
|
1250
|
+
/** 发送意图:把当前 chip 立即同步给服务端(pre-step 读取),并标记发送。 */
|
|
1251
|
+
const flushForSend = () => {
|
|
1252
|
+
if (!sessionId) return
|
|
1253
|
+
const live = liveSelectionItems(sessionId, snapshotRef.current.occurrences)
|
|
1254
|
+
if (live.length === 0) return
|
|
1255
|
+
syncSelectionContext(sessionId, live, true)
|
|
1256
|
+
for (const item of live) item.sent = true
|
|
1257
|
+
persistSelectionItems()
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
/** 插入/更新一条选段 chip;comment === undefined 表示「不改已有评论」。 */
|
|
1261
|
+
const insertSelection = (text, anchorEl, comment) => {
|
|
1262
|
+
const clean = String(text ?? '').trim().slice(0, CONTEXT_TEXT_MAX)
|
|
1263
|
+
if (!clean) return { status: 'empty' }
|
|
1264
|
+
const live = liveSelectionItems(sessionId, snapshotRef.current.occurrences)
|
|
1265
|
+
const existing = live.find((entry) => entry.text === clean)
|
|
1266
|
+
if (existing) {
|
|
1267
|
+
if (comment !== undefined) {
|
|
1268
|
+
const next = String(comment ?? '').slice(0, CONTEXT_COMMENT_MAX).trim()
|
|
1269
|
+
if (next !== (existing.comment ?? '')) {
|
|
1270
|
+
existing.comment = next
|
|
1271
|
+
const span = chipSpanOf(snapshotRef.current.occurrences, existing.id, snapshotRef.current.draftRev)
|
|
1272
|
+
if (span) applySelectionInsert(sessionId, existing, span)
|
|
1273
|
+
bump()
|
|
1274
|
+
syncSelectionContext(sessionId, liveSelectionItems(sessionId, snapshotRef.current.occurrences), false)
|
|
1275
|
+
persistSelectionItems()
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
return { status: 'dup', id: existing.id, item: existing }
|
|
1279
|
+
}
|
|
1280
|
+
if (live.length >= CONTEXT_MAX) return { status: 'full' }
|
|
1281
|
+
const actions = actionsRef.current
|
|
1282
|
+
if (typeof actions?.captureInsertion !== 'function') return { status: 'failed' }
|
|
1283
|
+
let span
|
|
1284
|
+
try {
|
|
1285
|
+
const insertion = actions.captureInsertion()
|
|
1286
|
+
// 折叠到选区末尾:只插入、不替换用户已选中的草稿文字。
|
|
1287
|
+
span = { start: insertion.end, end: insertion.end, draftRev: insertion.draftRev }
|
|
1288
|
+
} catch { return { status: 'failed' } }
|
|
1289
|
+
// 显示序号按当前输入框里的条目数重排(Trae 每个草稿从 1 开始)。
|
|
1290
|
+
const seq = live.reduce((max, entry) => Math.max(max, entry.seq ?? 0), 0) + 1
|
|
1291
|
+
const id = `${Date.now().toString(36)}${(++selectionIdSeq).toString(36)}`
|
|
1292
|
+
const item = {
|
|
1293
|
+
id, sessionId, seq,
|
|
1294
|
+
text: clean,
|
|
1295
|
+
comment: comment === undefined ? '' : String(comment ?? '').slice(0, CONTEXT_COMMENT_MAX).trim(),
|
|
1296
|
+
anchor: anchorEl instanceof Element ? new WeakRef(anchorEl) : null,
|
|
1297
|
+
sent: false,
|
|
1298
|
+
archived: false,
|
|
1299
|
+
at: Date.now(),
|
|
1300
|
+
}
|
|
1301
|
+
if (!applySelectionInsert(sessionId, item, span)) return { status: 'failed' }
|
|
1302
|
+
selectionItems.set(id, item)
|
|
1303
|
+
bump()
|
|
1304
|
+
syncSelectionContext(sessionId, [...live, item], false)
|
|
1305
|
+
persistSelectionItems()
|
|
1306
|
+
return { status: 'added', id, item }
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
/** 删除一条选段:chip + 条目 + 服务端同步(hover × / 编辑器移除共用)。 */
|
|
1310
|
+
const dropSelectionItem = (item) => {
|
|
1311
|
+
if (!item) return
|
|
1312
|
+
const span = chipSpanOf(snapshotRef.current.occurrences, item.id, snapshotRef.current.draftRev)
|
|
1313
|
+
if (span) applySelectionRemove(item.sessionId, item, span)
|
|
1314
|
+
selectionItems.delete(item.id)
|
|
1315
|
+
bump()
|
|
1316
|
+
syncSelectionContext(item.sessionId, liveSelectionItems(item.sessionId, snapshotRef.current.occurrences), false)
|
|
1317
|
+
persistSelectionItems()
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
React.useEffect(() => {
|
|
1321
|
+
const was = previousPhase.current
|
|
1322
|
+
previousPhase.current = phase
|
|
1323
|
+
if (was !== 'submitting' && phase === 'submitting') flushForSend()
|
|
1324
|
+
}, [phase, sessionId])
|
|
1325
|
+
|
|
1326
|
+
// 发送按钮 / Enter 比 phase 更早、更可靠;window capture 先于宿主监听。
|
|
1327
|
+
React.useEffect(() => {
|
|
1328
|
+
if (!sessionId) return undefined
|
|
1329
|
+
const onClick = (event) => {
|
|
1330
|
+
const target = event.target
|
|
1331
|
+
if (target?.closest?.('button[aria-label*="发送消息"], button[aria-label*="Send"], button[aria-label*="send"]')) flushForSend()
|
|
1332
|
+
}
|
|
1333
|
+
const onKey = (event) => {
|
|
1334
|
+
if (event.key !== 'Enter' || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return
|
|
1335
|
+
if (!event.target?.closest?.('[contenteditable="true"]')) return
|
|
1336
|
+
flushForSend()
|
|
1337
|
+
}
|
|
1338
|
+
window.addEventListener('click', onClick, true)
|
|
1339
|
+
window.addEventListener('keydown', onKey, true)
|
|
1340
|
+
return () => {
|
|
1341
|
+
window.removeEventListener('click', onClick, true)
|
|
1342
|
+
window.removeEventListener('keydown', onKey, true)
|
|
1343
|
+
}
|
|
1344
|
+
}, [sessionId])
|
|
1345
|
+
|
|
1346
|
+
// 草稿里的 chip 被键盘删掉 / 发送后清空:以 occurrences 为真相修剪条目。
|
|
1347
|
+
React.useEffect(() => {
|
|
1348
|
+
if (!sessionId) return
|
|
1349
|
+
const refs = new Set()
|
|
1350
|
+
for (const occ of occurrences ?? []) if (occ.source === SELECTION_SOURCE) refs.add(occ.ref)
|
|
1351
|
+
let changed = false
|
|
1352
|
+
for (const [id, item] of selectionItems) {
|
|
1353
|
+
if (item.sessionId !== sessionId) continue
|
|
1354
|
+
if (item.archived) {
|
|
1355
|
+
// 草稿恢复出的历史 chip:重新激活,参与同步
|
|
1356
|
+
if (refs.has(id)) { item.archived = false; item.sent = false; changed = true }
|
|
1357
|
+
continue
|
|
1358
|
+
}
|
|
1359
|
+
if (refs.has(id)) continue
|
|
1360
|
+
if (item.sent) { item.archived = true; changed = true; continue }
|
|
1361
|
+
selectionItems.delete(id)
|
|
1362
|
+
changed = true
|
|
1363
|
+
}
|
|
1364
|
+
if (changed) bump()
|
|
1365
|
+
syncSelectionContext(sessionId, liveSelectionItems(sessionId, occurrences), false)
|
|
1366
|
+
persistSelectionItems()
|
|
1367
|
+
}, [occurrences, sessionId])
|
|
1368
|
+
|
|
1369
|
+
// 详情弹窗请求(chip 点击 → source.openReference;添加成功后也会主动打开)。
|
|
1370
|
+
React.useEffect(() => {
|
|
1371
|
+
if (!sessionId) return undefined
|
|
1372
|
+
const listener = () => {
|
|
1373
|
+
const request = selectionDetailRequest
|
|
1374
|
+
if (!request || request.sessionId !== sessionId) return
|
|
1375
|
+
selectionDetailRequest = null
|
|
1376
|
+
const item = selectionItems.get(request.id)
|
|
1377
|
+
if (!item) return
|
|
1378
|
+
setEditor(null)
|
|
1379
|
+
setDialog({ id: request.id })
|
|
1380
|
+
}
|
|
1381
|
+
selectionDetailSubs.add(listener)
|
|
1382
|
+
listener()
|
|
1383
|
+
return () => { selectionDetailSubs.delete(listener) }
|
|
1384
|
+
}, [sessionId])
|
|
1385
|
+
|
|
1386
|
+
// Trae 的两个入口(previewCommentCard):
|
|
1387
|
+
// - 添加到对话:先插 chip,再把选区旁的卡片原位切成评论编辑器(addThenComment);
|
|
1388
|
+
// - 评论到对话:先弹评论卡片,保存后才插 chip(带评论)。
|
|
1389
|
+
React.useEffect(() => {
|
|
1390
|
+
if (!sessionId) return undefined
|
|
1391
|
+
const add = (text, anchorEl, rect) => {
|
|
1392
|
+
const result = insertSelection(text, anchorEl, undefined)
|
|
1393
|
+
if (result.status === 'added' || result.status === 'dup') {
|
|
1394
|
+
const item = result.item ?? (result.id ? selectionItems.get(result.id) : null)
|
|
1395
|
+
if (item) {
|
|
1396
|
+
// Trae addThenComment:chips 已经进输入框,卡片原位切换为评论编辑。
|
|
1397
|
+
setDialog(null)
|
|
1398
|
+
setEditor({
|
|
1399
|
+
id: item.id,
|
|
1400
|
+
text: item.text,
|
|
1401
|
+
comment: item.comment ?? '',
|
|
1402
|
+
anchorEl: anchorEl instanceof Element ? anchorEl : null,
|
|
1403
|
+
rect: normalizeSelectionRect(rect, anchorEl),
|
|
1404
|
+
})
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
return result.status
|
|
1408
|
+
}
|
|
1409
|
+
const openComment = (text, anchorEl, rect) => {
|
|
1410
|
+
const clean = String(text ?? '').trim().slice(0, CONTEXT_TEXT_MAX)
|
|
1411
|
+
if (!clean) return 'empty'
|
|
1412
|
+
const live = liveSelectionItems(sessionId, snapshotRef.current.occurrences)
|
|
1413
|
+
const existing = live.find((entry) => entry.text === clean)
|
|
1414
|
+
if (!existing && live.length >= CONTEXT_MAX) return 'full'
|
|
1415
|
+
setDialog(null)
|
|
1416
|
+
setEditor({
|
|
1417
|
+
id: existing?.id ?? null,
|
|
1418
|
+
text: clean,
|
|
1419
|
+
comment: existing?.comment ?? '',
|
|
1420
|
+
anchorEl: anchorEl instanceof Element ? anchorEl : null,
|
|
1421
|
+
rect: normalizeSelectionRect(rect, anchorEl),
|
|
1422
|
+
})
|
|
1423
|
+
return 'opened'
|
|
1424
|
+
}
|
|
1425
|
+
window.__dshSuperinputAddContext = add
|
|
1426
|
+
window.__dshSuperinputCommentContext = openComment
|
|
1427
|
+
// Trae dragDropMention 同义:拖放进来的引用只插 chip,不弹评论卡
|
|
1428
|
+
//(拖拽是快速引用手势;要评论走划选菜单的「评论到对话」)。
|
|
1429
|
+
window.__dshSuperinputDropContext = (text, anchorEl) => insertSelection(text, anchorEl, undefined)
|
|
1430
|
+
return () => {
|
|
1431
|
+
if (window.__dshSuperinputAddContext === add) delete window.__dshSuperinputAddContext
|
|
1432
|
+
if (window.__dshSuperinputCommentContext === openComment) delete window.__dshSuperinputCommentContext
|
|
1433
|
+
if (window.__dshSuperinputDropContext === insertSelection) delete window.__dshSuperinputDropContext
|
|
1434
|
+
}
|
|
1435
|
+
}, [sessionId])
|
|
1436
|
+
|
|
1437
|
+
// chip 悬停时左侧图标变 ×(Trae remove-mention-icon);点左侧 20px 移除。
|
|
1438
|
+
React.useEffect(() => {
|
|
1439
|
+
if (!sessionId) return undefined
|
|
1440
|
+
const onClick = (event) => {
|
|
1441
|
+
const target = event.target
|
|
1442
|
+
const chip = target?.closest?.('span[title]')
|
|
1443
|
+
if (!chip || !chip.closest?.('[contenteditable="true"]')) return
|
|
1444
|
+
if (!String(chip.title || '').startsWith(CHIP_MARK)) return
|
|
1445
|
+
const rect = chip.getBoundingClientRect()
|
|
1446
|
+
if (event.clientX > rect.left + 20 || !chip.matches(':hover')) return
|
|
1447
|
+
const label = String(chip.title)
|
|
1448
|
+
const item = [...selectionItems.values()].find((entry) => entry.sessionId === sessionId && chipLabelOf(entry) === label)
|
|
1449
|
+
if (!item) return
|
|
1450
|
+
event.preventDefault()
|
|
1451
|
+
event.stopPropagation()
|
|
1452
|
+
dropSelectionItem(item)
|
|
1453
|
+
}
|
|
1454
|
+
document.addEventListener('click', onClick, true)
|
|
1455
|
+
return () => document.removeEventListener('click', onClick, true)
|
|
1456
|
+
}, [sessionId])
|
|
1457
|
+
|
|
1458
|
+
const navigateTo = (item) => {
|
|
1459
|
+
try {
|
|
1460
|
+
const el = item.anchor?.deref?.()
|
|
1461
|
+
if (el?.isConnected) {
|
|
1462
|
+
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
1463
|
+
el.classList.add('dsh-cp-flash')
|
|
1464
|
+
setTimeout(() => el.classList.remove('dsh-cp-flash'), 1600)
|
|
1465
|
+
}
|
|
1466
|
+
} catch { /* 锚点失效:静默降级 */ }
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
/** 评论卡片保存:已有条目更新评论,新条目插 chip(带评论)。 */
|
|
1470
|
+
const saveEditorComment = (comment) => {
|
|
1471
|
+
const draft = editor
|
|
1472
|
+
if (!draft) return
|
|
1473
|
+
const next = String(comment ?? '').slice(0, CONTEXT_COMMENT_MAX).trim()
|
|
1474
|
+
const existing = draft.id ? selectionItems.get(draft.id) : null
|
|
1475
|
+
if (existing) {
|
|
1476
|
+
if (next !== (existing.comment ?? '')) {
|
|
1477
|
+
existing.comment = next
|
|
1478
|
+
const span = chipSpanOf(snapshotRef.current.occurrences, existing.id, snapshotRef.current.draftRev)
|
|
1479
|
+
if (span) applySelectionInsert(existing.sessionId, existing, span)
|
|
1480
|
+
bump()
|
|
1481
|
+
syncSelectionContext(existing.sessionId, liveSelectionItems(existing.sessionId, snapshotRef.current.occurrences), false)
|
|
1482
|
+
persistSelectionItems()
|
|
1483
|
+
window.__dshSuperinputToast?.('已保存评论')
|
|
1484
|
+
}
|
|
1485
|
+
} else {
|
|
1486
|
+
const result = insertSelection(draft.text, draft.anchorEl, next)
|
|
1487
|
+
if (result.status === 'added' || result.status === 'dup') window.__dshSuperinputToast?.('已添加到对话')
|
|
1488
|
+
else if (result.status === 'full') window.__dshSuperinputToast?.(`最多添加 ${CONTEXT_MAX} 段对话内容`)
|
|
1489
|
+
else if (result.status === 'failed') window.__dshSuperinputToast?.('保存失败')
|
|
1490
|
+
}
|
|
1491
|
+
setEditor(null)
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
const dialogItem = dialog ? selectionItems.get(dialog.id) : null
|
|
1495
|
+
const editorNode = editor ? h(SelectionCommentEditor, {
|
|
1496
|
+
key: `comment:${editor.id ?? 'new'}:${editor.text}`,
|
|
1497
|
+
draft: editor,
|
|
1498
|
+
onSave: saveEditorComment,
|
|
1499
|
+
onCancel: () => setEditor(null),
|
|
1500
|
+
}) : null
|
|
1501
|
+
const detailNode = dialogItem ? createPortal(h(SelectionDetail, {
|
|
1502
|
+
title: `${dialogItem.seq}. ${previewOf(dialogItem.text, 48)}`,
|
|
1503
|
+
text: dialogItem.text,
|
|
1504
|
+
comment: dialogItem.comment,
|
|
1505
|
+
onNavigate: dialogItem.anchor ? () => navigateTo(dialogItem) : null,
|
|
1506
|
+
onClose: () => setDialog(null),
|
|
1507
|
+
}), document.body) : null
|
|
1508
|
+
if (!editorNode && !detailNode) return null
|
|
1509
|
+
return h(React.Fragment, null, editorNode, detailNode)
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
/** 消息正文的选段 chip:user-text 规则把 token 换成同款标签(Trae 消息里的
|
|
1513
|
+
* mention chip 同义)。刷新后条目由 sessionStorage 恢复。 */
|
|
1514
|
+
const selectionUserTextRule = {
|
|
1515
|
+
id: SELECTION_RULE,
|
|
1516
|
+
order: 5,
|
|
1517
|
+
match(text, from) {
|
|
1518
|
+
const re = /\[选段#([0-9a-z]+)\]/g
|
|
1519
|
+
re.lastIndex = from
|
|
1520
|
+
const found = re.exec(text)
|
|
1521
|
+
if (!found) return undefined
|
|
1522
|
+
const item = selectionItems.get(found[1])
|
|
1523
|
+
if (!item) return undefined
|
|
1524
|
+
return {
|
|
1525
|
+
id: SELECTION_RULE,
|
|
1526
|
+
start: found.index,
|
|
1527
|
+
end: found.index + found[0].length,
|
|
1528
|
+
data: { id: item.id, seq: item.seq, text: item.text, comment: item.comment },
|
|
1529
|
+
}
|
|
1530
|
+
},
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
function SelectionMessageChip({ node, interactive }) {
|
|
1534
|
+
const data = node?.data ?? {}
|
|
1535
|
+
const [open, setOpen] = React.useState(false)
|
|
1536
|
+
const label = chipTextOf(data.seq, data.text, data.comment)
|
|
1537
|
+
return h(React.Fragment, null,
|
|
1538
|
+
h('span', {
|
|
1539
|
+
className: `dsh-si-msgchip${interactive ? ' interactive' : ''}`,
|
|
1540
|
+
title: data.text,
|
|
1541
|
+
onClick: interactive ? () => setOpen(true) : undefined,
|
|
1542
|
+
},
|
|
1543
|
+
h('span', { className: 'dsh-si-msgchip-icon', 'aria-hidden': true }),
|
|
1544
|
+
h('span', { className: 'dsh-si-msgchip-label' }, label)),
|
|
1545
|
+
open ? createPortal(h(SelectionDetail, {
|
|
1546
|
+
title: label,
|
|
1547
|
+
text: data.text,
|
|
1548
|
+
comment: data.comment,
|
|
1549
|
+
onClose: () => setOpen(false),
|
|
1550
|
+
}), document.body) : null)
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
/** 草稿桥:划选「添加评论」、模板插入、/ 命令插入、横条「引用文本」共用。
|
|
1554
|
+
* 挂 conversation.input.dock——该槽位会注入 useInput / inputActions;
|
|
1555
|
+
* composer.dock 不注入这些 props。片段模块移除时桥被一并删掉,这里补回。 */
|
|
1556
|
+
function PromptsDraftBridge({ useInput, inputActions }) {
|
|
1557
|
+
const draft = useInput((state) => state?.draft)
|
|
1558
|
+
React.useEffect(() => {
|
|
1559
|
+
window.__dshSuperinputDraft = () => (typeof draft === 'string' ? draft : '')
|
|
1560
|
+
if (typeof inputActions?.setDraft === 'function') {
|
|
1561
|
+
window.__dshSuperinputSetDraft = (value) => {
|
|
1562
|
+
try { inputActions.setDraft(value) } catch { /* ignore */ }
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
// 官方「引用文本」同款 API(0.2.2-rc.1 起可用):plain 模式下插 blockquote
|
|
1566
|
+
window.__dshSuperinputInsertQuote = (text) => {
|
|
1567
|
+
try { return typeof inputActions?.insertQuote === 'function' && inputActions.insertQuote(text) === true } catch { return false }
|
|
1568
|
+
}
|
|
1569
|
+
}, [draft, inputActions])
|
|
1570
|
+
return null
|
|
1571
|
+
}
|
|
1572
|
+
|
|
626
1573
|
function PromptsButton({ onSyncSlash }) {
|
|
627
1574
|
const [open, setOpen] = React.useState(false)
|
|
628
1575
|
const ref = React.useRef(null)
|
|
@@ -636,7 +1583,7 @@ function createPromptsClient(require) {
|
|
|
636
1583
|
}
|
|
637
1584
|
|
|
638
1585
|
return {
|
|
639
|
-
inject: ['slots'],
|
|
1586
|
+
inject: ['slots', 'sessions', 'uiConversation'],
|
|
640
1587
|
apply(ctx) {
|
|
641
1588
|
ensureStyle()
|
|
642
1589
|
installSelectionSaver()
|
|
@@ -693,6 +1640,70 @@ function createPromptsClient(require) {
|
|
|
693
1640
|
id: 'dsh-superinput-prompts-btn',
|
|
694
1641
|
order: 4,
|
|
695
1642
|
}, (props) => h(PromptsBoundary, null, h(PromptsButton, { ...props, onSyncSlash: syncSlash }))))
|
|
1643
|
+
|
|
1644
|
+
/* 划选引用的官方 input source:codec 决定 chip 的粘贴文本与送模文本;
|
|
1645
|
+
* candidates 恒空——只是借 @ 触发器的注册位拿到一个合法的自定义
|
|
1646
|
+
* source(官方 chip 插入/序列化按 source 名路由),不往 @ 菜单加条目。 */
|
|
1647
|
+
const selectionSource = {
|
|
1648
|
+
trigger: '@',
|
|
1649
|
+
name: SELECTION_SOURCE,
|
|
1650
|
+
showGroupTitle: false,
|
|
1651
|
+
async candidates() { return [] },
|
|
1652
|
+
onPick() { return undefined },
|
|
1653
|
+
openReference(session, reference) {
|
|
1654
|
+
const targetSession = session?.sessionId === undefined ? '' : String(session.sessionId)
|
|
1655
|
+
const ref = String(reference?.ref ?? '')
|
|
1656
|
+
if (!targetSession || !selectionItems.has(ref)) return false
|
|
1657
|
+
requestSelectionDetail(targetSession, ref, false)
|
|
1658
|
+
return true
|
|
1659
|
+
},
|
|
1660
|
+
codec: {
|
|
1661
|
+
clipboardText: (ref) => tokenOf(ref),
|
|
1662
|
+
serialize: async (ref) => tokenOf(ref),
|
|
1663
|
+
},
|
|
1664
|
+
}
|
|
1665
|
+
ctx.inject(['inputTriggers'], (scope) => {
|
|
1666
|
+
try {
|
|
1667
|
+
const service = scope.get('inputTriggers')
|
|
1668
|
+
ctx.effect(() => service.registerSource(selectionSource), 'dsh-superinput: selection source')
|
|
1669
|
+
} catch (error) {
|
|
1670
|
+
report(`selection source register failed: ${error?.message ?? error}`)
|
|
1671
|
+
}
|
|
1672
|
+
})
|
|
1673
|
+
ctx.inject(['sessions'], (scope) => {
|
|
1674
|
+
selectionSessions = scope.get('sessions')
|
|
1675
|
+
})
|
|
1676
|
+
|
|
1677
|
+
// 消息正文里的同款 chip:user-text 规则 + 节点渲染槽位。
|
|
1678
|
+
ctx.inject(['uiConversation'], (scope) => {
|
|
1679
|
+
try {
|
|
1680
|
+
const ui = scope.get('uiConversation')
|
|
1681
|
+
if (typeof ui?.userText?.register === 'function') {
|
|
1682
|
+
ctx.effect(() => ui.userText.register(selectionUserTextRule), 'dsh-superinput: selection user-text rule')
|
|
1683
|
+
}
|
|
1684
|
+
} catch (error) {
|
|
1685
|
+
report(`selection user-text rule failed: ${error?.message ?? error}`)
|
|
1686
|
+
}
|
|
1687
|
+
})
|
|
1688
|
+
ctx.slots.inject('conversation.user.text.node', () => ctx.slots.register({
|
|
1689
|
+
name: 'conversation.user.text.node',
|
|
1690
|
+
key: SELECTION_RULE,
|
|
1691
|
+
priority: 20,
|
|
1692
|
+
}, SelectionMessageChip))
|
|
1693
|
+
|
|
1694
|
+
// 草稿桥挂 input.dock(该槽位注入 useInput / inputActions)
|
|
1695
|
+
ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({
|
|
1696
|
+
name: 'conversation.input.dock',
|
|
1697
|
+
id: 'dsh-superinput-prompts-bridge',
|
|
1698
|
+
order: 96,
|
|
1699
|
+
}, PromptsDraftBridge))
|
|
1700
|
+
|
|
1701
|
+
// 划选引用桥:chip 已内联在编辑器里,这个槽位只提供入口与详情弹窗。
|
|
1702
|
+
ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({
|
|
1703
|
+
name: 'conversation.input.dock',
|
|
1704
|
+
id: 'dsh-superinput-selection-context',
|
|
1705
|
+
order: 97,
|
|
1706
|
+
}, (props) => h(PromptsBoundary, null, h(SelectionContextBridge, props))))
|
|
696
1707
|
},
|
|
697
1708
|
}
|
|
698
1709
|
}
|