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/CHANGELOG.md +92 -0
- package/README.md +205 -2
- package/cordis.patch.yml +14 -0
- package/lib/client.js +1980 -0
- package/lib/index.js +829 -0
- package/lib/logic.js +628 -0
- package/package.json +41 -4
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
|
+
})
|