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