@lsfcy/dsh-composer-predictions 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.en.md +71 -0
- package/README.md +71 -2
- package/build.mjs +26 -0
- package/client.js +1286 -0
- package/cordis.patch.yml +6 -0
- package/icon.svg +8 -0
- package/index.js +891 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +57 -3
- package/src/ghost.js +317 -0
- package/src/main.js +958 -0
package/src/main.js
ADDED
|
@@ -0,0 +1,958 @@
|
|
|
1
|
+
// Browser half body. build.mjs concatenates src/ghost.js (which defines
|
|
2
|
+
// globalThis.createComposerGhost) in front of this file and wraps the pair in
|
|
3
|
+
// the lazy-CJS bundle envelope, so `require` here is the module loader's.
|
|
4
|
+
//
|
|
5
|
+
// What this registers: one entry in the additive "conversation.input.dock"
|
|
6
|
+
// slot (kind: list, scope: session). The dock renders inside the composer stack
|
|
7
|
+
// and hands every entry { session, input } plus the standard session kit —
|
|
8
|
+
// including `sessionId` and `inputActions`. That is everything a prediction
|
|
9
|
+
// needs:
|
|
10
|
+
//
|
|
11
|
+
// running: true -> a turn is in flight; drop any suggestion
|
|
12
|
+
// running: false -> a turn just settled; ask the host for a prediction
|
|
13
|
+
// draft !== '' -> the user is writing; dismiss it and never refetch
|
|
14
|
+
//
|
|
15
|
+
// The visible suggestion is NOT React: src/ghost.js paints it into the Lexical
|
|
16
|
+
// composer (`[data-composer-input]`), because the only in-box seat DSH exposes
|
|
17
|
+
// is a full composer takeover. This entry renders a hidden anchor, which lets
|
|
18
|
+
// the layer find the composer seat that owns THIS session (so a second mounted
|
|
19
|
+
// conversation can never paint into the wrong editor) without adding a row,
|
|
20
|
+
// margin, or hint line that would shift the composer.
|
|
21
|
+
|
|
22
|
+
const react = require('react')
|
|
23
|
+
const h = react.createElement
|
|
24
|
+
const createGhostLayer = globalThis.createComposerGhost
|
|
25
|
+
|
|
26
|
+
const PREDICT_PATH = '/composer-predictions/predict'
|
|
27
|
+
const CONFIG_PATH = '/composer-predictions/config'
|
|
28
|
+
const OPTIONS_PATH = '/composer-predictions/options'
|
|
29
|
+
const CLIENT_TIMEOUT_MS = 30_000
|
|
30
|
+
const ATTACH_FRAMES = 120
|
|
31
|
+
|
|
32
|
+
const selectRunning = (state) => !!(state && state.running)
|
|
33
|
+
const noSession = () => undefined
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* One config snapshot shared by the dock (whether to ask at all) and the
|
|
37
|
+
* Settings page (what to show). The host owns persistence; this store only
|
|
38
|
+
* caches the last read so a re-render never refetches.
|
|
39
|
+
*/
|
|
40
|
+
const configStore = (() => {
|
|
41
|
+
let snapshot = null
|
|
42
|
+
let pending = null
|
|
43
|
+
let options = null
|
|
44
|
+
let optionsPending = null
|
|
45
|
+
const listeners = new Set()
|
|
46
|
+
const publish = (next) => {
|
|
47
|
+
snapshot = next
|
|
48
|
+
for (const listener of listeners) listener(next)
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
get: () => snapshot,
|
|
52
|
+
subscribe(listener) {
|
|
53
|
+
listeners.add(listener)
|
|
54
|
+
return () => listeners.delete(listener)
|
|
55
|
+
},
|
|
56
|
+
load() {
|
|
57
|
+
if (pending) return pending
|
|
58
|
+
pending = fetch(CONFIG_PATH, {
|
|
59
|
+
headers: { accept: 'application/json' },
|
|
60
|
+
cache: 'no-store',
|
|
61
|
+
credentials: 'same-origin',
|
|
62
|
+
})
|
|
63
|
+
.then((response) => (response.ok ? response.json() : Promise.reject(new Error('HTTP ' + response.status))))
|
|
64
|
+
.then((payload) => {
|
|
65
|
+
if (payload && payload.config) publish(payload.config)
|
|
66
|
+
return snapshot
|
|
67
|
+
})
|
|
68
|
+
.catch((error) => {
|
|
69
|
+
console.warn(
|
|
70
|
+
'[composer-predictions] config load failed:',
|
|
71
|
+
error && error.message ? error.message : error,
|
|
72
|
+
)
|
|
73
|
+
// A stale host (client updated first) must not silently disable
|
|
74
|
+
// predictions: fall back to the default-on behaviour and let the
|
|
75
|
+
// settings page explain that persistence is unavailable.
|
|
76
|
+
publish({ enabled: true, unavailable: true })
|
|
77
|
+
return snapshot
|
|
78
|
+
})
|
|
79
|
+
.finally(() => {
|
|
80
|
+
pending = null
|
|
81
|
+
})
|
|
82
|
+
return pending
|
|
83
|
+
},
|
|
84
|
+
getOptions: () => options,
|
|
85
|
+
loadOptions(force = false) {
|
|
86
|
+
if (optionsPending) return optionsPending
|
|
87
|
+
if (!force && options) return Promise.resolve(options)
|
|
88
|
+
optionsPending = fetch(OPTIONS_PATH, {
|
|
89
|
+
headers: { accept: 'application/json' },
|
|
90
|
+
cache: 'no-store',
|
|
91
|
+
credentials: 'same-origin',
|
|
92
|
+
})
|
|
93
|
+
.then((response) => (response.ok ? response.json() : Promise.reject(new Error('HTTP ' + response.status))))
|
|
94
|
+
.then((payload) => {
|
|
95
|
+
options = {
|
|
96
|
+
providers: Array.isArray(payload && payload.providers) ? payload.providers : [],
|
|
97
|
+
models: Array.isArray(payload && payload.models) ? payload.models : [],
|
|
98
|
+
autoProvider: payload && typeof payload.autoProvider === 'string' ? payload.autoProvider : '',
|
|
99
|
+
}
|
|
100
|
+
return options
|
|
101
|
+
})
|
|
102
|
+
.catch((error) => {
|
|
103
|
+
console.warn(
|
|
104
|
+
'[composer-predictions] options load failed:',
|
|
105
|
+
error && error.message ? error.message : error,
|
|
106
|
+
)
|
|
107
|
+
options = { providers: [], models: [], autoProvider: '' }
|
|
108
|
+
return options
|
|
109
|
+
})
|
|
110
|
+
.finally(() => {
|
|
111
|
+
optionsPending = null
|
|
112
|
+
})
|
|
113
|
+
return optionsPending
|
|
114
|
+
},
|
|
115
|
+
async save(patch) {
|
|
116
|
+
const response = await fetch(CONFIG_PATH, {
|
|
117
|
+
method: 'POST',
|
|
118
|
+
headers: { 'content-type': 'application/json' },
|
|
119
|
+
body: JSON.stringify({ config: patch }),
|
|
120
|
+
cache: 'no-store',
|
|
121
|
+
credentials: 'same-origin',
|
|
122
|
+
})
|
|
123
|
+
const payload = await response.json().catch(() => ({}))
|
|
124
|
+
if (!response.ok) throw new Error(payload && payload.error ? payload.error : 'HTTP ' + response.status)
|
|
125
|
+
if (payload && payload.config) publish(payload.config)
|
|
126
|
+
return payload && payload.config
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
})()
|
|
130
|
+
|
|
131
|
+
function useConfig() {
|
|
132
|
+
const [snapshot, setSnapshot] = react.useState(configStore.get)
|
|
133
|
+
react.useEffect(() => configStore.subscribe(setSnapshot), [])
|
|
134
|
+
react.useEffect(() => {
|
|
135
|
+
if (configStore.get() === null) configStore.load()
|
|
136
|
+
}, [])
|
|
137
|
+
return snapshot
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const COPY = {
|
|
141
|
+
zh: {
|
|
142
|
+
section: '输入框预测',
|
|
143
|
+
title: '输入框预测',
|
|
144
|
+
intro: '一轮回答结束后,在空的输入框里用灰字给出你接下来可能发送的消息。Tab 接受,Esc 忽略。',
|
|
145
|
+
enabled: '启用预测',
|
|
146
|
+
enabledHint: '关闭后不再请求模型,也不显示灰字;已显示的建议会立即隐藏。',
|
|
147
|
+
provider: '模型 provider',
|
|
148
|
+
providerHint: '留空 = 自动使用当前选中的 provider。',
|
|
149
|
+
model: '模型 id',
|
|
150
|
+
modelHint: '留空 = 自动选该 provider 下最便宜的型号。',
|
|
151
|
+
effort: '推理档',
|
|
152
|
+
effortHint: '留空 = 自动选支持的最省一档。',
|
|
153
|
+
auto: '自动',
|
|
154
|
+
save: '保存',
|
|
155
|
+
reset: '恢复默认',
|
|
156
|
+
saving: '保存中…',
|
|
157
|
+
saved: '已保存',
|
|
158
|
+
failed: '保存失败:',
|
|
159
|
+
loading: '正在读取配置…',
|
|
160
|
+
readOnly: '当前运行时不支持持久化配置。',
|
|
161
|
+
},
|
|
162
|
+
en: {
|
|
163
|
+
section: 'Composer prediction',
|
|
164
|
+
title: 'Composer prediction',
|
|
165
|
+
intro: 'After a turn settles, the empty composer shows a dim next-message suggestion. Tab accepts it; Escape dismisses it.',
|
|
166
|
+
enabled: 'Enable predictions',
|
|
167
|
+
enabledHint: 'Off stops every request and hides the ghost immediately.',
|
|
168
|
+
provider: 'Model provider',
|
|
169
|
+
providerHint: 'Empty = the provider you are currently using.',
|
|
170
|
+
model: 'Model id',
|
|
171
|
+
modelHint: 'Empty = the cheapest model on that provider.',
|
|
172
|
+
effort: 'Reasoning effort',
|
|
173
|
+
effortHint: 'Empty = the cheapest effort the model supports.',
|
|
174
|
+
auto: 'Auto',
|
|
175
|
+
save: 'Save',
|
|
176
|
+
reset: 'Reset to defaults',
|
|
177
|
+
saving: 'Saving…',
|
|
178
|
+
saved: 'Saved',
|
|
179
|
+
failed: 'Save failed: ',
|
|
180
|
+
loading: 'Loading configuration…',
|
|
181
|
+
readOnly: 'This runtime cannot persist configuration.',
|
|
182
|
+
},
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// The app's locale service mirrors the active language onto <html lang>; the
|
|
186
|
+
// Electron renderer's navigator.language stays en-US, so reading it would keep
|
|
187
|
+
// an English page under a Chinese app. Track the attribute and re-render on it.
|
|
188
|
+
function currentLocale() {
|
|
189
|
+
if (typeof document !== 'undefined' && document.documentElement && document.documentElement.lang) {
|
|
190
|
+
return document.documentElement.lang
|
|
191
|
+
}
|
|
192
|
+
return (typeof navigator !== 'undefined' && navigator.language) || 'en'
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function copy(locale) {
|
|
196
|
+
const language = String(locale || currentLocale()).toLowerCase()
|
|
197
|
+
return language.indexOf('zh') === 0 ? COPY.zh : COPY.en
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function useLocaleId() {
|
|
201
|
+
const [locale, setLocale] = react.useState(currentLocale)
|
|
202
|
+
react.useEffect(() => {
|
|
203
|
+
if (typeof document === 'undefined' || !document.documentElement) return undefined
|
|
204
|
+
const element = document.documentElement
|
|
205
|
+
const sync = () => setLocale(currentLocale())
|
|
206
|
+
const observer = new MutationObserver(sync)
|
|
207
|
+
observer.observe(element, { attributes: true, attributeFilter: ['lang'] })
|
|
208
|
+
sync()
|
|
209
|
+
return () => observer.disconnect()
|
|
210
|
+
}, [])
|
|
211
|
+
return locale
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const FIELD_STYLE = {
|
|
215
|
+
width: '100%',
|
|
216
|
+
boxSizing: 'border-box',
|
|
217
|
+
height: 34,
|
|
218
|
+
padding: '0 10px',
|
|
219
|
+
borderRadius: 8,
|
|
220
|
+
border: '1px solid var(--dsw-alias-border-l2, #e5e6eb)',
|
|
221
|
+
background: 'var(--dsw-alias-bg-module-platform, #fff)',
|
|
222
|
+
color: 'var(--dsw-alias-label-primary, #1d2129)',
|
|
223
|
+
font: 'inherit',
|
|
224
|
+
fontSize: 13,
|
|
225
|
+
outline: 'none',
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const CARD_STYLE = {
|
|
229
|
+
display: 'flex',
|
|
230
|
+
flexDirection: 'column',
|
|
231
|
+
gap: 18,
|
|
232
|
+
padding: '18px 20px',
|
|
233
|
+
borderRadius: 12,
|
|
234
|
+
border: '1px solid var(--dsw-alias-border-l2, #e5e6eb)',
|
|
235
|
+
background: 'var(--dsw-alias-bg-layer-3, transparent)',
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const LABEL_STYLE = {
|
|
239
|
+
display: 'flex',
|
|
240
|
+
flexDirection: 'column',
|
|
241
|
+
gap: 6,
|
|
242
|
+
fontSize: 13,
|
|
243
|
+
color: 'var(--dsw-alias-label-primary, #1d2129)',
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const HINT_STYLE = { fontSize: 12, lineHeight: '18px', color: 'var(--dsw-alias-label-tertiary, #86909c)' }
|
|
247
|
+
|
|
248
|
+
function Switch({ checked, label, onToggle }) {
|
|
249
|
+
return h(
|
|
250
|
+
'button',
|
|
251
|
+
{
|
|
252
|
+
type: 'button',
|
|
253
|
+
role: 'switch',
|
|
254
|
+
'aria-checked': checked,
|
|
255
|
+
'aria-label': label,
|
|
256
|
+
onClick: () => onToggle(!checked),
|
|
257
|
+
style: {
|
|
258
|
+
position: 'relative',
|
|
259
|
+
flex: 'none',
|
|
260
|
+
width: 40,
|
|
261
|
+
height: 22,
|
|
262
|
+
padding: 0,
|
|
263
|
+
border: 'none',
|
|
264
|
+
borderRadius: 999,
|
|
265
|
+
cursor: 'pointer',
|
|
266
|
+
background: checked
|
|
267
|
+
? 'var(--dsw-alias-state-business-primary, #2f6fed)'
|
|
268
|
+
: 'var(--dsw-alias-border-l2, #c9cdd4)',
|
|
269
|
+
transition: 'background .15s ease',
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
h('span', {
|
|
273
|
+
style: {
|
|
274
|
+
position: 'absolute',
|
|
275
|
+
top: 2,
|
|
276
|
+
left: checked ? 20 : 2,
|
|
277
|
+
width: 18,
|
|
278
|
+
height: 18,
|
|
279
|
+
borderRadius: '50%',
|
|
280
|
+
background: '#fff',
|
|
281
|
+
transition: 'left .15s ease',
|
|
282
|
+
boxShadow: '0 1px 2px rgba(0,0,0,.2)',
|
|
283
|
+
},
|
|
284
|
+
}),
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function Field({ label, hint, children }) {
|
|
289
|
+
return h('label', { style: LABEL_STYLE }, h('span', null, label), children, hint ? h('span', { style: HINT_STYLE }, hint) : null)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* A text input backed by a datalist: clicking shows the known providers or
|
|
294
|
+
* models, typing still accepts a value the catalog does not advertise.
|
|
295
|
+
*/
|
|
296
|
+
const POPUP_STYLE = {
|
|
297
|
+
position: 'absolute',
|
|
298
|
+
top: 'calc(100% + 4px)',
|
|
299
|
+
left: 0,
|
|
300
|
+
right: 0,
|
|
301
|
+
zIndex: 40,
|
|
302
|
+
padding: 4,
|
|
303
|
+
borderRadius: 10,
|
|
304
|
+
border: '1px solid var(--dsw-alias-border-l2, #e5e6eb)',
|
|
305
|
+
background: 'var(--dsw-alias-bg-module-platform, #fff)',
|
|
306
|
+
boxShadow: '0 8px 24px rgba(0, 0, 0, .12)',
|
|
307
|
+
maxHeight: 240,
|
|
308
|
+
overflowY: 'auto',
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function Chevron({ open }) {
|
|
312
|
+
return h(
|
|
313
|
+
'svg',
|
|
314
|
+
{
|
|
315
|
+
width: 12,
|
|
316
|
+
height: 12,
|
|
317
|
+
viewBox: '0 0 16 16',
|
|
318
|
+
fill: 'none',
|
|
319
|
+
stroke: 'currentColor',
|
|
320
|
+
strokeWidth: 1.8,
|
|
321
|
+
style: { transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .15s ease' },
|
|
322
|
+
},
|
|
323
|
+
h('path', { d: 'M4 6l4 4 4-4', strokeLinecap: 'round', strokeLinejoin: 'round' }),
|
|
324
|
+
)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* One themed combobox: the input stays editable (when `allowCustom`), the
|
|
329
|
+
* popup is a token-styled list rather than the OS select/datalist chrome.
|
|
330
|
+
*/
|
|
331
|
+
function Combo({ label, hint, value, placeholder, options, allowCustom = true, onChange }) {
|
|
332
|
+
const [open, setOpen] = react.useState(false)
|
|
333
|
+
const [active, setActive] = react.useState(-1)
|
|
334
|
+
const rootRef = react.useRef(null)
|
|
335
|
+
|
|
336
|
+
react.useEffect(() => {
|
|
337
|
+
if (!open) return undefined
|
|
338
|
+
const close = (event) => {
|
|
339
|
+
if (rootRef.current && !rootRef.current.contains(event.target)) setOpen(false)
|
|
340
|
+
}
|
|
341
|
+
document.addEventListener('mousedown', close)
|
|
342
|
+
return () => document.removeEventListener('mousedown', close)
|
|
343
|
+
}, [open])
|
|
344
|
+
|
|
345
|
+
const choose = (next) => {
|
|
346
|
+
onChange(next)
|
|
347
|
+
setOpen(false)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return h(
|
|
351
|
+
Field,
|
|
352
|
+
{ label, hint },
|
|
353
|
+
h(
|
|
354
|
+
'div',
|
|
355
|
+
{ ref: rootRef, style: { position: 'relative' } },
|
|
356
|
+
h('input', {
|
|
357
|
+
type: 'text',
|
|
358
|
+
value,
|
|
359
|
+
placeholder,
|
|
360
|
+
readOnly: !allowCustom,
|
|
361
|
+
autoComplete: 'off',
|
|
362
|
+
onChange: (event) => {
|
|
363
|
+
if (!allowCustom) return
|
|
364
|
+
onChange(event.target.value)
|
|
365
|
+
setOpen(true)
|
|
366
|
+
},
|
|
367
|
+
onClick: () => setOpen((current) => !current),
|
|
368
|
+
onKeyDown: (event) => {
|
|
369
|
+
if (event.key === 'Escape') {
|
|
370
|
+
setOpen(false)
|
|
371
|
+
return
|
|
372
|
+
}
|
|
373
|
+
if (event.key === 'ArrowDown') {
|
|
374
|
+
event.preventDefault()
|
|
375
|
+
setOpen(true)
|
|
376
|
+
setActive((index) => Math.min(options.length - 1, index + 1))
|
|
377
|
+
} else if (event.key === 'ArrowUp') {
|
|
378
|
+
event.preventDefault()
|
|
379
|
+
setActive((index) => Math.max(0, index - 1))
|
|
380
|
+
} else if (event.key === 'Enter' && open && active >= 0 && options[active]) {
|
|
381
|
+
event.preventDefault()
|
|
382
|
+
choose(options[active].value)
|
|
383
|
+
}
|
|
384
|
+
},
|
|
385
|
+
style: {
|
|
386
|
+
...FIELD_STYLE,
|
|
387
|
+
paddingRight: 34,
|
|
388
|
+
cursor: allowCustom ? 'text' : 'pointer',
|
|
389
|
+
},
|
|
390
|
+
}),
|
|
391
|
+
h(
|
|
392
|
+
'span',
|
|
393
|
+
{
|
|
394
|
+
style: {
|
|
395
|
+
position: 'absolute',
|
|
396
|
+
right: 10,
|
|
397
|
+
top: '50%',
|
|
398
|
+
transform: 'translateY(-50%)',
|
|
399
|
+
display: 'flex',
|
|
400
|
+
pointerEvents: 'none',
|
|
401
|
+
color: 'var(--dsw-alias-label-tertiary, #86909c)',
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
h(Chevron, { open }),
|
|
405
|
+
),
|
|
406
|
+
open && options.length > 0
|
|
407
|
+
? h(
|
|
408
|
+
'div',
|
|
409
|
+
{ style: POPUP_STYLE },
|
|
410
|
+
options.map((option, index) =>
|
|
411
|
+
h(
|
|
412
|
+
'button',
|
|
413
|
+
{
|
|
414
|
+
key: option.value === '' ? '__auto' : option.value,
|
|
415
|
+
type: 'button',
|
|
416
|
+
onMouseDown: (event) => event.preventDefault(),
|
|
417
|
+
onClick: () => choose(option.value),
|
|
418
|
+
onMouseEnter: () => setActive(index),
|
|
419
|
+
style: {
|
|
420
|
+
display: 'flex',
|
|
421
|
+
width: '100%',
|
|
422
|
+
border: 'none',
|
|
423
|
+
padding: '7px 10px',
|
|
424
|
+
borderRadius: 6,
|
|
425
|
+
font: 'inherit',
|
|
426
|
+
fontSize: 13,
|
|
427
|
+
textAlign: 'left',
|
|
428
|
+
cursor: 'pointer',
|
|
429
|
+
background:
|
|
430
|
+
option.value === value
|
|
431
|
+
? 'color-mix(in srgb, var(--dsw-alias-state-business-primary, #2f6fed) 10%, transparent)'
|
|
432
|
+
: 'transparent',
|
|
433
|
+
color:
|
|
434
|
+
option.value === value
|
|
435
|
+
? 'var(--dsw-alias-state-business-primary, #2f6fed)'
|
|
436
|
+
: 'var(--dsw-alias-label-primary, #1d2129)',
|
|
437
|
+
outline: index === active ? '1px solid var(--dsw-alias-border-l2, #e5e6eb)' : 'none',
|
|
438
|
+
},
|
|
439
|
+
},
|
|
440
|
+
option.label,
|
|
441
|
+
),
|
|
442
|
+
),
|
|
443
|
+
)
|
|
444
|
+
: null,
|
|
445
|
+
),
|
|
446
|
+
)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function PredictionSettings() {
|
|
450
|
+
const config = useConfig()
|
|
451
|
+
const locale = useLocaleId()
|
|
452
|
+
const words = copy(locale)
|
|
453
|
+
const [draft, setDraft] = react.useState(null)
|
|
454
|
+
const [status, setStatus] = react.useState('')
|
|
455
|
+
const [busy, setBusy] = react.useState(false)
|
|
456
|
+
const [options, setOptions] = react.useState(() => configStore.getOptions())
|
|
457
|
+
|
|
458
|
+
react.useEffect(() => {
|
|
459
|
+
let alive = true
|
|
460
|
+
const refresh = () => {
|
|
461
|
+
configStore.loadOptions(true).then((value) => {
|
|
462
|
+
if (alive) setOptions(value)
|
|
463
|
+
})
|
|
464
|
+
}
|
|
465
|
+
// A provider added in another settings section must be visible here on the
|
|
466
|
+
// next visit, and on returning to the window, not after a restart.
|
|
467
|
+
refresh()
|
|
468
|
+
window.addEventListener('focus', refresh)
|
|
469
|
+
return () => {
|
|
470
|
+
alive = false
|
|
471
|
+
window.removeEventListener('focus', refresh)
|
|
472
|
+
}
|
|
473
|
+
}, [])
|
|
474
|
+
|
|
475
|
+
react.useEffect(() => {
|
|
476
|
+
if (!config || config.unavailable) return
|
|
477
|
+
setDraft((current) =>
|
|
478
|
+
current ?? {
|
|
479
|
+
enabled: config.enabled !== false,
|
|
480
|
+
provider: config.provider || '',
|
|
481
|
+
model: config.model || '',
|
|
482
|
+
reasoningEffort: config.reasoningEffort || '',
|
|
483
|
+
},
|
|
484
|
+
)
|
|
485
|
+
}, [config])
|
|
486
|
+
|
|
487
|
+
const save = async (next) => {
|
|
488
|
+
setBusy(true)
|
|
489
|
+
setStatus('')
|
|
490
|
+
try {
|
|
491
|
+
await configStore.save(next)
|
|
492
|
+
setStatus(words.saved)
|
|
493
|
+
} catch (error) {
|
|
494
|
+
setStatus(words.failed + (error && error.message ? error.message : String(error)))
|
|
495
|
+
} finally {
|
|
496
|
+
setBusy(false)
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (config && config.unavailable) {
|
|
501
|
+
return h('section', { style: { padding: '8px 0', color: 'var(--dsw-alias-label-tertiary, #86909c)', fontSize: 13 } }, words.readOnly)
|
|
502
|
+
}
|
|
503
|
+
if (!config || !draft) {
|
|
504
|
+
return h('section', { style: { padding: '8px 0', color: 'var(--dsw-alias-label-tertiary, #86909c)', fontSize: 13 } }, words.loading)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const update = (patch) => setDraft({ ...draft, ...patch })
|
|
508
|
+
|
|
509
|
+
const providerChoices = (options ? options.providers : []).map((provider) => ({
|
|
510
|
+
value: provider.id,
|
|
511
|
+
label: provider.name && provider.name !== provider.id ? provider.name + ' · ' + provider.id : provider.id,
|
|
512
|
+
}))
|
|
513
|
+
const effectiveProvider = draft.provider || (options ? options.autoProvider : '')
|
|
514
|
+
const seenModels = new Set()
|
|
515
|
+
const modelChoices = []
|
|
516
|
+
for (const model of options ? options.models : []) {
|
|
517
|
+
if (model.provider !== effectiveProvider || seenModels.has(model.id)) continue
|
|
518
|
+
seenModels.add(model.id)
|
|
519
|
+
modelChoices.push({ value: model.id, label: model.name && model.name !== model.id ? model.name + ' · ' + model.id : model.id })
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
return h(
|
|
523
|
+
'section',
|
|
524
|
+
{ style: { display: 'flex', flexDirection: 'column', gap: 16, maxWidth: 640, padding: '8px 0 24px' } },
|
|
525
|
+
h('h2', { style: { margin: 0, fontSize: 16, fontWeight: 600 } }, words.title),
|
|
526
|
+
h('p', { style: { margin: 0, fontSize: 13, lineHeight: '20px', color: 'var(--dsw-alias-label-secondary, #4e5969)' } }, words.intro),
|
|
527
|
+
h(
|
|
528
|
+
'div',
|
|
529
|
+
{ style: CARD_STYLE },
|
|
530
|
+
h(
|
|
531
|
+
'div',
|
|
532
|
+
{ style: { display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 } },
|
|
533
|
+
h(
|
|
534
|
+
'div',
|
|
535
|
+
{ style: { display: 'flex', flexDirection: 'column', gap: 4 } },
|
|
536
|
+
h('span', { style: { fontSize: 13 } }, words.enabled),
|
|
537
|
+
h('span', { style: HINT_STYLE }, words.enabledHint),
|
|
538
|
+
),
|
|
539
|
+
h(Switch, {
|
|
540
|
+
checked: draft.enabled !== false,
|
|
541
|
+
label: words.enabled,
|
|
542
|
+
onToggle: (enabled) => update({ enabled }),
|
|
543
|
+
}),
|
|
544
|
+
),
|
|
545
|
+
h(Combo, {
|
|
546
|
+
label: words.provider,
|
|
547
|
+
hint: words.providerHint,
|
|
548
|
+
value: draft.provider,
|
|
549
|
+
placeholder: words.auto,
|
|
550
|
+
options: [{ value: '', label: words.auto }].concat(providerChoices),
|
|
551
|
+
onChange: (provider) => update({ provider }),
|
|
552
|
+
}),
|
|
553
|
+
h(Combo, {
|
|
554
|
+
label: words.model,
|
|
555
|
+
hint: words.modelHint,
|
|
556
|
+
value: draft.model,
|
|
557
|
+
placeholder: words.auto,
|
|
558
|
+
options: [{ value: '', label: words.auto }].concat(modelChoices),
|
|
559
|
+
onChange: (model) => update({ model }),
|
|
560
|
+
}),
|
|
561
|
+
h(Combo, {
|
|
562
|
+
label: words.effort,
|
|
563
|
+
hint: words.effortHint,
|
|
564
|
+
value: draft.reasoningEffort,
|
|
565
|
+
placeholder: words.auto,
|
|
566
|
+
allowCustom: false,
|
|
567
|
+
options: [''].concat(['off', 'none', 'minimal', 'low', 'high']).map((value) => ({
|
|
568
|
+
value,
|
|
569
|
+
label: value === '' ? words.auto : value,
|
|
570
|
+
})),
|
|
571
|
+
onChange: (reasoningEffort) => update({ reasoningEffort }),
|
|
572
|
+
}),
|
|
573
|
+
),
|
|
574
|
+
h(
|
|
575
|
+
'div',
|
|
576
|
+
{ style: { display: 'flex', alignItems: 'center', gap: 12 } },
|
|
577
|
+
h(
|
|
578
|
+
'button',
|
|
579
|
+
{
|
|
580
|
+
type: 'button',
|
|
581
|
+
disabled: busy,
|
|
582
|
+
onClick: () => save(draft),
|
|
583
|
+
style: {
|
|
584
|
+
height: 32,
|
|
585
|
+
padding: '0 16px',
|
|
586
|
+
borderRadius: 8,
|
|
587
|
+
border: 'none',
|
|
588
|
+
cursor: busy ? 'default' : 'pointer',
|
|
589
|
+
opacity: busy ? 0.6 : 1,
|
|
590
|
+
background: 'var(--dsw-alias-state-business-primary, #2f6fed)',
|
|
591
|
+
color: '#fff',
|
|
592
|
+
font: 'inherit',
|
|
593
|
+
fontSize: 13,
|
|
594
|
+
},
|
|
595
|
+
},
|
|
596
|
+
busy ? words.saving : words.save,
|
|
597
|
+
),
|
|
598
|
+
h(
|
|
599
|
+
'button',
|
|
600
|
+
{
|
|
601
|
+
type: 'button',
|
|
602
|
+
disabled: busy,
|
|
603
|
+
onClick: () => {
|
|
604
|
+
const next = { enabled: true, provider: '', model: '', reasoningEffort: '' }
|
|
605
|
+
setDraft(next)
|
|
606
|
+
save(next)
|
|
607
|
+
},
|
|
608
|
+
style: {
|
|
609
|
+
height: 32,
|
|
610
|
+
padding: '0 12px',
|
|
611
|
+
borderRadius: 8,
|
|
612
|
+
border: '1px solid var(--dsw-alias-border-l2, #e5e6eb)',
|
|
613
|
+
background: 'transparent',
|
|
614
|
+
color: 'var(--dsw-alias-label-secondary, #4e5969)',
|
|
615
|
+
cursor: busy ? 'default' : 'pointer',
|
|
616
|
+
font: 'inherit',
|
|
617
|
+
fontSize: 13,
|
|
618
|
+
},
|
|
619
|
+
},
|
|
620
|
+
words.reset,
|
|
621
|
+
),
|
|
622
|
+
status ? h('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-tertiary, #86909c)' } }, status) : null,
|
|
623
|
+
),
|
|
624
|
+
)
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* The composer editable surface that belongs to this dock's conversation seat.
|
|
629
|
+
* `[data-composer-input]` is a Lexical contenteditable, not a textarea.
|
|
630
|
+
*/
|
|
631
|
+
function findComposerInput(anchor) {
|
|
632
|
+
const seat = anchor && typeof anchor.closest === 'function'
|
|
633
|
+
? anchor.closest('[data-conversation-region="composer"]')
|
|
634
|
+
: null
|
|
635
|
+
const scope = seat || document
|
|
636
|
+
const nodes = scope.querySelectorAll('[data-composer-input]')
|
|
637
|
+
for (let index = nodes.length - 1; index >= 0; index -= 1) {
|
|
638
|
+
const element = nodes[index]
|
|
639
|
+
const box = element.getBoundingClientRect()
|
|
640
|
+
if (box.width > 0 && box.height > 0) return element
|
|
641
|
+
}
|
|
642
|
+
return null
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* A completion menu owns Tab/Escape while it is open; never steal either.
|
|
647
|
+
*
|
|
648
|
+
* The overlay anchor always exists and always contains the slot wrapper (and a
|
|
649
|
+
* hidden placeholder span in the closed state), so `childElementCount > 0` is
|
|
650
|
+
* permanently true — the bug that made Tab do nothing in the first real build.
|
|
651
|
+
* Only a laid-out overlay node counts: slot wrappers (`display: contents`) and
|
|
652
|
+
* hidden nodes are skipped, and the remaining node must have a real box.
|
|
653
|
+
*/
|
|
654
|
+
function completionMenuOpen(anchor) {
|
|
655
|
+
const seat = anchor && typeof anchor.closest === 'function'
|
|
656
|
+
? anchor.closest('[data-conversation-region="composer"]')
|
|
657
|
+
: null
|
|
658
|
+
const overlay = (seat || document).querySelector('[data-composer-overlay]')
|
|
659
|
+
if (!overlay) return false
|
|
660
|
+
const nodes = overlay.querySelectorAll('*')
|
|
661
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
662
|
+
const node = nodes[index]
|
|
663
|
+
if (node.hasAttribute('data-slot') || node.hasAttribute('hidden')) continue
|
|
664
|
+
const style = getComputedStyle(node)
|
|
665
|
+
if (style.display === 'none' || style.visibility === 'hidden') continue
|
|
666
|
+
const box = node.getBoundingClientRect()
|
|
667
|
+
if (box.width > 0 && box.height > 0) return true
|
|
668
|
+
}
|
|
669
|
+
return false
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function PredictionDock(props) {
|
|
673
|
+
const session = props.session || {}
|
|
674
|
+
const input = props.input || {}
|
|
675
|
+
const draft = typeof input.draft === 'string' ? input.draft : ''
|
|
676
|
+
// The standard session kit always supplies useSession; the fallback keeps the
|
|
677
|
+
// component usable in the bundle test and during a partial boot.
|
|
678
|
+
const useSession = typeof props.useSession === 'function' ? props.useSession : noSession
|
|
679
|
+
const liveRunning = useSession(selectRunning)
|
|
680
|
+
const running = typeof liveRunning === 'boolean' ? liveRunning : !!(session && session.running)
|
|
681
|
+
const pending = Array.isArray(session.pendingSubmissions)
|
|
682
|
+
? session.pendingSubmissions
|
|
683
|
+
: Array.isArray(session.pending)
|
|
684
|
+
? session.pending
|
|
685
|
+
: []
|
|
686
|
+
// Only queued submissions hold a turn open; transcript-placement entries are
|
|
687
|
+
// already part of the conversation.
|
|
688
|
+
const queued = pending.some((item) => item && item.placement === 'queued')
|
|
689
|
+
const sessionId = props.sessionId || session.sessionId || ''
|
|
690
|
+
const config = useConfig()
|
|
691
|
+
const configReady = config !== null
|
|
692
|
+
const disabled = config !== null && config.enabled === false
|
|
693
|
+
|
|
694
|
+
const [prediction, setPrediction] = react.useState('')
|
|
695
|
+
const predictionRef = react.useRef('')
|
|
696
|
+
predictionRef.current = prediction
|
|
697
|
+
const draftRef = react.useRef(draft)
|
|
698
|
+
draftRef.current = draft
|
|
699
|
+
const actionsRef = react.useRef(props.inputActions)
|
|
700
|
+
actionsRef.current = props.inputActions
|
|
701
|
+
// A request is wanted after mount, after a session switch, and after each
|
|
702
|
+
// finished turn. The host re-uses its per-session cache, so re-entering an
|
|
703
|
+
// idle session costs one loopback round-trip, not a model call.
|
|
704
|
+
const needs = react.useRef(true)
|
|
705
|
+
const requestSeq = react.useRef(0)
|
|
706
|
+
const retryCount = react.useRef(0)
|
|
707
|
+
const retryTimer = react.useRef(null)
|
|
708
|
+
const [retryTick, setRetryTick] = react.useState(0)
|
|
709
|
+
const anchorRef = react.useRef(null)
|
|
710
|
+
const ghostRef = react.useRef(null)
|
|
711
|
+
const editorRef = react.useRef(null)
|
|
712
|
+
const lastSession = react.useRef(sessionId)
|
|
713
|
+
|
|
714
|
+
/** Accepting writes a real draft: the text lands in the box and is editable. */
|
|
715
|
+
function accept(value) {
|
|
716
|
+
const text = String(value || predictionRef.current || '').trim()
|
|
717
|
+
if (!text) return
|
|
718
|
+
requestSeq.current += 1
|
|
719
|
+
predictionRef.current = ''
|
|
720
|
+
setPrediction('')
|
|
721
|
+
if (ghostRef.current) ghostRef.current.hide()
|
|
722
|
+
const actions = actionsRef.current
|
|
723
|
+
if (!actions || typeof actions.setDraft !== 'function') return
|
|
724
|
+
actions.setDraft(text)
|
|
725
|
+
const editor = editorRef.current
|
|
726
|
+
if (editor && editor.isConnected && typeof editor.focus === 'function') editor.focus()
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function dismiss() {
|
|
730
|
+
requestSeq.current += 1
|
|
731
|
+
predictionRef.current = ''
|
|
732
|
+
setPrediction('')
|
|
733
|
+
if (ghostRef.current) ghostRef.current.hide()
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function ensureGhost() {
|
|
737
|
+
const anchor = anchorRef.current
|
|
738
|
+
const editor = findComposerInput(anchor)
|
|
739
|
+
if (!editor) {
|
|
740
|
+
if (ghostRef.current) ghostRef.current.destroy()
|
|
741
|
+
ghostRef.current = null
|
|
742
|
+
editorRef.current = null
|
|
743
|
+
return null
|
|
744
|
+
}
|
|
745
|
+
if (ghostRef.current && editorRef.current === editor) return ghostRef.current
|
|
746
|
+
if (ghostRef.current) ghostRef.current.destroy()
|
|
747
|
+
editorRef.current = editor
|
|
748
|
+
ghostRef.current = createGhostLayer(editor, {
|
|
749
|
+
onAccept: (text) => accept(text),
|
|
750
|
+
onDismiss: () => dismiss(),
|
|
751
|
+
canIntercept: () => !completionMenuOpen(anchorRef.current),
|
|
752
|
+
})
|
|
753
|
+
return ghostRef.current
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// One layer per mounted composer. The composer can mount a frame or two after
|
|
757
|
+
// the dock (hero → conversation transition), so retry briefly.
|
|
758
|
+
react.useEffect(() => {
|
|
759
|
+
let disposed = false
|
|
760
|
+
let frames = 0
|
|
761
|
+
const retry = () => {
|
|
762
|
+
if (disposed) return
|
|
763
|
+
const ghost = ensureGhost()
|
|
764
|
+
if (ghost) {
|
|
765
|
+
ghost.setText(draftRef.current === '' ? predictionRef.current : '')
|
|
766
|
+
return
|
|
767
|
+
}
|
|
768
|
+
frames += 1
|
|
769
|
+
if (frames < ATTACH_FRAMES && typeof requestAnimationFrame === 'function') {
|
|
770
|
+
requestAnimationFrame(retry)
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
retry()
|
|
774
|
+
return () => {
|
|
775
|
+
disposed = true
|
|
776
|
+
if (ghostRef.current) ghostRef.current.destroy()
|
|
777
|
+
ghostRef.current = null
|
|
778
|
+
editorRef.current = null
|
|
779
|
+
}
|
|
780
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- attach once; refs carry state
|
|
781
|
+
}, [])
|
|
782
|
+
|
|
783
|
+
// A reused dock instance must never paint the previous session's prediction.
|
|
784
|
+
react.useEffect(() => {
|
|
785
|
+
if (lastSession.current === sessionId) return
|
|
786
|
+
lastSession.current = sessionId
|
|
787
|
+
if (retryTimer.current) {
|
|
788
|
+
clearTimeout(retryTimer.current)
|
|
789
|
+
retryTimer.current = null
|
|
790
|
+
}
|
|
791
|
+
retryCount.current = 0
|
|
792
|
+
needs.current = true
|
|
793
|
+
requestSeq.current += 1
|
|
794
|
+
predictionRef.current = ''
|
|
795
|
+
setPrediction('')
|
|
796
|
+
}, [sessionId])
|
|
797
|
+
|
|
798
|
+
react.useEffect(
|
|
799
|
+
() => () => {
|
|
800
|
+
if (retryTimer.current) clearTimeout(retryTimer.current)
|
|
801
|
+
retryTimer.current = null
|
|
802
|
+
},
|
|
803
|
+
[],
|
|
804
|
+
)
|
|
805
|
+
|
|
806
|
+
// Writing dismisses for good: clearing the box later must not resurrect a
|
|
807
|
+
// suggestion that belonged to a previous moment in the conversation.
|
|
808
|
+
react.useEffect(() => {
|
|
809
|
+
if (draft === '' || predictionRef.current === '') return
|
|
810
|
+
requestSeq.current += 1
|
|
811
|
+
predictionRef.current = ''
|
|
812
|
+
setPrediction('')
|
|
813
|
+
}, [draft])
|
|
814
|
+
|
|
815
|
+
// Paint (or clear) the ghost text.
|
|
816
|
+
react.useEffect(() => {
|
|
817
|
+
const ghost = ensureGhost()
|
|
818
|
+
if (!ghost) return
|
|
819
|
+
ghost.setText(disabled || draft !== '' ? '' : prediction)
|
|
820
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- refs are stable
|
|
821
|
+
}, [draft, prediction, sessionId, disabled])
|
|
822
|
+
|
|
823
|
+
// Ask the host once per settled turn.
|
|
824
|
+
react.useEffect(() => {
|
|
825
|
+
if (!sessionId) return undefined
|
|
826
|
+
// The master switch lives host-side; wait for the snapshot before asking.
|
|
827
|
+
if (!configReady) return undefined
|
|
828
|
+
if (disabled) {
|
|
829
|
+
needs.current = true
|
|
830
|
+
if (predictionRef.current !== '') {
|
|
831
|
+
requestSeq.current += 1
|
|
832
|
+
predictionRef.current = ''
|
|
833
|
+
setPrediction('')
|
|
834
|
+
}
|
|
835
|
+
return undefined
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
if (running) {
|
|
839
|
+
// The turn that just started will earn the next suggestion.
|
|
840
|
+
needs.current = true
|
|
841
|
+
if (predictionRef.current !== '') {
|
|
842
|
+
requestSeq.current += 1
|
|
843
|
+
predictionRef.current = ''
|
|
844
|
+
setPrediction('')
|
|
845
|
+
}
|
|
846
|
+
return undefined
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
if (!needs.current || draft !== '' || queued) return undefined
|
|
850
|
+
if (predictionRef.current !== '') {
|
|
851
|
+
needs.current = false
|
|
852
|
+
return undefined
|
|
853
|
+
}
|
|
854
|
+
needs.current = false
|
|
855
|
+
|
|
856
|
+
const seq = requestSeq.current + 1
|
|
857
|
+
requestSeq.current = seq
|
|
858
|
+
const controller = new AbortController()
|
|
859
|
+
const timer = setTimeout(() => controller.abort(), CLIENT_TIMEOUT_MS)
|
|
860
|
+
const settle = () => clearTimeout(timer)
|
|
861
|
+
|
|
862
|
+
fetch(PREDICT_PATH, {
|
|
863
|
+
method: 'POST',
|
|
864
|
+
headers: { 'content-type': 'application/json' },
|
|
865
|
+
body: JSON.stringify({ sessionId }),
|
|
866
|
+
cache: 'no-store',
|
|
867
|
+
credentials: 'same-origin',
|
|
868
|
+
signal: controller.signal,
|
|
869
|
+
})
|
|
870
|
+
.then(async (response) => {
|
|
871
|
+
if (response.ok) return response.json()
|
|
872
|
+
let detail = ''
|
|
873
|
+
try {
|
|
874
|
+
const body = await response.json()
|
|
875
|
+
detail = body && typeof body.error === 'string' ? body.error : ''
|
|
876
|
+
} catch {
|
|
877
|
+
detail = ''
|
|
878
|
+
}
|
|
879
|
+
const error = new Error('HTTP ' + response.status + (detail ? ': ' + detail : ''))
|
|
880
|
+
error.status = response.status
|
|
881
|
+
throw error
|
|
882
|
+
})
|
|
883
|
+
.then((payload) => {
|
|
884
|
+
retryCount.current = 0
|
|
885
|
+
if (controller.signal.aborted || requestSeq.current !== seq) return
|
|
886
|
+
if (draftRef.current !== '') return
|
|
887
|
+
const text = payload && typeof payload.text === 'string' ? payload.text.trim() : ''
|
|
888
|
+
if (!text || text === 'SKIP') return
|
|
889
|
+
predictionRef.current = text
|
|
890
|
+
setPrediction(text)
|
|
891
|
+
})
|
|
892
|
+
.catch((error) => {
|
|
893
|
+
if (controller.signal.aborted) return
|
|
894
|
+
const status = error && typeof error.status === 'number' ? error.status : 0
|
|
895
|
+
console.warn(
|
|
896
|
+
'[composer-predictions] prediction failed' + (sessionId ? ' for ' + sessionId : '') + ':',
|
|
897
|
+
error && error.message ? error.message : error,
|
|
898
|
+
)
|
|
899
|
+
// Opening an old session resumes it on the Host; the first request can
|
|
900
|
+
// race that resume and see 404. Re-arm and retry with backoff instead
|
|
901
|
+
// of leaving this session permanently without a suggestion.
|
|
902
|
+
if (status === 404 || status === 0) {
|
|
903
|
+
needs.current = true
|
|
904
|
+
if (retryCount.current < 6) {
|
|
905
|
+
retryCount.current += 1
|
|
906
|
+
if (retryTimer.current) clearTimeout(retryTimer.current)
|
|
907
|
+
retryTimer.current = setTimeout(() => {
|
|
908
|
+
retryTimer.current = null
|
|
909
|
+
setRetryTick((value) => value + 1)
|
|
910
|
+
}, 350 * retryCount.current)
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
})
|
|
914
|
+
.finally(settle)
|
|
915
|
+
|
|
916
|
+
return () => {
|
|
917
|
+
settle()
|
|
918
|
+
controller.abort()
|
|
919
|
+
}
|
|
920
|
+
}, [sessionId, running, draft, queued, retryTick, configReady, disabled])
|
|
921
|
+
|
|
922
|
+
return h('div', {
|
|
923
|
+
ref: anchorRef,
|
|
924
|
+
'data-composer-predictions': 'anchor',
|
|
925
|
+
'aria-hidden': 'true',
|
|
926
|
+
style: { display: 'none' },
|
|
927
|
+
})
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function apply(ctx) {
|
|
931
|
+
ctx.slots.inject('conversation.input.dock', () =>
|
|
932
|
+
ctx.slots.register(
|
|
933
|
+
{
|
|
934
|
+
name: 'conversation.input.dock',
|
|
935
|
+
id: 'composer-predictions',
|
|
936
|
+
// Existing dock entries: todo=0, goal=10, queue=20.
|
|
937
|
+
order: 30,
|
|
938
|
+
},
|
|
939
|
+
PredictionDock,
|
|
940
|
+
),
|
|
941
|
+
)
|
|
942
|
+
// Settings nav sits after "Plugins" (15) and before "Agent presets" (20).
|
|
943
|
+
ctx.slots.inject('settings.section', () =>
|
|
944
|
+
ctx.slots.register(
|
|
945
|
+
{
|
|
946
|
+
name: 'settings.section',
|
|
947
|
+
id: 'composer-predictions',
|
|
948
|
+
order: 18,
|
|
949
|
+
label: () => copy().section,
|
|
950
|
+
},
|
|
951
|
+
PredictionSettings,
|
|
952
|
+
),
|
|
953
|
+
)
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
exports.name = 'composer-predictions'
|
|
957
|
+
exports.inject = ['slots']
|
|
958
|
+
exports.apply = apply
|