dsh-plugin-effort-declare 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.
@@ -13,6 +13,10 @@ export interface RouteDraft {
13
13
  /** Whether `compat` is present on the user profile (not the effective view). */
14
14
  compatPresent: boolean;
15
15
  }
16
+ /** JSON-stable equality matching pathOps (key order included). */
17
+ export declare function sliceEqual(left: unknown, right: unknown): boolean;
18
+ /** Whether two settings slices differ. */
19
+ export declare function sliceChanged(before: unknown, after: unknown): boolean;
16
20
  /** Build a draft from the stored user subtree (never from effective `value`). */
17
21
  export declare function routeDraftFromUserProfile(args: {
18
22
  provider: string;
@@ -31,8 +35,36 @@ export declare function applySaveSuccess(drafts: readonly RouteDraft[], savedPro
31
35
  revision: number;
32
36
  }): RouteDraft[];
33
37
  /**
34
- * Apply a freshly loaded table. Dirty cards keep models/compat; originals and
35
- * revision follow the incoming snapshot so a later save is against the new user layer.
38
+ * Membership follows the latest user-layer models list (Models page add/delete).
39
+ * Local unsaved `reasoningEfforts` (including a cleared key) overlay by id.
40
+ */
41
+ export declare function mergeModelsById(args: {
42
+ prevModels: readonly Record<string, unknown>[];
43
+ prevOriginal: readonly Record<string, unknown>[];
44
+ incomingModels: readonly Record<string, unknown>[];
45
+ incomingOriginal: readonly Record<string, unknown>[];
46
+ }): {
47
+ models: Record<string, unknown>[];
48
+ conflicted: boolean;
49
+ };
50
+ /**
51
+ * Three-way compat merge: locally changed keys stay local; everything else
52
+ * follows incoming. Conflict only when a locally dirty key also moved in originals.
53
+ */
54
+ export declare function mergeCompat(args: {
55
+ prev: Record<string, unknown>;
56
+ prevOriginal: Record<string, unknown>;
57
+ incoming: Record<string, unknown>;
58
+ incomingOriginal: Record<string, unknown>;
59
+ }): {
60
+ compat: Record<string, unknown>;
61
+ conflicted: boolean;
62
+ };
63
+ /**
64
+ * Apply a freshly loaded table. Membership and metadata follow incoming;
65
+ * unsaved reasoningEfforts / dirty compat keys overlay by id. Conflict only
66
+ * when a locally dirty field also changed in originals (revision-only bumps
67
+ * and sibling-card saves do not warn).
36
68
  */
37
69
  export declare function mergeLoadedDrafts(current: readonly RouteDraft[], incoming: readonly RouteDraft[], options: {
38
70
  preserveDirty: boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"drafts.d.ts","sourceRoot":"","sources":["../../../src/core/drafts.ts"],"names":[],"mappings":"AAQA,iEAAiE;AACjE,MAAM,WAAW,UAAU;IACzB,QAAQ,EAAE,MAAM,CAAA;IAChB,WAAW,EAAE,MAAM,CAAA;IACnB,0EAA0E;IAC1E,YAAY,EAAE,SAAS,MAAM,EAAE,CAAA;IAC/B,uEAAuE;IACvE,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAA;IACjC,cAAc,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAA;IACzC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAC/B,cAAc,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IACvC,gFAAgF;IAChF,aAAa,EAAE,OAAO,CAAA;CACvB;AAED,iFAAiF;AACjF,wBAAgB,yBAAyB,CAAC,IAAI,EAAE;IAC9C,QAAQ,EAAE,MAAM,CAAA;IAChB,WAAW,EAAE,MAAM,CAAA;IACnB,YAAY,EAAE,SAAS,MAAM,EAAE,CAAA;IAC/B,QAAQ,EAAE,MAAM,CAAA;IAChB,WAAW,EAAE,OAAO,CAAA;CACrB,GAAG,UAAU,CAgBb;AAED,wEAAwE;AACxE,wBAAgB,UAAU,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAQrD;AAED,+EAA+E;AAC/E,wBAAgB,UAAU,CAAC,KAAK,EAAE,UAAU,GAAG,UAAU,CAOxD;AAED,4FAA4F;AAC5F,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,SAAS,UAAU,EAAE,EAC7B,aAAa,EAAE,MAAM,EACrB,KAAK,EAAE;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GACzC,UAAU,EAAE,CAcd;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,SAAS,UAAU,EAAE,EAC9B,QAAQ,EAAE,SAAS,UAAU,EAAE,EAC/B,OAAO,EAAE;IAAE,aAAa,EAAE,OAAO,CAAA;CAAE,GAClC;IAAE,MAAM,EAAE,UAAU,EAAE,CAAC;IAAC,UAAU,EAAE,MAAM,EAAE,CAAA;CAAE,CAkBhD;AAED,+EAA+E;AAC/E,wBAAgB,cAAc,CAAC,MAAM,EAAE;IAAE,OAAO,EAAE,MAAM,CAAA;CAAE,GAAG,MAAM,CAGlE;AAED,6DAA6D;AAC7D,wBAAgB,mBAAmB,CAAC,MAAM,EAAE;IAAE,OAAO,EAAE,MAAM,CAAA;CAAE,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAE5F;AAED,gFAAgF;AAChF,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,EAAE,OAAO,EAAE,OAAO,GAAG,MAAM,EAAE,CAK5F"}
1
+ {"version":3,"file":"drafts.d.ts","sourceRoot":"","sources":["../../../src/core/drafts.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-plugin-effort-declare",
3
3
  "description": "Settings page to declare per-model reasoningEfforts and openai-completions compat on hand-declared llm-pi-ai routes. Writes the official namespace; does not intercept llm/stream or replace the Models page.",
4
- "version": "0.1.0",
4
+ "version": "0.1.2",
5
5
  "private": false,
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -31,17 +31,16 @@ import { buildSaveOps } from '../core/path-ops.ts'
31
31
  import { cloneModels, cloneObject } from '../core/paths.ts'
32
32
  import { modelEffortError } from '../core/validate.ts'
33
33
  import { loadDrafts } from './load-drafts.ts'
34
- import type { SchemaOps } from './schema-ops.ts'
34
+ import { validateSaveDraft, type SchemaOps } from './schema-ops.ts'
35
35
  import type { EffortDeclareKey } from './locales.ts'
36
36
  import css from './effort-declare.module.css'
37
37
 
38
- export type InvalidationSource = 'settings' | 'directory'
38
+ export type InvalidationSource = 'settings' | 'directory' | 'writable'
39
39
 
40
40
  export interface EffortDeclareSectionInjected {
41
41
  api: Pick<IApiClient, 'settings' | 'llm'>
42
42
  describe: SettingsDescribeFace
43
43
  schema: SchemaOps
44
- t: (key: EffortDeclareKey) => string
45
44
  subscribeInvalidate: (listener: (source: InvalidationSource) => void) => () => void
46
45
  }
47
46
 
@@ -166,18 +165,20 @@ function RouteCard(props: {
166
165
  draft: RouteDraft
167
166
  formats: readonly string[]
168
167
  writable: boolean
168
+ /** This card is the in-flight mutate — show “Saving…”. */
169
169
  busy: boolean
170
- reloading: boolean
170
+ /** Namespace-level lock: any mutate, read-only, or first load. */
171
+ saveLocked: boolean
171
172
  notice: CardNotice | undefined
172
173
  t: (key: EffortDeclareKey) => string
173
174
  onChange: (draft: RouteDraft) => void
174
175
  onSave: (draft: RouteDraft) => void
175
176
  onCancel: (draft: RouteDraft) => void
176
177
  }): ReactNode {
177
- const { draft, writable, busy, reloading, t, onChange } = props
178
+ const { draft, writable, busy, saveLocked, t, onChange } = props
178
179
  const noModels = draft.models.length === 0
179
180
  const editDisabled = !writable || busy || noModels
180
- const saveLocked = editDisabled || reloading
181
+ const saveDisabled = saveLocked || noModels
181
182
  const formats = thinkingFormatChoices(props.formats, draft.compat.thinkingFormat)
182
183
  const summary = compatSummary(draft.compat)
183
184
  const sameWire = draft.compat.supportsReasoningEffort === false
@@ -303,7 +304,7 @@ function RouteCard(props: {
303
304
  <button
304
305
  type="button"
305
306
  className={css.primaryButton}
306
- disabled={saveLocked || !dirty || clientError !== undefined}
307
+ disabled={saveDisabled || !dirty || clientError !== undefined}
307
308
  onClick={() => { props.onSave(draft) }}
308
309
  >
309
310
  {busy ? t('saving') : t('save')}
@@ -336,12 +337,12 @@ export function EffortDeclareSection(props: EffortDeclareSectionProps): ReactNod
336
337
  return
337
338
  }
338
339
  const generation = nextGeneration(generationRef)
339
- setStatus('loading')
340
+ if (draftsRef.current.length === 0) setStatus('loading')
340
341
  setError('')
341
342
  void loadDrafts(api, describe, schema).then((result) => {
342
343
  if (!generationIsCurrent(generationRef, generation)) return
343
344
  setWritable(result.writable)
344
- if (result.formats.length > 0) setFormats(result.formats)
345
+ setFormats(result.formats)
345
346
  if (result.error !== undefined) {
346
347
  setStatus('error')
347
348
  setError(result.error)
@@ -350,9 +351,13 @@ export function EffortDeclareSection(props: EffortDeclareSectionProps): ReactNod
350
351
  const merged = mergeLoadedDrafts(draftsRef.current, result.drafts, { preserveDirty })
351
352
  setDrafts(merged.drafts)
352
353
  if (merged.conflicted.length > 0) {
353
- setNotices(Object.fromEntries(
354
- merged.conflicted.map(provider => [provider, { kind: 'conflict' as const, text: t('dirtyConflict') }]),
355
- ))
354
+ setNotices(current => {
355
+ const next = { ...current }
356
+ for (const provider of merged.conflicted) {
357
+ next[provider] = { kind: 'conflict', text: t('dirtyConflict') }
358
+ }
359
+ return next
360
+ })
356
361
  }
357
362
  setStatus('ready')
358
363
  }, (failure: unknown) => {
@@ -366,21 +371,40 @@ export function EffortDeclareSection(props: EffortDeclareSectionProps): ReactNod
366
371
 
367
372
  useEffect(() => {
368
373
  if (props.subscribeInvalidate === undefined) return undefined
369
- return props.subscribeInvalidate(() => { reload(true) })
370
- }, [props.subscribeInvalidate, reload])
374
+ return props.subscribeInvalidate((source) => {
375
+ if (source === 'writable') {
376
+ const view = describe?.getSnapshot().view
377
+ if (view !== undefined) setWritable(view.writable)
378
+ return
379
+ }
380
+ if (source === 'settings' || source === 'directory') reload(true)
381
+ })
382
+ }, [describe, props.subscribeInvalidate, reload])
383
+
384
+ const patchNotice = (provider: string, notice: CardNotice | undefined): void => {
385
+ setNotices(current => {
386
+ const copy = { ...current }
387
+ if (notice === undefined) delete copy[provider]
388
+ else copy[provider] = notice
389
+ return copy
390
+ })
391
+ }
371
392
 
372
393
  const save = async (draft: RouteDraft): Promise<void> => {
373
- if (api === undefined || describe === undefined) return
374
- if (status === 'loading' || busyRoute !== null) return
394
+ if (api === undefined || describe === undefined || schema === undefined) return
395
+ if (status === 'loading' || busyRoute !== null) {
396
+ patchNotice(draft.provider, { kind: 'error', text: t('saveBusy') })
397
+ return
398
+ }
375
399
  const blocking = draft.models
376
400
  .map(row => errorText(modelEffortError(row), t))
377
401
  .find(text => text !== undefined)
378
402
  if (blocking !== undefined) {
379
- setNotices({ [draft.provider]: { kind: 'error', text: blocking } })
403
+ patchNotice(draft.provider, { kind: 'error', text: blocking })
380
404
  return
381
405
  }
382
406
  setBusyRoute(draft.provider)
383
- setNotices({})
407
+ patchNotice(draft.provider, undefined)
384
408
  try {
385
409
  const ops = buildSaveOps({
386
410
  settingsPath: draft.settingsPath,
@@ -393,18 +417,44 @@ export function EffortDeclareSection(props: EffortDeclareSectionProps): ReactNod
393
417
  setDrafts(current => current.map(row => row.provider === draft.provider ? alignDraft(row) : row))
394
418
  return
395
419
  }
420
+ const willWriteCompat = ops.some(op => (
421
+ op.path.length > draft.settingsPath.length && op.path[draft.settingsPath.length] === 'compat'
422
+ ))
423
+ const pi = describe.getSnapshot().view?.namespaces.find(view => view.ns === LLM_PI_AI_NS)
424
+ if (pi !== undefined) {
425
+ let root: unknown
426
+ try {
427
+ root = schema.rehydrate(pi.schema)
428
+ } catch {
429
+ root = undefined
430
+ }
431
+ if (root !== undefined) {
432
+ const schemaError = validateSaveDraft(
433
+ schema,
434
+ root,
435
+ draft.settingsPath,
436
+ draft.models,
437
+ draft.compat,
438
+ willWriteCompat,
439
+ )
440
+ if (schemaError !== undefined) {
441
+ patchNotice(draft.provider, { kind: 'error', text: schemaError })
442
+ return
443
+ }
444
+ }
445
+ }
396
446
  const response = await api.settings.mutate({
397
447
  ns: LLM_PI_AI_NS,
398
448
  ops,
399
449
  expectedRevision: draft.revision,
400
450
  })
401
451
  if (!response.result.ok) {
402
- setNotices({
403
- [draft.provider]: {
404
- kind: 'error',
405
- text: response.result.error.code === 'settings-conflict' ? t('conflict') : response.result.error.message,
406
- },
452
+ const conflict = response.result.error.code === 'settings-conflict'
453
+ patchNotice(draft.provider, {
454
+ kind: conflict ? 'conflict' : 'error',
455
+ text: conflict ? t('conflict') : response.result.error.message,
407
456
  })
457
+ if (conflict) reload(true)
408
458
  return
409
459
  }
410
460
  const view = response.result.value
@@ -413,13 +463,11 @@ export function EffortDeclareSection(props: EffortDeclareSectionProps): ReactNod
413
463
  user: view.user ?? {},
414
464
  revision: view.revision,
415
465
  }))
416
- setNotices({ [draft.provider]: { kind: 'saved', text: t('saved') } })
466
+ patchNotice(draft.provider, { kind: 'saved', text: t('saved') })
417
467
  } catch (failure) {
418
- setNotices({
419
- [draft.provider]: {
420
- kind: 'error',
421
- text: failure instanceof Error ? failure.message : t('loadError'),
422
- },
468
+ patchNotice(draft.provider, {
469
+ kind: 'error',
470
+ text: failure instanceof Error ? failure.message : t('loadError'),
423
471
  })
424
472
  } finally {
425
473
  setBusyRoute(null)
@@ -429,6 +477,10 @@ export function EffortDeclareSection(props: EffortDeclareSectionProps): ReactNod
429
477
  const showLoading = status === 'loading' && drafts.length === 0
430
478
  const showEmpty = status === 'ready' && drafts.length === 0
431
479
  const showList = drafts.length > 0
480
+ const hasCardFailure = Object.values(notices).some(
481
+ notice => notice.kind === 'conflict' || notice.kind === 'error',
482
+ )
483
+ const showReload = status === 'error' || hasCardFailure || showEmpty || !writable || status === 'loading' || showList
432
484
 
433
485
  return (
434
486
  <div className={css.section}>
@@ -437,7 +489,7 @@ export function EffortDeclareSection(props: EffortDeclareSectionProps): ReactNod
437
489
  {!writable && status === 'ready' ? <p className={css.notice}>{t('readOnly')}</p> : null}
438
490
  {showLoading ? <p className={css.intro}>{t('loading')}</p> : null}
439
491
  {status === 'error' ? <p className={css.error}>{error}</p> : null}
440
- {status === 'error'
492
+ {showReload
441
493
  ? (
442
494
  <button type="button" className={css.secondaryButton} onClick={() => { reload(true) }}>{t('reload')}</button>
443
495
  )
@@ -460,24 +512,16 @@ export function EffortDeclareSection(props: EffortDeclareSectionProps): ReactNod
460
512
  formats={formats.length > 0 ? formats : []}
461
513
  writable={writable}
462
514
  busy={busyRoute === draft.provider}
463
- reloading={status === 'loading'}
515
+ saveLocked={!writable || busyRoute !== null || status === 'loading'}
464
516
  notice={notices[draft.provider]}
465
517
  t={t}
466
518
  onChange={(next) => {
467
- setNotices(current => {
468
- const copy = { ...current }
469
- delete copy[next.provider]
470
- return copy
471
- })
519
+ patchNotice(next.provider, undefined)
472
520
  setDrafts(current => current.map(row => row.provider === next.provider ? next : row))
473
521
  }}
474
522
  onSave={(next) => { void save(next) }}
475
523
  onCancel={(next) => {
476
- setNotices(current => {
477
- const copy = { ...current }
478
- delete copy[next.provider]
479
- return copy
480
- })
524
+ patchNotice(next.provider, undefined)
481
525
  setDrafts(current => current.map(row => row.provider === next.provider
482
526
  ? {
483
527
  ...row,
@@ -17,11 +17,11 @@ Cross-plugin work uses Cordis services only (`connection`, `settingsScope`, `set
17
17
 
18
18
  | File | Role |
19
19
  | --- | --- |
20
- | [`index.ts`](./index.ts) | Registers zh/en copy, CSS (`ctx.effect` insert/remove), and `settings.section` (id `effort-declare`, order 15). Subscribes in `apply` to `settings/document-updated` (`llm-pi-ai` only), `llm/adapters-updated`, and `connection/reset`. |
21
- | [`EffortDeclareSection.tsx`](./EffortDeclareSection.tsx) | Route cards, presets, Off tri-state, advanced protocol switches, save and cancel. |
22
- | [`load-drafts.ts`](./load-drafts.ts) | Builds drafts from `llm.providers` + the settings mirror; drafts come from `user`, protocol classification may use `value`. |
20
+ | [`index.ts`](./index.ts) | Registers zh/en copy, CSS (`ctx.effect` insert/remove), and `settings.section` (id `effort-declare`, order 12). `describe.subscribe` only syncs `writable` (it fires for every namespace, including this page’s own `acceptView`). Full reloads come from `settings/document-updated` (`llm-pi-ai` only), `llm/adapters-updated`, and `connection/reset`. `locale: NS` lets the framework inject `t`; `inject` only returns `api` / `describe` / `schema` / `subscribeInvalidate`. |
21
+ | [`EffortDeclareSection.tsx`](./EffortDeclareSection.tsx) | Route cards, presets, Off tri-state, advanced protocol switches, save and cancel. Save is exclusive for the whole `llm-pi-ai` namespace; `busy` is “Saving…” on the in-flight card, `saveLocked` disables the others. A successful write folds `acceptView` + `applySaveSuccess` and does not full-reload. |
22
+ | [`load-drafts.ts`](./load-drafts.ts) | Builds drafts from `llm.providers` + the settings mirror; drafts come from `user`, protocol classification may use `value`. `thinkingFormat` choices come only from the live schema union. |
23
23
  | [`locales.ts`](./locales.ts) | Copy namespace `plugin-effort-declare`. |
24
24
  | [`effort-declare.module.css`](./effort-declare.module.css) | `--dsw-alias-*` tokens only, so dark theme stays correct. |
25
- | [`schema-ops.ts`](./schema-ops.ts) | Binds `settingsSchema` as plain callbacks so the service identity is not passed into React. |
25
+ | [`schema-ops.ts`](./schema-ops.ts) | Binds `settingsSchema` as plain callbacks (including `validate`) so the service identity is not passed into React. |
26
26
 
27
27
  Visual language follows the official Models page, not `--ds-*` variables with light-mode fallbacks.
@@ -17,11 +17,11 @@
17
17
 
18
18
  | 文件 | 说明 |
19
19
  | --- | --- |
20
- | [`index.ts`](./index.ts) | 注册 zh/en 文案、CSS(`ctx.effect` 插入/移除)与 `settings.section`(id `effort-declare`,order 15)。在 `apply` 里订阅 `settings/document-updated`(仅 `llm-pi-ai`)、`llm/adapters-updated`、`connection/reset`。 |
21
- | [`EffortDeclareSection.tsx`](./EffortDeclareSection.tsx) | 路由卡片、预设、Off 三态、高级协议开关、保存与取消。 |
22
- | [`load-drafts.ts`](./load-drafts.ts) | 从 `llm.providers` + 镜像组装草稿;草稿取自 `user`,协议分类可用 `value`。 |
20
+ | [`index.ts`](./index.ts) | 注册 zh/en 文案、CSS(`ctx.effect` 插入/移除)与 `settings.section`(id `effort-declare`,order 12)。`describe.subscribe` 只同步 `writable`(任意 namespace 都会触发,含自己的 `acceptView`)。真正重载来自 `settings/document-updated`(仅 `llm-pi-ai`)、`llm/adapters-updated`、`connection/reset`。`locale: NS` 由框架注入 `t`,inject 只传 `api` / `describe` / `schema` / `subscribeInvalidate`。 |
21
+ | [`EffortDeclareSection.tsx`](./EffortDeclareSection.tsx) | 路由卡片、预设、Off 三态、高级协议开关、保存与取消。保存互斥到整个 `llm-pi-ai` namespace;`busy` 只给正在 mutate 的卡显示「保存中」,其它卡 `saveLocked`。自己写入成功走 `acceptView` + `applySaveSuccess`,不再全量 reload。 |
22
+ | [`load-drafts.ts`](./load-drafts.ts) | 从 `llm.providers` + 镜像组装草稿;草稿取自 `user`,协议分类可用 `value`。`thinkingFormat` 可选项只来自现场 schema union。 |
23
23
  | [`locales.ts`](./locales.ts) | 文案 namespace `plugin-effort-declare`。 |
24
24
  | [`effort-declare.module.css`](./effort-declare.module.css) | 仅使用 `--dsw-alias-*` 设计令牌,保证暗色主题正确。 |
25
- | [`schema-ops.ts`](./schema-ops.ts) | 将 `settingsSchema` 收成普通回调,避免把服务身份带进 React。 |
25
+ | [`schema-ops.ts`](./schema-ops.ts) | 将 `settingsSchema` 收成普通回调(含 `validate`),避免把服务身份带进 React。 |
26
26
 
27
27
  样式与交互对齐官方模型页,而不是带浅色 fallback 的 `--ds-*` 变量。
@@ -53,8 +53,12 @@ export function apply(ctx: ClientContext): void {
53
53
  ),
54
54
  getPath: (value, path) => settingsSchema.getPath(value, path),
55
55
  hasPath: (value, path) => settingsSchema.hasPath(value, path),
56
+ validate: (node, draft) => settingsSchema.validate(
57
+ node as Parameters<typeof settingsSchema.validate>[0],
58
+ draft,
59
+ ),
56
60
  })
57
- const t = ctx.locale.bind(NS) as EffortDeclareSectionInjected['t']
61
+ const t = ctx.locale.bind(NS)
58
62
  const describe = ctx.settingsScope.describe()
59
63
  const invalidation = new Set<(source: InvalidationSource) => void>()
60
64
 
@@ -62,7 +66,11 @@ export function apply(ctx: ClientContext): void {
62
66
  const emit = (source: InvalidationSource) => {
63
67
  for (const listener of invalidation) listener(source)
64
68
  }
69
+ // Mirror subscribe fires on every namespace (theme, locale, own acceptView).
70
+ // Only fold writable from that; llm-pi-ai document changes come from the
71
+ // Host event below so a dirty card is not treated as a conflict.
65
72
  const disposers = [
73
+ describe.subscribe(() => { emit('writable') }),
66
74
  ctx.remote.$on('settings/document-updated', (ns: string) => {
67
75
  if (ns !== LLM_PI_AI_NS) return
68
76
  emit('settings')
@@ -83,14 +91,13 @@ export function apply(ctx: ClientContext): void {
83
91
  ctx.slots.inject('settings.section', () => ctx.slots.register({
84
92
  name: 'settings.section',
85
93
  id: 'effort-declare',
86
- order: 15,
94
+ order: 12,
87
95
  label: () => t('nav'),
88
96
  locale: NS,
89
97
  inject: (): EffortDeclareSectionInjected => ({
90
98
  api: connection.api,
91
99
  describe,
92
100
  schema,
93
- t,
94
101
  subscribeInvalidate,
95
102
  }),
96
103
  }, EffortDeclareSection))
@@ -5,7 +5,6 @@
5
5
  import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
6
6
  import type { SettingsDescribeFace } from '@deepseek-ai/dsh-client-ui-settings/client'
7
7
  import {
8
- FALLBACK_THINKING_FORMATS,
9
8
  LLM_PI_AI_NS,
10
9
  SCHEMA_PROBE_ROUTE,
11
10
  } from '../core/catalog.ts'
@@ -29,6 +28,9 @@ export interface LoadDraftsResult {
29
28
  /**
30
29
  * First paint: `ensure()` (reads only from idle). Never treat ensure as refresh.
31
30
  * Callers that must not apply a stale settlement compare generation themselves.
31
+ *
32
+ * `formats` is the live schema union only. Empty means the dropdown has no
33
+ * writable choices (stored values stay visible via `thinkingFormatChoices`).
32
34
  */
33
35
  export async function loadDrafts(
34
36
  api: Pick<IApiClient, 'llm'>,
@@ -38,20 +40,20 @@ export async function loadDrafts(
38
40
  await describe.ensure()
39
41
  const mirrored = describe.getSnapshot()
40
42
  if (mirrored.view === undefined) {
41
- return { writable: false, formats: [...FALLBACK_THINKING_FORMATS], drafts: [], error: mirrored.error ?? undefined }
43
+ return { writable: false, formats: [], drafts: [], error: mirrored.error ?? undefined }
42
44
  }
43
45
  const providersResponse = await api.llm.providers({})
44
46
  if (!providersResponse.result.ok) {
45
47
  return {
46
48
  writable: mirrored.view.writable,
47
- formats: [...FALLBACK_THINKING_FORMATS],
49
+ formats: [],
48
50
  drafts: [],
49
51
  error: providersResponse.result.error.message,
50
52
  }
51
53
  }
52
54
  const namespaces = new Map(mirrored.view.namespaces.map((view: SettingsNamespaceView) => [view.ns, view]))
53
55
  const pi = namespaces.get(LLM_PI_AI_NS)
54
- let formats: string[] = [...FALLBACK_THINKING_FORMATS]
56
+ let formats: string[] = []
55
57
  let schemaDefaultApi: string | undefined
56
58
  if (pi !== undefined) {
57
59
  try {
@@ -61,7 +63,7 @@ export async function loadDrafts(
61
63
  if (fromSchema.length > 0) formats = fromSchema
62
64
  schemaDefaultApi = schemaDefaultString(schema.nodeAtPath(root, ['providers', SCHEMA_PROBE_ROUTE, 'api']))
63
65
  } catch {
64
- // Live schema walk is best-effort; fallback list is test-pinned.
66
+ // Live schema walk is best-effort; UI options stay empty rather than a handwritten list.
65
67
  }
66
68
  }
67
69
  const drafts: RouteDraft[] = []
@@ -13,6 +13,7 @@ export type EffortDeclareKey =
13
13
  | 'readOnly'
14
14
  | 'save'
15
15
  | 'saving'
16
+ | 'saveBusy'
16
17
  | 'cancel'
17
18
  | 'saved'
18
19
  | 'conflict'
@@ -57,6 +58,7 @@ export const zh: Record<EffortDeclareKey, string> = {
57
58
  readOnly: '当前设置为只读,无法保存。',
58
59
  save: '保存',
59
60
  saving: '保存中…',
61
+ saveBusy: '另有路由正在保存,请稍候。',
60
62
  cancel: '取消',
61
63
  saved: '已保存。对话选择器会按新的能力声明显示 Effort 行。',
62
64
  conflict: '设置已被其他地方改过,请重新加载后再保存。',
@@ -77,7 +79,7 @@ export const zh: Record<EffortDeclareKey, string> = {
77
79
  clear: '清除本模型声明',
78
80
  advanced: '高级:协议方言',
79
81
  thinkingFormat: 'thinkingFormat',
80
- thinkingFormatDefault: '默认(openai)',
82
+ thinkingFormatDefault: '默认(省略该键)',
81
83
  supportsDeveloperRole: '系统提示走 system 而不是 developer(supportsDeveloperRole: false)',
82
84
  supportsReasoningEffort: '不发 reasoning_effort,只发开关(supportsReasoningEffort: false)',
83
85
  developerTrueHint: '当前文档是 supportsDeveloperRole: true。v1 只能强制 false 或缺席;勾选会写成 false,取消勾选会删除该键。',
@@ -100,6 +102,7 @@ export const en: Record<EffortDeclareKey, string> = {
100
102
  readOnly: 'Settings are read-only; saving is disabled.',
101
103
  save: 'Save',
102
104
  saving: 'Saving…',
105
+ saveBusy: 'Another route is saving. Wait, then save this card.',
103
106
  cancel: 'Cancel',
104
107
  saved: 'Saved. The composer Effort row follows this capability declaration.',
105
108
  conflict: 'Settings changed elsewhere. Reload, then save again.',
@@ -120,7 +123,7 @@ export const en: Record<EffortDeclareKey, string> = {
120
123
  clear: 'Clear this model’s declaration',
121
124
  advanced: 'Advanced: protocol dialect',
122
125
  thinkingFormat: 'thinkingFormat',
123
- thinkingFormatDefault: 'Default (openai)',
126
+ thinkingFormatDefault: 'Default (omit the key)',
124
127
  supportsDeveloperRole: 'Send system prompts as system, not developer (supportsDeveloperRole: false)',
125
128
  supportsReasoningEffort: 'Do not send reasoning_effort; switch only (supportsReasoningEffort: false)',
126
129
  developerTrueHint: 'The document has supportsDeveloperRole: true. v1 can only force false or omit the key; checking writes false, unchecking deletes the key.',
@@ -7,6 +7,11 @@ export interface SchemaOps {
7
7
  nodeAtPath: (root: unknown, path: readonly string[]) => unknown
8
8
  getPath: (value: unknown, path: readonly string[]) => unknown
9
9
  hasPath: (value: unknown, path: readonly string[]) => boolean
10
+ /**
11
+ * Official `settingsSchema.validate`: failure text, or `undefined` when valid.
12
+ * Missing nodes are skipped by the caller — do not invent a second validator.
13
+ */
14
+ validate: (node: unknown, draft: unknown) => string | undefined
10
15
  }
11
16
 
12
17
  /** Wrap a live settingsSchema service as plain callbacks. */
@@ -16,5 +21,33 @@ export function bindSchema(service: SchemaOps): SchemaOps {
16
21
  nodeAtPath: (root, path) => service.nodeAtPath(root, path),
17
22
  getPath: (value, path) => service.getPath(value, path),
18
23
  hasPath: (value, path) => service.hasPath(value, path),
24
+ validate: (node, draft) => service.validate(node, draft),
19
25
  }
20
26
  }
27
+
28
+ /**
29
+ * Pre-mutate schema check used by the settings page.
30
+ * A returned string means do not call `settings.mutate`.
31
+ */
32
+ export function validateSaveDraft(
33
+ schema: SchemaOps,
34
+ root: unknown,
35
+ settingsPath: readonly string[],
36
+ afterModels: unknown,
37
+ afterCompat: unknown,
38
+ willWriteCompat: boolean,
39
+ ): string | undefined {
40
+ const modelsNode = schema.nodeAtPath(root, [...settingsPath, 'models'])
41
+ if (modelsNode !== undefined) {
42
+ const error = schema.validate(modelsNode, afterModels)
43
+ if (error !== undefined) return error
44
+ }
45
+ if (willWriteCompat) {
46
+ const compatNode = schema.nodeAtPath(root, [...settingsPath, 'compat'])
47
+ if (compatNode !== undefined) {
48
+ const error = schema.validate(compatNode, afterCompat)
49
+ if (error !== undefined) return error
50
+ }
51
+ }
52
+ return undefined
53
+ }
@@ -9,7 +9,7 @@
9
9
 
10
10
  Domain logic for the plugin: no React, no Cordis service instances. The settings page and Vitest both depend on these pure functions, so effort semantics can regress without booting DSH.
11
11
 
12
- Canonical level names and `thinkingFormat` values follow `@deepseek-ai/dsh-llm-pi-ai` (0.1.0-rc.8). A fallback whitelist is pinned by tests so the UI cannot silently drift from the upstream schema.
12
+ Canonical level names and `thinkingFormat` values follow `@deepseek-ai/dsh-llm-pi-ai` (0.1.0-rc.8; 0.1.1-rc.2 uses the same level set). `thinkingFormat` is pinned to the live-schema snapshot in [`tests/fixtures/pi-ai-thinking-format-union.ts`](../../tests/fixtures/pi-ai-thinking-format-union.ts); effort keys stay a locally pinned whitelist. UI choices come only from the live schema union.
13
13
 
14
14
  ## What's in this directory
15
15
 
@@ -19,7 +19,7 @@ Canonical level names and `thinkingFormat` values follow `@deepseek-ai/dsh-llm-p
19
19
  | [`efforts.ts`](./efforts.ts) | `reasoningEfforts` read/write, Off tri-state, validation (empty object / Off-only / unknown keys return an error code, never throw). |
20
20
  | [`presets.ts`](./presets.ts) | DeepSeek, OpenAI, and on/off presets; each states all three dialect keys; spread onto existing model rows and route `compat`. |
21
21
  | [`path-ops.ts`](./path-ops.ts) | Same one-level key diff as the official Models page; `buildSaveOps` uses `settingsPath` and only submits that route’s full `models` table and dirty `compat` keys. |
22
- | [`drafts.ts`](./drafts.ts) | User-layer drafts, dirty aligned with pathOps, post-save revision bump, dirty merge, generation. |
22
+ | [`drafts.ts`](./drafts.ts) | User-layer drafts, dirty aligned with pathOps, post-save revision. On refresh, latest user-layer model membership wins; unsaved `reasoningEfforts` (including a cleared key) overlay by id; `compat` is a per-key 3-way merge. Conflict only when a locally dirty field also moved in originals (revision-only bumps and sibling-card saves do not warn). |
23
23
  | [`paths.ts`](./paths.ts) | Nested reads; clones of objects and model tables (non-object rows skipped). |
24
24
  | [`filter.ts`](./filter.ts) | Which routes are editable: hand-declared `llm-pi-ai` + openai-completions; skip catalog and official DeepSeek. |
25
25
  | [`validate.ts`](./validate.ts) | Per-row `reasoningEfforts` validation. |
@@ -9,7 +9,7 @@
9
9
 
10
10
  本插件的领域逻辑:无 React、无 Cordis 服务实例。设置页与 Vitest 都只依赖这些纯函数,因此档位语义可以在不启动 DSH 的情况下回归。
11
11
 
12
- 规范键名与 `thinkingFormat` 取值对齐 `@deepseek-ai/dsh-llm-pi-ai`(0.1.0-rc.8)。仓库内有回退白名单,并由测试钉住,避免 UI 与上游 schema 静默分叉。
12
+ 规范键名与 `thinkingFormat` 取值对齐 `@deepseek-ai/dsh-llm-pi-ai`(0.1.0-rc.8;0.1.1-rc.2 档位集合相同)。`thinkingFormat` 由 [`tests/fixtures/pi-ai-thinking-format-union.ts`](../../tests/fixtures/pi-ai-thinking-format-union.ts) 钉现场 schema;档位键由测试钉本地白名单。UI 可选项只来自现场 schema union。
13
13
 
14
14
  ## 这个目录里有什么
15
15
 
@@ -19,7 +19,7 @@
19
19
  | [`efforts.ts`](./efforts.ts) | `reasoningEfforts` 读写、Off 三态、校验(空对象 / 只开 Off / 未知键返回错误码,不抛异常)。 |
20
20
  | [`presets.ts`](./presets.ts) | DeepSeek、OpenAI、仅开/关三套预设;每套对三个方言键都表态;spread 到已有模型行与路由 `compat`。 |
21
21
  | [`path-ops.ts`](./path-ops.ts) | 与官方模型页相同的一层键 diff;`buildSaveOps` 使用 `settingsPath`,只提交该路由的 `models` 整表和有差异的 `compat` 键。 |
22
- | [`drafts.ts`](./drafts.ts) | 用户层草稿、dirty 与 pathOps 一致、保存后 revision、脏卡合并、generation。 |
22
+ | [`drafts.ts`](./drafts.ts) | 用户层草稿、dirty 与 pathOps 一致、保存后 revision。刷新时以最新用户层模型名单为成员真理,按 id 贴回未保存的 `reasoningEfforts`(含已清除=删键);`compat` 按键三路合并。冲突仅当本地脏字段的 originals 也变了(只 bump revision 或别的卡保存不报)。 |
23
23
  | [`paths.ts`](./paths.ts) | 嵌套读取、对象与模型表的 clone(非对象行跳过)。 |
24
24
  | [`filter.ts`](./filter.ts) | 哪些路由可编辑:手工 `llm-pi-ai` + openai-completions;排除 catalog 与官方 DeepSeek。 |
25
25
  | [`validate.ts`](./validate.ts) | 对单行 `reasoningEfforts` 调用校验。 |
@@ -3,9 +3,11 @@
3
3
  *
4
4
  * Levels match `@deepseek-ai/dsh-llm-pi-ai` catalog.ts `THINKING_LEVELS`.
5
5
  * Formats match `SUPPORTED_THINKING_FORMATS` in the same file (rc.8).
6
- * Runtime UI prefers the live settings schema union; these lists are the
7
- * fallback plus a test pin so a silent drift is a failing test, not a
8
- * second hand-maintained copy nobody notices.
6
+ * Tests pin these lists against a checked-in schema fixture
7
+ * (`tests/fixtures/pi-ai-thinking-format-union.ts`) and the local level
8
+ * whitelist. The settings page never offers the handwritten thinkingFormat
9
+ * list as writable choices — only the live schema union, plus a stored
10
+ * value that the union omitted.
9
11
  */
10
12
 
11
13
  /** Selectable reasoning levels, in pi-ai escalation order. */
@@ -28,7 +30,7 @@ export const THINKING_LEVELS_WITHOUT_OFF = THINKING_LEVELS.filter(
28
30
 
29
31
  /**
30
32
  * openai-completions thinkingFormat values from llm-pi-ai catalog.ts rc.8.
31
- * Tests pin this list; the settings page prefers schema union choices.
33
+ * Test pin only not a writable UI fallback.
32
34
  */
33
35
  export const FALLBACK_THINKING_FORMATS = [
34
36
  'openai',