dsh-plugin-effort-declare 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -31,7 +31,7 @@ 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
 
@@ -41,7 +41,6 @@ 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
 
@@ -341,7 +340,7 @@ export function EffortDeclareSection(props: EffortDeclareSectionProps): ReactNod
341
340
  void loadDrafts(api, describe, schema).then((result) => {
342
341
  if (!generationIsCurrent(generationRef, generation)) return
343
342
  setWritable(result.writable)
344
- if (result.formats.length > 0) setFormats(result.formats)
343
+ setFormats(result.formats)
345
344
  if (result.error !== undefined) {
346
345
  setStatus('error')
347
346
  setError(result.error)
@@ -350,9 +349,13 @@ export function EffortDeclareSection(props: EffortDeclareSectionProps): ReactNod
350
349
  const merged = mergeLoadedDrafts(draftsRef.current, result.drafts, { preserveDirty })
351
350
  setDrafts(merged.drafts)
352
351
  if (merged.conflicted.length > 0) {
353
- setNotices(Object.fromEntries(
354
- merged.conflicted.map(provider => [provider, { kind: 'conflict' as const, text: t('dirtyConflict') }]),
355
- ))
352
+ setNotices(current => {
353
+ const next = { ...current }
354
+ for (const provider of merged.conflicted) {
355
+ next[provider] = { kind: 'conflict', text: t('dirtyConflict') }
356
+ }
357
+ return next
358
+ })
356
359
  }
357
360
  setStatus('ready')
358
361
  }, (failure: unknown) => {
@@ -366,21 +369,32 @@ export function EffortDeclareSection(props: EffortDeclareSectionProps): ReactNod
366
369
 
367
370
  useEffect(() => {
368
371
  if (props.subscribeInvalidate === undefined) return undefined
369
- return props.subscribeInvalidate(() => { reload(true) })
372
+ return props.subscribeInvalidate((source) => {
373
+ if (source === 'settings' || source === 'directory') reload(true)
374
+ })
370
375
  }, [props.subscribeInvalidate, reload])
371
376
 
377
+ const patchNotice = (provider: string, notice: CardNotice | undefined): void => {
378
+ setNotices(current => {
379
+ const copy = { ...current }
380
+ if (notice === undefined) delete copy[provider]
381
+ else copy[provider] = notice
382
+ return copy
383
+ })
384
+ }
385
+
372
386
  const save = async (draft: RouteDraft): Promise<void> => {
373
- if (api === undefined || describe === undefined) return
387
+ if (api === undefined || describe === undefined || schema === undefined) return
374
388
  if (status === 'loading' || busyRoute !== null) return
375
389
  const blocking = draft.models
376
390
  .map(row => errorText(modelEffortError(row), t))
377
391
  .find(text => text !== undefined)
378
392
  if (blocking !== undefined) {
379
- setNotices({ [draft.provider]: { kind: 'error', text: blocking } })
393
+ patchNotice(draft.provider, { kind: 'error', text: blocking })
380
394
  return
381
395
  }
382
396
  setBusyRoute(draft.provider)
383
- setNotices({})
397
+ patchNotice(draft.provider, undefined)
384
398
  try {
385
399
  const ops = buildSaveOps({
386
400
  settingsPath: draft.settingsPath,
@@ -393,18 +407,44 @@ export function EffortDeclareSection(props: EffortDeclareSectionProps): ReactNod
393
407
  setDrafts(current => current.map(row => row.provider === draft.provider ? alignDraft(row) : row))
394
408
  return
395
409
  }
410
+ const willWriteCompat = ops.some(op => (
411
+ op.path.length > draft.settingsPath.length && op.path[draft.settingsPath.length] === 'compat'
412
+ ))
413
+ const pi = describe.getSnapshot().view?.namespaces.find(view => view.ns === LLM_PI_AI_NS)
414
+ if (pi !== undefined) {
415
+ let root: unknown
416
+ try {
417
+ root = schema.rehydrate(pi.schema)
418
+ } catch {
419
+ root = undefined
420
+ }
421
+ if (root !== undefined) {
422
+ const schemaError = validateSaveDraft(
423
+ schema,
424
+ root,
425
+ draft.settingsPath,
426
+ draft.models,
427
+ draft.compat,
428
+ willWriteCompat,
429
+ )
430
+ if (schemaError !== undefined) {
431
+ patchNotice(draft.provider, { kind: 'error', text: schemaError })
432
+ return
433
+ }
434
+ }
435
+ }
396
436
  const response = await api.settings.mutate({
397
437
  ns: LLM_PI_AI_NS,
398
438
  ops,
399
439
  expectedRevision: draft.revision,
400
440
  })
401
441
  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
- },
442
+ const conflict = response.result.error.code === 'settings-conflict'
443
+ patchNotice(draft.provider, {
444
+ kind: conflict ? 'conflict' : 'error',
445
+ text: conflict ? t('conflict') : response.result.error.message,
407
446
  })
447
+ if (conflict) reload(true)
408
448
  return
409
449
  }
410
450
  const view = response.result.value
@@ -413,13 +453,11 @@ export function EffortDeclareSection(props: EffortDeclareSectionProps): ReactNod
413
453
  user: view.user ?? {},
414
454
  revision: view.revision,
415
455
  }))
416
- setNotices({ [draft.provider]: { kind: 'saved', text: t('saved') } })
456
+ patchNotice(draft.provider, { kind: 'saved', text: t('saved') })
417
457
  } catch (failure) {
418
- setNotices({
419
- [draft.provider]: {
420
- kind: 'error',
421
- text: failure instanceof Error ? failure.message : t('loadError'),
422
- },
458
+ patchNotice(draft.provider, {
459
+ kind: 'error',
460
+ text: failure instanceof Error ? failure.message : t('loadError'),
423
461
  })
424
462
  } finally {
425
463
  setBusyRoute(null)
@@ -429,6 +467,10 @@ export function EffortDeclareSection(props: EffortDeclareSectionProps): ReactNod
429
467
  const showLoading = status === 'loading' && drafts.length === 0
430
468
  const showEmpty = status === 'ready' && drafts.length === 0
431
469
  const showList = drafts.length > 0
470
+ const hasCardFailure = Object.values(notices).some(
471
+ notice => notice.kind === 'conflict' || notice.kind === 'error',
472
+ )
473
+ const showReload = status === 'error' || hasCardFailure || showEmpty || !writable || status === 'loading' || showList
432
474
 
433
475
  return (
434
476
  <div className={css.section}>
@@ -437,7 +479,7 @@ export function EffortDeclareSection(props: EffortDeclareSectionProps): ReactNod
437
479
  {!writable && status === 'ready' ? <p className={css.notice}>{t('readOnly')}</p> : null}
438
480
  {showLoading ? <p className={css.intro}>{t('loading')}</p> : null}
439
481
  {status === 'error' ? <p className={css.error}>{error}</p> : null}
440
- {status === 'error'
482
+ {showReload
441
483
  ? (
442
484
  <button type="button" className={css.secondaryButton} onClick={() => { reload(true) }}>{t('reload')}</button>
443
485
  )
@@ -464,20 +506,12 @@ export function EffortDeclareSection(props: EffortDeclareSectionProps): ReactNod
464
506
  notice={notices[draft.provider]}
465
507
  t={t}
466
508
  onChange={(next) => {
467
- setNotices(current => {
468
- const copy = { ...current }
469
- delete copy[next.provider]
470
- return copy
471
- })
509
+ patchNotice(next.provider, undefined)
472
510
  setDrafts(current => current.map(row => row.provider === next.provider ? next : row))
473
511
  }}
474
512
  onSave={(next) => { void save(next) }}
475
513
  onCancel={(next) => {
476
- setNotices(current => {
477
- const copy = { ...current }
478
- delete copy[next.provider]
479
- return copy
480
- })
514
+ patchNotice(next.provider, undefined)
481
515
  setDrafts(current => current.map(row => row.provider === next.provider
482
516
  ? {
483
517
  ...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`. |
20
+ | [`index.ts`](./index.ts) | Registers zh/en copy, CSS (`ctx.effect` insert/remove), and `settings.section` (id `effort-declare`, order 12). Subscribes in `apply` to the mirror `describe.subscribe`, `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
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`. |
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`。 |
20
+ | [`index.ts`](./index.ts) | 注册 zh/en 文案、CSS(`ctx.effect` 插入/移除)与 `settings.section`(id `effort-declare`,order 12)。在 `apply` 里订阅镜像 `describe.subscribe`、`settings/document-updated`(仅 `llm-pi-ai`)、`llm/adapters-updated`、`connection/reset`。`locale: NS` 由框架注入 `t`,inject 只传 `api` / `describe` / `schema` / `subscribeInvalidate`。 |
21
21
  | [`EffortDeclareSection.tsx`](./EffortDeclareSection.tsx) | 路由卡片、预设、Off 三态、高级协议开关、保存与取消。 |
22
- | [`load-drafts.ts`](./load-drafts.ts) | 从 `llm.providers` + 镜像组装草稿;草稿取自 `user`,协议分类可用 `value`。 |
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
 
@@ -63,6 +67,7 @@ export function apply(ctx: ClientContext): void {
63
67
  for (const listener of invalidation) listener(source)
64
68
  }
65
69
  const disposers = [
70
+ describe.subscribe(() => { emit('settings') }),
66
71
  ctx.remote.$on('settings/document-updated', (ns: string) => {
67
72
  if (ns !== LLM_PI_AI_NS) return
68
73
  emit('settings')
@@ -83,14 +88,13 @@ export function apply(ctx: ClientContext): void {
83
88
  ctx.slots.inject('settings.section', () => ctx.slots.register({
84
89
  name: 'settings.section',
85
90
  id: 'effort-declare',
86
- order: 15,
91
+ order: 12,
87
92
  label: () => t('nav'),
88
93
  locale: NS,
89
94
  inject: (): EffortDeclareSectionInjected => ({
90
95
  api: connection.api,
91
96
  describe,
92
97
  schema,
93
- t,
94
98
  subscribeInvalidate,
95
99
  }),
96
100
  }, 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[] = []
@@ -77,7 +77,7 @@ export const zh: Record<EffortDeclareKey, string> = {
77
77
  clear: '清除本模型声明',
78
78
  advanced: '高级:协议方言',
79
79
  thinkingFormat: 'thinkingFormat',
80
- thinkingFormatDefault: '默认(openai)',
80
+ thinkingFormatDefault: '默认(省略该键)',
81
81
  supportsDeveloperRole: '系统提示走 system 而不是 developer(supportsDeveloperRole: false)',
82
82
  supportsReasoningEffort: '不发 reasoning_effort,只发开关(supportsReasoningEffort: false)',
83
83
  developerTrueHint: '当前文档是 supportsDeveloperRole: true。v1 只能强制 false 或缺席;勾选会写成 false,取消勾选会删除该键。',
@@ -120,7 +120,7 @@ export const en: Record<EffortDeclareKey, string> = {
120
120
  clear: 'Clear this model’s declaration',
121
121
  advanced: 'Advanced: protocol dialect',
122
122
  thinkingFormat: 'thinkingFormat',
123
- thinkingFormatDefault: 'Default (openai)',
123
+ thinkingFormatDefault: 'Default (omit the key)',
124
124
  supportsDeveloperRole: 'Send system prompts as system, not developer (supportsDeveloperRole: false)',
125
125
  supportsReasoningEffort: 'Do not send reasoning_effort; switch only (supportsReasoningEffort: false)',
126
126
  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). A whitelist is pinned by tests; UI choices come only from the live schema union.
13
13
 
14
14
  ## What's in this directory
15
15
 
@@ -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)。仓库内有测试钉白名单;UI 可选项只来自现场 schema union。
13
13
 
14
14
  ## 这个目录里有什么
15
15
 
@@ -3,9 +3,9 @@
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 llm-pi-ai. The settings page never offers
7
+ * the handwritten thinkingFormat list as writable choices — only the live
8
+ * schema union, plus a stored value that the union omitted.
9
9
  */
10
10
 
11
11
  /** Selectable reasoning levels, in pi-ai escalation order. */
@@ -28,7 +28,7 @@ export const THINKING_LEVELS_WITHOUT_OFF = THINKING_LEVELS.filter(
28
28
 
29
29
  /**
30
30
  * openai-completions thinkingFormat values from llm-pi-ai catalog.ts rc.8.
31
- * Tests pin this list; the settings page prefers schema union choices.
31
+ * Test pin only — not a writable UI fallback.
32
32
  */
33
33
  export const FALLBACK_THINKING_FORMATS = [
34
34
  'openai',