@fonlan/dsh-model-parameters 0.1.4 → 0.1.6

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.
@@ -1,98 +1,43 @@
1
1
  /**
2
- * Styles for the Model Parameters Settings Card (设置 → 插件 → 插件配置).
2
+ * Styles for the Model Parameters settings page.
3
3
  *
4
- * The expandable chrome mirrors the built-in plugin cards
5
- * (dsh-client-ui-settings-plugins PluginCard.module.css): external plugins
6
- * cannot import PluginCard, so the shell/head/chevron/body are re-drawn with
7
- * the same tokens and geometry. Inner controls follow the built-in cards'
8
- * field language (ValueField): border-l2 inputs on bg-layer-3, inverted
9
- * primary buttons, divider-separated groups.
4
+ * The page occupies the settings panel's content column: a header (title plus
5
+ * a one-line summary) over divider-separated control groups. Inner controls
6
+ * follow the built-in settings language: border-l2 inputs on bg-layer-3,
7
+ * inverted primary buttons.
10
8
  */
11
9
 
12
- /* ── card chrome (aligned with the built-in PluginCard) ─────────────────── */
10
+ /* page layout */
13
11
 
14
- .mp-card {
15
- border: 1px solid var(--dsw-alias-border-l2, #2a3346);
16
- background: var(--dsw-alias-bg-layer-3, #0d1017);
17
- border-radius: 12px;
18
- list-style: none;
19
- transition: border-color .16s, background .16s;
20
- }
21
-
22
- .mp-card:hover {
23
- border-color: var(--dsw-alias-label-dimmed, #4a5468);
24
- }
25
-
26
- .mp-card[data-open] {
27
- background: var(--dsw-alias-bg-layer-2, #141a26);
28
- border-color: var(--dsw-alias-label-dimmed, #4a5468);
29
- }
30
-
31
- .mp-card-head {
32
- appearance: none;
33
- width: 100%;
34
- font: inherit;
35
- color: inherit;
36
- text-align: left;
37
- cursor: pointer;
38
- background: transparent;
39
- border: 0;
40
- border-radius: 12px;
41
- align-items: center;
42
- gap: 12px;
43
- padding: 14px 16px;
12
+ .mp-page {
44
13
  display: flex;
14
+ flex-direction: column;
15
+ gap: 16px;
16
+ max-width: 720px;
45
17
  }
46
18
 
47
- .mp-card-head:focus-visible {
48
- outline: 2px solid var(--dsw-alias-brand-primary, #4f8cff);
49
- outline-offset: -2px;
50
- }
51
-
52
- .mp-card-headText {
53
- flex-direction: column;
54
- flex: 1;
55
- gap: 4px;
56
- min-width: 0;
19
+ .mp-page-head {
57
20
  display: flex;
21
+ flex-direction: column;
22
+ gap: 6px;
58
23
  }
59
24
 
60
- .mp-card-title {
25
+ .mp-page-title {
26
+ margin: 0;
61
27
  color: var(--dsw-alias-label-primary, #e6e6e6);
62
28
  font-size: 15px;
63
29
  font-weight: 600;
64
30
  line-height: 1.4;
65
31
  }
66
32
 
67
- .mp-card-sub {
33
+ .mp-page-sub {
34
+ margin: 0;
68
35
  color: var(--dsw-alias-label-tertiary, #8b93a7);
69
36
  font-size: 13px;
70
37
  line-height: 1.5;
71
38
  }
72
39
 
73
- .mp-card-chevron {
74
- color: var(--dsw-alias-label-tertiary, #8b93a7);
75
- flex: none;
76
- transition: transform .16s;
77
- display: flex;
78
- align-items: center;
79
- justify-content: center;
80
- }
81
-
82
- .mp-card-chevron[data-open] {
83
- transform: rotate(180deg);
84
- }
85
-
86
- .mp-card-body {
87
- border-top: 1px solid var(--dsw-alias-border-l2, #2a3346);
88
- margin: 0 16px;
89
- padding: 12px 0 8px;
90
- display: flex;
91
- flex-direction: column;
92
- gap: 12px;
93
- }
94
-
95
- /* ── body layout ────────────────────────────────────────────────────────── */
40
+ /* body layout */
96
41
 
97
42
  .mp-body {
98
43
  display: flex;
@@ -141,7 +86,7 @@
141
86
  line-height: 1.6;
142
87
  }
143
88
 
144
- /* ── toggles ────────────────────────────────────────────────────────────── */
89
+ /* toggles */
145
90
 
146
91
  .mp-toggle {
147
92
  display: flex;
@@ -177,7 +122,7 @@
177
122
  line-height: 1.5;
178
123
  }
179
124
 
180
- /* ── catalog meta / ttl ─────────────────────────────────────────────────── */
125
+ /* catalog meta / ttl */
181
126
 
182
127
  .mp-meta {
183
128
  display: flex;
@@ -198,7 +143,7 @@
198
143
  line-height: 1.6;
199
144
  }
200
145
 
201
- /* ── inputs (ValueField language) ───────────────────────────────────────── */
146
+ /* inputs */
202
147
 
203
148
  .mp-input {
204
149
  border: 1px solid var(--dsw-alias-border-l2, #2a3346);
@@ -226,7 +171,7 @@
226
171
  width: 72px;
227
172
  }
228
173
 
229
- /* ── mapping rows ───────────────────────────────────────────────────────── */
174
+ /* mapping rows */
230
175
 
231
176
  .mp-map-row {
232
177
  display: flex;
@@ -244,7 +189,7 @@
244
189
  flex: none;
245
190
  }
246
191
 
247
- /* ── buttons (PluginCard footer language) ────────────────────────────────── */
192
+ /* buttons */
248
193
 
249
194
  .mp-actions {
250
195
  display: flex;
@@ -291,7 +236,7 @@
291
236
  border-color: var(--dsw-alias-label-dimmed, #4a5468);
292
237
  }
293
238
 
294
- /* ── report / badges / code ─────────────────────────────────────────────── */
239
+ /* report / badges / code */
295
240
 
296
241
  .mp-report {
297
242
  margin: 0;
@@ -1,18 +1,15 @@
1
1
  /**
2
- * The Model Parameters Settings Card: master + per-field toggles, catalog TTL,
3
- * provider→models.dev mapping overrides, a manual refresh/fill action, the
4
- * catalog freshness line, and the last fill report (filled / touched /
5
- * unmatched).
2
+ * The Model Parameters settings page (设置 → 侧栏「模型参数补全」).
6
3
  *
7
- * The card registers into the `settings.plugin.item` slot keyed by the
8
- * `model-parameters` settings namespace, so it renders inside
9
- * 设置 → 插件 → 插件配置, stacked with the built-in plugin cards. The chrome is
10
- * self-drawn (external plugins cannot import the built-in PluginCard) with
11
- * styles aligned to it: an expandable header (collapsed by default) and a body
12
- * that discloses the controls in place.
4
+ * Registers into the `settings.section` slot, so the page owns one entry in
5
+ * the settings sidebar and renders its content in the panel's content column.
6
+ * Content: master + per-field toggles, catalog TTL, provider→models.dev
7
+ * mapping overrides, a manual refresh/fill action, the catalog freshness line,
8
+ * and the last fill report (filled / touched / unmatched).
13
9
  *
14
10
  * All reads/mutations go through the plugin's fenced API; config persists into
15
- * the `model-parameters` settings namespace (settings.yaml).
11
+ * the `model-parameters` profile entry — the plugin's own loader entry config,
12
+ * written through the host settings service.
16
13
  */
17
14
  import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'
18
15
  import type { Context } from '@deepseek-ai/cordis'
@@ -21,7 +18,7 @@ import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
21
18
  import { LOCALE_NS } from './locales'
22
19
  import { api, type PluginState } from './api'
23
20
  import type { ModelParametersConfig } from '../shared/config.js'
24
- import './settings-card.css'
21
+ import './settings-section.css'
25
22
 
26
23
  function useLocaleRevision(ctx: ClientContext): number {
27
24
  const subscribe = useCallback(
@@ -52,7 +49,7 @@ function formatTime(epochMs: number): string {
52
49
  }
53
50
  }
54
51
 
55
- export function makeSettingsCard(ctx: ClientContext): () => JSX.Element {
52
+ export function makeSettingsSection(ctx: ClientContext): () => JSX.Element {
56
53
  const t: Translate = (() => {
57
54
  try {
58
55
  return ctx.locale.bind(LOCALE_NS) as unknown as Translate
@@ -61,10 +58,8 @@ export function makeSettingsCard(ctx: ClientContext): () => JSX.Element {
61
58
  }
62
59
  })()
63
60
 
64
- return function ModelParametersCard(): JSX.Element {
61
+ return function ModelParametersSettingsSection(): JSX.Element {
65
62
  useLocaleRevision(ctx)
66
- // Card-local disclosure: collapsed by default, like the built-in plugin cards.
67
- const [open, setOpen] = useState(false)
68
63
  const [state, setState] = useState<PluginState | null>(null)
69
64
  const [error, setError] = useState<string | null>(null)
70
65
  const [loading, setLoading] = useState(true)
@@ -140,58 +135,42 @@ export function makeSettingsCard(ctx: ClientContext): () => JSX.Element {
140
135
  await patchConfig({ providerMap })
141
136
  }, [mappingRows, patchConfig])
142
137
 
143
- const title = t('settingsTitle')
144
-
145
138
  return (
146
- <li className="mp-card" data-open={open ? '' : undefined}>
147
- <button type="button" className="mp-card-head" aria-expanded={open}
148
- aria-label={(open ? t('collapse') : t('expand')) + ':' + title}
149
- onClick={() => setOpen(!open)}>
150
- <span className="mp-card-headText">
151
- <span className="mp-card-title">{title}</span>
152
- <span className="mp-card-sub">{t('cardSub')}</span>
153
- </span>
154
- <span className="mp-card-chevron" data-open={open ? '' : undefined} aria-hidden="true">
155
- <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
156
- <path d="M3.5 5.25L7 8.75L10.5 5.25" stroke="currentColor" strokeWidth="1.5"
157
- strokeLinecap="round" strokeLinejoin="round" />
158
- </svg>
159
- </span>
160
- </button>
161
- {open && (
162
- <div className="mp-card-body">
163
- {error !== null && state === null ? (
164
- <p className="mp-error">{t('error')}: {error}</p>
165
- ) : loading || state === null ? (
166
- <p className="mp-muted">{t('saving')}</p>
167
- ) : (
168
- <ModelParametersCardBody
169
- t={t}
170
- state={state}
171
- error={error}
172
- saving={saving}
173
- busy={busy}
174
- mappingRows={mappingRows}
175
- newFrom={newFrom}
176
- newTo={newTo}
177
- setNewFrom={setNewFrom}
178
- setNewTo={setNewTo}
179
- setMappingRows={setMappingRows}
180
- onPatchConfig={patchConfig}
181
- onRefresh={refresh}
182
- onReconcile={reconcileNow}
183
- onApplyMappings={applyMappings}
184
- />
185
- )}
186
- </div>
139
+ <div className="mp-page">
140
+ <header className="mp-page-head">
141
+ <h3 className="mp-page-title">{t('settingsTitle')}</h3>
142
+ <p className="mp-page-sub">{t('sectionSub')}</p>
143
+ </header>
144
+ {error !== null && state === null ? (
145
+ <p className="mp-error">{t('error')}: {error}</p>
146
+ ) : loading || state === null ? (
147
+ <p className="mp-muted">{t('saving')}</p>
148
+ ) : (
149
+ <ModelParametersBody
150
+ t={t}
151
+ state={state}
152
+ error={error}
153
+ saving={saving}
154
+ busy={busy}
155
+ mappingRows={mappingRows}
156
+ newFrom={newFrom}
157
+ newTo={newTo}
158
+ setNewFrom={setNewFrom}
159
+ setNewTo={setNewTo}
160
+ setMappingRows={setMappingRows}
161
+ onPatchConfig={patchConfig}
162
+ onRefresh={refresh}
163
+ onReconcile={reconcileNow}
164
+ onApplyMappings={applyMappings}
165
+ />
187
166
  )}
188
- </li>
167
+ </div>
189
168
  )
190
169
  }
191
170
  }
192
171
 
193
- /** The card body: divider-separated control groups, styled like the built-in cards. */
194
- function ModelParametersCardBody(props: {
172
+ /** The page body: divider-separated control groups. */
173
+ function ModelParametersBody(props: {
195
174
  t: Translate
196
175
  state: PluginState
197
176
  error: string | null
package/src/index.ts CHANGED
@@ -6,11 +6,11 @@
6
6
  * tokens / reasoning efforts / input modalities (the norm for a freshly
7
7
  * synced gateway), it fills the missing fields from a cached models.dev
8
8
  * catalog (TTL-refreshed), and serves the fenced JSON API the web settings
9
- * page calls. The plugin's own toggles live in the `model-parameters`
10
- * settings namespace (settings.yaml).
9
+ * page calls. The plugin's own toggles are its loader entry config (the
10
+ * `model-parameters` profile entry, schema {@link Config}).
11
11
  */
12
12
  import type { Context } from '@deepseek-ai/cordis'
13
- import { Config } from './shared/config.js'
13
+ import { Config, type ModelParametersLiveConfig } from './shared/config.js'
14
14
  import { ModelParametersService } from './server/service.js'
15
15
  import { registerApiRoutes } from './server/rpc.js'
16
16
  import {
@@ -32,8 +32,8 @@ export { Config }
32
32
  export { CatalogManager, acceptedInputModalities, identityReasoningEfforts }
33
33
  export type { CatalogEntry, CatalogSnapshot }
34
34
 
35
- export function apply(ctx: Context): void {
36
- const service = new ModelParametersService(ctx)
35
+ export function apply(ctx: Context, config: ModelParametersLiveConfig = {}): void {
36
+ const service = new ModelParametersService(ctx, config)
37
37
 
38
38
  ctx.effect(() => {
39
39
  service.start()
@@ -1,26 +1,26 @@
1
1
  /**
2
- * The model-parameters service: owns the plugin settings namespace, watches
3
- * the `llm-pi-ai` namespace for provider model writes, reconciles missing
4
- * fields from the cached models.dev catalog, and tracks a fill report.
2
+ * The model-parameters service: completes missing model fields in the
3
+ * `llm-pi-ai` settings namespace from the cached models.dev catalog and tracks
4
+ * a fill report. Its own settings ARE this plugin's loader entry config (see
5
+ * shared/config.ts), so there is no per-plugin settings scope to own here.
5
6
  *
6
7
  * Trigger model:
7
8
  * - any committed `llm-pi-ai` settings change (settings UI apply, manual
8
- * settings.yaml edit published by the file provider, model-router-style
9
- * external writes) runs a reconcile;
9
+ * profile-patch edit, model-router-style external writes) runs a reconcile;
10
10
  * - plugin start runs a backfill reconcile over the existing configuration.
11
11
  * Reconcile is fill-only: it never overwrites a present field, and it writes
12
12
  * nothing when nothing is missing — so its own writes cannot re-trigger a
13
13
  * second patch (the next event sees the fields present and produces no ops).
14
14
  */
15
15
  import type { Context } from '@deepseek-ai/cordis'
16
- import type { SettingsScope, SettingsPathOp } from '@deepseek-ai/dsh-settings'
16
+ import type { SettingsPathOp } from '@deepseek-ai/dsh-settings'
17
17
  import type { PiAiModelProfile } from '@deepseek-ai/dsh-llm-pi-ai'
18
18
  import {
19
- Config,
20
- DEFAULT_CONFIG,
21
19
  PLUGIN_NS,
22
20
  TARGET_NS,
21
+ readConfig,
23
22
  type ModelParametersConfig,
23
+ type ModelParametersLiveConfig,
24
24
  } from '../shared/config.js'
25
25
  import {
26
26
  CatalogManager,
@@ -53,56 +53,66 @@ export class ModelParametersService {
53
53
  * a sibling fiber. The injected child ctx itself is only valid while its
54
54
  * fiber is active — the provider can be reloaded (and every dependent
55
55
  * fiber torn down) while an async reconcile is in flight — so all reads
56
- * and writes go through the captured service value, which is a plain
57
- * object and never walks the fiber chain.
56
+ * and writes of the `llm-pi-ai` namespace go through the captured service
57
+ * value, which is a plain object and never walks the fiber chain.
58
58
  */
59
59
  private settings: Context['settings'] | undefined
60
- private scope: SettingsScope<ModelParametersConfig> | undefined
61
60
  private lastReport: FillReport | null = null
62
61
  private readonly catalog: CatalogManager
63
62
  private reconciling: Promise<void> | null = null
64
63
 
65
- constructor(private readonly ctx: Context) {
64
+ /**
65
+ * @param ctx the plugin context.
66
+ * @param entryConfig this plugin's own live entry config (the profile entry
67
+ * `model-parameters`): the resolved `Config` document, whose `.volatile()`
68
+ * fields are live cells that the loader swaps in place on every settings
69
+ * write. Read through {@link readConfig} on each access, never cached.
70
+ */
71
+ constructor(
72
+ private readonly ctx: Context,
73
+ private readonly entryConfig: ModelParametersLiveConfig | undefined,
74
+ ) {
66
75
  this.catalog = new CatalogManager(ctx.logger)
67
76
  }
68
77
 
69
78
  start(): void {
70
- // Own settings namespace (config toggles, TTL, provider map). The inject
71
- // callback fires asynchronously once the settings service is available, so
72
- // the first-run backfill reconcile lives INSIDE it — reconcile needs the
79
+ // The plugin's own settings ARE this entry config, so nothing is
80
+ // registered here: `config()` projects the live entry config and writes
81
+ // go through the settings service (see updateConfig). The inject callback
82
+ // fires asynchronously once the settings service is available, so the
83
+ // first-run backfill reconcile lives INSIDE it — reconcile needs the
73
84
  // settings service (to read the target namespace and persist fills).
74
85
  this.ctx.inject(['settings'], (sctx) => {
75
86
  this.settings = sctx.settings
76
- this.scope = this.settings.register(OWN, Config) as SettingsScope<ModelParametersConfig>
77
87
  // First-run backfill: ensure a fresh-enough catalog, then fill existing
78
88
  // models. Never blocks plugin startup.
79
89
  void this.reconcile()
80
90
  })
81
91
 
82
92
  // Watch the target namespace: any committed provider-model change may
83
- // leave fields missing -> reconcile.
84
- this.ctx.on('settings/updated', (ns) => {
93
+ // leave fields missing -> reconcile. `settings/document-updated` is the
94
+ // committed-change signal (dsh-settings bumps a form revision after the
95
+ // profile patch was written); it also fires for this plugin's own entry,
96
+ // which is filtered out here so a toggle write never triggers a reconcile.
97
+ this.ctx.on('settings/document-updated', (ns) => {
85
98
  if (ns !== TARGET) return
86
99
  void this.reconcile()
87
100
  })
88
101
  }
89
102
 
90
103
  stop(): void {
91
- this.scope = undefined
92
104
  this.settings = undefined
93
105
  }
94
106
 
95
107
  // ── reads for the settings page ────────────────────────────────────────────
96
108
 
109
+ /**
110
+ * The plugin's current settings, projected from the live entry config.
111
+ * Works with no settings service at all (headless), which is why the plain
112
+ * {@link DEFAULT_CONFIG} fallback lives in {@link readConfig}.
113
+ */
97
114
  config(): ModelParametersConfig {
98
- if (this.scope !== undefined) {
99
- try {
100
- return this.scope.get()
101
- } catch {
102
- // fall through to defaults
103
- }
104
- }
105
- return { ...DEFAULT_CONFIG, providerMap: {}, officialProviders: [...DEFAULT_CONFIG.officialProviders] }
115
+ return readConfig(this.entryConfig)
106
116
  }
107
117
 
108
118
  catalogSnapshot(): CatalogSnapshot {
@@ -119,13 +129,29 @@ export class ModelParametersService {
119
129
  await this.reconcile()
120
130
  }
121
131
 
122
- /** Update plugin config toggles from the settings page. */
132
+ /**
133
+ * Update the plugin's own settings from the settings page. `dsh-settings`
134
+ * addresses the plugin's loader entry by its id and refuses any path the
135
+ * `Config` schema did not mark volatile — every field here is volatile.
136
+ */
123
137
  async updateConfig(patch: Partial<ModelParametersConfig>): Promise<void> {
124
- if (this.scope === undefined) {
138
+ const settings = this.settings
139
+ if (settings === undefined) {
125
140
  throw new Error('model-parameters: settings service is not available in this profile')
126
141
  }
127
- const current = this.config()
128
- await this.scope.update({ ...current, ...patch })
142
+ // `update` MERGES the patch into the entry's stored section — the
143
+ // "merge a partial into the current config" contract the page has always
144
+ // used. `providerMap` is the one plain OBJECT the page re-sends whole (a
145
+ // removed mapping row must disappear), and a deep merge would resurrect
146
+ // the removed key, so that field rides a path `set` op instead: `mutate`
147
+ // delegates to the same `write()` and replaces the value at the path
148
+ // wholesale. Arrays (`officialProviders`) are replaced by the merge
149
+ // itself, since the merge only recurses into plain objects.
150
+ const { providerMap, ...rest } = patch
151
+ if (Object.keys(rest).length > 0) await settings.update(OWN, rest)
152
+ if (providerMap !== undefined) {
153
+ await settings.mutate(OWN, [{ op: 'set', path: ['providerMap'], value: providerMap }])
154
+ }
129
155
  }
130
156
 
131
157
  // ── reconcile ──────────────────────────────────────────────────────────────
@@ -158,7 +184,7 @@ export class ModelParametersService {
158
184
  // Operate on the RAW user section (descriptor.user), not the resolved
159
185
  // value: the resolved section is schema-normalized (pi-ai's loose object
160
186
  // resolution adds empty `input`/`compat` defaults), and persisting that
161
- // shape would leak schema-default noise into settings.yaml. The raw
187
+ // shape would leak schema-default noise into the profile patch. The raw
162
188
  // section is exactly what the user document stores.
163
189
  const descriptor = settings.describe({ redactSecrets: false }).find((candidate) => candidate.ns === TARGET)
164
190
  const section = descriptor?.user as { providers?: Record<string, { models?: PiAiModelProfile[] }> } | undefined