dsh-btw-plugin 0.0.0-stage → 0.5.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/lib/client.js ADDED
@@ -0,0 +1,1980 @@
1
+ /**
2
+ * dsh-btw — client half (browser bundle).
3
+ *
4
+ * One button in `conversation.input.right`, one floating window in
5
+ * `shell.overlay` (DSH's only frame-wide floating layer). The window can be
6
+ * dragged by its header and resized from its right/bottom edges and bottom-right
7
+ * corner; its last geometry is remembered in localStorage. The dialog keeps its
8
+ * turns in a module-level store and streams answers from the host half's
9
+ * `/btw/ask` SSE route; nothing here touches the conversation transcript.
10
+ *
11
+ * B scope: a context bar (range + snapshot stats + refresh) previews what the
12
+ * host would send before asking; the SSE `meta` frame repeats it so a range
13
+ * changed at send time cannot make the badge lie. Cold (persisted, not open)
14
+ * conversations are read from the log by the host.
15
+ * TODO(C): model picker + usage display + settings panel.
16
+ */
17
+ window.__ModuleLoader__.load({
18
+ id: 'dsh-btw',
19
+ factory: (require) => {
20
+ const module = { exports: {} }
21
+ const exports = module.exports
22
+
23
+ let React = null
24
+ try { React = require('react') } catch { React = null }
25
+ if (!React || typeof React.createElement !== 'function') {
26
+ // No shell React: the plugin cannot render anything, but keeping the
27
+ // factory valid avoids breaking the boot graph.
28
+ exports.apply = function noop() {}
29
+ exports.inject = []
30
+ return module.exports
31
+ }
32
+ const h = React.createElement
33
+
34
+ // Debug marker: lets you confirm from DevTools which build the shell loaded.
35
+ // __DSH_BTW__ -> { version, loadedAt }
36
+ try { window.__DSH_BTW__ = { version: '0.5.1', loadedAt: Date.now() } } catch { /* ignore */ }
37
+
38
+ /* ----------------------------------------------- optional shell extras */
39
+
40
+ // Shared shell primitives are resolved LAZILY: activation must not touch a
41
+ // shared module at all, so a broken/missing optional dependency can never
42
+ // poison boot-time module evaluation for other plugins.
43
+ let primitivesCache
44
+ let primitivesResolved = false
45
+ function primitivesOf() {
46
+ if (!primitivesResolved) {
47
+ primitivesResolved = true
48
+ try { primitivesCache = require('@deepseek-ai/dsh-client-ui-primitives') } catch { primitivesCache = null }
49
+ }
50
+ return primitivesCache
51
+ }
52
+ const MD_LABELS = {
53
+ code: {
54
+ copyLabel: '复制',
55
+ copiedLabel: '已复制',
56
+ toolbarLabels: { codeLabel: '代码', wrapLabel: '自动换行', unwrapLabel: '取消换行' },
57
+ },
58
+ footnotes: '',
59
+ }
60
+
61
+ /* ------------------------------------------------------------- styles */
62
+
63
+ const CSS = `
64
+ .btw-trigger {
65
+ display: inline-flex; align-items: center; justify-content: center;
66
+ min-width: 34px; height: 26px; padding: 0 8px; border-radius: 7px;
67
+ border: 1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
68
+ background: transparent; color: var(--dsw-alias-label-secondary, light-dark(#475467, #9aa0a6));
69
+ font: inherit; font-size: 11px; font-weight: 600; letter-spacing: .02em;
70
+ cursor: pointer; line-height: 1;
71
+ }
72
+ .btw-trigger:hover {
73
+ color: var(--dsw-alias-label-primary, light-dark(#1f2328, #e8eaed));
74
+ border-color: var(--dsw-alias-border-l3, rgba(127,127,127,.6));
75
+ background: var(--dsw-alias-bg-layer-2, rgba(127,127,127,.12));
76
+ }
77
+
78
+ /* frame-wide click-through layer; only the window itself takes input */
79
+ .btw-layer {
80
+ position: fixed; inset: 0; z-index: 60; pointer-events: none;
81
+ }
82
+ .btw-window {
83
+ position: fixed; display: flex; flex-direction: column;
84
+ min-width: 420px; min-height: 300px; pointer-events: auto;
85
+ border-radius: 12px; overflow: hidden;
86
+ background: var(--dsw-alias-bg-layer-2, light-dark(#ffffff, #26282c));
87
+ color: var(--dsw-alias-label-primary, light-dark(#1f2328, #e8eaed));
88
+ border: 1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.3));
89
+ box-shadow: 0 18px 60px light-dark(rgba(15, 23, 42, .18), rgba(0, 0, 0, .5));
90
+ }
91
+ .btw-head {
92
+ flex: 0 0 auto; display: flex; align-items: flex-start; gap: 10px;
93
+ padding: 10px 12px 8px;
94
+ border-bottom: 1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.22));
95
+ cursor: grab; user-select: none; touch-action: none;
96
+ }
97
+ .btw-window.is-dragging .btw-head { cursor: grabbing; }
98
+ .btw-head-text { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
99
+ .btw-title { font-size: 13px; font-weight: 600; }
100
+ .btw-head-hint { font-size: 11px; line-height: 1.4; color: var(--dsw-alias-label-secondary, light-dark(#475467, #9aa0a6)); }
101
+ .btw-close {
102
+ flex: 0 0 auto; border: 0; background: transparent; cursor: pointer;
103
+ color: var(--dsw-alias-label-secondary, light-dark(#475467, #9aa0a6));
104
+ font-size: 18px; line-height: 1; padding: 2px 6px; border-radius: 6px;
105
+ }
106
+ .btw-close:hover { background: var(--dsw-alias-bg-layer-3, rgba(127,127,127,.2)); color: var(--dsw-alias-label-primary, light-dark(#1f2328, #e8eaed)); }
107
+
108
+ .btw-list {
109
+ flex: 1 1 auto; min-height: 0; overflow-y: auto;
110
+ display: flex; flex-direction: column; gap: 8px; padding: 10px 14px;
111
+ }
112
+ .btw-empty {
113
+ color: var(--dsw-alias-label-secondary, light-dark(#475467, #9aa0a6)); font-size: 12px; line-height: 1.6;
114
+ padding: 12px 10px; border: 1px dashed var(--dsw-alias-border-l2, rgba(127,127,127,.35));
115
+ border-radius: 8px;
116
+ }
117
+ .btw-msg { padding: 8px 10px; border-radius: 8px; font-size: 13px; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }
118
+ .btw-msg-user { align-self: flex-end; max-width: 88%; background: var(--dsw-alias-bg-layer-2, rgba(127,127,127,.16)); }
119
+ .btw-msg-assistant { align-self: stretch; border: 1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.22)); }
120
+ .btw-msg-assistant.is-error { border-color: var(--dsw-alias-state-error-primary, rgba(255,110,110,.5)); }
121
+ .btw-msg-error { margin-top: 6px; font-size: 12px; color: var(--dsw-alias-state-error-primary, #ff8a8a); }
122
+ .btw-msg-streaming::after { content: '●'; margin-left: 2px; opacity: .7; animation: btw-blink 1s steps(1) infinite; }
123
+ @keyframes btw-blink { 50% { opacity: 0; } }
124
+
125
+ .btw-compose {
126
+ flex: 0 0 auto; display: flex; flex-direction: column; gap: 8px;
127
+ padding: 10px 12px;
128
+ border-top: 1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.22));
129
+ background: var(--dsw-alias-bg-layer-1, rgba(255,255,255,.02));
130
+ }
131
+ .btw-input-row { display: flex; gap: 8px; align-items: flex-end; }
132
+ .btw-input {
133
+ flex: 1 1 auto; resize: none; min-height: 46px; max-height: 160px;
134
+ padding: 8px 10px; border-radius: 8px; font: inherit; font-size: 13px; line-height: 1.5;
135
+ color: var(--dsw-alias-label-primary, light-dark(#1f2328, #e8eaed));
136
+ background: var(--dsw-alias-bg-layer-1, rgba(255,255,255,.04));
137
+ border: 1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
138
+ outline: none;
139
+ }
140
+ .btw-input:focus { border-color: var(--dsw-alias-border-l3, rgba(127,127,127,.7)); }
141
+ .btw-input::placeholder, .btw-transfer-text::placeholder {
142
+ color: var(--dsw-alias-label-tertiary, light-dark(#667085, #8b8f96));
143
+ opacity: 1;
144
+ }
145
+ .btw-btn {
146
+ flex: 0 0 auto; height: 34px; padding: 0 14px; border-radius: 8px; font: inherit; font-size: 12px;
147
+ cursor: pointer; border: 1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
148
+ background: var(--dsw-alias-bg-layer-2, rgba(127,127,127,.14)); color: var(--dsw-alias-label-primary, light-dark(#1f2328, #e8eaed));
149
+ }
150
+ .btw-btn:disabled { opacity: .45; cursor: default; }
151
+ .btw-btn-primary { background: var(--dsw-alias-label-primary-bluish, light-dark(#2563c9, #4c8dff)); border-color: transparent; color: #fff; }
152
+ .btw-hint { font-size: 11px; color: var(--dsw-alias-label-secondary, light-dark(#475467, #9aa0a6)); line-height: 1.5; }
153
+
154
+ /* 送入主会话: header action + review panel */
155
+ .btw-transfer-btn {
156
+ flex: 0 0 auto; height: 24px; padding: 0 10px; border-radius: 6px;
157
+ border: 1px solid var(--dsw-alias-label-primary-bluish, light-dark(#2563c9, #4c8dff));
158
+ background: transparent; color: var(--dsw-alias-label-primary-bluish, light-dark(#2563c9, #4c8dff));
159
+ font: inherit; font-size: 11px; font-weight: 600; cursor: pointer; white-space: nowrap;
160
+ }
161
+ .btw-transfer-btn:hover:not(:disabled) { background: light-dark(rgba(37, 99, 201, .10), rgba(76, 141, 255, .12)); }
162
+ .btw-transfer-btn:disabled { opacity: .4; cursor: default; }
163
+ .btw-transfer-btn.is-sent {
164
+ border-color: var(--dsw-alias-border-l2, rgba(127,127,127,.35));
165
+ color: var(--dsw-alias-label-tertiary, light-dark(#667085, #8b8f96));
166
+ }
167
+ .btw-transfer-panel {
168
+ flex: 1 1 auto; min-height: 0; overflow-y: auto;
169
+ display: flex; flex-direction: column; gap: 10px; padding: 12px 14px;
170
+ }
171
+ .btw-transfer-head { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
172
+ .btw-transfer-title { font-size: 13px; color: var(--dsw-alias-label-primary, light-dark(#1f2328, #e8eaed)); }
173
+ .btw-transfer-sub { font-size: 11px; color: var(--dsw-alias-label-tertiary, light-dark(#667085, #8b8f96)); }
174
+ .btw-transfer-loading {
175
+ flex: 1 1 auto; display: flex; align-items: center; justify-content: center;
176
+ font-size: 12px; color: var(--dsw-alias-label-secondary, light-dark(#475467, #9aa0a6));
177
+ }
178
+ .btw-transfer-text {
179
+ flex: 1 1 auto; min-height: 180px; width: 100%; resize: none;
180
+ padding: 10px; border-radius: 8px; font: inherit; font-size: 12.5px; line-height: 1.6;
181
+ color: var(--dsw-alias-label-primary, light-dark(#1f2328, #e8eaed));
182
+ background: var(--dsw-alias-bg-layer-1, rgba(255,255,255,.04));
183
+ border: 1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
184
+ outline: none; white-space: pre-wrap;
185
+ }
186
+ .btw-transfer-text:focus { border-color: var(--dsw-alias-border-l3, rgba(127,127,127,.7)); }
187
+ .btw-transfer-note { font-size: 11px; line-height: 1.5; color: var(--dsw-alias-label-secondary, light-dark(#475467, #9aa0a6)); }
188
+ .btw-transfer-warn { font-size: 11px; line-height: 1.5; color: light-dark(#8a5a00, #d8a14a); }
189
+ .btw-transfer-error { font-size: 12px; line-height: 1.5; color: var(--dsw-alias-state-error-primary, #ff8a8a); }
190
+ .btw-transfer-ok { font-size: 12px; line-height: 1.5; color: light-dark(#1a7f37, #58c07a); }
191
+ .btw-transfer-foot { flex: 0 0 auto; display: flex; align-items: center; gap: 8px; }
192
+ .btw-transfer-foot .btw-grow { flex: 1 1 auto; }
193
+
194
+ /* assistant body: shared markdown renderer, with a plain-text fallback */
195
+ .btw-msg-assistant { white-space: normal; }
196
+ .btw-msg-body { font-size: 13px; line-height: 1.65; overflow-wrap: anywhere; }
197
+ .btw-msg-body > :first-child { margin-top: 0; }
198
+ .btw-msg-body > :last-child { margin-bottom: 0; }
199
+ .btw-msg-body p { margin: 0 0 8px; }
200
+ .btw-msg-body ul, .btw-msg-body ol { margin: 0 0 8px; padding-left: 20px; }
201
+ .btw-msg-body li { margin: 2px 0; }
202
+ .btw-msg-body h1, .btw-msg-body h2, .btw-msg-body h3, .btw-msg-body h4 {
203
+ margin: 12px 0 6px; font-size: 14px; line-height: 1.4;
204
+ }
205
+ .btw-msg-body h1 { font-size: 16px; }
206
+ .btw-msg-body code {
207
+ font-family: var(--dsh-font-family-code, ui-monospace, SFMono-Regular, Consolas, monospace);
208
+ font-size: 12px;
209
+ }
210
+ .btw-msg-body pre {
211
+ margin: 8px 0; padding: 8px 10px; border-radius: 6px; overflow: auto;
212
+ background: var(--dsw-alias-bg-layer-1, rgba(0, 0, 0, .25));
213
+ border: 1px solid var(--dsw-alias-border-l2, rgba(127, 127, 127, .22));
214
+ }
215
+ .btw-msg-body pre code { font-size: 12px; }
216
+ .btw-msg-body a { color: var(--dsw-alias-label-primary-bluish, #6cb0ff); text-decoration: none; }
217
+ .btw-msg-body a:hover { text-decoration: underline; }
218
+ .btw-msg-body blockquote {
219
+ margin: 0 0 8px; padding-left: 10px;
220
+ border-left: 2px solid var(--dsw-alias-border-l3, rgba(127, 127, 127, .6));
221
+ color: var(--dsw-alias-label-secondary, light-dark(#475467, #9aa0a6));
222
+ }
223
+ .btw-msg-body table { border-collapse: collapse; margin: 0 0 8px; font-size: 12px; }
224
+ .btw-msg-body th, .btw-msg-body td {
225
+ border: 1px solid var(--dsw-alias-border-l2, rgba(127, 127, 127, .3));
226
+ padding: 4px 8px; text-align: left;
227
+ }
228
+ .btw-msg-body hr { border: 0; border-top: 1px solid var(--dsw-alias-border-l2, rgba(127, 127, 127, .25)); margin: 10px 0; }
229
+ .btw-plain { white-space: pre-wrap; }
230
+
231
+ .btw-actions { display: flex; align-items: center; gap: 6px; margin-top: 6px; }
232
+ .btw-action {
233
+ border: 1px solid var(--dsw-alias-border-l2, rgba(127, 127, 127, .35));
234
+ background: transparent; color: var(--dsw-alias-label-secondary, light-dark(#475467, #9aa0a6));
235
+ border-radius: 5px; font: inherit; font-size: 11px; line-height: 1; padding: 4px 7px; cursor: pointer;
236
+ }
237
+ .btw-action:hover {
238
+ color: var(--dsw-alias-label-primary, light-dark(#1f2328, #e8eaed));
239
+ background: var(--dsw-alias-bg-layer-2, rgba(127, 127, 127, .12));
240
+ }
241
+ .btw-meta { margin-left: auto; font-size: 11px; color: var(--dsw-alias-label-secondary, light-dark(#475467, #9aa0a6)); }
242
+
243
+ /* context bar: range selector + snapshot stats + refresh */
244
+ .btw-context {
245
+ flex: 0 0 auto; display: flex; align-items: center; gap: 8px;
246
+ padding: 6px 12px; font-size: 11px; line-height: 1.4;
247
+ color: var(--dsw-alias-label-secondary, light-dark(#475467, #9aa0a6));
248
+ border-bottom: 1px solid var(--dsw-alias-border-l2, rgba(127, 127, 127, .18));
249
+ background: var(--dsw-alias-bg-layer-1, rgba(255, 255, 255, .02));
250
+ }
251
+ .btw-select {
252
+ flex: 0 0 auto; height: 24px; max-width: 186px; padding: 0 6px; border-radius: 6px;
253
+ border: 1px solid var(--dsw-alias-border-l2, rgba(127, 127, 127, .35));
254
+ background: var(--dsw-alias-bg-layer-2, rgba(127, 127, 127, .14));
255
+ color: var(--dsw-alias-label-primary, light-dark(#1f2328, #e8eaed)); font: inherit; font-size: 11px; outline: none;
256
+ }
257
+ .btw-ctx-stats {
258
+ flex: 1 1 auto; min-width: 0;
259
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
260
+ }
261
+ .btw-ctx-stats.is-error { color: var(--dsw-alias-state-error-primary, #ff8a8a); }
262
+ .btw-refresh {
263
+ flex: 0 0 auto;
264
+ border: 1px solid var(--dsw-alias-border-l2, rgba(127, 127, 127, .35));
265
+ background: transparent; color: var(--dsw-alias-label-secondary, light-dark(#475467, #9aa0a6));
266
+ border-radius: 5px; font: inherit; font-size: 11px; line-height: 1; padding: 4px 7px; cursor: pointer;
267
+ }
268
+ .btw-refresh:hover {
269
+ color: var(--dsw-alias-label-primary, light-dark(#1f2328, #e8eaed));
270
+ background: var(--dsw-alias-bg-layer-2, rgba(127, 127, 127, .12));
271
+ }
272
+ .btw-refresh:disabled { opacity: .5; cursor: default; }
273
+ .btw-model { max-width: 214px; }
274
+ .btw-custom { gap: 6px; }
275
+ .btw-custom-input {
276
+ flex: 1 1 0; min-width: 0; height: 24px; padding: 0 6px; border-radius: 6px;
277
+ border: 1px solid var(--dsw-alias-border-l2, rgba(127, 127, 127, .35));
278
+ background: var(--dsw-alias-bg-layer-2, rgba(127, 127, 127, .14));
279
+ color: var(--dsw-alias-label-primary, light-dark(#1f2328, #e8eaed)); font: inherit; font-size: 11px; outline: none;
280
+ }
281
+ .btw-gear { font-size: 13px; line-height: 1; padding: 2px 6px; }
282
+ .btw-gear.is-active {
283
+ color: var(--dsw-alias-label-primary, light-dark(#1f2328, #e8eaed));
284
+ border-color: var(--dsw-alias-border-l3, rgba(127, 127, 127, .6));
285
+ background: var(--dsw-alias-bg-layer-2, rgba(127, 127, 127, .14));
286
+ }
287
+
288
+ /* focus: a text selection carried with the ask */
289
+ .btw-focus {
290
+ flex: 0 0 auto; display: flex; align-items: center; gap: 8px;
291
+ padding: 5px 12px; font-size: 11px; line-height: 1.4;
292
+ color: var(--dsw-alias-label-secondary, light-dark(#475467, #9aa0a6));
293
+ border-bottom: 1px solid var(--dsw-alias-border-l2, rgba(127, 127, 127, .18));
294
+ background: var(--dsw-alias-bg-layer-2, rgba(127, 127, 127, .08));
295
+ }
296
+ .btw-focus-label { flex: 0 0 auto; font-weight: 600; color: var(--dsw-alias-label-primary, light-dark(#1f2328, #e8eaed)); }
297
+ .btw-focus-text { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
298
+ .btw-focus-clear {
299
+ flex: 0 0 auto; border: 0; background: transparent; color: var(--dsw-alias-label-tertiary, light-dark(#667085, #8b8f96));
300
+ font: inherit; font-size: 14px; line-height: 1; cursor: pointer; padding: 0 2px;
301
+ }
302
+ .btw-focus-clear:hover { color: var(--dsw-alias-label-primary, light-dark(#1f2328, #e8eaed)); }
303
+
304
+ /* C tier: per-call settings popover */
305
+ .btw-settings {
306
+ flex: 0 0 auto; display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px;
307
+ padding: 8px 12px; font-size: 11px; line-height: 1.4;
308
+ color: var(--dsw-alias-label-secondary, light-dark(#475467, #9aa0a6));
309
+ border-bottom: 1px solid var(--dsw-alias-border-l2, rgba(127, 127, 127, .18));
310
+ background: var(--dsw-alias-bg-layer-2, rgba(127, 127, 127, .06));
311
+ }
312
+ .btw-field { display: flex; align-items: center; gap: 6px; }
313
+ .btw-field input[type="number"], .btw-field select {
314
+ height: 24px; padding: 0 6px; border-radius: 6px;
315
+ border: 1px solid var(--dsw-alias-border-l2, rgba(127, 127, 127, .35));
316
+ background: var(--dsw-alias-bg-layer-1, rgba(0, 0, 0, .12));
317
+ color: var(--dsw-alias-label-primary, light-dark(#1f2328, #e8eaed)); font: inherit; font-size: 11px; outline: none;
318
+ }
319
+ .btw-field input[type="number"] { width: 78px; }
320
+ .btw-field-wide { flex: 1 1 100%; }
321
+ .btw-settings-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; }
322
+ .btw-settings-note { font-size: 10px; color: var(--dsw-alias-label-tertiary, light-dark(#667085, #8b8f96)); }
323
+
324
+ /* resize handles: right edge, bottom edge, bottom-right corner */
325
+ .btw-resize { position: absolute; touch-action: none; }
326
+ .btw-resize-e { top: 48px; right: 0; bottom: 14px; width: 6px; cursor: ew-resize; }
327
+ .btw-resize-s { left: 14px; right: 14px; bottom: 0; height: 6px; cursor: ns-resize; }
328
+ .btw-resize-se {
329
+ right: 0; bottom: 0; width: 16px; height: 16px; cursor: nwse-resize;
330
+ background:
331
+ linear-gradient(135deg, transparent 0 45%, var(--dsw-alias-label-tertiary, light-dark(#667085, #8b8f96)) 45% 52%, transparent 52% 64%, var(--dsw-alias-label-tertiary, light-dark(#667085, #8b8f96)) 64% 71%, transparent 71%);
332
+ opacity: .75;
333
+ }
334
+ .btw-resize-se:hover { opacity: 1; }
335
+ `
336
+
337
+ function installStyles() {
338
+ try {
339
+ let style = document.getElementById('dsh-btw-css')
340
+ if (!style) {
341
+ style = document.createElement('style')
342
+ style.id = 'dsh-btw-css'
343
+ ;(document.head || document.documentElement).appendChild(style)
344
+ }
345
+ style.textContent = CSS
346
+ } catch { /* SSR / test environments have no document */ }
347
+ }
348
+
349
+ /* ---------------------------------------------------- window geometry */
350
+
351
+ const WIN_KEY = 'dsh-btw:window'
352
+ const MIN_W = 420
353
+ const MIN_H = 300
354
+
355
+ function viewport() {
356
+ try {
357
+ return {
358
+ w: Math.max(640, window.innerWidth || 1280),
359
+ h: Math.max(480, window.innerHeight || 860),
360
+ }
361
+ } catch {
362
+ return { w: 1280, h: 860 }
363
+ }
364
+ }
365
+
366
+ function clampGeometry(geometry) {
367
+ const vp = viewport()
368
+ const maxW = Math.max(MIN_W, vp.w - 16)
369
+ const maxH = Math.max(MIN_H, vp.h - 16)
370
+ const w = Math.min(Math.max(Math.round(geometry.w), MIN_W), maxW)
371
+ const height = Math.min(Math.max(Math.round(geometry.h), MIN_H), maxH)
372
+ const x = Math.min(Math.max(Math.round(geometry.x), 8), Math.max(8, vp.w - w - 8))
373
+ const y = Math.min(Math.max(Math.round(geometry.y), 8), Math.max(8, vp.h - height - 8))
374
+ return { x, y, w, h: height }
375
+ }
376
+
377
+ function loadSavedGeometry() {
378
+ try {
379
+ const raw = window.localStorage.getItem(WIN_KEY)
380
+ if (!raw) return null
381
+ const value = JSON.parse(raw)
382
+ if (!value || typeof value !== 'object') return null
383
+ if (![value.x, value.y, value.w, value.h].every(number => typeof number === 'number' && Number.isFinite(number))) return null
384
+ return value
385
+ } catch {
386
+ return null
387
+ }
388
+ }
389
+
390
+ function saveGeometry(geometry) {
391
+ try {
392
+ window.localStorage.setItem(WIN_KEY, JSON.stringify(geometry))
393
+ } catch { /* storage may be disabled */ }
394
+ }
395
+
396
+ function initialGeometry() {
397
+ const saved = loadSavedGeometry()
398
+ if (saved) return clampGeometry(saved)
399
+ const vp = viewport()
400
+ const w = Math.min(760, Math.max(MIN_W, vp.w - 120))
401
+ const height = Math.min(600, Math.max(MIN_H, vp.h - 180))
402
+ return clampGeometry({
403
+ x: Math.round((vp.w - w) / 2),
404
+ y: Math.max(48, Math.round((vp.h - height) / 3)),
405
+ w,
406
+ h: height,
407
+ })
408
+ }
409
+
410
+ /* --------------------------------------------------------------- range */
411
+
412
+ // Mirrors the host's normaliseRange(): the value lives in localStorage and
413
+ // is sent as-is; the host always re-validates it.
414
+ const RANGE_KEY = 'dsh-btw:range'
415
+ // Optional entry point: the composer button can be hidden (the `/btw`
416
+ // command stays available either way). localStorage['dsh-btw:button']='0'.
417
+ const BUTTON_KEY = 'dsh-btw:button'
418
+ const MODEL_KEY = 'dsh-btw:model'
419
+ const SETTINGS_KEY = 'dsh-btw:settings'
420
+
421
+ function buttonVisible() {
422
+ try { return window.localStorage.getItem(BUTTON_KEY) !== '0' } catch { return true }
423
+ }
424
+
425
+ function loadJson(key) {
426
+ try { return JSON.parse(window.localStorage.getItem(key) || 'null') } catch { return null }
427
+ }
428
+
429
+ function saveJson(key, value) {
430
+ try { window.localStorage.setItem(key, JSON.stringify(value)) } catch { /* storage may be disabled */ }
431
+ }
432
+
433
+ function loadModel() {
434
+ const raw = loadJson(MODEL_KEY)
435
+ return raw && typeof raw.provider === 'string' && typeof raw.model === 'string' && raw.provider && raw.model
436
+ ? { provider: raw.provider, model: raw.model }
437
+ : null
438
+ }
439
+
440
+ function loadSettings() {
441
+ const raw = loadJson(SETTINGS_KEY)
442
+ const value = raw && typeof raw === 'object' ? raw : {}
443
+ return {
444
+ temperature: typeof value.temperature === 'number' && value.temperature >= 0 && value.temperature <= 2
445
+ ? value.temperature : null,
446
+ maxTokens: Number.isInteger(value.maxTokens) && value.maxTokens > 0 ? value.maxTokens : null,
447
+ reasoningEffort: typeof value.reasoningEffort === 'string' ? value.reasoningEffort : '',
448
+ }
449
+ }
450
+
451
+ const REASONING_OPTIONS = [
452
+ { value: '', label: '推理强度:默认' },
453
+ { value: 'none', label: '推理强度:none' },
454
+ { value: 'low', label: '推理强度:low' },
455
+ { value: 'medium', label: '推理强度:medium' },
456
+ { value: 'high', label: '推理强度:high' },
457
+ { value: 'max', label: '推理强度:max' },
458
+ ]
459
+
460
+ const RANGE_OPTIONS = [
461
+ { value: 'auto', label: '自动(最近对话)', mode: 'auto', recentMessages: 0 },
462
+ { value: 'recent:40', label: '最近 40 条', mode: 'recent', recentMessages: 40 },
463
+ { value: 'recent:120', label: '最近 120 条', mode: 'recent', recentMessages: 120 },
464
+ { value: 'all', label: '全部历史', mode: 'all', recentMessages: 0 },
465
+ { value: 'no-tools', label: '全部 · 不含工具输出', mode: 'no-tools', recentMessages: 0 },
466
+ ]
467
+
468
+ function rangePayload(value) {
469
+ const found = RANGE_OPTIONS.find((option) => option.value === value)
470
+ return found ? { mode: found.mode, recentMessages: found.recentMessages } : { mode: 'auto' }
471
+ }
472
+
473
+ function loadRange() {
474
+ try {
475
+ const raw = window.localStorage.getItem(RANGE_KEY)
476
+ return RANGE_OPTIONS.some((option) => option.value === raw) ? raw : 'auto'
477
+ } catch {
478
+ return 'auto'
479
+ }
480
+ }
481
+
482
+ function saveRange(value) {
483
+ try { window.localStorage.setItem(RANGE_KEY, value) } catch { /* storage may be disabled */ }
484
+ }
485
+
486
+ /* --------------------------------------------------------------- focus */
487
+
488
+ const FOCUS_MAX_CHARS = 2000
489
+
490
+ /**
491
+ * Read the current text selection. Returns null for a missing or trivial
492
+ * selection.
493
+ */
494
+ function readSelection() {
495
+ try {
496
+ const selection = window.getSelection ? window.getSelection() : null
497
+ const text = String(selection ? selection.toString() : '').trim()
498
+ if (text.length < 2) return null
499
+ return { kind: 'selection', text: text.slice(0, FOCUS_MAX_CHARS) }
500
+ } catch {
501
+ return null
502
+ }
503
+ }
504
+
505
+ // Chromium can clear the document selection before a click handler runs
506
+ // (focus moves to the button). Keep the last real selection around briefly
507
+ // so the button can still quote it.
508
+ const lastSelection = { focus: null, at: 0 }
509
+
510
+ function rememberSelection() {
511
+ const focus = readSelection()
512
+ if (!focus) return
513
+ lastSelection.focus = focus
514
+ lastSelection.at = Date.now()
515
+ }
516
+
517
+ /** Current selection, or one made within the last few seconds. */
518
+ function captureSelection() {
519
+ const focus = readSelection()
520
+ if (focus) {
521
+ lastSelection.focus = focus
522
+ lastSelection.at = Date.now()
523
+ return focus
524
+ }
525
+ if (lastSelection.focus && Date.now() - lastSelection.at < 5000) {
526
+ return { kind: 'selection', text: lastSelection.focus.text }
527
+ }
528
+ return null
529
+ }
530
+
531
+ /** Quote block inlined into the request (the host keeps the dialog private). */
532
+ function focusPreamble(focus) {
533
+ if (!focus || typeof focus !== 'object' || typeof focus.text !== 'string') return ''
534
+ const text = focus.text.trim()
535
+ if (text === '') return ''
536
+ return [
537
+ '[btw] 用户正在针对用户选中的文本提问,引用如下:',
538
+ '"""',
539
+ text.slice(0, FOCUS_MAX_CHARS),
540
+ '"""',
541
+ '请优先解释或回答与此段内容相关的问题;如果会话快照不足以回答,请明确说明。',
542
+ ].join('\n')
543
+ }
544
+
545
+ function clearFocus() {
546
+ if (!state.focus) return
547
+ state.focus = null
548
+ emit()
549
+ }
550
+
551
+ function focusPreview(focus) {
552
+ if (!focus || typeof focus.text !== 'string') return ''
553
+ return focus.text.length > 120 ? `${focus.text.slice(0, 120)}…` : focus.text
554
+ }
555
+
556
+ /* -------------------------------------------------------------- store */
557
+
558
+ const state = {
559
+ open: false,
560
+ sessionId: null,
561
+ // Session whose composer button is currently mounted. A dialog opened in
562
+ // another session is hidden while this differs (its conversation is stashed).
563
+ activeSession: null,
564
+ // Per-session stashed conversations: switching away hides the dialog but
565
+ // keeps its messages here, so switching back can restore them.
566
+ stash: new Map(),
567
+ messages: [],
568
+ streaming: false,
569
+ abort: null,
570
+ win: null,
571
+ prevWin: null,
572
+ // "送入主会话": the review panel, its generated text and write status.
573
+ transferOpen: false,
574
+ transferPhase: 'idle', // idle | loading | ready | sending | sent | error
575
+ transferText: '',
576
+ transferError: null,
577
+ transferWarning: null,
578
+ transferUsage: null,
579
+ transferTurns: 0,
580
+ transferAbort: null,
581
+ sentSignature: '',
582
+ // Optional quoted selection: `{ kind: 'selection', text }`, captured from
583
+ // the composer button or the /btw command, inlined into the question.
584
+ focus: null,
585
+ rangeValue: loadRange(),
586
+ // What /btw/context (or the SSE meta frame) last reported for this ask.
587
+ context: { loading: false, stats: null, dropped: 0, cold: false, error: null },
588
+ contextRequest: 0,
589
+ // C-tier: per-dialog model pick and one-off call settings.
590
+ model: loadModel(),
591
+ models: { loading: false, providers: [], default: null, current: null, error: null },
592
+ customOpen: false,
593
+ customDraft: { provider: '', model: '' },
594
+ settings: loadSettings(),
595
+ settingsOpen: false,
596
+ }
597
+ const listeners = new Set()
598
+
599
+ function emit() {
600
+ for (const listener of Array.from(listeners)) {
601
+ try { listener() } catch { /* a broken subscriber must not break the store */ }
602
+ }
603
+ }
604
+
605
+ function subscribe(listener) {
606
+ listeners.add(listener)
607
+ return () => listeners.delete(listener)
608
+ }
609
+
610
+ /* One dialog at a time, but its conversation survives session switches:
611
+ * switching away hides the window and stashes its messages; switching back
612
+ * restores them. Only the close button (×) clears a conversation. */
613
+
614
+ function snapshotDialog() {
615
+ return {
616
+ messages: state.messages,
617
+ focus: state.focus,
618
+ rangeValue: state.rangeValue,
619
+ context: state.context,
620
+ win: state.win,
621
+ prevWin: state.prevWin,
622
+ transferOpen: state.transferOpen,
623
+ transferPhase: state.transferPhase,
624
+ transferText: state.transferText,
625
+ transferError: state.transferError,
626
+ transferWarning: state.transferWarning,
627
+ transferUsage: state.transferUsage,
628
+ transferTurns: state.transferTurns,
629
+ sentSignature: state.sentSignature,
630
+ }
631
+ }
632
+
633
+ function blankTransfer() {
634
+ return {
635
+ transferOpen: false,
636
+ transferPhase: 'idle',
637
+ transferText: '',
638
+ transferError: null,
639
+ transferWarning: null,
640
+ transferUsage: null,
641
+ transferTurns: 0,
642
+ transferAbort: null,
643
+ }
644
+ }
645
+
646
+ function resetVisibleDialog() {
647
+ Object.assign(state, {
648
+ open: false,
649
+ sessionId: null,
650
+ messages: [],
651
+ streaming: false,
652
+ abort: null,
653
+ focus: null,
654
+ context: { loading: false, stats: null, dropped: 0, cold: false, error: null },
655
+ settingsOpen: false,
656
+ ...blankTransfer(),
657
+ })
658
+ }
659
+
660
+ function stashDialog() {
661
+ if (!state.open || !state.sessionId) return
662
+ abortCurrent()
663
+ if (state.transferAbort) {
664
+ try { state.transferAbort.abort() } catch { /* already finished */ }
665
+ state.transferAbort = null
666
+ }
667
+ // A summary request cannot outlive the visible dialog; a write may have
668
+ // reached the host already, so say so instead of pretending it failed.
669
+ if (state.transferPhase === 'loading') {
670
+ state.transferPhase = 'idle'
671
+ state.transferOpen = false
672
+ } else if (state.transferPhase === 'sending') {
673
+ state.transferPhase = 'error'
674
+ state.transferError = '已切换会话,写入结果未知;切回后可重新送入(可能重复)'
675
+ }
676
+ state.streaming = false
677
+ state.abort = null
678
+ const last = state.messages[state.messages.length - 1]
679
+ if (last && last.role === 'assistant' && last.state === 'streaming') {
680
+ last.state = 'done'
681
+ if (!last.text) last.text = '(已切换会话,已停止)'
682
+ }
683
+ state.stash.set(state.sessionId, snapshotDialog())
684
+ }
685
+
686
+ function restoreDialog(sessionId) {
687
+ const saved = state.stash.get(sessionId)
688
+ if (!saved) return false
689
+ state.stash.delete(sessionId)
690
+ Object.assign(state, {
691
+ open: true,
692
+ sessionId,
693
+ messages: saved.messages,
694
+ streaming: false,
695
+ abort: null,
696
+ focus: saved.focus || null,
697
+ rangeValue: saved.rangeValue || loadRange(),
698
+ context: saved.context || { loading: false, stats: null, dropped: 0, cold: false, error: null },
699
+ win: saved.win || state.win || initialGeometry(),
700
+ prevWin: saved.prevWin || null,
701
+ settingsOpen: false,
702
+ transferOpen: saved.transferOpen === true,
703
+ transferPhase: saved.transferPhase || 'idle',
704
+ transferText: saved.transferText || '',
705
+ transferError: saved.transferError || null,
706
+ transferWarning: saved.transferWarning || null,
707
+ transferUsage: saved.transferUsage || null,
708
+ transferTurns: saved.transferTurns || 0,
709
+ transferAbort: null,
710
+ sentSignature: saved.sentSignature || '',
711
+ })
712
+ emit()
713
+ refreshContext()
714
+ return true
715
+ }
716
+
717
+ function openFor(sessionId, focus = null) {
718
+ if (typeof sessionId !== 'string' || sessionId === '') return
719
+ if (state.open && state.sessionId === sessionId) return
720
+ // The dialog belongs to another session: stash that conversation for
721
+ // when the user switches back, then open this session's own dialog.
722
+ if (state.open) stashDialog()
723
+ if (restoreDialog(sessionId)) {
724
+ if (focus && typeof focus === 'object') state.focus = focus
725
+ emit()
726
+ return
727
+ }
728
+ abortCurrent()
729
+ Object.assign(state, {
730
+ open: true,
731
+ sessionId,
732
+ messages: [],
733
+ streaming: false,
734
+ abort: null,
735
+ win: state.win || initialGeometry(),
736
+ prevWin: null,
737
+ focus: focus && typeof focus === 'object' ? focus : null,
738
+ context: { loading: true, stats: null, dropped: 0, cold: false, error: null },
739
+ settingsOpen: false,
740
+ ...blankTransfer(),
741
+ })
742
+ emit()
743
+ refreshContext()
744
+ loadModels()
745
+ }
746
+
747
+ function close() {
748
+ // Explicit close is the only thing that forgets a conversation.
749
+ if (state.sessionId) state.stash.delete(state.sessionId)
750
+ abortCurrent()
751
+ if (state.transferAbort) {
752
+ try { state.transferAbort.abort() } catch { /* already finished */ }
753
+ }
754
+ Object.assign(state, {
755
+ open: false,
756
+ sessionId: null,
757
+ messages: [],
758
+ streaming: false,
759
+ abort: null,
760
+ win: null,
761
+ prevWin: null,
762
+ focus: null,
763
+ context: { loading: false, stats: null, dropped: 0, cold: false, error: null },
764
+ settingsOpen: false,
765
+ ...blankTransfer(),
766
+ })
767
+ emit()
768
+ }
769
+
770
+ /** Hide a dialog opened from another session; restore it on return. */
771
+ function noteActiveSession(sessionId) {
772
+ if (typeof sessionId !== 'string' || sessionId === '') return
773
+ if (state.activeSession === sessionId) return
774
+ state.activeSession = sessionId
775
+ if (state.open && state.sessionId && state.sessionId !== sessionId) {
776
+ stashDialog()
777
+ resetVisibleDialog()
778
+ }
779
+ if (!state.open) restoreDialog(sessionId)
780
+ emit()
781
+ }
782
+
783
+ /** A stable fingerprint of the finished dialog, used to prevent double sends. */
784
+ function dialogSignature() {
785
+ let hash = 2166136261
786
+ for (const message of state.messages) {
787
+ const line = `${message.role}:${typeof message.text === 'string' ? message.text : ''}\n`
788
+ for (let index = 0; index < line.length; index += 1) {
789
+ hash ^= line.charCodeAt(index)
790
+ hash = Math.imul(hash, 16777619)
791
+ }
792
+ }
793
+ return (hash >>> 0).toString(36)
794
+ }
795
+
796
+ function canTransfer() {
797
+ return !state.streaming && state.messages.some((message) => message.role === 'assistant'
798
+ && message.state === 'done' && typeof message.text === 'string' && message.text.trim() !== '')
799
+ }
800
+
801
+ function dialogTranscript() {
802
+ return state.messages
803
+ .filter((message) => (message.role === 'user' || message.role === 'assistant')
804
+ && typeof message.text === 'string' && message.text.trim() !== '' && message.state !== 'streaming')
805
+ .map((message) => ({ role: message.role, text: message.text }))
806
+ }
807
+
808
+ function openTransfer() {
809
+ if (!state.open || !state.sessionId || state.streaming || !canTransfer()) return
810
+ const transcript = dialogTranscript()
811
+ Object.assign(state, {
812
+ transferOpen: true,
813
+ transferPhase: 'loading',
814
+ transferText: '',
815
+ transferError: null,
816
+ transferWarning: null,
817
+ transferUsage: null,
818
+ transferTurns: transcript.filter((message) => message.role === 'user').length,
819
+ })
820
+ emit()
821
+ summarizeTransfer(transcript)
822
+ }
823
+
824
+ function closeTransfer() {
825
+ if (state.transferAbort) {
826
+ try { state.transferAbort.abort() } catch { /* already finished */ }
827
+ state.transferAbort = null
828
+ }
829
+ Object.assign(state, blankTransfer())
830
+ emit()
831
+ }
832
+
833
+ function updateTransferText(text) {
834
+ state.transferText = text
835
+ if (state.transferPhase === 'sent') state.transferPhase = 'ready'
836
+ if (state.transferError) state.transferError = null
837
+ emit()
838
+ }
839
+
840
+ async function postJson(path, payload, signal) {
841
+ let response
842
+ try {
843
+ response = await fetch(apiUrl(path), {
844
+ method: 'POST',
845
+ headers: { 'content-type': 'application/json' },
846
+ body: JSON.stringify(payload),
847
+ signal,
848
+ })
849
+ } catch (error) {
850
+ if (error && error.name === 'AbortError') throw error
851
+ throw new Error('无法连接 btw 服务(host 路由未就绪?)')
852
+ }
853
+ let body = null
854
+ try { body = await response.json() } catch { /* non-JSON error body */ }
855
+ if (!response.ok) {
856
+ const message = body && body.error && typeof body.error.message === 'string'
857
+ ? body.error.message
858
+ : `HTTP ${response.status}`
859
+ throw new Error(message)
860
+ }
861
+ return body || {}
862
+ }
863
+
864
+ async function summarizeTransfer(transcript) {
865
+ const sessionId = state.sessionId
866
+ const controller = new AbortController()
867
+ state.transferAbort = controller
868
+ try {
869
+ const payload = {
870
+ sessionId,
871
+ messages: transcript,
872
+ focus: state.focus && typeof state.focus.text === 'string' ? { text: state.focus.text } : null,
873
+ }
874
+ if (state.model && typeof state.model.provider === 'string' && typeof state.model.model === 'string') {
875
+ payload.provider = state.model.provider
876
+ payload.model = state.model.model
877
+ }
878
+ const body = await postJson('btw/summarize', payload, controller.signal)
879
+ if (state.sessionId !== sessionId || !state.transferOpen) return
880
+ const summary = typeof body.summary === 'string' ? body.summary : ''
881
+ if (summary.trim() === '') throw new Error('摘要为空')
882
+ state.transferText = summary
883
+ state.transferPhase = 'ready'
884
+ state.transferWarning = typeof body.warning === 'string' ? body.warning : null
885
+ state.transferUsage = body.usage || null
886
+ } catch (error) {
887
+ if (controller.signal.aborted) return
888
+ if (state.sessionId !== sessionId || !state.transferOpen) return
889
+ state.transferPhase = 'error'
890
+ state.transferError = errorText(error)
891
+ } finally {
892
+ if (state.transferAbort === controller) state.transferAbort = null
893
+ if (state.sessionId === sessionId) emit()
894
+ }
895
+ }
896
+
897
+ async function commitTransfer() {
898
+ const text = state.transferText.trim()
899
+ if (!text || !state.sessionId || state.transferPhase === 'loading' || state.transferPhase === 'sending') return
900
+ const sessionId = state.sessionId
901
+ state.transferPhase = 'sending'
902
+ state.transferError = null
903
+ emit()
904
+ try {
905
+ await postJson('btw/commit', {
906
+ sessionId,
907
+ summary: text,
908
+ turns: state.transferTurns,
909
+ focus: !!state.focus,
910
+ at: new Date().toISOString(),
911
+ })
912
+ if (state.sessionId !== sessionId) return
913
+ state.transferPhase = 'sent'
914
+ state.transferError = null
915
+ state.sentSignature = dialogSignature()
916
+ } catch (error) {
917
+ if (state.sessionId !== sessionId) return
918
+ state.transferPhase = 'error'
919
+ state.transferError = errorText(error)
920
+ } finally {
921
+ if (state.sessionId === sessionId) emit()
922
+ }
923
+ }
924
+
925
+ /** Double-click the title bar: fill the viewport, or restore. */
926
+ function toggleMaximize() {
927
+ if (!state.win) return
928
+ if (state.prevWin) {
929
+ state.win = clampGeometry(state.prevWin)
930
+ state.prevWin = null
931
+ } else {
932
+ const vp = viewport()
933
+ state.prevWin = state.win
934
+ state.win = clampGeometry({ x: 12, y: 12, w: vp.w - 24, h: vp.h - 24 })
935
+ }
936
+ emit()
937
+ commitWindow()
938
+ }
939
+
940
+ function abortCurrent() {
941
+ if (state.abort) {
942
+ try { state.abort.abort() } catch { /* ignore */ }
943
+ }
944
+ }
945
+
946
+ function stop() {
947
+ abortCurrent()
948
+ state.streaming = false
949
+ state.abort = null
950
+ const last = state.messages[state.messages.length - 1]
951
+ if (last && last.role === 'assistant' && last.state === 'streaming') {
952
+ last.state = 'done'
953
+ if (!last.text) last.text = '(已停止)'
954
+ }
955
+ emit()
956
+ }
957
+
958
+ function setWindow(geometry) {
959
+ if (!state.win) return
960
+ state.win = clampGeometry({ ...state.win, ...geometry })
961
+ emit()
962
+ }
963
+
964
+ function commitWindow() {
965
+ if (state.win) saveGeometry(state.prevWin || state.win)
966
+ }
967
+
968
+ function refitWindow() {
969
+ if (!state.win) return
970
+ state.win = clampGeometry(state.win)
971
+ emit()
972
+ saveGeometry(state.prevWin || state.win)
973
+ }
974
+
975
+ /* -------------------------------------------------------- context preview */
976
+
977
+ async function fetchContext(sessionId, rangeValue) {
978
+ let response
979
+ try {
980
+ response = await fetch(apiUrl('btw/context'), {
981
+ method: 'POST',
982
+ headers: { 'content-type': 'application/json' },
983
+ body: JSON.stringify({ sessionId, range: rangePayload(rangeValue) }),
984
+ })
985
+ } catch {
986
+ throw new Error('无法连接 btw 服务(host 路由未就绪?)')
987
+ }
988
+ let body = null
989
+ try { body = await response.json() } catch { body = null }
990
+ if (!response.ok) {
991
+ const detail = body && body.error && typeof body.error.message === 'string' && body.error.message
992
+ ? body.error.message
993
+ : `HTTP ${response.status}`
994
+ throw new Error(detail)
995
+ }
996
+ return body || {}
997
+ }
998
+
999
+ /** Ask the host how much history the selected range would send. */
1000
+ async function refreshContext() {
1001
+ const sessionId = state.sessionId
1002
+ if (!sessionId) return
1003
+ const requestId = state.contextRequest + 1
1004
+ state.contextRequest = requestId
1005
+ state.context = { ...state.context, loading: true, error: null }
1006
+ emit()
1007
+ try {
1008
+ const data = await fetchContext(sessionId, state.rangeValue)
1009
+ if (state.sessionId !== sessionId || state.contextRequest !== requestId) return
1010
+ state.context = {
1011
+ loading: false,
1012
+ stats: data.stats || null,
1013
+ dropped: Number.isFinite(data.dropped) ? data.dropped : 0,
1014
+ cold: data.cold === true,
1015
+ error: null,
1016
+ }
1017
+ } catch (error) {
1018
+ if (state.sessionId !== sessionId || state.contextRequest !== requestId) return
1019
+ state.context = { loading: false, stats: null, dropped: 0, cold: false, error: errorText(error) }
1020
+ }
1021
+ emit()
1022
+ }
1023
+
1024
+ function setRangeValue(value) {
1025
+ if (!RANGE_OPTIONS.some((option) => option.value === value)) return
1026
+ state.rangeValue = value
1027
+ saveRange(value)
1028
+ emit()
1029
+ refreshContext()
1030
+ }
1031
+
1032
+ /* ------------------------------------------------------------- C: models */
1033
+
1034
+ function routeMatches(route, providers) {
1035
+ if (!route || typeof route.provider !== 'string') return false
1036
+ const provider = providers.find((entry) => entry.id === route.provider)
1037
+ return !!provider && provider.models.some((model) => model.id === route.model)
1038
+ }
1039
+
1040
+ /** Load the provider/model catalog and settle on an initial route. */
1041
+ async function loadModels() {
1042
+ const sessionId = state.sessionId
1043
+ if (!sessionId) return
1044
+ state.models = { ...state.models, loading: true, error: null }
1045
+ emit()
1046
+ try {
1047
+ const response = await fetch(apiUrl(`btw/models?sessionId=${encodeURIComponent(sessionId)}`), {
1048
+ signal: typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
1049
+ ? AbortSignal.timeout(8000)
1050
+ : undefined,
1051
+ })
1052
+ let body = null
1053
+ try { body = await response.json() } catch { body = null }
1054
+ if (state.sessionId !== sessionId) return
1055
+ if (!response.ok) {
1056
+ const detail = body && body.error && typeof body.error.message === 'string' ? body.error.message : `HTTP ${response.status}`
1057
+ throw new Error(detail)
1058
+ }
1059
+ const listed = Array.isArray(body && body.providers)
1060
+ ? body.providers.filter((entry) => entry && typeof entry.id === 'string' && Array.isArray(entry.models))
1061
+ : []
1062
+ // Adapters that are not catalog-driven report no models; keep the routes
1063
+ // the host already knows about (session route, harness default, saved
1064
+ // pick) selectable so the control never ends up empty.
1065
+ const providers = mergeKnownRoutes(listed, [
1066
+ body && body.current,
1067
+ body && body.default,
1068
+ state.model,
1069
+ ])
1070
+ const current = body && body.current && routeMatches(body.current, providers) ? body.current : null
1071
+ state.models = {
1072
+ loading: false,
1073
+ providers,
1074
+ default: body && body.default ? body.default : null,
1075
+ current,
1076
+ error: null,
1077
+ }
1078
+ } catch (error) {
1079
+ if (state.sessionId !== sessionId) return
1080
+ const providers = mergeKnownRoutes([], [state.models.current, state.models.default, state.model])
1081
+ state.models = { loading: false, providers, default: state.models.default, current: state.models.current, error: errorText(error) }
1082
+ }
1083
+ emit()
1084
+ }
1085
+
1086
+ function mergeKnownRoutes(providers, routes) {
1087
+ const out = providers.map((provider) => ({ ...provider, models: [...provider.models] }))
1088
+ for (const route of routes) {
1089
+ if (!route || typeof route.provider !== 'string' || typeof route.model !== 'string') continue
1090
+ if (!route.provider || !route.model) continue
1091
+ let entry = out.find((provider) => provider.id === route.provider)
1092
+ if (!entry) {
1093
+ entry = { id: route.provider, name: route.provider, models: [] }
1094
+ out.push(entry)
1095
+ }
1096
+ if (!entry.models.some((model) => model.id === route.model)) {
1097
+ entry.models.push({ id: route.model, name: route.model })
1098
+ }
1099
+ }
1100
+ return out
1101
+ }
1102
+
1103
+ function openCustomModel() {
1104
+ state.customOpen = true
1105
+ state.customDraft = state.model
1106
+ ? { provider: state.model.provider, model: state.model.model }
1107
+ : { provider: state.models.current?.provider || '', model: state.models.current?.model || '' }
1108
+ emit()
1109
+ }
1110
+
1111
+ function updateCustomDraft(patch) {
1112
+ state.customDraft = { ...state.customDraft, ...patch }
1113
+ emit()
1114
+ }
1115
+
1116
+ function applyCustomModel() {
1117
+ const provider = String(state.customDraft.provider || '').trim()
1118
+ const model = String(state.customDraft.model || '').trim()
1119
+ if (!provider || !model) return
1120
+ state.models = { ...state.models, providers: mergeKnownRoutes(state.models.providers, [{ provider, model }]) }
1121
+ state.customOpen = false
1122
+ setModel(provider, model)
1123
+ }
1124
+
1125
+ function setModel(provider, model) {
1126
+ if (typeof provider !== 'string' || typeof model !== 'string' || !provider || !model) {
1127
+ state.model = null
1128
+ try { window.localStorage.removeItem(MODEL_KEY) } catch { /* storage disabled */ }
1129
+ emit()
1130
+ return
1131
+ }
1132
+ state.model = { provider, model }
1133
+ saveJson(MODEL_KEY, state.model)
1134
+ emit()
1135
+ }
1136
+
1137
+ function updateSettings(patch) {
1138
+ state.settings = { ...state.settings, ...patch }
1139
+ saveJson(SETTINGS_KEY, state.settings)
1140
+ emit()
1141
+ }
1142
+
1143
+ function resetSettings() {
1144
+ state.settings = { temperature: null, maxTokens: null, reasoningEffort: '' }
1145
+ saveJson(SETTINGS_KEY, state.settings)
1146
+ emit()
1147
+ }
1148
+
1149
+ function toggleSettings() {
1150
+ state.settingsOpen = !state.settingsOpen
1151
+ emit()
1152
+ }
1153
+
1154
+ function modelLabel() {
1155
+ if (state.models.loading) return '读取模型…'
1156
+ if (state.model) return `${state.model.provider}/${state.model.model}`
1157
+ if (state.models.error) return '模型读取失败'
1158
+ return '跟随会话模型'
1159
+ }
1160
+
1161
+ function usageText(usage) {
1162
+ if (!usage || typeof usage !== 'object') return ''
1163
+ const input = Number.isFinite(usage.inputTokens) ? usage.inputTokens : null
1164
+ const output = Number.isFinite(usage.outputTokens) ? usage.outputTokens : null
1165
+ if (input === null && output === null) return ''
1166
+ const parts = []
1167
+ if (input !== null) parts.push(`↑ ${input}`)
1168
+ if (output !== null) parts.push(`↓ ${output}`)
1169
+ if (Number.isFinite(usage.reasoningTokens) && usage.reasoningTokens > 0) parts.push(`推理 ${usage.reasoningTokens}`)
1170
+ return parts.join(' ')
1171
+ }
1172
+
1173
+ /** The ask route's first SSE frame is authoritative for the badge. */
1174
+ function applyMeta(meta) {
1175
+ if (!meta || typeof meta !== 'object') return
1176
+ state.context = {
1177
+ loading: false,
1178
+ stats: meta.stats || state.context.stats,
1179
+ dropped: Number.isFinite(meta.dropped) ? meta.dropped : state.context.dropped,
1180
+ cold: meta.cold === true,
1181
+ error: null,
1182
+ }
1183
+ emit()
1184
+ }
1185
+
1186
+ function contextSummary(context) {
1187
+ if (!context) return ''
1188
+ if (context.loading) return '读取快照…'
1189
+ if (context.error) return `快照失败:${context.error}`
1190
+ const stats = context.stats
1191
+ if (!stats) return ''
1192
+ const parts = [`${stats.messages} 条消息`, `约 ${stats.tokens} tokens`]
1193
+ if (context.dropped > 0) parts.push(`省略 ${context.dropped} 条`)
1194
+ if (context.cold) parts.push('已保存会话')
1195
+ return parts.join(' · ')
1196
+ }
1197
+
1198
+ /** Earlier completed dialog turns, sent back with the next question. */
1199
+ function completedPairs(messages) {
1200
+ const pairs = []
1201
+ for (let index = 0; index < messages.length; index += 1) {
1202
+ const user = messages[index]
1203
+ const assistant = messages[index + 1]
1204
+ if (user && user.role === 'user' && assistant && assistant.role === 'assistant' && assistant.state === 'done') {
1205
+ pairs.push(
1206
+ { role: 'user', text: user.text },
1207
+ { role: 'assistant', text: assistant.text, provider: assistant.provider, model: assistant.model },
1208
+ )
1209
+ index += 1
1210
+ }
1211
+ }
1212
+ return pairs
1213
+ }
1214
+
1215
+ async function send(question) {
1216
+ const text = typeof question === 'string' ? question.trim() : ''
1217
+ if (!text || state.streaming || !state.sessionId) return
1218
+ if (state.transferOpen && state.transferPhase !== 'sending') closeTransfer()
1219
+
1220
+ const sessionId = state.sessionId
1221
+ const priorTurns = completedPairs(state.messages)
1222
+ // Hold the array locally: switching sessions resets `state.messages`, and
1223
+ // late stream callbacks must not write into another dialog's transcript.
1224
+ const messages = [
1225
+ ...state.messages,
1226
+ { role: 'user', text, state: 'done' },
1227
+ { role: 'assistant', text: '', state: 'streaming' },
1228
+ ]
1229
+ state.messages = messages
1230
+ const assistantIndex = messages.length - 1
1231
+ const abort = new AbortController()
1232
+ Object.assign(state, { streaming: true, abort })
1233
+ emit()
1234
+
1235
+ const patch = (fields) => {
1236
+ const message = messages[assistantIndex]
1237
+ if (!message) return
1238
+ Object.assign(message, fields)
1239
+ emit()
1240
+ }
1241
+
1242
+ try {
1243
+ const done = await streamAsk({
1244
+ sessionId,
1245
+ question: text,
1246
+ priorTurns,
1247
+ range: rangePayload(state.rangeValue),
1248
+ model: state.model,
1249
+ settings: state.settings,
1250
+ focus: state.focus,
1251
+ signal: abort.signal,
1252
+ onMeta: (meta) => {
1253
+ if (state.sessionId === sessionId) applyMeta(meta)
1254
+ },
1255
+ onUsage: (usage) => patch({ usage }),
1256
+ onText: (chunk) => {
1257
+ const message = messages[assistantIndex]
1258
+ if (!message) return
1259
+ message.text += chunk
1260
+ emit()
1261
+ },
1262
+ })
1263
+ if (state.sessionId !== sessionId) return
1264
+ const message = messages[assistantIndex]
1265
+ if (message && !message.text && done && typeof done.text === 'string') message.text = done.text
1266
+ patch({
1267
+ state: 'done',
1268
+ provider: done?.provider ?? null,
1269
+ model: done?.model ?? null,
1270
+ usage: done?.usage ?? message?.usage,
1271
+ truncated: done?.truncated === true,
1272
+ })
1273
+ } catch (error) {
1274
+ if (abort.signal.aborted) {
1275
+ patch({ state: 'done', text: messages[assistantIndex]?.text || '(已停止)' })
1276
+ } else {
1277
+ patch({ state: 'error', error: errorText(error), question: text })
1278
+ }
1279
+ } finally {
1280
+ if (state.abort === abort) state.abort = null
1281
+ // A stashed dialog was already marked idle; don't clear another
1282
+ // session's streaming flag when this request finishes late.
1283
+ if (state.sessionId === sessionId) {
1284
+ state.streaming = false
1285
+ emit()
1286
+ }
1287
+ }
1288
+ }
1289
+
1290
+ function errorText(error) {
1291
+ if (error && typeof error.message === 'string' && error.message) return error.message
1292
+ return String(error)
1293
+ }
1294
+
1295
+ /* ------------------------------------------------------------ transport */
1296
+
1297
+ function apiUrl(path) {
1298
+ try {
1299
+ return new URL(path, document.baseURI).toString()
1300
+ } catch {
1301
+ return path
1302
+ }
1303
+ }
1304
+
1305
+ /**
1306
+ * POST /btw/ask and consume the SSE response. Resolves with the `done`
1307
+ * payload; rejects with a readable message for HTTP and SSE errors.
1308
+ */
1309
+ async function streamAsk({ sessionId, question, priorTurns, range, model, settings, focus, signal, onText, onMeta, onUsage }) {
1310
+ // The quote travels inside the question itself: this keeps the feature
1311
+ // working against hosts that predate the `focus` field, and needs no
1312
+ // second code path on the host.
1313
+ const preamble = focusPreamble(focus)
1314
+ const asked = preamble ? `${preamble}\n\n${question}` : question
1315
+ const payload = { sessionId, question: asked, priorTurns, range }
1316
+ if (model && typeof model.provider === 'string' && typeof model.model === 'string') {
1317
+ payload.provider = model.provider
1318
+ payload.model = model.model
1319
+ }
1320
+ if (settings && typeof settings === 'object') {
1321
+ if (Number.isFinite(settings.temperature)) payload.temperature = settings.temperature
1322
+ if (Number.isInteger(settings.maxTokens)) payload.maxTokens = settings.maxTokens
1323
+ if (typeof settings.reasoningEffort === 'string' && settings.reasoningEffort !== '') {
1324
+ payload.reasoningEffort = settings.reasoningEffort
1325
+ }
1326
+ }
1327
+ let response
1328
+ try {
1329
+ response = await fetch(apiUrl('btw/ask'), {
1330
+ method: 'POST',
1331
+ headers: { 'content-type': 'application/json' },
1332
+ body: JSON.stringify(payload),
1333
+ signal,
1334
+ })
1335
+ } catch (error) {
1336
+ if (error && error.name === 'AbortError') throw error
1337
+ throw new Error('无法连接 btw 服务(host 路由未就绪?)')
1338
+ }
1339
+ if (!response.ok) {
1340
+ let detail = `HTTP ${response.status}`
1341
+ try {
1342
+ const body = await response.json()
1343
+ if (body && body.error && typeof body.error.message === 'string' && body.error.message) detail = body.error.message
1344
+ } catch { /* non-JSON error body */ }
1345
+ throw new Error(detail)
1346
+ }
1347
+ if (!response.body) throw new Error('响应不支持流式读取')
1348
+
1349
+ const reader = response.body.getReader()
1350
+ const decoder = new TextDecoder()
1351
+ let buffer = ''
1352
+ let donePayload = null
1353
+ let streamError = null
1354
+
1355
+ const handleFrame = (frame) => {
1356
+ const parsed = parseFrame(frame)
1357
+ if (!parsed) return
1358
+ if (parsed.event === 'delta') {
1359
+ const data = parsed.data || {}
1360
+ if (data.kind === 'reasoning') return
1361
+ if (typeof data.text === 'string' && data.text) onText(data.text)
1362
+ } else if (parsed.event === 'meta') {
1363
+ if (typeof onMeta === 'function') onMeta(parsed.data || {})
1364
+ } else if (parsed.event === 'usage') {
1365
+ if (typeof onUsage === 'function') onUsage(parsed.data || {})
1366
+ } else if (parsed.event === 'done') {
1367
+ donePayload = parsed.data || {}
1368
+ } else if (parsed.event === 'error') {
1369
+ streamError = parsed.data || {}
1370
+ }
1371
+ }
1372
+
1373
+ for (;;) {
1374
+ const { value, done } = await reader.read()
1375
+ if (done) break
1376
+ buffer += decoder.decode(value, { stream: true })
1377
+ let boundary
1378
+ while ((boundary = buffer.indexOf('\n\n')) >= 0) {
1379
+ const frame = buffer.slice(0, boundary)
1380
+ buffer = buffer.slice(boundary + 2)
1381
+ handleFrame(frame)
1382
+ }
1383
+ }
1384
+ if (buffer.trim() !== '') handleFrame(buffer)
1385
+
1386
+ if (streamError) {
1387
+ throw new Error(streamError.message || streamError.code || 'btw 请求失败')
1388
+ }
1389
+ return donePayload || {}
1390
+ }
1391
+
1392
+ function parseFrame(frame) {
1393
+ const lines = String(frame).split('\n')
1394
+ let event = 'message'
1395
+ const dataLines = []
1396
+ for (const line of lines) {
1397
+ if (line.startsWith('event:')) event = line.slice(6).trim()
1398
+ else if (line.startsWith('data:')) dataLines.push(line.slice(5).trimStart())
1399
+ }
1400
+ if (dataLines.length === 0) return null
1401
+ const raw = dataLines.join('\n')
1402
+ try {
1403
+ return { event, data: JSON.parse(raw) }
1404
+ } catch {
1405
+ return null
1406
+ }
1407
+ }
1408
+
1409
+ /* --------------------------------------------------------------- hooks */
1410
+
1411
+ function useStore() {
1412
+ const [, forceRender] = React.useReducer((value) => value + 1, 0)
1413
+ React.useEffect(() => subscribe(forceRender), [])
1414
+ return state
1415
+ }
1416
+
1417
+ /* ------------------------------------------------------------------ UI */
1418
+
1419
+ function BtwButton(props) {
1420
+ useStore()
1421
+ const sessionId = props && typeof props.sessionId === 'string' ? props.sessionId : ''
1422
+ const useInput = props && props.useInput
1423
+ const inputState = typeof useInput === 'function' ? useInput((snapshot) => snapshot) : null
1424
+ const draftRef = React.useRef('')
1425
+ draftRef.current = inputState && typeof inputState.draft === 'string' ? inputState.draft : ''
1426
+ // mouse-down collapses the selection, so remember it before the click.
1427
+ const selectionRef = React.useRef(null)
1428
+
1429
+ // Track the visible session: a dialog opened elsewhere is hidden here and
1430
+ // restored when its own session comes back.
1431
+ React.useEffect(() => {
1432
+ if (!sessionId) return
1433
+ noteActiveSession(sessionId)
1434
+ }, [sessionId])
1435
+
1436
+ if (!sessionId || !buttonVisible()) return null
1437
+ return h('button', {
1438
+ type: 'button',
1439
+ className: 'btw-trigger',
1440
+ title: 'btw · 基于当前会话的临时旁问(也可输入 /btw)',
1441
+ 'aria-label': '打开 btw 旁问',
1442
+ // Prevent the button from stealing focus before the click fires; that
1443
+ // focus change is what used to collapse the selection first.
1444
+ onPointerDown: (event) => {
1445
+ selectionRef.current = captureSelection()
1446
+ try { event.preventDefault() } catch { /* synthetic events may not allow it */ }
1447
+ },
1448
+ onMouseDown: (event) => {
1449
+ selectionRef.current = selectionRef.current || captureSelection()
1450
+ try { event.preventDefault() } catch { /* synthetic events may not allow it */ }
1451
+ },
1452
+ onClick: () => openFor(sessionId, selectionRef.current || captureSelection()),
1453
+ }, 'btw')
1454
+ }
1455
+
1456
+ function MessageBody({ message, streaming }) {
1457
+ if (message.text) {
1458
+ const markdown = primitivesOf()
1459
+ const MarkdownText = markdown && typeof markdown.MarkdownText === 'function' ? markdown.MarkdownText : null
1460
+ if (MarkdownText) {
1461
+ return h('div', { className: 'btw-msg-body' },
1462
+ h(MarkdownText, { text: message.text, labels: MD_LABELS }))
1463
+ }
1464
+ return h('div', { className: 'btw-plain' }, message.text)
1465
+ }
1466
+ return streaming ? '思考中…' : ''
1467
+ }
1468
+
1469
+ function MessageActions({ message }) {
1470
+ if (message.state === 'error') {
1471
+ return h('div', { className: 'btw-actions' },
1472
+ message.question ? h('button', {
1473
+ type: 'button',
1474
+ className: 'btw-action',
1475
+ onClick: () => send(message.question),
1476
+ }, '重试') : null)
1477
+ }
1478
+ if (message.state === 'streaming' || !message.text) return null
1479
+ const meta = []
1480
+ if (message.usage) meta.push(h('span', { key: 'usage', className: 'btw-meta', title: '本次调用的 token 用量' }, usageText(message.usage)))
1481
+ if (message.model) meta.push(h('span', { key: 'model', className: 'btw-meta' }, `${message.provider || ''}/${message.model}`))
1482
+ return meta.length ? h('div', { className: 'btw-actions' }, ...meta) : null
1483
+ }
1484
+
1485
+ function MessageList({ current, listRef }) {
1486
+ if (current.messages.length === 0) {
1487
+ return h('div', { className: 'btw-list', ref: listRef },
1488
+ h('div', { className: 'btw-empty' },
1489
+ '基于当前会话问一个临时问题。回答只存在于这个窗口里;确认「送入主会话」后,只写入一份压缩摘要(不会自动写入)。'))
1490
+ }
1491
+ return h('div', { className: 'btw-list', ref: listRef },
1492
+ current.messages.map((message, index) => {
1493
+ if (message.role === 'user') {
1494
+ return h('div', { key: index, className: 'btw-msg btw-msg-user' }, message.text)
1495
+ }
1496
+ const streaming = message.state === 'streaming'
1497
+ const className = 'btw-msg btw-msg-assistant'
1498
+ + (message.state === 'error' ? ' is-error' : '')
1499
+ + (streaming ? ' btw-msg-streaming' : '')
1500
+ return h('div', { key: index, className },
1501
+ h(MessageBody, { message, streaming }),
1502
+ message.error ? h('div', { className: 'btw-msg-error' }, message.error) : null,
1503
+ h(MessageActions, { message }))
1504
+ }))
1505
+ }
1506
+
1507
+ function transferButton(current) {
1508
+ const sent = !!current.sentSignature && current.sentSignature === dialogSignature()
1509
+ const busy = current.transferPhase === 'loading' || current.transferPhase === 'sending'
1510
+ const label = current.transferOpen
1511
+ ? (busy ? '处理中…' : sent ? '完成' : '收起')
1512
+ : sent ? '已送入 ✓' : '送入主会话'
1513
+ const disabled = busy || (!current.transferOpen && (!canTransfer() || sent))
1514
+ return h('button', {
1515
+ type: 'button',
1516
+ className: 'btw-transfer-btn' + (sent ? ' is-sent' : ''),
1517
+ disabled,
1518
+ title: sent
1519
+ ? '这段旁问已送入主会话;没有新的问答前不会重复写入'
1520
+ : canTransfer()
1521
+ ? '把整段旁问压缩成一份记录,预览后再送入主会话'
1522
+ : '先让 btw 回答完一轮',
1523
+ onPointerDown: (event) => event.stopPropagation(),
1524
+ onClick: () => {
1525
+ if (current.transferOpen && !busy) closeTransfer()
1526
+ else openTransfer()
1527
+ },
1528
+ }, label)
1529
+ }
1530
+
1531
+ function TransferPanel({ current }) {
1532
+ const phase = current.transferPhase
1533
+ const busy = phase === 'loading' || phase === 'sending'
1534
+ const chars = current.transferText.length
1535
+ const usage = current.transferUsage ? usageText(current.transferUsage) : ''
1536
+ const canCommit = current.transferText.trim() !== '' && phase !== 'sending' && phase !== 'loading'
1537
+ return h('div', { className: 'btw-transfer-panel' },
1538
+ h('div', { className: 'btw-transfer-head' },
1539
+ h('strong', { className: 'btw-transfer-title' }, '送入主会话'),
1540
+ h('span', { className: 'btw-transfer-sub' }, '只写入这份压缩摘要;主会话之后能读到它,现在不会立刻回复。')),
1541
+ phase === 'loading' ? h('div', { className: 'btw-transfer-loading' }, '正在压缩这次旁问…') : null,
1542
+ phase === 'sending' ? h('div', { className: 'btw-transfer-loading' }, '正在写入主会话…') : null,
1543
+ phase === 'sent' ? h('div', { className: 'btw-transfer-ok' }, '✓ 已写入主会话。之后的对话会带着这份摘要,但不会触发回复。') : null,
1544
+ (phase === 'ready' || phase === 'error' || phase === 'sending') ? h('textarea', {
1545
+ className: 'btw-transfer-text',
1546
+ rows: 8,
1547
+ value: current.transferText,
1548
+ disabled: busy,
1549
+ onChange: (event) => updateTransferText(event.target.value),
1550
+ }) : null,
1551
+ current.transferWarning ? h('div', { className: 'btw-transfer-warn' }, current.transferWarning) : null,
1552
+ current.transferError ? h('div', { className: 'btw-transfer-error' }, current.transferError) : null,
1553
+ h('div', { className: 'btw-transfer-foot' },
1554
+ h('span', { className: 'btw-transfer-note' },
1555
+ `${current.transferTurns} 轮旁问 · ${chars} 字${usage ? ` · ${usage}` : ''}`),
1556
+ h('span', { className: 'btw-grow' }),
1557
+ phase === 'sent'
1558
+ ? h('button', { type: 'button', className: 'btw-action', onClick: closeTransfer }, '完成')
1559
+ : h('button', { type: 'button', className: 'btw-action', onClick: closeTransfer }, '取消'),
1560
+ phase !== 'sent' ? h('button', {
1561
+ type: 'button',
1562
+ className: 'btw-action',
1563
+ disabled: busy,
1564
+ title: '重新压缩(会覆盖当前预览)',
1565
+ onClick: openTransfer,
1566
+ }, '重新生成') : null,
1567
+ phase !== 'sent' ? h('button', {
1568
+ type: 'button',
1569
+ className: 'btw-btn btw-btn-primary',
1570
+ disabled: !canCommit,
1571
+ onClick: commitTransfer,
1572
+ }, phase === 'sending' ? '写入中…' : phase === 'error' ? '重试写入' : '确认送入') : null))
1573
+ }
1574
+
1575
+ function BtwDialog() {
1576
+ const current = useStore()
1577
+ const [draft, setDraft] = React.useState('')
1578
+ const [dragging, setDragging] = React.useState(false)
1579
+ const listRef = React.useRef(null)
1580
+ const rootRef = React.useRef(null)
1581
+ const dragRef = React.useRef(null)
1582
+ const canSend = !current.streaming && draft.trim() !== ''
1583
+
1584
+ // Follow the bottom as the answer streams in.
1585
+ React.useEffect(() => {
1586
+ const node = listRef.current
1587
+ if (node) node.scrollTop = node.scrollHeight
1588
+ }, [current.open, current.messages, current.streaming])
1589
+
1590
+ // Keep the window inside the viewport when the frame is resized.
1591
+ React.useEffect(() => {
1592
+ if (!current.open) return undefined
1593
+ const onResize = () => refitWindow()
1594
+ window.addEventListener('resize', onResize)
1595
+ return () => window.removeEventListener('resize', onResize)
1596
+ }, [current.open])
1597
+
1598
+ // Escape closes, but only while focus is inside our floating window.
1599
+ React.useEffect(() => {
1600
+ if (!current.open) return undefined
1601
+ const onKeyDown = (event) => {
1602
+ if (event.key !== 'Escape') return
1603
+ const root = rootRef.current
1604
+ if (!root) return
1605
+ const active = document.activeElement
1606
+ if (!(active && root.contains(active)) && !root.contains(event.target)) return
1607
+ event.preventDefault()
1608
+ event.stopPropagation()
1609
+ close()
1610
+ }
1611
+ document.addEventListener('keydown', onKeyDown, true)
1612
+ return () => document.removeEventListener('keydown', onKeyDown, true)
1613
+ }, [current.open])
1614
+
1615
+ if (!current.open || !current.win) return null
1616
+ // A dialog opened from another session is hidden, not closed: its context
1617
+ // and selection belong to that session. It reappears when the user
1618
+ // switches back; the store keeps its messages meanwhile.
1619
+ if (current.activeSession && current.sessionId && current.activeSession !== current.sessionId) return null
1620
+
1621
+ const beginPointerDrag = (event, mode) => {
1622
+ if (event.button !== 0 || !current.win) return
1623
+ event.preventDefault()
1624
+ event.stopPropagation()
1625
+ try { event.currentTarget.setPointerCapture(event.pointerId) } catch { /* older engines */ }
1626
+ dragRef.current = {
1627
+ mode,
1628
+ pointerId: event.pointerId,
1629
+ startX: event.clientX,
1630
+ startY: event.clientY,
1631
+ originX: current.win.x,
1632
+ originY: current.win.y,
1633
+ originW: current.win.w,
1634
+ originH: current.win.h,
1635
+ }
1636
+ setDragging(true)
1637
+ }
1638
+
1639
+ const onPointerMove = (event) => {
1640
+ const drag = dragRef.current
1641
+ if (!drag || drag.pointerId !== event.pointerId) return
1642
+ event.preventDefault()
1643
+ const dx = event.clientX - drag.startX
1644
+ const dy = event.clientY - drag.startY
1645
+ if (drag.mode === 'move') {
1646
+ setWindow({ x: drag.originX + dx, y: drag.originY + dy })
1647
+ } else if (drag.mode === 'se') {
1648
+ setWindow({ w: drag.originW + dx, h: drag.originH + dy })
1649
+ } else if (drag.mode === 'e') {
1650
+ setWindow({ w: drag.originW + dx })
1651
+ } else if (drag.mode === 's') {
1652
+ setWindow({ h: drag.originH + dy })
1653
+ }
1654
+ }
1655
+
1656
+ const endPointerDrag = (event) => {
1657
+ const drag = dragRef.current
1658
+ if (!drag || drag.pointerId !== event.pointerId) return
1659
+ dragRef.current = null
1660
+ setDragging(false)
1661
+ commitWindow()
1662
+ }
1663
+
1664
+ const submit = () => {
1665
+ const text = draft.trim()
1666
+ if (!text || current.streaming) return
1667
+ setDraft('')
1668
+ send(text)
1669
+ }
1670
+
1671
+ const onKeyDown = (event) => {
1672
+ if (event.key !== 'Enter' || event.shiftKey) return
1673
+ const composing = event.nativeEvent && event.nativeEvent.isComposing === true
1674
+ if (composing) return
1675
+ event.preventDefault()
1676
+ submit()
1677
+ }
1678
+
1679
+ // Every handle shares the same move/up plumbing (pointer capture keeps
1680
+ // events flowing even when the pointer leaves the tiny handle).
1681
+ const pointerHandlers = {
1682
+ onPointerMove,
1683
+ onPointerUp: endPointerDrag,
1684
+ onPointerCancel: endPointerDrag,
1685
+ onLostPointerCapture: endPointerDrag,
1686
+ }
1687
+
1688
+ return h('div', { className: 'btw-layer', ref: rootRef },
1689
+ h('div', {
1690
+ className: 'btw-window' + (dragging ? ' is-dragging' : ''),
1691
+ style: {
1692
+ left: `${current.win.x}px`,
1693
+ top: `${current.win.y}px`,
1694
+ width: `${current.win.w}px`,
1695
+ height: `${current.win.h}px`,
1696
+ },
1697
+ role: 'dialog',
1698
+ 'aria-modal': 'false',
1699
+ 'aria-label': 'btw 旁问',
1700
+ },
1701
+ h('div', Object.assign({
1702
+ className: 'btw-head',
1703
+ onPointerDown: (event) => beginPointerDrag(event, 'move'),
1704
+ onDoubleClick: (event) => {
1705
+ event.preventDefault()
1706
+ event.stopPropagation()
1707
+ toggleMaximize()
1708
+ },
1709
+ title: '拖动移动 · 双击最大化/还原',
1710
+ }, pointerHandlers),
1711
+ h('div', { className: 'btw-head-text' },
1712
+ h('strong', { className: 'btw-title' }, 'btw · 旁问'),
1713
+ h('span', { className: 'btw-head-hint' }, '基于主会话已提交内容 · 回答默认不写入,可压缩后送入 · 拖动移动,拖边角改大小')),
1714
+ transferButton(current),
1715
+ h('button', {
1716
+ type: 'button',
1717
+ className: 'btw-close',
1718
+ 'aria-label': '关闭',
1719
+ onPointerDown: (event) => event.stopPropagation(),
1720
+ onClick: close,
1721
+ }, '×')),
1722
+ h('div', { className: 'btw-context' },
1723
+ h('select', {
1724
+ className: 'btw-select',
1725
+ value: current.rangeValue,
1726
+ disabled: current.streaming,
1727
+ title: '选择随问题发给 btw 的会话历史范围',
1728
+ onChange: (event) => setRangeValue(event.target.value),
1729
+ }, RANGE_OPTIONS.map((option) => h('option', { key: option.value, value: option.value }, option.label))),
1730
+ h('select', {
1731
+ className: 'btw-select btw-model',
1732
+ value: current.model ? `${current.model.provider}\u0000${current.model.model}` : '',
1733
+ disabled: current.streaming,
1734
+ title: current.models.error
1735
+ ? `模型目录读取失败:${current.models.error}(可选「自定义模型…」直接填 provider/model)`
1736
+ : '只影响 btw 这次旁问,不改变主会话模型;目录里没有的模型可选「自定义模型…」',
1737
+ onChange: (event) => {
1738
+ const raw = event.target.value
1739
+ if (raw === '__custom__') return openCustomModel()
1740
+ if (!raw) return setModel(null, null)
1741
+ const at = raw.indexOf('\u0000')
1742
+ if (at > 0) setModel(raw.slice(0, at), raw.slice(at + 1))
1743
+ },
1744
+ },
1745
+ h('option', { key: '', value: '' }, current.models.loading ? '读取模型…' : '跟随会话模型'),
1746
+ current.models.providers.filter((provider) => provider.models.length > 0).map((provider) => h('optgroup', {
1747
+ key: provider.id,
1748
+ label: provider.name || provider.id,
1749
+ }, provider.models.map((model) => h('option', {
1750
+ key: `${provider.id}\u0000${model.id}`,
1751
+ value: `${provider.id}\u0000${model.id}`,
1752
+ }, model.name || model.id)))),
1753
+ h('option', { key: '__custom__', value: '__custom__' }, '自定义模型…')),
1754
+ h('span', {
1755
+ className: 'btw-ctx-stats' + (current.context.error ? ' is-error' : ''),
1756
+ title: '本次旁问会携带的会话快照',
1757
+ }, contextSummary(current.context)),
1758
+ h('button', {
1759
+ type: 'button',
1760
+ className: 'btw-refresh',
1761
+ disabled: current.context.loading || current.streaming,
1762
+ title: '重新读取当前会话快照',
1763
+ onClick: refreshContext,
1764
+ }, current.context.loading ? '…' : '刷新'),
1765
+ h('button', {
1766
+ type: 'button',
1767
+ className: 'btw-refresh btw-gear' + (current.settingsOpen ? ' is-active' : ''),
1768
+ title: 'btw 设置(仅影响本窗口的调用)',
1769
+ 'aria-expanded': current.settingsOpen ? 'true' : 'false',
1770
+ onClick: toggleSettings,
1771
+ }, '⚙')),
1772
+ current.focus ? h('div', { className: 'btw-focus' },
1773
+ h('span', { className: 'btw-focus-label' }, '引用选中文本'),
1774
+ h('span', { className: 'btw-focus-text', title: focusPreview(current.focus) }, focusPreview(current.focus)),
1775
+ h('button', {
1776
+ type: 'button',
1777
+ className: 'btw-focus-clear',
1778
+ title: '清除引用',
1779
+ 'aria-label': '清除引用',
1780
+ onClick: clearFocus,
1781
+ }, '×')) : null,
1782
+ current.customOpen ? h('div', { className: 'btw-context btw-custom' },
1783
+ h('input', {
1784
+ className: 'btw-custom-input',
1785
+ value: current.customDraft.provider,
1786
+ placeholder: 'provider(如 opencode-go)',
1787
+ onChange: (event) => updateCustomDraft({ provider: event.target.value }),
1788
+ onKeyDown: (event) => { if (event.key === 'Enter') applyCustomModel() },
1789
+ }),
1790
+ h('input', {
1791
+ className: 'btw-custom-input',
1792
+ value: current.customDraft.model,
1793
+ placeholder: 'model(如 deepseek-v4.1-flash)',
1794
+ onChange: (event) => updateCustomDraft({ model: event.target.value }),
1795
+ onKeyDown: (event) => { if (event.key === 'Enter') applyCustomModel() },
1796
+ }),
1797
+ h('button', {
1798
+ type: 'button',
1799
+ className: 'btw-refresh',
1800
+ disabled: !current.customDraft.provider.trim() || !current.customDraft.model.trim(),
1801
+ title: '目录里没有列出的模型也可以直接填 provider/model',
1802
+ onClick: applyCustomModel,
1803
+ }, '应用'),
1804
+ h('button', {
1805
+ type: 'button',
1806
+ className: 'btw-refresh',
1807
+ onClick: () => { state.customOpen = false; emit() },
1808
+ }, '取消')) : null,
1809
+ current.settingsOpen ? h('div', { className: 'btw-settings' },
1810
+ h('div', { className: 'btw-field' },
1811
+ h('span', {}, '温度'),
1812
+ h('input', {
1813
+ type: 'number', min: 0, max: 2, step: 0.1, placeholder: '默认',
1814
+ value: current.settings.temperature === null ? '' : String(current.settings.temperature),
1815
+ onChange: (event) => {
1816
+ const raw = event.target.value
1817
+ const value = raw === '' ? null : Number(raw)
1818
+ updateSettings({ temperature: Number.isFinite(value) && value >= 0 && value <= 2 ? value : null })
1819
+ },
1820
+ })),
1821
+ h('div', { className: 'btw-field' },
1822
+ h('span', {}, '最大输出'),
1823
+ h('input', {
1824
+ type: 'number', min: 1, max: 32768, step: 1, placeholder: '默认',
1825
+ value: current.settings.maxTokens === null ? '' : String(current.settings.maxTokens),
1826
+ onChange: (event) => {
1827
+ const raw = event.target.value
1828
+ const value = Number(raw)
1829
+ updateSettings({ maxTokens: raw !== '' && Number.isInteger(value) && value > 0 ? Math.min(value, 32768) : null })
1830
+ },
1831
+ })),
1832
+ h('div', { className: 'btw-field' },
1833
+ h('select', {
1834
+ value: current.settings.reasoningEffort,
1835
+ title: '只对支持推理强度的模型生效',
1836
+ onChange: (event) => updateSettings({ reasoningEffort: event.target.value }),
1837
+ }, REASONING_OPTIONS.map((option) => h('option', { key: option.value, value: option.value }, option.label)))),
1838
+ h('label', { className: 'btw-field btw-field-wide' },
1839
+ h('input', {
1840
+ type: 'checkbox',
1841
+ checked: buttonVisible(),
1842
+ onChange: (event) => {
1843
+ try {
1844
+ if (event.target.checked) window.localStorage.removeItem(BUTTON_KEY)
1845
+ else window.localStorage.setItem(BUTTON_KEY, '0')
1846
+ } catch { /* storage disabled */ }
1847
+ emit()
1848
+ },
1849
+ }),
1850
+ h('span', {}, '在输入框旁显示 btw 按钮(/btw 始终可用)')),
1851
+ h('div', { className: 'btw-settings-actions' },
1852
+ h('span', { className: 'btw-settings-note' }, '只影响 btw,不改主会话'),
1853
+ h('button', { type: 'button', className: 'btw-action', onClick: resetSettings }, '恢复默认'))) : null,
1854
+ current.transferOpen ? h(TransferPanel, { current }) : h(MessageList, { current, listRef }),
1855
+ h('div', { className: 'btw-compose' },
1856
+ h('div', { className: 'btw-input-row' },
1857
+ h('textarea', {
1858
+ className: 'btw-input',
1859
+ value: draft,
1860
+ rows: 2,
1861
+ autoFocus: true,
1862
+ placeholder: '问当前会话一个临时问题…(Enter 发送 / Shift+Enter 换行)',
1863
+ disabled: current.streaming,
1864
+ onChange: (event) => setDraft(event.target.value),
1865
+ onKeyDown,
1866
+ }),
1867
+ current.streaming
1868
+ ? h('button', { type: 'button', className: 'btw-btn', onClick: stop }, '停止')
1869
+ : h('button', {
1870
+ type: 'button',
1871
+ className: 'btw-btn btw-btn-primary',
1872
+ disabled: !canSend,
1873
+ onClick: submit,
1874
+ }, '发送')),
1875
+ h('div', { className: 'btw-hint' },
1876
+ current.streaming ? '生成中… 可点「停止」中止' : '切到别的会话时隐藏、切回恢复;点 × 才清空 · 回答不会自动进入主会话,确认「送入」后只写入压缩摘要')),
1877
+ h('div', Object.assign({ className: 'btw-resize btw-resize-e', onPointerDown: (event) => beginPointerDrag(event, 'e') }, pointerHandlers)),
1878
+ h('div', Object.assign({ className: 'btw-resize btw-resize-s', onPointerDown: (event) => beginPointerDrag(event, 's') }, pointerHandlers)),
1879
+ h('div', Object.assign({ className: 'btw-resize btw-resize-se', onPointerDown: (event) => beginPointerDrag(event, 'se') }, pointerHandlers)),
1880
+ ))
1881
+ }
1882
+
1883
+ /* --------------------------------------------------------------- apply */
1884
+
1885
+ function serviceOf(scope, name) {
1886
+ if (!scope) return null
1887
+ if (typeof scope.get === 'function') {
1888
+ try {
1889
+ const service = scope.get(name)
1890
+ if (service) return service
1891
+ } catch { /* fall through to the property */ }
1892
+ }
1893
+ return scope[name] || null
1894
+ }
1895
+
1896
+ function warn(label, error) {
1897
+ try { console.warn('[dsh-btw] ' + label, error) } catch { /* console may be unavailable */ }
1898
+ }
1899
+
1900
+ function apply(ctx) {
1901
+ try { installStyles() } catch (error) { warn('style injection failed', error) }
1902
+ if (!ctx || typeof ctx.inject !== 'function') return
1903
+
1904
+ // Keep the last real selection so a click that collapses it still quotes
1905
+ // the text the user picked moments ago.
1906
+ try {
1907
+ const install = () => {
1908
+ if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') return () => {}
1909
+ document.addEventListener('selectionchange', rememberSelection)
1910
+ return () => document.removeEventListener('selectionchange', rememberSelection)
1911
+ }
1912
+ if (typeof ctx.effect === 'function') ctx.effect(install, 'btw: selection cache')
1913
+ } catch (error) { warn('selection listener failed', error) }
1914
+
1915
+ // Defensive rule for every registration: a throw here must fail only this
1916
+ // plugin entry, never the core plugin whose slot is being declared (a slot
1917
+ // inject callback runs inside the declaring plugin's activation).
1918
+ try {
1919
+ ctx.inject(['slots'], (scope) => {
1920
+ const slots = serviceOf(scope, 'slots')
1921
+ if (!slots) return
1922
+ const register = (slotName, options, Component, label) => {
1923
+ scope.effect(() => slots.inject(slotName, () => {
1924
+ try {
1925
+ return slots.register(options, Component)
1926
+ } catch (error) {
1927
+ warn(`${label} registration failed`, error)
1928
+ return () => {}
1929
+ }
1930
+ }))
1931
+ }
1932
+ register('conversation.input.right',
1933
+ { name: 'conversation.input.right', id: 'btw:button', order: 40 }, BtwButton, 'input.right')
1934
+ register('shell.overlay',
1935
+ { name: 'shell.overlay', id: 'btw:dialog', order: 50 }, BtwDialog, 'shell.overlay')
1936
+ })
1937
+ } catch (error) { warn('slots activation failed', error) }
1938
+
1939
+ // Ctrl+Alt+B is deliberately NOT registered: DSH core already binds it to
1940
+ // sidebar.left.toggle / sidebar.right.toggle, and the shortcut registry
1941
+ // rejects duplicate default bindings at boot (which used to fail core
1942
+ // layout activation). The button and `/btw` remain the two entry points.
1943
+
1944
+ // `/btw` in the composer command menu. The action callback receives the
1945
+ // session it was invoked from, so this path needs no lastSessionId.
1946
+ try {
1947
+ ctx.inject(['commandUi'], (scope) => {
1948
+ const commandUi = serviceOf(scope, 'commandUi')
1949
+ if (!commandUi || typeof commandUi.register !== 'function') return
1950
+ scope.effect(() => {
1951
+ try {
1952
+ return commandUi.register({
1953
+ name: 'btw',
1954
+ label: () => 'btw 旁问',
1955
+ available: (session) => !!session && typeof session.sessionId === 'string',
1956
+ ui: {
1957
+ kind: 'action',
1958
+ run: (session) => {
1959
+ if (session && typeof session.sessionId === 'string') {
1960
+ openFor(session.sessionId, captureSelection())
1961
+ }
1962
+ },
1963
+ },
1964
+ })
1965
+ } catch (error) {
1966
+ warn('commandUi registration failed', error)
1967
+ return () => {}
1968
+ }
1969
+ })
1970
+ })
1971
+ } catch (error) { warn('commandUi activation failed', error) }
1972
+ }
1973
+
1974
+ exports.apply = apply
1975
+ // Only `slots` gates activation; commandUi is picked up lazily below so an
1976
+ // older shell without it still gets the button and dialog.
1977
+ exports.inject = ['slots']
1978
+ return module.exports
1979
+ },
1980
+ })