@lsfcy/dsh-composer-predictions 0.1.0 → 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/README.en.md +2 -2
- package/README.md +2 -2
- package/client.js +250 -36
- package/index.js +80 -1
- package/package.json +1 -1
- package/src/main.js +250 -36
package/README.en.md
CHANGED
|
@@ -15,7 +15,7 @@ The assistant finishes with "All 12 tests pass." The empty composer then shows,
|
|
|
15
15
|
|
|
16
16
|
Press Tab and it becomes an editable draft. Press Esc, or start typing, and it disappears.
|
|
17
17
|
|
|
18
|
-

|
|
18
|
+

|
|
19
19
|
|
|
20
20
|
## Install
|
|
21
21
|
|
|
@@ -37,7 +37,7 @@ dsh plugin --profile desktop add @lsfcy/dsh-composer-predictions
|
|
|
37
37
|
```
|
|
38
38
|
|
|
39
39
|
Restart DSH afterwards. The desktop app uses `desktop`; other entrypoints use their profile under `~/.dsh/profiles`, e.g. `web`. To pin a version without npm:
|
|
40
|
-
`dsh plugin --profile desktop add git+https://github.com/lsfcy/dsh-composer-predictions.git#v0.1.
|
|
40
|
+
`dsh plugin --profile desktop add git+https://github.com/lsfcy/dsh-composer-predictions.git#v0.1.1`.
|
|
41
41
|
|
|
42
42
|
## Config
|
|
43
43
|
|
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
按 Tab,它变成可编辑的草稿;按 Esc 或直接打字,它消失。
|
|
17
17
|
|
|
18
|
-

|
|
18
|
+

|
|
19
19
|
|
|
20
20
|
## 安装
|
|
21
21
|
|
|
@@ -37,7 +37,7 @@ dsh plugin --profile desktop add @lsfcy/dsh-composer-predictions
|
|
|
37
37
|
```
|
|
38
38
|
|
|
39
39
|
装完重启 DSH。桌面端 profile 是 `desktop`;其他入口用 `~/.dsh/profiles` 里对应的名字,如 `web`。不想走 npm 时,也可以用固定版本:
|
|
40
|
-
`dsh plugin --profile desktop add git+https://github.com/lsfcy/dsh-composer-predictions.git#v0.1.
|
|
40
|
+
`dsh plugin --profile desktop add git+https://github.com/lsfcy/dsh-composer-predictions.git#v0.1.1`。
|
|
41
41
|
|
|
42
42
|
## 配置
|
|
43
43
|
|
package/client.js
CHANGED
|
@@ -349,6 +349,7 @@ const createGhostLayer = globalThis.createComposerGhost
|
|
|
349
349
|
|
|
350
350
|
const PREDICT_PATH = '/composer-predictions/predict'
|
|
351
351
|
const CONFIG_PATH = '/composer-predictions/config'
|
|
352
|
+
const OPTIONS_PATH = '/composer-predictions/options'
|
|
352
353
|
const CLIENT_TIMEOUT_MS = 30_000
|
|
353
354
|
const ATTACH_FRAMES = 120
|
|
354
355
|
|
|
@@ -363,6 +364,8 @@ const noSession = () => undefined
|
|
|
363
364
|
const configStore = (() => {
|
|
364
365
|
let snapshot = null
|
|
365
366
|
let pending = null
|
|
367
|
+
let options = null
|
|
368
|
+
let optionsPending = null
|
|
366
369
|
const listeners = new Set()
|
|
367
370
|
const publish = (next) => {
|
|
368
371
|
snapshot = next
|
|
@@ -402,6 +405,37 @@ const configStore = (() => {
|
|
|
402
405
|
})
|
|
403
406
|
return pending
|
|
404
407
|
},
|
|
408
|
+
getOptions: () => options,
|
|
409
|
+
loadOptions(force = false) {
|
|
410
|
+
if (optionsPending) return optionsPending
|
|
411
|
+
if (!force && options) return Promise.resolve(options)
|
|
412
|
+
optionsPending = fetch(OPTIONS_PATH, {
|
|
413
|
+
headers: { accept: 'application/json' },
|
|
414
|
+
cache: 'no-store',
|
|
415
|
+
credentials: 'same-origin',
|
|
416
|
+
})
|
|
417
|
+
.then((response) => (response.ok ? response.json() : Promise.reject(new Error('HTTP ' + response.status))))
|
|
418
|
+
.then((payload) => {
|
|
419
|
+
options = {
|
|
420
|
+
providers: Array.isArray(payload && payload.providers) ? payload.providers : [],
|
|
421
|
+
models: Array.isArray(payload && payload.models) ? payload.models : [],
|
|
422
|
+
autoProvider: payload && typeof payload.autoProvider === 'string' ? payload.autoProvider : '',
|
|
423
|
+
}
|
|
424
|
+
return options
|
|
425
|
+
})
|
|
426
|
+
.catch((error) => {
|
|
427
|
+
console.warn(
|
|
428
|
+
'[composer-predictions] options load failed:',
|
|
429
|
+
error && error.message ? error.message : error,
|
|
430
|
+
)
|
|
431
|
+
options = { providers: [], models: [], autoProvider: '' }
|
|
432
|
+
return options
|
|
433
|
+
})
|
|
434
|
+
.finally(() => {
|
|
435
|
+
optionsPending = null
|
|
436
|
+
})
|
|
437
|
+
return optionsPending
|
|
438
|
+
},
|
|
405
439
|
async save(patch) {
|
|
406
440
|
const response = await fetch(CONFIG_PATH, {
|
|
407
441
|
method: 'POST',
|
|
@@ -579,6 +613,163 @@ function Field({ label, hint, children }) {
|
|
|
579
613
|
return h('label', { style: LABEL_STYLE }, h('span', null, label), children, hint ? h('span', { style: HINT_STYLE }, hint) : null)
|
|
580
614
|
}
|
|
581
615
|
|
|
616
|
+
/**
|
|
617
|
+
* A text input backed by a datalist: clicking shows the known providers or
|
|
618
|
+
* models, typing still accepts a value the catalog does not advertise.
|
|
619
|
+
*/
|
|
620
|
+
const POPUP_STYLE = {
|
|
621
|
+
position: 'absolute',
|
|
622
|
+
top: 'calc(100% + 4px)',
|
|
623
|
+
left: 0,
|
|
624
|
+
right: 0,
|
|
625
|
+
zIndex: 40,
|
|
626
|
+
padding: 4,
|
|
627
|
+
borderRadius: 10,
|
|
628
|
+
border: '1px solid var(--dsw-alias-border-l2, #e5e6eb)',
|
|
629
|
+
background: 'var(--dsw-alias-bg-module-platform, #fff)',
|
|
630
|
+
boxShadow: '0 8px 24px rgba(0, 0, 0, .12)',
|
|
631
|
+
maxHeight: 240,
|
|
632
|
+
overflowY: 'auto',
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function Chevron({ open }) {
|
|
636
|
+
return h(
|
|
637
|
+
'svg',
|
|
638
|
+
{
|
|
639
|
+
width: 12,
|
|
640
|
+
height: 12,
|
|
641
|
+
viewBox: '0 0 16 16',
|
|
642
|
+
fill: 'none',
|
|
643
|
+
stroke: 'currentColor',
|
|
644
|
+
strokeWidth: 1.8,
|
|
645
|
+
style: { transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .15s ease' },
|
|
646
|
+
},
|
|
647
|
+
h('path', { d: 'M4 6l4 4 4-4', strokeLinecap: 'round', strokeLinejoin: 'round' }),
|
|
648
|
+
)
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* One themed combobox: the input stays editable (when `allowCustom`), the
|
|
653
|
+
* popup is a token-styled list rather than the OS select/datalist chrome.
|
|
654
|
+
*/
|
|
655
|
+
function Combo({ label, hint, value, placeholder, options, allowCustom = true, onChange }) {
|
|
656
|
+
const [open, setOpen] = react.useState(false)
|
|
657
|
+
const [active, setActive] = react.useState(-1)
|
|
658
|
+
const rootRef = react.useRef(null)
|
|
659
|
+
|
|
660
|
+
react.useEffect(() => {
|
|
661
|
+
if (!open) return undefined
|
|
662
|
+
const close = (event) => {
|
|
663
|
+
if (rootRef.current && !rootRef.current.contains(event.target)) setOpen(false)
|
|
664
|
+
}
|
|
665
|
+
document.addEventListener('mousedown', close)
|
|
666
|
+
return () => document.removeEventListener('mousedown', close)
|
|
667
|
+
}, [open])
|
|
668
|
+
|
|
669
|
+
const choose = (next) => {
|
|
670
|
+
onChange(next)
|
|
671
|
+
setOpen(false)
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
return h(
|
|
675
|
+
Field,
|
|
676
|
+
{ label, hint },
|
|
677
|
+
h(
|
|
678
|
+
'div',
|
|
679
|
+
{ ref: rootRef, style: { position: 'relative' } },
|
|
680
|
+
h('input', {
|
|
681
|
+
type: 'text',
|
|
682
|
+
value,
|
|
683
|
+
placeholder,
|
|
684
|
+
readOnly: !allowCustom,
|
|
685
|
+
autoComplete: 'off',
|
|
686
|
+
onChange: (event) => {
|
|
687
|
+
if (!allowCustom) return
|
|
688
|
+
onChange(event.target.value)
|
|
689
|
+
setOpen(true)
|
|
690
|
+
},
|
|
691
|
+
onClick: () => setOpen((current) => !current),
|
|
692
|
+
onKeyDown: (event) => {
|
|
693
|
+
if (event.key === 'Escape') {
|
|
694
|
+
setOpen(false)
|
|
695
|
+
return
|
|
696
|
+
}
|
|
697
|
+
if (event.key === 'ArrowDown') {
|
|
698
|
+
event.preventDefault()
|
|
699
|
+
setOpen(true)
|
|
700
|
+
setActive((index) => Math.min(options.length - 1, index + 1))
|
|
701
|
+
} else if (event.key === 'ArrowUp') {
|
|
702
|
+
event.preventDefault()
|
|
703
|
+
setActive((index) => Math.max(0, index - 1))
|
|
704
|
+
} else if (event.key === 'Enter' && open && active >= 0 && options[active]) {
|
|
705
|
+
event.preventDefault()
|
|
706
|
+
choose(options[active].value)
|
|
707
|
+
}
|
|
708
|
+
},
|
|
709
|
+
style: {
|
|
710
|
+
...FIELD_STYLE,
|
|
711
|
+
paddingRight: 34,
|
|
712
|
+
cursor: allowCustom ? 'text' : 'pointer',
|
|
713
|
+
},
|
|
714
|
+
}),
|
|
715
|
+
h(
|
|
716
|
+
'span',
|
|
717
|
+
{
|
|
718
|
+
style: {
|
|
719
|
+
position: 'absolute',
|
|
720
|
+
right: 10,
|
|
721
|
+
top: '50%',
|
|
722
|
+
transform: 'translateY(-50%)',
|
|
723
|
+
display: 'flex',
|
|
724
|
+
pointerEvents: 'none',
|
|
725
|
+
color: 'var(--dsw-alias-label-tertiary, #86909c)',
|
|
726
|
+
},
|
|
727
|
+
},
|
|
728
|
+
h(Chevron, { open }),
|
|
729
|
+
),
|
|
730
|
+
open && options.length > 0
|
|
731
|
+
? h(
|
|
732
|
+
'div',
|
|
733
|
+
{ style: POPUP_STYLE },
|
|
734
|
+
options.map((option, index) =>
|
|
735
|
+
h(
|
|
736
|
+
'button',
|
|
737
|
+
{
|
|
738
|
+
key: option.value === '' ? '__auto' : option.value,
|
|
739
|
+
type: 'button',
|
|
740
|
+
onMouseDown: (event) => event.preventDefault(),
|
|
741
|
+
onClick: () => choose(option.value),
|
|
742
|
+
onMouseEnter: () => setActive(index),
|
|
743
|
+
style: {
|
|
744
|
+
display: 'flex',
|
|
745
|
+
width: '100%',
|
|
746
|
+
border: 'none',
|
|
747
|
+
padding: '7px 10px',
|
|
748
|
+
borderRadius: 6,
|
|
749
|
+
font: 'inherit',
|
|
750
|
+
fontSize: 13,
|
|
751
|
+
textAlign: 'left',
|
|
752
|
+
cursor: 'pointer',
|
|
753
|
+
background:
|
|
754
|
+
option.value === value
|
|
755
|
+
? 'color-mix(in srgb, var(--dsw-alias-state-business-primary, #2f6fed) 10%, transparent)'
|
|
756
|
+
: 'transparent',
|
|
757
|
+
color:
|
|
758
|
+
option.value === value
|
|
759
|
+
? 'var(--dsw-alias-state-business-primary, #2f6fed)'
|
|
760
|
+
: 'var(--dsw-alias-label-primary, #1d2129)',
|
|
761
|
+
outline: index === active ? '1px solid var(--dsw-alias-border-l2, #e5e6eb)' : 'none',
|
|
762
|
+
},
|
|
763
|
+
},
|
|
764
|
+
option.label,
|
|
765
|
+
),
|
|
766
|
+
),
|
|
767
|
+
)
|
|
768
|
+
: null,
|
|
769
|
+
),
|
|
770
|
+
)
|
|
771
|
+
}
|
|
772
|
+
|
|
582
773
|
function PredictionSettings() {
|
|
583
774
|
const config = useConfig()
|
|
584
775
|
const locale = useLocaleId()
|
|
@@ -586,6 +777,24 @@ function PredictionSettings() {
|
|
|
586
777
|
const [draft, setDraft] = react.useState(null)
|
|
587
778
|
const [status, setStatus] = react.useState('')
|
|
588
779
|
const [busy, setBusy] = react.useState(false)
|
|
780
|
+
const [options, setOptions] = react.useState(() => configStore.getOptions())
|
|
781
|
+
|
|
782
|
+
react.useEffect(() => {
|
|
783
|
+
let alive = true
|
|
784
|
+
const refresh = () => {
|
|
785
|
+
configStore.loadOptions(true).then((value) => {
|
|
786
|
+
if (alive) setOptions(value)
|
|
787
|
+
})
|
|
788
|
+
}
|
|
789
|
+
// A provider added in another settings section must be visible here on the
|
|
790
|
+
// next visit, and on returning to the window, not after a restart.
|
|
791
|
+
refresh()
|
|
792
|
+
window.addEventListener('focus', refresh)
|
|
793
|
+
return () => {
|
|
794
|
+
alive = false
|
|
795
|
+
window.removeEventListener('focus', refresh)
|
|
796
|
+
}
|
|
797
|
+
}, [])
|
|
589
798
|
|
|
590
799
|
react.useEffect(() => {
|
|
591
800
|
if (!config || config.unavailable) return
|
|
@@ -621,6 +830,19 @@ function PredictionSettings() {
|
|
|
621
830
|
|
|
622
831
|
const update = (patch) => setDraft({ ...draft, ...patch })
|
|
623
832
|
|
|
833
|
+
const providerChoices = (options ? options.providers : []).map((provider) => ({
|
|
834
|
+
value: provider.id,
|
|
835
|
+
label: provider.name && provider.name !== provider.id ? provider.name + ' · ' + provider.id : provider.id,
|
|
836
|
+
}))
|
|
837
|
+
const effectiveProvider = draft.provider || (options ? options.autoProvider : '')
|
|
838
|
+
const seenModels = new Set()
|
|
839
|
+
const modelChoices = []
|
|
840
|
+
for (const model of options ? options.models : []) {
|
|
841
|
+
if (model.provider !== effectiveProvider || seenModels.has(model.id)) continue
|
|
842
|
+
seenModels.add(model.id)
|
|
843
|
+
modelChoices.push({ value: model.id, label: model.name && model.name !== model.id ? model.name + ' · ' + model.id : model.id })
|
|
844
|
+
}
|
|
845
|
+
|
|
624
846
|
return h(
|
|
625
847
|
'section',
|
|
626
848
|
{ style: { display: 'flex', flexDirection: 'column', gap: 16, maxWidth: 640, padding: '8px 0 24px' } },
|
|
@@ -644,42 +866,34 @@ function PredictionSettings() {
|
|
|
644
866
|
onToggle: (enabled) => update({ enabled }),
|
|
645
867
|
}),
|
|
646
868
|
),
|
|
647
|
-
h(
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
{
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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
|
-
),
|
|
869
|
+
h(Combo, {
|
|
870
|
+
label: words.provider,
|
|
871
|
+
hint: words.providerHint,
|
|
872
|
+
value: draft.provider,
|
|
873
|
+
placeholder: words.auto,
|
|
874
|
+
options: [{ value: '', label: words.auto }].concat(providerChoices),
|
|
875
|
+
onChange: (provider) => update({ provider }),
|
|
876
|
+
}),
|
|
877
|
+
h(Combo, {
|
|
878
|
+
label: words.model,
|
|
879
|
+
hint: words.modelHint,
|
|
880
|
+
value: draft.model,
|
|
881
|
+
placeholder: words.auto,
|
|
882
|
+
options: [{ value: '', label: words.auto }].concat(modelChoices),
|
|
883
|
+
onChange: (model) => update({ model }),
|
|
884
|
+
}),
|
|
885
|
+
h(Combo, {
|
|
886
|
+
label: words.effort,
|
|
887
|
+
hint: words.effortHint,
|
|
888
|
+
value: draft.reasoningEffort,
|
|
889
|
+
placeholder: words.auto,
|
|
890
|
+
allowCustom: false,
|
|
891
|
+
options: [''].concat(['off', 'none', 'minimal', 'low', 'high']).map((value) => ({
|
|
892
|
+
value,
|
|
893
|
+
label: value === '' ? words.auto : value,
|
|
894
|
+
})),
|
|
895
|
+
onChange: (reasoningEffort) => update({ reasoningEffort }),
|
|
896
|
+
}),
|
|
683
897
|
),
|
|
684
898
|
h(
|
|
685
899
|
'div',
|
package/index.js
CHANGED
|
@@ -23,12 +23,13 @@
|
|
|
23
23
|
import z from '@deepseek-ai/schemastery'
|
|
24
24
|
|
|
25
25
|
export const name = 'composer-predictions'
|
|
26
|
-
export const GENERATOR_VERSION = '0.1.
|
|
26
|
+
export const GENERATOR_VERSION = '0.1.1'
|
|
27
27
|
// webServer: the route; sessions: the transcript; llm: the model call.
|
|
28
28
|
export const inject = ['webServer', 'sessions', 'llm']
|
|
29
29
|
|
|
30
30
|
const PREDICT_PATH = '/composer-predictions/predict'
|
|
31
31
|
const CONFIG_PATH = '/composer-predictions/config'
|
|
32
|
+
const OPTIONS_PATH = '/composer-predictions/options'
|
|
32
33
|
const BODY_LIMIT_BYTES = 8 * 1024
|
|
33
34
|
|
|
34
35
|
const CHEAP_MODEL = /\b(flash|mini|small|lite|fast|haiku|nano)\b/i
|
|
@@ -737,6 +738,79 @@ export function sanitizeSettings(input) {
|
|
|
737
738
|
return out
|
|
738
739
|
}
|
|
739
740
|
|
|
741
|
+
/** A provider that cannot enumerate models must not hang the settings page. */
|
|
742
|
+
function withTimeout(promise, ms) {
|
|
743
|
+
return new Promise((resolve, reject) => {
|
|
744
|
+
const timer = setTimeout(() => reject(new Error('timeout')), ms)
|
|
745
|
+
Promise.resolve(promise).then(
|
|
746
|
+
(value) => {
|
|
747
|
+
clearTimeout(timer)
|
|
748
|
+
resolve(value)
|
|
749
|
+
},
|
|
750
|
+
(error) => {
|
|
751
|
+
clearTimeout(timer)
|
|
752
|
+
reject(error)
|
|
753
|
+
},
|
|
754
|
+
)
|
|
755
|
+
})
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* The provider/model catalog the settings page offers. Kept off the predict
|
|
760
|
+
* path: the dock asks for a suggestion on every mount and must stay fast.
|
|
761
|
+
*/
|
|
762
|
+
export async function collectOptions(ctx) {
|
|
763
|
+
// No cache: a provider added in Settings → Models must appear on the next
|
|
764
|
+
// open of this page, not after a TTL.
|
|
765
|
+
const llm = ctx && typeof ctx.get === 'function' ? ctx.get('llm') : undefined
|
|
766
|
+
const listed = llm && typeof llm.listProviders === 'function' ? llm.listProviders() : []
|
|
767
|
+
const providers = (Array.isArray(listed) ? listed : [])
|
|
768
|
+
.filter((provider) => provider && typeof provider.id === 'string')
|
|
769
|
+
.map((provider) => ({ id: provider.id, name: typeof provider.name === 'string' ? provider.name : provider.id }))
|
|
770
|
+
const models = []
|
|
771
|
+
if (llm && typeof llm.listModels === 'function') {
|
|
772
|
+
await Promise.all(
|
|
773
|
+
providers.map(async (provider) => {
|
|
774
|
+
try {
|
|
775
|
+
const list = await withTimeout(llm.listModels(provider.id), 3_000)
|
|
776
|
+
for (const model of Array.isArray(list) ? list : []) {
|
|
777
|
+
if (model && typeof model.id === 'string') {
|
|
778
|
+
models.push({ provider: provider.id, id: model.id, name: typeof model.name === 'string' ? model.name : model.id })
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
} catch {
|
|
782
|
+
// A provider with no reachable catalog simply contributes no suggestions.
|
|
783
|
+
}
|
|
784
|
+
}),
|
|
785
|
+
)
|
|
786
|
+
}
|
|
787
|
+
const selection = defaultSelection(ctx)
|
|
788
|
+
const value = {
|
|
789
|
+
providers,
|
|
790
|
+
models,
|
|
791
|
+
autoProvider: selection && typeof selection.provider === 'string' ? selection.provider : '',
|
|
792
|
+
}
|
|
793
|
+
return value
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
export function createOptionsHandler(ctx) {
|
|
797
|
+
return async function handle(request, response) {
|
|
798
|
+
if (request.method !== 'GET') {
|
|
799
|
+
response.writeHead(405, { allow: 'GET' }).end()
|
|
800
|
+
return
|
|
801
|
+
}
|
|
802
|
+
if (!requestAllowed(request)) {
|
|
803
|
+
send(response, 403, { error: 'forbidden' })
|
|
804
|
+
return
|
|
805
|
+
}
|
|
806
|
+
try {
|
|
807
|
+
send(response, 200, await collectOptions(ctx))
|
|
808
|
+
} catch (error) {
|
|
809
|
+
send(response, 500, { error: error && error.message ? error.message : String(error) })
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
740
814
|
/**
|
|
741
815
|
* Settings endpoint. Writes go through the platform's configEditor, which
|
|
742
816
|
* validates against the Config schema and persists the override in the
|
|
@@ -801,6 +875,7 @@ export function apply(ctx, config) {
|
|
|
801
875
|
state.options = next
|
|
802
876
|
},
|
|
803
877
|
)
|
|
878
|
+
const optionsHandler = createOptionsHandler(ctx)
|
|
804
879
|
ctx.effect(
|
|
805
880
|
() => ctx.webServer.register({ kind: 'exact', path: PREDICT_PATH, handler: predictHandler }),
|
|
806
881
|
'composer-predictions: predict route',
|
|
@@ -809,4 +884,8 @@ export function apply(ctx, config) {
|
|
|
809
884
|
() => ctx.webServer.register({ kind: 'exact', path: CONFIG_PATH, handler: configHandler }),
|
|
810
885
|
'composer-predictions: config route',
|
|
811
886
|
)
|
|
887
|
+
ctx.effect(
|
|
888
|
+
() => ctx.webServer.register({ kind: 'exact', path: OPTIONS_PATH, handler: optionsHandler }),
|
|
889
|
+
'composer-predictions: options route',
|
|
890
|
+
)
|
|
812
891
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lsfcy/dsh-composer-predictions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "Codex-style composer predictions for DeepSeek Harness: after a turn settles, the empty composer shows a dimmed suggestion of your next message; Tab accepts it.",
|
package/src/main.js
CHANGED
|
@@ -25,6 +25,7 @@ const createGhostLayer = globalThis.createComposerGhost
|
|
|
25
25
|
|
|
26
26
|
const PREDICT_PATH = '/composer-predictions/predict'
|
|
27
27
|
const CONFIG_PATH = '/composer-predictions/config'
|
|
28
|
+
const OPTIONS_PATH = '/composer-predictions/options'
|
|
28
29
|
const CLIENT_TIMEOUT_MS = 30_000
|
|
29
30
|
const ATTACH_FRAMES = 120
|
|
30
31
|
|
|
@@ -39,6 +40,8 @@ const noSession = () => undefined
|
|
|
39
40
|
const configStore = (() => {
|
|
40
41
|
let snapshot = null
|
|
41
42
|
let pending = null
|
|
43
|
+
let options = null
|
|
44
|
+
let optionsPending = null
|
|
42
45
|
const listeners = new Set()
|
|
43
46
|
const publish = (next) => {
|
|
44
47
|
snapshot = next
|
|
@@ -78,6 +81,37 @@ const configStore = (() => {
|
|
|
78
81
|
})
|
|
79
82
|
return pending
|
|
80
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
|
+
},
|
|
81
115
|
async save(patch) {
|
|
82
116
|
const response = await fetch(CONFIG_PATH, {
|
|
83
117
|
method: 'POST',
|
|
@@ -255,6 +289,163 @@ function Field({ label, hint, children }) {
|
|
|
255
289
|
return h('label', { style: LABEL_STYLE }, h('span', null, label), children, hint ? h('span', { style: HINT_STYLE }, hint) : null)
|
|
256
290
|
}
|
|
257
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
|
+
|
|
258
449
|
function PredictionSettings() {
|
|
259
450
|
const config = useConfig()
|
|
260
451
|
const locale = useLocaleId()
|
|
@@ -262,6 +453,24 @@ function PredictionSettings() {
|
|
|
262
453
|
const [draft, setDraft] = react.useState(null)
|
|
263
454
|
const [status, setStatus] = react.useState('')
|
|
264
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
|
+
}, [])
|
|
265
474
|
|
|
266
475
|
react.useEffect(() => {
|
|
267
476
|
if (!config || config.unavailable) return
|
|
@@ -297,6 +506,19 @@ function PredictionSettings() {
|
|
|
297
506
|
|
|
298
507
|
const update = (patch) => setDraft({ ...draft, ...patch })
|
|
299
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
|
+
|
|
300
522
|
return h(
|
|
301
523
|
'section',
|
|
302
524
|
{ style: { display: 'flex', flexDirection: 'column', gap: 16, maxWidth: 640, padding: '8px 0 24px' } },
|
|
@@ -320,42 +542,34 @@ function PredictionSettings() {
|
|
|
320
542
|
onToggle: (enabled) => update({ enabled }),
|
|
321
543
|
}),
|
|
322
544
|
),
|
|
323
|
-
h(
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
{
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
),
|
|
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
|
+
}),
|
|
359
573
|
),
|
|
360
574
|
h(
|
|
361
575
|
'div',
|