dsh-plugin-effort-declare 0.1.0

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.
Files changed (62) hide show
  1. package/CONTRIBUTING.en.md +57 -0
  2. package/CONTRIBUTING.md +57 -0
  3. package/INSTALL.en.md +62 -0
  4. package/INSTALL.md +62 -0
  5. package/LICENSE +21 -0
  6. package/README.en.md +64 -0
  7. package/README.md +64 -0
  8. package/cordis.patch.yml +5 -0
  9. package/lib/client.js +1237 -0
  10. package/lib/client.js.map +1 -0
  11. package/lib/index.js +6 -0
  12. package/lib/types/client/EffortDeclareSection.d.ts +24 -0
  13. package/lib/types/client/EffortDeclareSection.d.ts.map +1 -0
  14. package/lib/types/client/index.d.ts +15 -0
  15. package/lib/types/client/index.d.ts.map +1 -0
  16. package/lib/types/client/load-drafts.d.ts +20 -0
  17. package/lib/types/client/load-drafts.d.ts.map +1 -0
  18. package/lib/types/client/locales.d.ts +8 -0
  19. package/lib/types/client/locales.d.ts.map +1 -0
  20. package/lib/types/client/schema-ops.d.ts +13 -0
  21. package/lib/types/client/schema-ops.d.ts.map +1 -0
  22. package/lib/types/core/catalog.d.ts +31 -0
  23. package/lib/types/core/catalog.d.ts.map +1 -0
  24. package/lib/types/core/drafts.d.ts +53 -0
  25. package/lib/types/core/drafts.d.ts.map +1 -0
  26. package/lib/types/core/efforts.d.ts +32 -0
  27. package/lib/types/core/efforts.d.ts.map +1 -0
  28. package/lib/types/core/filter.d.ts +28 -0
  29. package/lib/types/core/filter.d.ts.map +1 -0
  30. package/lib/types/core/path-ops.d.ts +40 -0
  31. package/lib/types/core/path-ops.d.ts.map +1 -0
  32. package/lib/types/core/paths.d.ts +16 -0
  33. package/lib/types/core/paths.d.ts.map +1 -0
  34. package/lib/types/core/presets.d.ts +31 -0
  35. package/lib/types/core/presets.d.ts.map +1 -0
  36. package/lib/types/core/validate.d.ts +3 -0
  37. package/lib/types/core/validate.d.ts.map +1 -0
  38. package/lib/types/index.d.ts +10 -0
  39. package/lib/types/index.d.ts.map +1 -0
  40. package/package.json +87 -0
  41. package/src/README.en.md +23 -0
  42. package/src/README.md +23 -0
  43. package/src/client/EffortDeclareSection.tsx +496 -0
  44. package/src/client/README.en.md +27 -0
  45. package/src/client/README.md +27 -0
  46. package/src/client/effort-declare.module.css +278 -0
  47. package/src/client/index.ts +97 -0
  48. package/src/client/load-drafts.ts +83 -0
  49. package/src/client/locales.ts +133 -0
  50. package/src/client/schema-ops.ts +20 -0
  51. package/src/core/README.en.md +27 -0
  52. package/src/core/README.md +27 -0
  53. package/src/core/catalog.ts +61 -0
  54. package/src/core/drafts.ts +137 -0
  55. package/src/core/efforts.ts +119 -0
  56. package/src/core/filter.ts +82 -0
  57. package/src/core/path-ops.ts +65 -0
  58. package/src/core/paths.ts +45 -0
  59. package/src/core/presets.ts +104 -0
  60. package/src/core/validate.ts +7 -0
  61. package/src/css-modules.d.ts +6 -0
  62. package/src/index.ts +11 -0
@@ -0,0 +1,10 @@
1
+ /**
2
+ * dsh-plugin-effort-declare — host half.
3
+ * v1 contributes no host services, adapters, or events. The cordis row still
4
+ * has to exist so a later `dsh plugin add` can load the bundle.
5
+ */
6
+ import type { Context } from '@deepseek-ai/cordis';
7
+ export declare const name = "dsh-plugin-effort-declare";
8
+ /** Host apply is intentionally empty in v1. */
9
+ export declare function apply(_ctx: Context): void;
10
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAElD,eAAO,MAAM,IAAI,8BAA8B,CAAA;AAE/C,+CAA+C;AAC/C,wBAAgB,KAAK,CAAC,IAAI,EAAE,OAAO,GAAG,IAAI,CAAG"}
package/package.json ADDED
@@ -0,0 +1,87 @@
1
+ {
2
+ "name": "dsh-plugin-effort-declare",
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",
5
+ "private": false,
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/zimodzh/dsh-plugin-effort-declare.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/zimodzh/dsh-plugin-effort-declare/issues"
13
+ },
14
+ "homepage": "https://github.com/zimodzh/dsh-plugin-effort-declare#readme",
15
+ "type": "module",
16
+ "main": "lib/index.js",
17
+ "types": "lib/types/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./lib/types/index.d.ts",
21
+ "default": "./lib/index.js"
22
+ },
23
+ "./client": {
24
+ "types": "./lib/types/client/index.d.ts",
25
+ "default": "./lib/client.js"
26
+ },
27
+ "./src/*": "./src/*",
28
+ "./package.json": "./package.json"
29
+ },
30
+ "dsh": {
31
+ "bundle": {
32
+ "patch": "./cordis.patch.yml"
33
+ },
34
+ "client": {
35
+ "inject": [
36
+ "@deepseek-ai/dsh-client-runtime",
37
+ "@deepseek-ai/dsh-client-locale",
38
+ "@deepseek-ai/dsh-client-ui-settings",
39
+ "@deepseek-ai/dsh-api-remotes"
40
+ ],
41
+ "platform": "web"
42
+ }
43
+ },
44
+ "peerDependencies": {
45
+ "@deepseek-ai/dsh-api-remotes": "^0.1.0-rc.8",
46
+ "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.8",
47
+ "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.8",
48
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.8",
49
+ "react": "^18.2.0"
50
+ },
51
+ "devDependencies": {
52
+ "@deepseek-ai/cordis": "^4.0.1",
53
+ "@deepseek-ai/dsh-api-remotes": "^0.1.0-rc.8",
54
+ "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.8",
55
+ "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.8",
56
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.8",
57
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.8",
58
+ "@types/node": "^22.20.0",
59
+ "@types/react": "~18.3.1",
60
+ "lightningcss": "^1.32.0",
61
+ "react": "^18.3.1",
62
+ "tsdown": "0.22.2",
63
+ "typescript": "~5.7.2",
64
+ "vitest": "^4.1.8"
65
+ },
66
+ "files": [
67
+ "lib/*.js",
68
+ "lib/*.js.map",
69
+ "lib/types/**/*.d.ts",
70
+ "lib/types/**/*.d.ts.map",
71
+ "src",
72
+ "cordis.patch.yml",
73
+ "README.md",
74
+ "README.en.md",
75
+ "INSTALL.md",
76
+ "INSTALL.en.md",
77
+ "CONTRIBUTING.md",
78
+ "CONTRIBUTING.en.md",
79
+ "LICENSE"
80
+ ],
81
+ "scripts": {
82
+ "build": "tsc -b && tsdown",
83
+ "watch": "tsdown --watch",
84
+ "test": "vitest run",
85
+ "typecheck": "tsc -b --pretty false"
86
+ }
87
+ }
@@ -0,0 +1,23 @@
1
+ # src/
2
+
3
+ <p align="center">
4
+ <samp>
5
+ <a href="./README.md">中文</a> ·
6
+ <strong>English</strong>
7
+ </samp>
8
+ </p>
9
+
10
+ TypeScript sources for the plugin. The host (Node) and the browser must be separate TypeScript programs: their `Context` merges cannot share one `ts.Program`.
11
+
12
+ Install: [INSTALL.en.md](../INSTALL.en.md). Usage: root [README.en.md](../README.en.md). Contributing: [CONTRIBUTING.en.md](../CONTRIBUTING.en.md).
13
+
14
+ ## What's in this directory
15
+
16
+ | Path | Role |
17
+ | --- | --- |
18
+ | [`index.ts`](./index.ts) | Host entry. Exports `name` and `apply`. v1 registers no host services or adapters; the bundle still needs this entry so `dsh plugin add` can load the package. |
19
+ | [`core/`](./core/) | UI-free pure functions: efforts, presets, save ops, draft merge, route filtering. Shared by the client and tests; the v1 host entry does not import them (`tsconfig.host` still compiles `core` for types). |
20
+ | [`client/`](./client/) | Browser half: locale, settings page, `settings.mutate` against `llm-pi-ai`. |
21
+ | [`css-modules.d.ts`](./css-modules.d.ts) | Types for `*.module.css` (class map plus `cssText` / `cssTagId`). |
22
+
23
+ `tsconfig.host.json` compiles host + `core`; `tsconfig.client.json` compiles `client` + `core`. Build output lives in [`lib/`](../lib/).
package/src/README.md ADDED
@@ -0,0 +1,23 @@
1
+ # src/
2
+
3
+ <p align="center">
4
+ <samp>
5
+ <strong>中文</strong> ·
6
+ <a href="./README.en.md">English</a>
7
+ </samp>
8
+ </p>
9
+
10
+ 插件 TypeScript 源码。Host(Node)与浏览器必须分成两个 TypeScript Program:两边对 `Context` 的 merge 不能共处同一份 `ts.Program`。
11
+
12
+ 安装见 [INSTALL.md](../INSTALL.md),使用见仓库根 [README.md](../README.md),贡献见 [CONTRIBUTING.md](../CONTRIBUTING.md)。
13
+
14
+ ## 这个目录里有什么
15
+
16
+ | 路径 | 说明 |
17
+ | --- | --- |
18
+ | [`index.ts`](./index.ts) | Host 入口。导出 `name` 与 `apply`。v1 不注册 host 服务、不挂适配器;组合包仍需要这一入口才能被 `dsh plugin add` 加载。 |
19
+ | [`core/`](./core/) | 与 UI 无关的纯函数:档位、预设、保存 ops、草稿合并、路由过滤。client 与单测共用;Host 入口 v1 不引用这些函数(`tsconfig.host` 仍编译 `core` 以产出类型)。 |
20
+ | [`client/`](./client/) | 浏览器半区:locale、设置页、对 `llm-pi-ai` 的 `settings.mutate`。 |
21
+ | [`css-modules.d.ts`](./css-modules.d.ts) | `*.module.css` 模块类型(class map + `cssText` / `cssTagId`)。 |
22
+
23
+ `tsconfig.host.json` 编译 host + `core`;`tsconfig.client.json` 编译 `client` + `core`。构建产物在 [`lib/`](../lib/)。
@@ -0,0 +1,496 @@
1
+ /**
2
+ * Settings section: per-model reasoningEfforts + openai-completions compat
3
+ * for hand-declared llm-pi-ai routes.
4
+ */
5
+ import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
6
+ import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
7
+ import type { SettingsDescribeFace } from '@deepseek-ai/dsh-client-ui-settings/client'
8
+ import { LLM_PI_AI_NS, THINKING_LEVELS_WITHOUT_OFF } from '../core/catalog.ts'
9
+ import {
10
+ alignDraft,
11
+ applySaveSuccess,
12
+ draftDirty,
13
+ generationIsCurrent,
14
+ mergeLoadedDrafts,
15
+ nextGeneration,
16
+ thinkingFormatChoices,
17
+ type RouteDraft,
18
+ } from '../core/drafts.ts'
19
+ import {
20
+ hasLevel,
21
+ readEfforts,
22
+ readOff,
23
+ setWireSpelling,
24
+ toggleLevel,
25
+ writeEfforts,
26
+ writeOff,
27
+ clearReasoningEfforts,
28
+ } from '../core/efforts.ts'
29
+ import { applyPresetCompat, applyPresetEfforts, PRESETS, type PresetId } from '../core/presets.ts'
30
+ import { buildSaveOps } from '../core/path-ops.ts'
31
+ import { cloneModels, cloneObject } from '../core/paths.ts'
32
+ import { modelEffortError } from '../core/validate.ts'
33
+ import { loadDrafts } from './load-drafts.ts'
34
+ import type { SchemaOps } from './schema-ops.ts'
35
+ import type { EffortDeclareKey } from './locales.ts'
36
+ import css from './effort-declare.module.css'
37
+
38
+ export type InvalidationSource = 'settings' | 'directory'
39
+
40
+ export interface EffortDeclareSectionInjected {
41
+ api: Pick<IApiClient, 'settings' | 'llm'>
42
+ describe: SettingsDescribeFace
43
+ schema: SchemaOps
44
+ t: (key: EffortDeclareKey) => string
45
+ subscribeInvalidate: (listener: (source: InvalidationSource) => void) => () => void
46
+ }
47
+
48
+ export interface EffortDeclareSectionProps extends Partial<EffortDeclareSectionInjected> {
49
+ t: (key: EffortDeclareKey) => string
50
+ close: () => void
51
+ }
52
+
53
+ export { loadDrafts } from './load-drafts.ts'
54
+
55
+ type CardNotice = { kind: 'saved' | 'conflict' | 'error'; text: string }
56
+
57
+ function compatSummary(compat: Record<string, unknown>): string {
58
+ const parts: string[] = []
59
+ if (typeof compat.thinkingFormat === 'string') parts.push(`thinkingFormat=${compat.thinkingFormat}`)
60
+ if (compat.supportsDeveloperRole === false) parts.push('supportsDeveloperRole=false')
61
+ if (compat.supportsReasoningEffort === false) parts.push('supportsReasoningEffort=false')
62
+ return parts.join(' · ')
63
+ }
64
+
65
+ function errorText(code: ReturnType<typeof modelEffortError>, t: (key: EffortDeclareKey) => string): string | undefined {
66
+ if (code === 'empty') return t('errorEmpty')
67
+ if (code === 'off-only') return t('errorOffOnly')
68
+ if (code === 'bad-wire') return t('errorBadWire')
69
+ return undefined
70
+ }
71
+
72
+ function ModelRowEditor(props: {
73
+ row: Record<string, unknown>
74
+ index: number
75
+ radioName: string
76
+ disabled: boolean
77
+ t: (key: EffortDeclareKey) => string
78
+ onChange: (row: Record<string, unknown>) => void
79
+ }): ReactNode {
80
+ const { row, disabled, t, onChange } = props
81
+ const efforts = readEfforts(row) ?? {}
82
+ const off = readOff(readEfforts(row))
83
+ const id = typeof row.id === 'string' ? row.id : ''
84
+ const name = typeof row.name === 'string' ? row.name : ''
85
+
86
+ const patchEfforts = (next: typeof efforts): void => {
87
+ onChange(writeEfforts(row, Object.keys(next).length === 0 ? undefined : next))
88
+ }
89
+
90
+ return (
91
+ <div className={css.modelEntry}>
92
+ <div className={css.modelHead}>
93
+ <span className={css.modelId}>{id || t('model')}</span>
94
+ {name !== '' && name !== id ? <span className={css.modelName}>{name}</span> : null}
95
+ <button
96
+ type="button"
97
+ className={css.linkButton}
98
+ disabled={disabled || !('reasoningEfforts' in row)}
99
+ onClick={() => { onChange(clearReasoningEfforts(row)) }}
100
+ >
101
+ {t('clear')}
102
+ </button>
103
+ </div>
104
+ <span className={css.fieldLabel}>{t('levels')}</span>
105
+ <div className={css.levels}>
106
+ {THINKING_LEVELS_WITHOUT_OFF.map((level) => (
107
+ <label key={level} className={css.level}>
108
+ <input
109
+ type="checkbox"
110
+ checked={hasLevel(efforts, level)}
111
+ disabled={disabled}
112
+ onChange={(event) => { patchEfforts(toggleLevel(efforts, level, event.target.checked)) }}
113
+ />
114
+ {level}
115
+ </label>
116
+ ))}
117
+ </div>
118
+ <div className={css.wireRow}>
119
+ <span className={css.fieldLabel}>{t('wire')}</span>
120
+ {THINKING_LEVELS_WITHOUT_OFF.filter(level => hasLevel(efforts, level)).map((level) => (
121
+ <label key={level} className={css.level}>
122
+ {level}
123
+ <input
124
+ className={`${css.input} ${css.wireInput}`}
125
+ type="text"
126
+ value={efforts[level] ?? level}
127
+ disabled={disabled}
128
+ aria-label={`${t('wire')} ${level}`}
129
+ onChange={(event) => { patchEfforts(setWireSpelling(efforts, level, event.target.value)) }}
130
+ />
131
+ </label>
132
+ ))}
133
+ </div>
134
+ <div className={css.offGroup}>
135
+ <span className={css.fieldLabel}>{t('offMode')}</span>
136
+ {(['absent', 'empty', 'value'] as const).map((mode) => (
137
+ <label key={mode} className={css.level}>
138
+ <input
139
+ type="radio"
140
+ name={props.radioName}
141
+ checked={off.mode === mode}
142
+ disabled={disabled}
143
+ onChange={() => { patchEfforts(writeOff(efforts, mode, off.value)) }}
144
+ />
145
+ {mode === 'absent' ? t('offAbsent') : mode === 'empty' ? t('offEmpty') : t('offValue')}
146
+ </label>
147
+ ))}
148
+ {off.mode === 'value'
149
+ ? (
150
+ <input
151
+ className={`${css.input} ${css.wireInput}`}
152
+ type="text"
153
+ value={off.value}
154
+ placeholder={t('offValuePlaceholder')}
155
+ disabled={disabled}
156
+ onChange={(event) => { patchEfforts(writeOff(efforts, 'value', event.target.value)) }}
157
+ />
158
+ )
159
+ : null}
160
+ </div>
161
+ </div>
162
+ )
163
+ }
164
+
165
+ function RouteCard(props: {
166
+ draft: RouteDraft
167
+ formats: readonly string[]
168
+ writable: boolean
169
+ busy: boolean
170
+ reloading: boolean
171
+ notice: CardNotice | undefined
172
+ t: (key: EffortDeclareKey) => string
173
+ onChange: (draft: RouteDraft) => void
174
+ onSave: (draft: RouteDraft) => void
175
+ onCancel: (draft: RouteDraft) => void
176
+ }): ReactNode {
177
+ const { draft, writable, busy, reloading, t, onChange } = props
178
+ const noModels = draft.models.length === 0
179
+ const editDisabled = !writable || busy || noModels
180
+ const saveLocked = editDisabled || reloading
181
+ const formats = thinkingFormatChoices(props.formats, draft.compat.thinkingFormat)
182
+ const summary = compatSummary(draft.compat)
183
+ const sameWire = draft.compat.supportsReasoningEffort === false
184
+ const clientError = draft.models
185
+ .map(row => errorText(modelEffortError(row), t))
186
+ .find(text => text !== undefined)
187
+ const dirty = draftDirty(draft)
188
+
189
+ const applyPreset = (id: PresetId): void => {
190
+ const preset = PRESETS[id]
191
+ onChange({
192
+ ...draft,
193
+ models: applyPresetEfforts(draft.models, preset),
194
+ compat: applyPresetCompat(draft.compat, preset),
195
+ })
196
+ }
197
+
198
+ return (
199
+ <li className={css.rowCard}>
200
+ <div className={css.rowHead}>
201
+ <span className={css.rowName}>{draft.displayName}</span>
202
+ <span className={css.rowTag}>{draft.provider}</span>
203
+ </div>
204
+ <p className={css.compatSummary}>
205
+ {t('compatSummary')}
206
+ {summary === '' ? '' : `: ${summary}`}
207
+ </p>
208
+ <div className={css.presetRow}>
209
+ <span className={css.fieldLabel}>{t('presets')}</span>
210
+ <button type="button" className={css.secondaryButton} disabled={editDisabled} onClick={() => { applyPreset('deepseek') }}>
211
+ {t('presetDeepSeek')}
212
+ </button>
213
+ <button type="button" className={css.secondaryButton} disabled={editDisabled} onClick={() => { applyPreset('openai') }}>
214
+ {t('presetOpenAI')}
215
+ </button>
216
+ <button type="button" className={css.secondaryButton} disabled={editDisabled} onClick={() => { applyPreset('toggle') }}>
217
+ {t('presetToggle')}
218
+ </button>
219
+ </div>
220
+ {sameWire ? <p className={css.notice}>{t('presetToggleWarn')}</p> : null}
221
+ {noModels ? <p className={css.notice}>{t('noModels')}</p> : null}
222
+ {draft.models.map((row, index) => (
223
+ <ModelRowEditor
224
+ key={`${String(row.id)}-${String(index)}`}
225
+ row={row}
226
+ index={index}
227
+ radioName={`off-${draft.provider}-${String(index)}`}
228
+ disabled={editDisabled}
229
+ t={t}
230
+ onChange={(next) => {
231
+ const models = draft.models.map((candidate, at) => at === index ? next : candidate)
232
+ onChange({ ...draft, models })
233
+ }}
234
+ />
235
+ ))}
236
+ <details className={css.advanced}>
237
+ <summary>{t('advanced')}</summary>
238
+ <div className={css.advancedBody}>
239
+ <label className={css.fieldLabel}>
240
+ {t('thinkingFormat')}
241
+ <select
242
+ className={`${css.input} ${css.selectInput}`}
243
+ value={typeof draft.compat.thinkingFormat === 'string' ? draft.compat.thinkingFormat : ''}
244
+ disabled={editDisabled}
245
+ onChange={(event) => {
246
+ const compat = { ...draft.compat }
247
+ if (event.target.value === '') delete compat.thinkingFormat
248
+ else compat.thinkingFormat = event.target.value
249
+ onChange({ ...draft, compat })
250
+ }}
251
+ >
252
+ <option value="">{t('thinkingFormatDefault')}</option>
253
+ {formats.map(format => <option key={format} value={format}>{format}</option>)}
254
+ </select>
255
+ </label>
256
+ {draft.compat.supportsDeveloperRole === true
257
+ ? <p className={css.notice}>{t('developerTrueHint')}</p>
258
+ : null}
259
+ <label className={css.check}>
260
+ <input
261
+ type="checkbox"
262
+ checked={draft.compat.supportsDeveloperRole === false}
263
+ disabled={editDisabled}
264
+ onChange={(event) => {
265
+ const compat = { ...draft.compat }
266
+ if (event.target.checked) compat.supportsDeveloperRole = false
267
+ else delete compat.supportsDeveloperRole
268
+ onChange({ ...draft, compat })
269
+ }}
270
+ />
271
+ {t('supportsDeveloperRole')}
272
+ </label>
273
+ <label className={css.check}>
274
+ <input
275
+ type="checkbox"
276
+ checked={draft.compat.supportsReasoningEffort === false}
277
+ disabled={editDisabled}
278
+ onChange={(event) => {
279
+ const compat = { ...draft.compat }
280
+ if (event.target.checked) compat.supportsReasoningEffort = false
281
+ else delete compat.supportsReasoningEffort
282
+ onChange({ ...draft, compat })
283
+ }}
284
+ />
285
+ {t('supportsReasoningEffort')}
286
+ </label>
287
+ </div>
288
+ </details>
289
+ {clientError !== undefined ? <p className={css.error}>{clientError}</p> : null}
290
+ {props.notice?.kind === 'saved' ? <p className={css.savedNotice}>{props.notice.text}</p> : null}
291
+ {props.notice?.kind === 'conflict' || props.notice?.kind === 'error'
292
+ ? <p className={css.error}>{props.notice.text}</p>
293
+ : null}
294
+ <div className={css.actions}>
295
+ <button
296
+ type="button"
297
+ className={css.secondaryButton}
298
+ disabled={busy || !dirty}
299
+ onClick={() => { props.onCancel(draft) }}
300
+ >
301
+ {t('cancel')}
302
+ </button>
303
+ <button
304
+ type="button"
305
+ className={css.primaryButton}
306
+ disabled={saveLocked || !dirty || clientError !== undefined}
307
+ onClick={() => { props.onSave(draft) }}
308
+ >
309
+ {busy ? t('saving') : t('save')}
310
+ </button>
311
+ </div>
312
+ </li>
313
+ )
314
+ }
315
+
316
+ export function EffortDeclareSection(props: EffortDeclareSectionProps): ReactNode {
317
+ const t = props.t
318
+ const api = props.api
319
+ const describe = props.describe
320
+ const schema = props.schema
321
+ const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading')
322
+ const [error, setError] = useState('')
323
+ const [writable, setWritable] = useState(false)
324
+ const [formats, setFormats] = useState<string[]>([])
325
+ const [drafts, setDrafts] = useState<RouteDraft[]>([])
326
+ const [busyRoute, setBusyRoute] = useState<string | null>(null)
327
+ const [notices, setNotices] = useState<Record<string, CardNotice>>({})
328
+ const generationRef = useRef(0)
329
+ const draftsRef = useRef(drafts)
330
+ draftsRef.current = drafts
331
+
332
+ const reload = useCallback((preserveDirty: boolean): void => {
333
+ if (api === undefined || describe === undefined || schema === undefined) {
334
+ setStatus('error')
335
+ setError(t('loadError'))
336
+ return
337
+ }
338
+ const generation = nextGeneration(generationRef)
339
+ setStatus('loading')
340
+ setError('')
341
+ void loadDrafts(api, describe, schema).then((result) => {
342
+ if (!generationIsCurrent(generationRef, generation)) return
343
+ setWritable(result.writable)
344
+ if (result.formats.length > 0) setFormats(result.formats)
345
+ if (result.error !== undefined) {
346
+ setStatus('error')
347
+ setError(result.error)
348
+ return
349
+ }
350
+ const merged = mergeLoadedDrafts(draftsRef.current, result.drafts, { preserveDirty })
351
+ setDrafts(merged.drafts)
352
+ if (merged.conflicted.length > 0) {
353
+ setNotices(Object.fromEntries(
354
+ merged.conflicted.map(provider => [provider, { kind: 'conflict' as const, text: t('dirtyConflict') }]),
355
+ ))
356
+ }
357
+ setStatus('ready')
358
+ }, (failure: unknown) => {
359
+ if (!generationIsCurrent(generationRef, generation)) return
360
+ setStatus('error')
361
+ setError(failure instanceof Error ? failure.message : t('loadError'))
362
+ })
363
+ }, [api, describe, schema, t])
364
+
365
+ useEffect(() => { reload(false) }, [reload])
366
+
367
+ useEffect(() => {
368
+ if (props.subscribeInvalidate === undefined) return undefined
369
+ return props.subscribeInvalidate(() => { reload(true) })
370
+ }, [props.subscribeInvalidate, reload])
371
+
372
+ const save = async (draft: RouteDraft): Promise<void> => {
373
+ if (api === undefined || describe === undefined) return
374
+ if (status === 'loading' || busyRoute !== null) return
375
+ const blocking = draft.models
376
+ .map(row => errorText(modelEffortError(row), t))
377
+ .find(text => text !== undefined)
378
+ if (blocking !== undefined) {
379
+ setNotices({ [draft.provider]: { kind: 'error', text: blocking } })
380
+ return
381
+ }
382
+ setBusyRoute(draft.provider)
383
+ setNotices({})
384
+ try {
385
+ const ops = buildSaveOps({
386
+ settingsPath: draft.settingsPath,
387
+ beforeModels: draft.originalModels,
388
+ afterModels: draft.models,
389
+ beforeCompat: draft.compatPresent ? draft.originalCompat : undefined,
390
+ afterCompat: draft.compat,
391
+ })
392
+ if (ops.length === 0) {
393
+ setDrafts(current => current.map(row => row.provider === draft.provider ? alignDraft(row) : row))
394
+ return
395
+ }
396
+ const response = await api.settings.mutate({
397
+ ns: LLM_PI_AI_NS,
398
+ ops,
399
+ expectedRevision: draft.revision,
400
+ })
401
+ 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
+ },
407
+ })
408
+ return
409
+ }
410
+ const view = response.result.value
411
+ describe.acceptView(view)
412
+ setDrafts(current => applySaveSuccess(current, draft.provider, {
413
+ user: view.user ?? {},
414
+ revision: view.revision,
415
+ }))
416
+ setNotices({ [draft.provider]: { kind: 'saved', text: t('saved') } })
417
+ } catch (failure) {
418
+ setNotices({
419
+ [draft.provider]: {
420
+ kind: 'error',
421
+ text: failure instanceof Error ? failure.message : t('loadError'),
422
+ },
423
+ })
424
+ } finally {
425
+ setBusyRoute(null)
426
+ }
427
+ }
428
+
429
+ const showLoading = status === 'loading' && drafts.length === 0
430
+ const showEmpty = status === 'ready' && drafts.length === 0
431
+ const showList = drafts.length > 0
432
+
433
+ return (
434
+ <div className={css.section}>
435
+ <h2 className={css.title}>{t('title')}</h2>
436
+ <p className={css.intro}>{t('intro')}</p>
437
+ {!writable && status === 'ready' ? <p className={css.notice}>{t('readOnly')}</p> : null}
438
+ {showLoading ? <p className={css.intro}>{t('loading')}</p> : null}
439
+ {status === 'error' ? <p className={css.error}>{error}</p> : null}
440
+ {status === 'error'
441
+ ? (
442
+ <button type="button" className={css.secondaryButton} onClick={() => { reload(true) }}>{t('reload')}</button>
443
+ )
444
+ : null}
445
+ {showEmpty
446
+ ? (
447
+ <>
448
+ <p className={css.notice}>{t('empty')}</p>
449
+ <p className={css.intro}>{t('emptyHint')}</p>
450
+ </>
451
+ )
452
+ : null}
453
+ {showList
454
+ ? (
455
+ <ul className={css.rows}>
456
+ {drafts.map(draft => (
457
+ <RouteCard
458
+ key={draft.provider}
459
+ draft={draft}
460
+ formats={formats.length > 0 ? formats : []}
461
+ writable={writable}
462
+ busy={busyRoute === draft.provider}
463
+ reloading={status === 'loading'}
464
+ notice={notices[draft.provider]}
465
+ t={t}
466
+ onChange={(next) => {
467
+ setNotices(current => {
468
+ const copy = { ...current }
469
+ delete copy[next.provider]
470
+ return copy
471
+ })
472
+ setDrafts(current => current.map(row => row.provider === next.provider ? next : row))
473
+ }}
474
+ onSave={(next) => { void save(next) }}
475
+ onCancel={(next) => {
476
+ setNotices(current => {
477
+ const copy = { ...current }
478
+ delete copy[next.provider]
479
+ return copy
480
+ })
481
+ setDrafts(current => current.map(row => row.provider === next.provider
482
+ ? {
483
+ ...row,
484
+ models: cloneModels(row.originalModels),
485
+ compat: cloneObject(row.originalCompat),
486
+ }
487
+ : row))
488
+ }}
489
+ />
490
+ ))}
491
+ </ul>
492
+ )
493
+ : null}
494
+ </div>
495
+ )
496
+ }
@@ -0,0 +1,27 @@
1
+ # src/client/
2
+
3
+ <p align="center">
4
+ <samp>
5
+ <a href="./README.md">中文</a> ·
6
+ <strong>English</strong>
7
+ </samp>
8
+ </p>
9
+
10
+ Browser half: registers **Reasoning efforts** in the Web UI settings panel and writes `llm-pi-ai` through the official settings RPC.
11
+
12
+ Wiring failures in `apply` throw, same as the official Models page. Do not swallow `apply` with a blanket `try/catch`. Locale, CSS, and event subscriptions are cleaned up through `ctx.effect` on unload.
13
+
14
+ Cross-plugin work uses Cordis services only (`connection`, `settingsScope`, `settingsSchema`, `slots`, `locale`, `remote`). Do not value-import `@deepseek-ai/dsh-client-ui-settings-models`: the client bundle purity rule forbids inlining other `@deepseek-ai/*` packages except platform modules in the shell’s frozen table.
15
+
16
+ ## What's in this directory
17
+
18
+ | File | Role |
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`. |
23
+ | [`locales.ts`](./locales.ts) | Copy namespace `plugin-effort-declare`. |
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. |
26
+
27
+ Visual language follows the official Models page, not `--ds-*` variables with light-mode fallbacks.