@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 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
- ![Composer prediction: ghost appears, Tab accepts](demo/demo-en.gif)
18
+ ![Composer prediction: ghost appears, Tab accepts](https://raw.githubusercontent.com/lsfcy/dsh-composer-predictions/main/demo/demo-en.gif)
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.0`.
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
- ![灰字预测:出现、Tab 接受](demo/demo-zh.gif)
18
+ ![灰字预测:出现、Tab 接受](https://raw.githubusercontent.com/lsfcy/dsh-composer-predictions/main/demo/demo-zh.gif)
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.0`。
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
- Field,
649
- { label: words.provider, hint: words.providerHint },
650
- h('input', {
651
- type: 'text',
652
- value: draft.provider,
653
- placeholder: words.auto,
654
- onChange: (event) => update({ provider: event.target.value }),
655
- style: FIELD_STYLE,
656
- }),
657
- ),
658
- h(
659
- Field,
660
- { label: words.model, hint: words.modelHint },
661
- h('input', {
662
- type: 'text',
663
- value: draft.model,
664
- placeholder: words.auto,
665
- onChange: (event) => update({ model: event.target.value }),
666
- style: FIELD_STYLE,
667
- }),
668
- ),
669
- h(
670
- Field,
671
- { label: words.effort, hint: words.effortHint },
672
- h(
673
- 'select',
674
- {
675
- value: draft.reasoningEffort,
676
- onChange: (event) => update({ reasoningEffort: event.target.value }),
677
- style: FIELD_STYLE,
678
- },
679
- h('option', { value: '' }, words.auto),
680
- ...[['off'], ['none'], ['minimal'], ['low'], ['high']].map(([value]) => h('option', { key: value, value }, value)),
681
- ),
682
- ),
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.0'
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
- export async function resolveEffort(llm, route, signal) {
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
- const ids = efforts
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 = config.reasoningEffort || (await resolveEffort(ctx.llm, route, signal))
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.0",
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
- 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
- ),
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',