@lsfcy/dsh-composer-predictions 0.1.0 → 0.1.2
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 +252 -38
- package/index.js +105 -4
- package/package.json +1 -1
- package/src/main.js +252 -38
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.2`.
|
|
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.2`。
|
|
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',
|
|
@@ -439,7 +473,7 @@ const COPY = {
|
|
|
439
473
|
model: '模型 id',
|
|
440
474
|
modelHint: '留空 = 自动选该 provider 下最便宜的型号。',
|
|
441
475
|
effort: '推理档',
|
|
442
|
-
effortHint: '留空 =
|
|
476
|
+
effortHint: '留空 = 自动选支持的最省一档;模型不支持的档位会自动退到最省档。',
|
|
443
477
|
auto: '自动',
|
|
444
478
|
save: '保存',
|
|
445
479
|
reset: '恢复默认',
|
|
@@ -460,7 +494,7 @@ const COPY = {
|
|
|
460
494
|
model: 'Model id',
|
|
461
495
|
modelHint: 'Empty = the cheapest model on that provider.',
|
|
462
496
|
effort: 'Reasoning effort',
|
|
463
|
-
effortHint: 'Empty = the cheapest effort the model supports.',
|
|
497
|
+
effortHint: 'Empty = the cheapest effort the model supports; an unsupported pinned effort falls back to the cheapest one.',
|
|
464
498
|
auto: 'Auto',
|
|
465
499
|
save: 'Save',
|
|
466
500
|
reset: 'Reset to defaults',
|
|
@@ -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.2'
|
|
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
|
|
@@ -316,7 +317,7 @@ export async function resolveRoute(llm, config = {}, signal) {
|
|
|
316
317
|
* The cheapest reasoning effort the adapter supports, or undefined when the
|
|
317
318
|
* model does not reason (or cannot be inspected). Explicit config always wins.
|
|
318
319
|
*/
|
|
319
|
-
|
|
320
|
+
async function supportedEfforts(llm, route, signal) {
|
|
320
321
|
if (!llm || typeof llm.resolveModelInfo !== 'function') return undefined
|
|
321
322
|
let info
|
|
322
323
|
try {
|
|
@@ -325,9 +326,31 @@ export async function resolveEffort(llm, route, signal) {
|
|
|
325
326
|
return undefined
|
|
326
327
|
}
|
|
327
328
|
const efforts = info && info.reasoning && Array.isArray(info.reasoning.efforts) ? info.reasoning.efforts : []
|
|
328
|
-
|
|
329
|
+
return efforts
|
|
329
330
|
.map((effort) => (effort && effort.id != null ? String(effort.id) : ''))
|
|
330
331
|
.filter(Boolean)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** The cheapest effort the model advertises, when the adapter can tell us. */
|
|
335
|
+
export async function resolveEffort(llm, route, signal) {
|
|
336
|
+
const ids = await supportedEfforts(llm, route, signal)
|
|
337
|
+
if (!ids) return undefined
|
|
338
|
+
for (const candidate of EFFORT_PREFERENCE) {
|
|
339
|
+
if (ids.includes(candidate)) return candidate
|
|
340
|
+
}
|
|
341
|
+
return undefined
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* A pinned effort is only used when the model advertises it. Otherwise the
|
|
346
|
+
* prediction falls back to the cheapest supported effort: an explicit but
|
|
347
|
+
* unsupported id makes the provider reject the whole call
|
|
348
|
+
* (UNSUPPORTED_REASONING_EFFORT) and the composer would show nothing.
|
|
349
|
+
*/
|
|
350
|
+
export async function chooseEffort(llm, route, signal, requested) {
|
|
351
|
+
const ids = await supportedEfforts(llm, route, signal)
|
|
352
|
+
if (!ids) return undefined
|
|
353
|
+
if (requested && ids.includes(requested)) return requested
|
|
331
354
|
for (const candidate of EFFORT_PREFERENCE) {
|
|
332
355
|
if (ids.includes(candidate)) return candidate
|
|
333
356
|
}
|
|
@@ -540,7 +563,7 @@ export async function predict(ctx, config, sessionId, signal) {
|
|
|
540
563
|
},
|
|
541
564
|
signal,
|
|
542
565
|
)
|
|
543
|
-
const reasoningEffort =
|
|
566
|
+
const reasoningEffort = await chooseEffort(ctx.llm, route, signal, config.reasoningEffort)
|
|
544
567
|
|
|
545
568
|
let raw = ''
|
|
546
569
|
let finish
|
|
@@ -737,6 +760,79 @@ export function sanitizeSettings(input) {
|
|
|
737
760
|
return out
|
|
738
761
|
}
|
|
739
762
|
|
|
763
|
+
/** A provider that cannot enumerate models must not hang the settings page. */
|
|
764
|
+
function withTimeout(promise, ms) {
|
|
765
|
+
return new Promise((resolve, reject) => {
|
|
766
|
+
const timer = setTimeout(() => reject(new Error('timeout')), ms)
|
|
767
|
+
Promise.resolve(promise).then(
|
|
768
|
+
(value) => {
|
|
769
|
+
clearTimeout(timer)
|
|
770
|
+
resolve(value)
|
|
771
|
+
},
|
|
772
|
+
(error) => {
|
|
773
|
+
clearTimeout(timer)
|
|
774
|
+
reject(error)
|
|
775
|
+
},
|
|
776
|
+
)
|
|
777
|
+
})
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* The provider/model catalog the settings page offers. Kept off the predict
|
|
782
|
+
* path: the dock asks for a suggestion on every mount and must stay fast.
|
|
783
|
+
*/
|
|
784
|
+
export async function collectOptions(ctx) {
|
|
785
|
+
// No cache: a provider added in Settings → Models must appear on the next
|
|
786
|
+
// open of this page, not after a TTL.
|
|
787
|
+
const llm = ctx && typeof ctx.get === 'function' ? ctx.get('llm') : undefined
|
|
788
|
+
const listed = llm && typeof llm.listProviders === 'function' ? llm.listProviders() : []
|
|
789
|
+
const providers = (Array.isArray(listed) ? listed : [])
|
|
790
|
+
.filter((provider) => provider && typeof provider.id === 'string')
|
|
791
|
+
.map((provider) => ({ id: provider.id, name: typeof provider.name === 'string' ? provider.name : provider.id }))
|
|
792
|
+
const models = []
|
|
793
|
+
if (llm && typeof llm.listModels === 'function') {
|
|
794
|
+
await Promise.all(
|
|
795
|
+
providers.map(async (provider) => {
|
|
796
|
+
try {
|
|
797
|
+
const list = await withTimeout(llm.listModels(provider.id), 3_000)
|
|
798
|
+
for (const model of Array.isArray(list) ? list : []) {
|
|
799
|
+
if (model && typeof model.id === 'string') {
|
|
800
|
+
models.push({ provider: provider.id, id: model.id, name: typeof model.name === 'string' ? model.name : model.id })
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
} catch {
|
|
804
|
+
// A provider with no reachable catalog simply contributes no suggestions.
|
|
805
|
+
}
|
|
806
|
+
}),
|
|
807
|
+
)
|
|
808
|
+
}
|
|
809
|
+
const selection = defaultSelection(ctx)
|
|
810
|
+
const value = {
|
|
811
|
+
providers,
|
|
812
|
+
models,
|
|
813
|
+
autoProvider: selection && typeof selection.provider === 'string' ? selection.provider : '',
|
|
814
|
+
}
|
|
815
|
+
return value
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
export function createOptionsHandler(ctx) {
|
|
819
|
+
return async function handle(request, response) {
|
|
820
|
+
if (request.method !== 'GET') {
|
|
821
|
+
response.writeHead(405, { allow: 'GET' }).end()
|
|
822
|
+
return
|
|
823
|
+
}
|
|
824
|
+
if (!requestAllowed(request)) {
|
|
825
|
+
send(response, 403, { error: 'forbidden' })
|
|
826
|
+
return
|
|
827
|
+
}
|
|
828
|
+
try {
|
|
829
|
+
send(response, 200, await collectOptions(ctx))
|
|
830
|
+
} catch (error) {
|
|
831
|
+
send(response, 500, { error: error && error.message ? error.message : String(error) })
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
740
836
|
/**
|
|
741
837
|
* Settings endpoint. Writes go through the platform's configEditor, which
|
|
742
838
|
* validates against the Config schema and persists the override in the
|
|
@@ -801,6 +897,7 @@ export function apply(ctx, config) {
|
|
|
801
897
|
state.options = next
|
|
802
898
|
},
|
|
803
899
|
)
|
|
900
|
+
const optionsHandler = createOptionsHandler(ctx)
|
|
804
901
|
ctx.effect(
|
|
805
902
|
() => ctx.webServer.register({ kind: 'exact', path: PREDICT_PATH, handler: predictHandler }),
|
|
806
903
|
'composer-predictions: predict route',
|
|
@@ -809,4 +906,8 @@ export function apply(ctx, config) {
|
|
|
809
906
|
() => ctx.webServer.register({ kind: 'exact', path: CONFIG_PATH, handler: configHandler }),
|
|
810
907
|
'composer-predictions: config route',
|
|
811
908
|
)
|
|
909
|
+
ctx.effect(
|
|
910
|
+
() => ctx.webServer.register({ kind: 'exact', path: OPTIONS_PATH, handler: optionsHandler }),
|
|
911
|
+
'composer-predictions: options route',
|
|
912
|
+
)
|
|
812
913
|
}
|
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.2",
|
|
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',
|
|
@@ -115,7 +149,7 @@ const COPY = {
|
|
|
115
149
|
model: '模型 id',
|
|
116
150
|
modelHint: '留空 = 自动选该 provider 下最便宜的型号。',
|
|
117
151
|
effort: '推理档',
|
|
118
|
-
effortHint: '留空 =
|
|
152
|
+
effortHint: '留空 = 自动选支持的最省一档;模型不支持的档位会自动退到最省档。',
|
|
119
153
|
auto: '自动',
|
|
120
154
|
save: '保存',
|
|
121
155
|
reset: '恢复默认',
|
|
@@ -136,7 +170,7 @@ const COPY = {
|
|
|
136
170
|
model: 'Model id',
|
|
137
171
|
modelHint: 'Empty = the cheapest model on that provider.',
|
|
138
172
|
effort: 'Reasoning effort',
|
|
139
|
-
effortHint: 'Empty = the cheapest effort the model supports.',
|
|
173
|
+
effortHint: 'Empty = the cheapest effort the model supports; an unsupported pinned effort falls back to the cheapest one.',
|
|
140
174
|
auto: 'Auto',
|
|
141
175
|
save: 'Save',
|
|
142
176
|
reset: 'Reset to defaults',
|
|
@@ -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',
|