@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/src/main.js ADDED
@@ -0,0 +1,744 @@
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 CLIENT_TIMEOUT_MS = 30_000
29
+ const ATTACH_FRAMES = 120
30
+
31
+ const selectRunning = (state) => !!(state && state.running)
32
+ const noSession = () => undefined
33
+
34
+ /**
35
+ * One config snapshot shared by the dock (whether to ask at all) and the
36
+ * Settings page (what to show). The host owns persistence; this store only
37
+ * caches the last read so a re-render never refetches.
38
+ */
39
+ const configStore = (() => {
40
+ let snapshot = null
41
+ let pending = null
42
+ const listeners = new Set()
43
+ const publish = (next) => {
44
+ snapshot = next
45
+ for (const listener of listeners) listener(next)
46
+ }
47
+ return {
48
+ get: () => snapshot,
49
+ subscribe(listener) {
50
+ listeners.add(listener)
51
+ return () => listeners.delete(listener)
52
+ },
53
+ load() {
54
+ if (pending) return pending
55
+ pending = fetch(CONFIG_PATH, {
56
+ headers: { accept: 'application/json' },
57
+ cache: 'no-store',
58
+ credentials: 'same-origin',
59
+ })
60
+ .then((response) => (response.ok ? response.json() : Promise.reject(new Error('HTTP ' + response.status))))
61
+ .then((payload) => {
62
+ if (payload && payload.config) publish(payload.config)
63
+ return snapshot
64
+ })
65
+ .catch((error) => {
66
+ console.warn(
67
+ '[composer-predictions] config load failed:',
68
+ error && error.message ? error.message : error,
69
+ )
70
+ // A stale host (client updated first) must not silently disable
71
+ // predictions: fall back to the default-on behaviour and let the
72
+ // settings page explain that persistence is unavailable.
73
+ publish({ enabled: true, unavailable: true })
74
+ return snapshot
75
+ })
76
+ .finally(() => {
77
+ pending = null
78
+ })
79
+ return pending
80
+ },
81
+ async save(patch) {
82
+ const response = await fetch(CONFIG_PATH, {
83
+ method: 'POST',
84
+ headers: { 'content-type': 'application/json' },
85
+ body: JSON.stringify({ config: patch }),
86
+ cache: 'no-store',
87
+ credentials: 'same-origin',
88
+ })
89
+ const payload = await response.json().catch(() => ({}))
90
+ if (!response.ok) throw new Error(payload && payload.error ? payload.error : 'HTTP ' + response.status)
91
+ if (payload && payload.config) publish(payload.config)
92
+ return payload && payload.config
93
+ },
94
+ }
95
+ })()
96
+
97
+ function useConfig() {
98
+ const [snapshot, setSnapshot] = react.useState(configStore.get)
99
+ react.useEffect(() => configStore.subscribe(setSnapshot), [])
100
+ react.useEffect(() => {
101
+ if (configStore.get() === null) configStore.load()
102
+ }, [])
103
+ return snapshot
104
+ }
105
+
106
+ const COPY = {
107
+ zh: {
108
+ section: '输入框预测',
109
+ title: '输入框预测',
110
+ intro: '一轮回答结束后,在空的输入框里用灰字给出你接下来可能发送的消息。Tab 接受,Esc 忽略。',
111
+ enabled: '启用预测',
112
+ enabledHint: '关闭后不再请求模型,也不显示灰字;已显示的建议会立即隐藏。',
113
+ provider: '模型 provider',
114
+ providerHint: '留空 = 自动使用当前选中的 provider。',
115
+ model: '模型 id',
116
+ modelHint: '留空 = 自动选该 provider 下最便宜的型号。',
117
+ effort: '推理档',
118
+ effortHint: '留空 = 自动选支持的最省一档。',
119
+ auto: '自动',
120
+ save: '保存',
121
+ reset: '恢复默认',
122
+ saving: '保存中…',
123
+ saved: '已保存',
124
+ failed: '保存失败:',
125
+ loading: '正在读取配置…',
126
+ readOnly: '当前运行时不支持持久化配置。',
127
+ },
128
+ en: {
129
+ section: 'Composer prediction',
130
+ title: 'Composer prediction',
131
+ intro: 'After a turn settles, the empty composer shows a dim next-message suggestion. Tab accepts it; Escape dismisses it.',
132
+ enabled: 'Enable predictions',
133
+ enabledHint: 'Off stops every request and hides the ghost immediately.',
134
+ provider: 'Model provider',
135
+ providerHint: 'Empty = the provider you are currently using.',
136
+ model: 'Model id',
137
+ modelHint: 'Empty = the cheapest model on that provider.',
138
+ effort: 'Reasoning effort',
139
+ effortHint: 'Empty = the cheapest effort the model supports.',
140
+ auto: 'Auto',
141
+ save: 'Save',
142
+ reset: 'Reset to defaults',
143
+ saving: 'Saving…',
144
+ saved: 'Saved',
145
+ failed: 'Save failed: ',
146
+ loading: 'Loading configuration…',
147
+ readOnly: 'This runtime cannot persist configuration.',
148
+ },
149
+ }
150
+
151
+ // The app's locale service mirrors the active language onto <html lang>; the
152
+ // Electron renderer's navigator.language stays en-US, so reading it would keep
153
+ // an English page under a Chinese app. Track the attribute and re-render on it.
154
+ function currentLocale() {
155
+ if (typeof document !== 'undefined' && document.documentElement && document.documentElement.lang) {
156
+ return document.documentElement.lang
157
+ }
158
+ return (typeof navigator !== 'undefined' && navigator.language) || 'en'
159
+ }
160
+
161
+ function copy(locale) {
162
+ const language = String(locale || currentLocale()).toLowerCase()
163
+ return language.indexOf('zh') === 0 ? COPY.zh : COPY.en
164
+ }
165
+
166
+ function useLocaleId() {
167
+ const [locale, setLocale] = react.useState(currentLocale)
168
+ react.useEffect(() => {
169
+ if (typeof document === 'undefined' || !document.documentElement) return undefined
170
+ const element = document.documentElement
171
+ const sync = () => setLocale(currentLocale())
172
+ const observer = new MutationObserver(sync)
173
+ observer.observe(element, { attributes: true, attributeFilter: ['lang'] })
174
+ sync()
175
+ return () => observer.disconnect()
176
+ }, [])
177
+ return locale
178
+ }
179
+
180
+ const FIELD_STYLE = {
181
+ width: '100%',
182
+ boxSizing: 'border-box',
183
+ height: 34,
184
+ padding: '0 10px',
185
+ borderRadius: 8,
186
+ border: '1px solid var(--dsw-alias-border-l2, #e5e6eb)',
187
+ background: 'var(--dsw-alias-bg-module-platform, #fff)',
188
+ color: 'var(--dsw-alias-label-primary, #1d2129)',
189
+ font: 'inherit',
190
+ fontSize: 13,
191
+ outline: 'none',
192
+ }
193
+
194
+ const CARD_STYLE = {
195
+ display: 'flex',
196
+ flexDirection: 'column',
197
+ gap: 18,
198
+ padding: '18px 20px',
199
+ borderRadius: 12,
200
+ border: '1px solid var(--dsw-alias-border-l2, #e5e6eb)',
201
+ background: 'var(--dsw-alias-bg-layer-3, transparent)',
202
+ }
203
+
204
+ const LABEL_STYLE = {
205
+ display: 'flex',
206
+ flexDirection: 'column',
207
+ gap: 6,
208
+ fontSize: 13,
209
+ color: 'var(--dsw-alias-label-primary, #1d2129)',
210
+ }
211
+
212
+ const HINT_STYLE = { fontSize: 12, lineHeight: '18px', color: 'var(--dsw-alias-label-tertiary, #86909c)' }
213
+
214
+ function Switch({ checked, label, onToggle }) {
215
+ return h(
216
+ 'button',
217
+ {
218
+ type: 'button',
219
+ role: 'switch',
220
+ 'aria-checked': checked,
221
+ 'aria-label': label,
222
+ onClick: () => onToggle(!checked),
223
+ style: {
224
+ position: 'relative',
225
+ flex: 'none',
226
+ width: 40,
227
+ height: 22,
228
+ padding: 0,
229
+ border: 'none',
230
+ borderRadius: 999,
231
+ cursor: 'pointer',
232
+ background: checked
233
+ ? 'var(--dsw-alias-state-business-primary, #2f6fed)'
234
+ : 'var(--dsw-alias-border-l2, #c9cdd4)',
235
+ transition: 'background .15s ease',
236
+ },
237
+ },
238
+ h('span', {
239
+ style: {
240
+ position: 'absolute',
241
+ top: 2,
242
+ left: checked ? 20 : 2,
243
+ width: 18,
244
+ height: 18,
245
+ borderRadius: '50%',
246
+ background: '#fff',
247
+ transition: 'left .15s ease',
248
+ boxShadow: '0 1px 2px rgba(0,0,0,.2)',
249
+ },
250
+ }),
251
+ )
252
+ }
253
+
254
+ function Field({ label, hint, children }) {
255
+ return h('label', { style: LABEL_STYLE }, h('span', null, label), children, hint ? h('span', { style: HINT_STYLE }, hint) : null)
256
+ }
257
+
258
+ function PredictionSettings() {
259
+ const config = useConfig()
260
+ const locale = useLocaleId()
261
+ const words = copy(locale)
262
+ const [draft, setDraft] = react.useState(null)
263
+ const [status, setStatus] = react.useState('')
264
+ const [busy, setBusy] = react.useState(false)
265
+
266
+ react.useEffect(() => {
267
+ if (!config || config.unavailable) return
268
+ setDraft((current) =>
269
+ current ?? {
270
+ enabled: config.enabled !== false,
271
+ provider: config.provider || '',
272
+ model: config.model || '',
273
+ reasoningEffort: config.reasoningEffort || '',
274
+ },
275
+ )
276
+ }, [config])
277
+
278
+ const save = async (next) => {
279
+ setBusy(true)
280
+ setStatus('')
281
+ try {
282
+ await configStore.save(next)
283
+ setStatus(words.saved)
284
+ } catch (error) {
285
+ setStatus(words.failed + (error && error.message ? error.message : String(error)))
286
+ } finally {
287
+ setBusy(false)
288
+ }
289
+ }
290
+
291
+ if (config && config.unavailable) {
292
+ return h('section', { style: { padding: '8px 0', color: 'var(--dsw-alias-label-tertiary, #86909c)', fontSize: 13 } }, words.readOnly)
293
+ }
294
+ if (!config || !draft) {
295
+ return h('section', { style: { padding: '8px 0', color: 'var(--dsw-alias-label-tertiary, #86909c)', fontSize: 13 } }, words.loading)
296
+ }
297
+
298
+ const update = (patch) => setDraft({ ...draft, ...patch })
299
+
300
+ return h(
301
+ 'section',
302
+ { style: { display: 'flex', flexDirection: 'column', gap: 16, maxWidth: 640, padding: '8px 0 24px' } },
303
+ h('h2', { style: { margin: 0, fontSize: 16, fontWeight: 600 } }, words.title),
304
+ h('p', { style: { margin: 0, fontSize: 13, lineHeight: '20px', color: 'var(--dsw-alias-label-secondary, #4e5969)' } }, words.intro),
305
+ h(
306
+ 'div',
307
+ { style: CARD_STYLE },
308
+ h(
309
+ 'div',
310
+ { style: { display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 } },
311
+ h(
312
+ 'div',
313
+ { style: { display: 'flex', flexDirection: 'column', gap: 4 } },
314
+ h('span', { style: { fontSize: 13 } }, words.enabled),
315
+ h('span', { style: HINT_STYLE }, words.enabledHint),
316
+ ),
317
+ h(Switch, {
318
+ checked: draft.enabled !== false,
319
+ label: words.enabled,
320
+ onToggle: (enabled) => update({ enabled }),
321
+ }),
322
+ ),
323
+ h(
324
+ Field,
325
+ { label: words.provider, hint: words.providerHint },
326
+ h('input', {
327
+ type: 'text',
328
+ value: draft.provider,
329
+ placeholder: words.auto,
330
+ onChange: (event) => update({ provider: event.target.value }),
331
+ style: FIELD_STYLE,
332
+ }),
333
+ ),
334
+ h(
335
+ Field,
336
+ { label: words.model, hint: words.modelHint },
337
+ h('input', {
338
+ type: 'text',
339
+ value: draft.model,
340
+ placeholder: words.auto,
341
+ onChange: (event) => update({ model: event.target.value }),
342
+ style: FIELD_STYLE,
343
+ }),
344
+ ),
345
+ h(
346
+ Field,
347
+ { label: words.effort, hint: words.effortHint },
348
+ h(
349
+ 'select',
350
+ {
351
+ value: draft.reasoningEffort,
352
+ onChange: (event) => update({ reasoningEffort: event.target.value }),
353
+ style: FIELD_STYLE,
354
+ },
355
+ h('option', { value: '' }, words.auto),
356
+ ...[['off'], ['none'], ['minimal'], ['low'], ['high']].map(([value]) => h('option', { key: value, value }, value)),
357
+ ),
358
+ ),
359
+ ),
360
+ h(
361
+ 'div',
362
+ { style: { display: 'flex', alignItems: 'center', gap: 12 } },
363
+ h(
364
+ 'button',
365
+ {
366
+ type: 'button',
367
+ disabled: busy,
368
+ onClick: () => save(draft),
369
+ style: {
370
+ height: 32,
371
+ padding: '0 16px',
372
+ borderRadius: 8,
373
+ border: 'none',
374
+ cursor: busy ? 'default' : 'pointer',
375
+ opacity: busy ? 0.6 : 1,
376
+ background: 'var(--dsw-alias-state-business-primary, #2f6fed)',
377
+ color: '#fff',
378
+ font: 'inherit',
379
+ fontSize: 13,
380
+ },
381
+ },
382
+ busy ? words.saving : words.save,
383
+ ),
384
+ h(
385
+ 'button',
386
+ {
387
+ type: 'button',
388
+ disabled: busy,
389
+ onClick: () => {
390
+ const next = { enabled: true, provider: '', model: '', reasoningEffort: '' }
391
+ setDraft(next)
392
+ save(next)
393
+ },
394
+ style: {
395
+ height: 32,
396
+ padding: '0 12px',
397
+ borderRadius: 8,
398
+ border: '1px solid var(--dsw-alias-border-l2, #e5e6eb)',
399
+ background: 'transparent',
400
+ color: 'var(--dsw-alias-label-secondary, #4e5969)',
401
+ cursor: busy ? 'default' : 'pointer',
402
+ font: 'inherit',
403
+ fontSize: 13,
404
+ },
405
+ },
406
+ words.reset,
407
+ ),
408
+ status ? h('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-tertiary, #86909c)' } }, status) : null,
409
+ ),
410
+ )
411
+ }
412
+
413
+ /**
414
+ * The composer editable surface that belongs to this dock's conversation seat.
415
+ * `[data-composer-input]` is a Lexical contenteditable, not a textarea.
416
+ */
417
+ function findComposerInput(anchor) {
418
+ const seat = anchor && typeof anchor.closest === 'function'
419
+ ? anchor.closest('[data-conversation-region="composer"]')
420
+ : null
421
+ const scope = seat || document
422
+ const nodes = scope.querySelectorAll('[data-composer-input]')
423
+ for (let index = nodes.length - 1; index >= 0; index -= 1) {
424
+ const element = nodes[index]
425
+ const box = element.getBoundingClientRect()
426
+ if (box.width > 0 && box.height > 0) return element
427
+ }
428
+ return null
429
+ }
430
+
431
+ /**
432
+ * A completion menu owns Tab/Escape while it is open; never steal either.
433
+ *
434
+ * The overlay anchor always exists and always contains the slot wrapper (and a
435
+ * hidden placeholder span in the closed state), so `childElementCount > 0` is
436
+ * permanently true — the bug that made Tab do nothing in the first real build.
437
+ * Only a laid-out overlay node counts: slot wrappers (`display: contents`) and
438
+ * hidden nodes are skipped, and the remaining node must have a real box.
439
+ */
440
+ function completionMenuOpen(anchor) {
441
+ const seat = anchor && typeof anchor.closest === 'function'
442
+ ? anchor.closest('[data-conversation-region="composer"]')
443
+ : null
444
+ const overlay = (seat || document).querySelector('[data-composer-overlay]')
445
+ if (!overlay) return false
446
+ const nodes = overlay.querySelectorAll('*')
447
+ for (let index = 0; index < nodes.length; index += 1) {
448
+ const node = nodes[index]
449
+ if (node.hasAttribute('data-slot') || node.hasAttribute('hidden')) continue
450
+ const style = getComputedStyle(node)
451
+ if (style.display === 'none' || style.visibility === 'hidden') continue
452
+ const box = node.getBoundingClientRect()
453
+ if (box.width > 0 && box.height > 0) return true
454
+ }
455
+ return false
456
+ }
457
+
458
+ function PredictionDock(props) {
459
+ const session = props.session || {}
460
+ const input = props.input || {}
461
+ const draft = typeof input.draft === 'string' ? input.draft : ''
462
+ // The standard session kit always supplies useSession; the fallback keeps the
463
+ // component usable in the bundle test and during a partial boot.
464
+ const useSession = typeof props.useSession === 'function' ? props.useSession : noSession
465
+ const liveRunning = useSession(selectRunning)
466
+ const running = typeof liveRunning === 'boolean' ? liveRunning : !!(session && session.running)
467
+ const pending = Array.isArray(session.pendingSubmissions)
468
+ ? session.pendingSubmissions
469
+ : Array.isArray(session.pending)
470
+ ? session.pending
471
+ : []
472
+ // Only queued submissions hold a turn open; transcript-placement entries are
473
+ // already part of the conversation.
474
+ const queued = pending.some((item) => item && item.placement === 'queued')
475
+ const sessionId = props.sessionId || session.sessionId || ''
476
+ const config = useConfig()
477
+ const configReady = config !== null
478
+ const disabled = config !== null && config.enabled === false
479
+
480
+ const [prediction, setPrediction] = react.useState('')
481
+ const predictionRef = react.useRef('')
482
+ predictionRef.current = prediction
483
+ const draftRef = react.useRef(draft)
484
+ draftRef.current = draft
485
+ const actionsRef = react.useRef(props.inputActions)
486
+ actionsRef.current = props.inputActions
487
+ // A request is wanted after mount, after a session switch, and after each
488
+ // finished turn. The host re-uses its per-session cache, so re-entering an
489
+ // idle session costs one loopback round-trip, not a model call.
490
+ const needs = react.useRef(true)
491
+ const requestSeq = react.useRef(0)
492
+ const retryCount = react.useRef(0)
493
+ const retryTimer = react.useRef(null)
494
+ const [retryTick, setRetryTick] = react.useState(0)
495
+ const anchorRef = react.useRef(null)
496
+ const ghostRef = react.useRef(null)
497
+ const editorRef = react.useRef(null)
498
+ const lastSession = react.useRef(sessionId)
499
+
500
+ /** Accepting writes a real draft: the text lands in the box and is editable. */
501
+ function accept(value) {
502
+ const text = String(value || predictionRef.current || '').trim()
503
+ if (!text) return
504
+ requestSeq.current += 1
505
+ predictionRef.current = ''
506
+ setPrediction('')
507
+ if (ghostRef.current) ghostRef.current.hide()
508
+ const actions = actionsRef.current
509
+ if (!actions || typeof actions.setDraft !== 'function') return
510
+ actions.setDraft(text)
511
+ const editor = editorRef.current
512
+ if (editor && editor.isConnected && typeof editor.focus === 'function') editor.focus()
513
+ }
514
+
515
+ function dismiss() {
516
+ requestSeq.current += 1
517
+ predictionRef.current = ''
518
+ setPrediction('')
519
+ if (ghostRef.current) ghostRef.current.hide()
520
+ }
521
+
522
+ function ensureGhost() {
523
+ const anchor = anchorRef.current
524
+ const editor = findComposerInput(anchor)
525
+ if (!editor) {
526
+ if (ghostRef.current) ghostRef.current.destroy()
527
+ ghostRef.current = null
528
+ editorRef.current = null
529
+ return null
530
+ }
531
+ if (ghostRef.current && editorRef.current === editor) return ghostRef.current
532
+ if (ghostRef.current) ghostRef.current.destroy()
533
+ editorRef.current = editor
534
+ ghostRef.current = createGhostLayer(editor, {
535
+ onAccept: (text) => accept(text),
536
+ onDismiss: () => dismiss(),
537
+ canIntercept: () => !completionMenuOpen(anchorRef.current),
538
+ })
539
+ return ghostRef.current
540
+ }
541
+
542
+ // One layer per mounted composer. The composer can mount a frame or two after
543
+ // the dock (hero → conversation transition), so retry briefly.
544
+ react.useEffect(() => {
545
+ let disposed = false
546
+ let frames = 0
547
+ const retry = () => {
548
+ if (disposed) return
549
+ const ghost = ensureGhost()
550
+ if (ghost) {
551
+ ghost.setText(draftRef.current === '' ? predictionRef.current : '')
552
+ return
553
+ }
554
+ frames += 1
555
+ if (frames < ATTACH_FRAMES && typeof requestAnimationFrame === 'function') {
556
+ requestAnimationFrame(retry)
557
+ }
558
+ }
559
+ retry()
560
+ return () => {
561
+ disposed = true
562
+ if (ghostRef.current) ghostRef.current.destroy()
563
+ ghostRef.current = null
564
+ editorRef.current = null
565
+ }
566
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- attach once; refs carry state
567
+ }, [])
568
+
569
+ // A reused dock instance must never paint the previous session's prediction.
570
+ react.useEffect(() => {
571
+ if (lastSession.current === sessionId) return
572
+ lastSession.current = sessionId
573
+ if (retryTimer.current) {
574
+ clearTimeout(retryTimer.current)
575
+ retryTimer.current = null
576
+ }
577
+ retryCount.current = 0
578
+ needs.current = true
579
+ requestSeq.current += 1
580
+ predictionRef.current = ''
581
+ setPrediction('')
582
+ }, [sessionId])
583
+
584
+ react.useEffect(
585
+ () => () => {
586
+ if (retryTimer.current) clearTimeout(retryTimer.current)
587
+ retryTimer.current = null
588
+ },
589
+ [],
590
+ )
591
+
592
+ // Writing dismisses for good: clearing the box later must not resurrect a
593
+ // suggestion that belonged to a previous moment in the conversation.
594
+ react.useEffect(() => {
595
+ if (draft === '' || predictionRef.current === '') return
596
+ requestSeq.current += 1
597
+ predictionRef.current = ''
598
+ setPrediction('')
599
+ }, [draft])
600
+
601
+ // Paint (or clear) the ghost text.
602
+ react.useEffect(() => {
603
+ const ghost = ensureGhost()
604
+ if (!ghost) return
605
+ ghost.setText(disabled || draft !== '' ? '' : prediction)
606
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- refs are stable
607
+ }, [draft, prediction, sessionId, disabled])
608
+
609
+ // Ask the host once per settled turn.
610
+ react.useEffect(() => {
611
+ if (!sessionId) return undefined
612
+ // The master switch lives host-side; wait for the snapshot before asking.
613
+ if (!configReady) return undefined
614
+ if (disabled) {
615
+ needs.current = true
616
+ if (predictionRef.current !== '') {
617
+ requestSeq.current += 1
618
+ predictionRef.current = ''
619
+ setPrediction('')
620
+ }
621
+ return undefined
622
+ }
623
+
624
+ if (running) {
625
+ // The turn that just started will earn the next suggestion.
626
+ needs.current = true
627
+ if (predictionRef.current !== '') {
628
+ requestSeq.current += 1
629
+ predictionRef.current = ''
630
+ setPrediction('')
631
+ }
632
+ return undefined
633
+ }
634
+
635
+ if (!needs.current || draft !== '' || queued) return undefined
636
+ if (predictionRef.current !== '') {
637
+ needs.current = false
638
+ return undefined
639
+ }
640
+ needs.current = false
641
+
642
+ const seq = requestSeq.current + 1
643
+ requestSeq.current = seq
644
+ const controller = new AbortController()
645
+ const timer = setTimeout(() => controller.abort(), CLIENT_TIMEOUT_MS)
646
+ const settle = () => clearTimeout(timer)
647
+
648
+ fetch(PREDICT_PATH, {
649
+ method: 'POST',
650
+ headers: { 'content-type': 'application/json' },
651
+ body: JSON.stringify({ sessionId }),
652
+ cache: 'no-store',
653
+ credentials: 'same-origin',
654
+ signal: controller.signal,
655
+ })
656
+ .then(async (response) => {
657
+ if (response.ok) return response.json()
658
+ let detail = ''
659
+ try {
660
+ const body = await response.json()
661
+ detail = body && typeof body.error === 'string' ? body.error : ''
662
+ } catch {
663
+ detail = ''
664
+ }
665
+ const error = new Error('HTTP ' + response.status + (detail ? ': ' + detail : ''))
666
+ error.status = response.status
667
+ throw error
668
+ })
669
+ .then((payload) => {
670
+ retryCount.current = 0
671
+ if (controller.signal.aborted || requestSeq.current !== seq) return
672
+ if (draftRef.current !== '') return
673
+ const text = payload && typeof payload.text === 'string' ? payload.text.trim() : ''
674
+ if (!text || text === 'SKIP') return
675
+ predictionRef.current = text
676
+ setPrediction(text)
677
+ })
678
+ .catch((error) => {
679
+ if (controller.signal.aborted) return
680
+ const status = error && typeof error.status === 'number' ? error.status : 0
681
+ console.warn(
682
+ '[composer-predictions] prediction failed' + (sessionId ? ' for ' + sessionId : '') + ':',
683
+ error && error.message ? error.message : error,
684
+ )
685
+ // Opening an old session resumes it on the Host; the first request can
686
+ // race that resume and see 404. Re-arm and retry with backoff instead
687
+ // of leaving this session permanently without a suggestion.
688
+ if (status === 404 || status === 0) {
689
+ needs.current = true
690
+ if (retryCount.current < 6) {
691
+ retryCount.current += 1
692
+ if (retryTimer.current) clearTimeout(retryTimer.current)
693
+ retryTimer.current = setTimeout(() => {
694
+ retryTimer.current = null
695
+ setRetryTick((value) => value + 1)
696
+ }, 350 * retryCount.current)
697
+ }
698
+ }
699
+ })
700
+ .finally(settle)
701
+
702
+ return () => {
703
+ settle()
704
+ controller.abort()
705
+ }
706
+ }, [sessionId, running, draft, queued, retryTick, configReady, disabled])
707
+
708
+ return h('div', {
709
+ ref: anchorRef,
710
+ 'data-composer-predictions': 'anchor',
711
+ 'aria-hidden': 'true',
712
+ style: { display: 'none' },
713
+ })
714
+ }
715
+
716
+ function apply(ctx) {
717
+ ctx.slots.inject('conversation.input.dock', () =>
718
+ ctx.slots.register(
719
+ {
720
+ name: 'conversation.input.dock',
721
+ id: 'composer-predictions',
722
+ // Existing dock entries: todo=0, goal=10, queue=20.
723
+ order: 30,
724
+ },
725
+ PredictionDock,
726
+ ),
727
+ )
728
+ // Settings nav sits after "Plugins" (15) and before "Agent presets" (20).
729
+ ctx.slots.inject('settings.section', () =>
730
+ ctx.slots.register(
731
+ {
732
+ name: 'settings.section',
733
+ id: 'composer-predictions',
734
+ order: 18,
735
+ label: () => copy().section,
736
+ },
737
+ PredictionSettings,
738
+ ),
739
+ )
740
+ }
741
+
742
+ exports.name = 'composer-predictions'
743
+ exports.inject = ['slots']
744
+ exports.apply = apply