@dsh-plus/subagent-model 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -45,24 +45,12 @@ const SETTINGS_NS = "dsh-plus-subagent-model";
45
45
 
46
46
  //#endregion
47
47
  //#region src/client/api.ts
48
- const ROUTE_CONFIG = "/dsh-plus/subagent-model/config";
49
48
  const ROUTE_CATALOG = "/dsh-plus/subagent-model/catalog";
50
49
  async function parse(res) {
51
50
  const body = await res.json();
52
51
  if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
53
52
  return body;
54
53
  }
55
- async function fetchConfig() {
56
- return parse(await fetch(ROUTE_CONFIG, { credentials: "same-origin" }));
57
- }
58
- async function saveConfig(patch) {
59
- return parse(await fetch(ROUTE_CONFIG, {
60
- method: "PUT",
61
- credentials: "same-origin",
62
- headers: { "content-type": "application/json" },
63
- body: JSON.stringify(patch)
64
- }));
65
- }
66
54
  async function fetchCatalog() {
67
55
  return parse(await fetch(ROUTE_CATALOG, { credentials: "same-origin" }));
68
56
  }
@@ -131,11 +119,13 @@ const EMPTY_ROW = {
131
119
  };
132
120
  const EFFORT_INHERIT = "inherit";
133
121
  const EFFORT_DEFAULT = "default";
134
- function draftFromWire(wire) {
122
+ function draftFrom(value, catalog) {
135
123
  const rows = {};
136
- const names = [...wire.subagentProviders, ...Object.keys(wire.entries).sort()];
124
+ const names = /* @__PURE__ */ new Set();
125
+ for (const name$1 of catalog?.subagentProviders ?? []) names.add(name$1);
126
+ for (const name$1 of Object.keys(value.entries).sort()) names.add(name$1);
137
127
  for (const name$1 of names) {
138
- const entry = wire.entries[name$1];
128
+ const entry = value.entries[name$1];
139
129
  rows[name$1] = entry === void 0 ? { ...EMPTY_ROW } : {
140
130
  enabled: entry.enabled,
141
131
  provider: entry.provider,
@@ -144,7 +134,7 @@ function draftFromWire(wire) {
144
134
  };
145
135
  }
146
136
  return {
147
- enabled: wire.enabled,
137
+ enabled: value.enabled,
148
138
  rows
149
139
  };
150
140
  }
@@ -154,12 +144,6 @@ function toPatch(draft) {
154
144
  entries: draft.rows
155
145
  };
156
146
  }
157
- function rowNamesOf(wire, draft) {
158
- const names = /* @__PURE__ */ new Set();
159
- for (const name$1 of wire.subagentProviders) names.add(name$1);
160
- for (const name$1 of Object.keys(draft.rows).sort()) names.add(name$1);
161
- return [...names];
162
- }
163
147
  function unknownOption(value, label) {
164
148
  return {
165
149
  value,
@@ -275,28 +259,33 @@ function RowBlock(props) {
275
259
  });
276
260
  }
277
261
  function SubagentModelCard(props) {
278
- const { t } = props;
262
+ const { t, scope, api } = props;
263
+ const snapshot = (0, react.useSyncExternalStore)((listener) => scope.subscribe(listener), () => scope.getSnapshot());
264
+ const value = snapshot.value;
279
265
  const [open, setOpen] = (0, react.useState)(false);
280
- const [wire, setWire] = (0, react.useState)(null);
281
266
  const [draft, setDraft] = (0, react.useState)(null);
282
267
  const [catalog, setCatalog] = (0, react.useState)(null);
283
268
  const [catalogFailed, setCatalogFailed] = (0, react.useState)(false);
284
- const [loadFailed, setLoadFailed] = (0, react.useState)(false);
285
269
  const [saving, setSaving] = (0, react.useState)(false);
286
270
  const [status, setStatus] = (0, react.useState)(IDLE_STATUS);
287
271
  (0, react.useEffect)(() => {
288
- let alive = true;
289
- fetchConfig().then((loaded) => {
290
- if (!alive) return;
291
- setWire(loaded);
292
- setDraft(draftFromWire(loaded));
293
- }).catch(() => {
294
- if (alive) setLoadFailed(true);
272
+ if (value === void 0) return;
273
+ setDraft((current) => {
274
+ if (current === null) return draftFrom(value, catalog);
275
+ if (catalog === null) return current;
276
+ const rows = { ...current.rows };
277
+ let changed = false;
278
+ for (const name$1 of catalog.subagentProviders) {
279
+ if (name$1 in rows) continue;
280
+ rows[name$1] = { ...EMPTY_ROW };
281
+ changed = true;
282
+ }
283
+ return changed ? {
284
+ ...current,
285
+ rows
286
+ } : current;
295
287
  });
296
- return () => {
297
- alive = false;
298
- };
299
- }, []);
288
+ }, [value, catalog]);
300
289
  (0, react.useEffect)(() => {
301
290
  if (!open || catalog !== null) return;
302
291
  let alive = true;
@@ -311,20 +300,23 @@ function SubagentModelCard(props) {
311
300
  alive = false;
312
301
  };
313
302
  }, [open, catalog]);
314
- const dirty = (0, react.useMemo)(() => wire !== null && draft !== null && JSON.stringify(toPatch(draft)) !== JSON.stringify(toPatch(draftFromWire(wire))), [wire, draft]);
303
+ const dirty = (0, react.useMemo)(() => value !== void 0 && draft !== null && JSON.stringify(toPatch(draft)) !== JSON.stringify(toPatch(draftFrom(value, catalog))), [
304
+ value,
305
+ draft,
306
+ catalog
307
+ ]);
315
308
  const invalid = (0, react.useMemo)(() => draft !== null && Object.values(draft.rows).some((row) => row.model.length > 0 && row.provider.length === 0), [draft]);
316
- if (loadFailed) return null;
317
- if (wire === null || draft === null) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", {
309
+ if (value === void 0 || draft === null) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", {
318
310
  className: "dsm-card",
319
311
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
320
312
  className: "dsm-empty",
321
313
  children: t("loading")
322
314
  })
323
315
  });
324
- const edit = (key, value) => {
316
+ const edit = (key, editValue) => {
325
317
  setDraft({
326
318
  ...draft,
327
- [key]: value
319
+ [key]: editValue
328
320
  });
329
321
  setStatus(IDLE_STATUS);
330
322
  };
@@ -340,9 +332,16 @@ function SubagentModelCard(props) {
340
332
  };
341
333
  const onSave = () => {
342
334
  setSaving(true);
343
- saveConfig(toPatch(draft)).then((saved) => {
344
- setWire(saved);
345
- setDraft(draftFromWire(saved));
335
+ const revision = scope.getSnapshot().revision;
336
+ api.update({
337
+ ns: SETTINGS_NS,
338
+ patch: toPatch(draft),
339
+ ...revision !== void 0 ? { expectedRevision: revision } : {}
340
+ }).then(async (response) => {
341
+ if (!response.result.ok) throw new Error(response.result.error?.message ?? t("saveFailed"));
342
+ await scope.load();
343
+ const next = scope.getSnapshot().value;
344
+ if (next !== void 0) setDraft(draftFrom(next, catalog));
346
345
  setStatus(IDLE_STATUS);
347
346
  }).catch((error) => {
348
347
  setStatus({
@@ -355,8 +354,8 @@ function SubagentModelCard(props) {
355
354
  setCatalog(null);
356
355
  setCatalogFailed(false);
357
356
  };
358
- const disabled = !wire.writable;
359
- const rowNames = rowNamesOf(wire, draft);
357
+ const disabled = !snapshot.writable;
358
+ const rowNames = Object.keys(draft.rows).sort();
360
359
  const providerCount = (catalog?.providers ?? []).length;
361
360
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
362
361
  className: `dsm-card${open ? " dsm-cardOpen" : ""}`,
@@ -446,7 +445,7 @@ function SubagentModelCard(props) {
446
445
  className: "dsm-btn dsm-btnGhost",
447
446
  disabled: !dirty || saving,
448
447
  onClick: () => {
449
- setDraft(draftFromWire(wire));
448
+ setDraft(draftFrom(value, catalog));
450
449
  setStatus(IDLE_STATUS);
451
450
  },
452
451
  children: t("discard")
@@ -609,7 +608,12 @@ function injectStyle() {
609
608
  //#region src/client/client.ts
610
609
  const name = "dsh-plus-subagent-model";
611
610
  /** 浏览器半需要的 cordis 服务 key(loader 据此注入;package.json 的 dsh.client.inject 管包加载顺序)。 */
612
- const inject = ["slots", "locale"];
611
+ const inject = [
612
+ "slots",
613
+ "locale",
614
+ "settingsScope",
615
+ "connection"
616
+ ];
613
617
  function apply(ctx) {
614
618
  const c = ctx;
615
619
  const tag = injectStyle();
@@ -620,12 +624,17 @@ function apply(ctx) {
620
624
  zh,
621
625
  en
622
626
  }), "subagent-model: locale");
627
+ const scope = c.settingsScope.bind({ namespace: SETTINGS_NS });
628
+ const api = c.get("connection").api.settings;
623
629
  c.slots.inject("settings.plugin.item", () => c.slots.register({
624
630
  name: "settings.plugin.item",
625
631
  key: SETTINGS_NS,
626
- order: 100,
627
632
  locale: NS,
628
- inject: () => ({ t: c.locale.bind(NS) })
633
+ inject: () => ({
634
+ t: c.locale.bind(NS),
635
+ scope,
636
+ api
637
+ })
629
638
  }, SubagentModelCard));
630
639
  }
631
640
 
package/lib/index.js CHANGED
@@ -79,28 +79,13 @@ function validateEntry(entry) {
79
79
  if (effort.length === 0) return "reasoningEffort 不能为空(inherit / default / 档位 id)";
80
80
  return null;
81
81
  }
82
- function toWire(cfg, subagentProviders, writable) {
83
- const entries = {};
84
- for (const [name$1, entry] of Object.entries(cfg.entries)) entries[name$1] = {
85
- enabled: entry.enabled,
86
- provider: entry.provider,
87
- model: entry.model,
88
- reasoningEffort: entry.reasoningEffort
89
- };
90
- return {
91
- enabled: cfg.enabled,
92
- entries,
93
- subagentProviders: [...subagentProviders],
94
- writable
95
- };
96
- }
97
- const WirePatch = z.object({
98
- enabled: z.boolean(),
99
- entries: z.dict(EntrySchema)
100
- });
101
- /** 把卡片提交的 patch 规整为 settings.update 的用户层 patch(entries 整体替换)。 */
102
- function toUserPatch(input) {
103
- return { ...input };
82
+ /** 校验整组条目(settings 写入的 validate 钩子用);返回首个错误消息,合法时返回 null。 */
83
+ function validateEntries(entries) {
84
+ for (const [name$1, entry] of Object.entries(entries)) {
85
+ const error = validateEntry(entry);
86
+ if (error !== null) return `条目 ${name$1}: ${error}`;
87
+ }
88
+ return null;
104
89
  }
105
90
 
106
91
  //#endregion
@@ -172,76 +157,16 @@ function subagentProvidersOf(ctx) {
172
157
 
173
158
  //#endregion
174
159
  //#region src/config-api.ts
175
- const ROUTE_CONFIG = "/dsh-plus/subagent-model/config";
176
160
  const ROUTE_CATALOG = "/dsh-plus/subagent-model/catalog";
177
- const MAX_BODY_BYTES = 256 * 1024;
178
161
  function sendJson(res, status, body) {
179
162
  res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
180
163
  res.end(JSON.stringify(body));
181
164
  }
182
- function readBody(req) {
183
- return new Promise((resolve, reject) => {
184
- const chunks = [];
185
- let size = 0;
186
- req.on("data", (chunk) => {
187
- size += chunk.length;
188
- if (size > MAX_BODY_BYTES) {
189
- reject(/* @__PURE__ */ new Error("request body too large"));
190
- req.destroy();
191
- return;
192
- }
193
- chunks.push(chunk);
194
- });
195
- req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
196
- req.on("error", reject);
197
- });
198
- }
199
- async function readPatch(req) {
200
- const raw = await readBody(req);
201
- let parsed;
202
- try {
203
- parsed = JSON.parse(raw);
204
- } catch {
205
- throw new Error("request body is not valid JSON");
206
- }
207
- return WirePatch(parsed);
208
- }
209
- /** 校验用户提交的 entries(model 不能脱离 provider 等);返回首个错误消息或 null。 */
210
- function firstEntryError(patch) {
211
- for (const [name$1, entry] of Object.entries(patch.entries)) {
212
- const error = validateEntry(entry);
213
- if (error !== null) return `条目 ${name$1}: ${error}`;
214
- }
215
- return null;
216
- }
217
- async function handleConfig(ctx, service, req, res) {
218
- const settings = ctx.get("settings");
219
- if (req.method === "GET") {
220
- sendJson(res, 200, toWire(service.currentConfig(), service.subagentProviders(), settings !== void 0));
221
- return;
222
- }
223
- if (req.method !== "PUT") {
224
- sendJson(res, 405, { error: "method not allowed" });
225
- return;
226
- }
227
- if (settings === void 0) {
228
- sendJson(res, 503, { error: "settings provider 不可用,无法在线保存;请编辑 settings.yaml" });
229
- return;
230
- }
231
- const patch = await readPatch(req);
232
- const entryError = firstEntryError(patch);
233
- if (entryError !== null) {
234
- sendJson(res, 400, { error: entryError });
235
- return;
236
- }
237
- await settings.update(SETTINGS_NS, toUserPatch(patch));
238
- sendJson(res, 200, toWire(service.currentConfig(), service.subagentProviders(), true));
239
- }
240
165
  async function handleCatalog(ctx, res) {
241
166
  sendJson(res, 200, await buildModelCatalog(ctx));
242
167
  }
243
- /** 注册配置读写与目录路由;返回是否注册成功(webServer 缺失时由调用方保证不调用)。 */
244
- function registerConfigApi(ctx, service) {
168
+ /** 注册目录路由;返回是否注册成功(webServer 缺失时由调用方保证不调用)。 */
169
+ function registerCatalogApi(ctx) {
245
170
  const logger = ctx.logger("subagent-model");
246
171
  const guard = (handler) => {
247
172
  return async (req, res) => {
@@ -249,17 +174,12 @@ function registerConfigApi(ctx, service) {
249
174
  await handler(req, res);
250
175
  } catch (error) {
251
176
  const message = error instanceof Error ? error.message : String(error);
252
- logger.warn(`config api ${req.method ?? "?"} ${req.url ?? "?"} failed: ${message}`);
177
+ logger.warn(`catalog api ${req.method ?? "?"} ${req.url ?? "?"} failed: ${message}`);
253
178
  if (!res.headersSent) sendJson(res, 400, { error: message });
254
179
  else res.end();
255
180
  }
256
181
  };
257
182
  };
258
- ctx.webServer.register({
259
- kind: "exact",
260
- path: ROUTE_CONFIG,
261
- handler: guard((req, res) => handleConfig(ctx, service, req, res))
262
- });
263
183
  ctx.webServer.register({
264
184
  kind: "exact",
265
185
  path: ROUTE_CATALOG,
@@ -358,6 +278,10 @@ var SubagentModelService = class extends Service {
358
278
  super(ctx, "subagentModel");
359
279
  this.current = () => config;
360
280
  installSettingsSection(ctx, SETTINGS_NS, Config, config, {
281
+ validate: (cfg) => {
282
+ const error = validateEntries(cfg.entries);
283
+ if (error !== null) throw new Error(`subagent-model: ${error}`);
284
+ },
361
285
  setSource: (source) => {
362
286
  this.current = source;
363
287
  },
@@ -366,7 +290,7 @@ var SubagentModelService = class extends Service {
366
290
  installDelegationHook(ctx, () => this.current());
367
291
  installChildRoute(ctx);
368
292
  ctx.inject(["webServer"], (webCtx) => {
369
- registerConfigApi(webCtx, this);
293
+ registerCatalogApi(webCtx);
370
294
  });
371
295
  }
372
296
  /** 当前生效配置(settings 用户层解析结果或 cordis 行级 config)。 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dsh-plus/subagent-model",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "dsh-plus service+ui plugin: 为 subagent/subagent_fork 等子代理按提供商配置模型与思考程度(或继承主代理)",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -24,7 +24,9 @@
24
24
  "client": {
25
25
  "inject": [
26
26
  "@deepseek-ai/dsh-client-runtime",
27
- "@deepseek-ai/dsh-client-locale"
27
+ "@deepseek-ai/dsh-client-locale",
28
+ "@deepseek-ai/dsh-client-connection",
29
+ "@deepseek-ai/dsh-client-ui-settings"
28
30
  ],
29
31
  "platform": "web"
30
32
  }
package/src/client/api.ts CHANGED
@@ -1,28 +1,9 @@
1
1
  /**
2
- * 配置卡片数据通道:同源 fetch 调自建 webServer 路由。
3
- * (官方 settings.* RPC 白名单硬编码不含第三方 namespace,见 config-api.ts 注释。)
2
+ * 自定义端点通道:仅剩「模型目录」(配置读写已迁移到官方 settingsScope
3
+ * 传输,见 scope.ts 与 card.tsx)。
4
4
  * @module subagent-model/client/api
5
5
  */
6
6
 
7
- export interface WireEntry {
8
- enabled: boolean
9
- provider: string
10
- model: string
11
- reasoningEffort: string
12
- }
13
-
14
- export interface WireConfig {
15
- enabled: boolean
16
- entries: Record<string, WireEntry>
17
- subagentProviders: string[]
18
- writable: boolean
19
- }
20
-
21
- export interface WirePatch {
22
- enabled: boolean
23
- entries: Record<string, WireEntry>
24
- }
25
-
26
7
  export interface CatalogEffort {
27
8
  id: string
28
9
  name: string
@@ -51,7 +32,6 @@ export interface ModelCatalog {
51
32
  subagentProviders: string[]
52
33
  }
53
34
 
54
- const ROUTE_CONFIG = '/dsh-plus/subagent-model/config'
55
35
  const ROUTE_CATALOG = '/dsh-plus/subagent-model/catalog'
56
36
 
57
37
  async function parse<T>(res: Response): Promise<T> {
@@ -60,21 +40,6 @@ async function parse<T>(res: Response): Promise<T> {
60
40
  return body
61
41
  }
62
42
 
63
- export async function fetchConfig(): Promise<WireConfig> {
64
- return parse<WireConfig>(await fetch(ROUTE_CONFIG, { credentials: 'same-origin' }))
65
- }
66
-
67
- export async function saveConfig(patch: WirePatch): Promise<WireConfig> {
68
- return parse<WireConfig>(
69
- await fetch(ROUTE_CONFIG, {
70
- method: 'PUT',
71
- credentials: 'same-origin',
72
- headers: { 'content-type': 'application/json' },
73
- body: JSON.stringify(patch),
74
- }),
75
- )
76
- }
77
-
78
43
  export async function fetchCatalog(): Promise<ModelCatalog> {
79
44
  return parse<ModelCatalog>(await fetch(ROUTE_CATALOG, { credentials: 'same-origin' }))
80
45
  }
@@ -1,20 +1,38 @@
1
1
  /**
2
2
  * 「子代理模型配置」配置卡片:注册进 settings.plugin.item 插槽(官方插件配置页)。
3
- * 交互对齐官方卡片:折叠/展开、staged draft、未保存标记、保存/放弃;
4
- * 数据走自建同源路由(api.ts)。行集合 = 已注册子代理 provider ∪ 已配置条目,
5
- * 保存时全量写回 entries(未配置的新 provider 行以默认空值落盘,自文档化)。
3
+ * 交互对齐官方卡片:折叠/展开、staged draft、未保存标记、保存/放弃。
4
+ * 配置读写走官方 settingsScope 传输(scope.ts):value 为 schema 解析后的
5
+ * 命名空间值(enabled + entries),行集合 = 目录返回的已注册子代理 provider
6
+ * ∪ 已配置条目,保存时全量写回 entries(未配置的新 provider 行以默认空值
7
+ * 落盘,自文档化);「模型目录」为唯一保留的自定义端点。
6
8
  * @module subagent-model/client/card
7
9
  */
8
- import { useEffect, useMemo, useState, type ReactElement } from 'react'
10
+ import { useEffect, useMemo, useSyncExternalStore, useState, type ReactElement } from 'react'
9
11
 
12
+ import { SETTINGS_NS } from '../ns.ts'
10
13
  import {
11
- fetchCatalog, fetchConfig, saveConfig,
12
- type CatalogProvider, type ModelCatalog, type WireConfig, type WireEntry, type WirePatch,
14
+ fetchCatalog, type CatalogProvider, type ModelCatalog,
13
15
  } from './api.ts'
14
16
  import { CheckRow, SelectField, type SelectOption } from './fields.tsx'
17
+ import type { Scope, SettingsApi } from './scope.ts'
15
18
 
16
19
  export interface CardProps {
17
20
  t(key: string): string
21
+ scope: Scope
22
+ api: SettingsApi
23
+ }
24
+
25
+ /** settings 命名空间的解析值(无 secret 字段)。 */
26
+ export interface ConfigValue {
27
+ enabled: boolean
28
+ entries: Record<string, WireEntry>
29
+ }
30
+
31
+ export interface WireEntry {
32
+ enabled: boolean
33
+ provider: string
34
+ model: string
35
+ reasoningEffort: string
18
36
  }
19
37
 
20
38
  interface DraftRow {
@@ -39,11 +57,13 @@ const EMPTY_ROW: DraftRow = { enabled: false, provider: '', model: '', reasoning
39
57
  const EFFORT_INHERIT = 'inherit'
40
58
  const EFFORT_DEFAULT = 'default'
41
59
 
42
- function draftFromWire(wire: WireConfig): Draft {
60
+ function draftFrom(value: ConfigValue, catalog: ModelCatalog | null): Draft {
43
61
  const rows: Record<string, DraftRow> = {}
44
- const names = [...wire.subagentProviders, ...Object.keys(wire.entries).sort()]
62
+ const names = new Set<string>()
63
+ for (const name of catalog?.subagentProviders ?? []) names.add(name)
64
+ for (const name of Object.keys(value.entries).sort()) names.add(name)
45
65
  for (const name of names) {
46
- const entry = wire.entries[name]
66
+ const entry = value.entries[name]
47
67
  rows[name] = entry === undefined
48
68
  ? { ...EMPTY_ROW }
49
69
  : {
@@ -53,20 +73,13 @@ function draftFromWire(wire: WireConfig): Draft {
53
73
  reasoningEffort: entry.reasoningEffort,
54
74
  }
55
75
  }
56
- return { enabled: wire.enabled, rows }
76
+ return { enabled: value.enabled, rows }
57
77
  }
58
78
 
59
- function toPatch(draft: Draft): WirePatch {
79
+ function toPatch(draft: Draft): Record<string, unknown> {
60
80
  return { enabled: draft.enabled, entries: draft.rows }
61
81
  }
62
82
 
63
- function rowNamesOf(wire: WireConfig, draft: Draft): string[] {
64
- const names = new Set<string>()
65
- for (const name of wire.subagentProviders) names.add(name)
66
- for (const name of Object.keys(draft.rows).sort()) names.add(name)
67
- return [...names]
68
- }
69
-
70
83
  function unknownOption(value: string, label: string): SelectOption {
71
84
  return { value, label: `${value}(${label})` }
72
85
  }
@@ -139,31 +152,36 @@ function RowBlock(props: RowBlockProps): ReactElement {
139
152
  }
140
153
 
141
154
  export function SubagentModelCard(props: CardProps): ReactElement | null {
142
- const { t } = props
155
+ const { t, scope, api } = props
156
+ const snapshot = useSyncExternalStore(
157
+ (listener: () => void) => scope.subscribe(listener),
158
+ () => scope.getSnapshot(),
159
+ )
160
+ const value = snapshot.value as ConfigValue | undefined
143
161
  const [open, setOpen] = useState(false)
144
- const [wire, setWire] = useState<WireConfig | null>(null)
145
162
  const [draft, setDraft] = useState<Draft | null>(null)
146
163
  const [catalog, setCatalog] = useState<ModelCatalog | null>(null)
147
164
  const [catalogFailed, setCatalogFailed] = useState(false)
148
- const [loadFailed, setLoadFailed] = useState(false)
149
165
  const [saving, setSaving] = useState(false)
150
166
  const [status, setStatus] = useState<Status>(IDLE_STATUS)
151
167
 
168
+ // 播种草稿(行集 = 已配置条目);catalog 到达后只补缺失的 provider 空行,
169
+ // 不覆盖在途编辑;后续 Host 更新同样不覆盖。
152
170
  useEffect(() => {
153
- let alive = true
154
- fetchConfig()
155
- .then((loaded) => {
156
- if (!alive) return
157
- setWire(loaded)
158
- setDraft(draftFromWire(loaded))
159
- })
160
- .catch(() => {
161
- if (alive) setLoadFailed(true)
162
- })
163
- return () => {
164
- alive = false
165
- }
166
- }, [])
171
+ if (value === undefined) return
172
+ setDraft((current) => {
173
+ if (current === null) return draftFrom(value, catalog)
174
+ if (catalog === null) return current
175
+ const rows = { ...current.rows }
176
+ let changed = false
177
+ for (const name of catalog.subagentProviders) {
178
+ if (name in rows) continue
179
+ rows[name] = { ...EMPTY_ROW }
180
+ changed = true
181
+ }
182
+ return changed ? { ...current, rows } : current
183
+ })
184
+ }, [value, catalog])
167
185
 
168
186
  useEffect(() => {
169
187
  if (!open || catalog !== null) return
@@ -183,9 +201,10 @@ export function SubagentModelCard(props: CardProps): ReactElement | null {
183
201
  }, [open, catalog])
184
202
 
185
203
  const dirty = useMemo(
186
- () => wire !== null && draft !== null &&
187
- JSON.stringify(toPatch(draft)) !== JSON.stringify(toPatch(draftFromWire(wire))),
188
- [wire, draft],
204
+ () =>
205
+ value !== undefined && draft !== null &&
206
+ JSON.stringify(toPatch(draft)) !== JSON.stringify(toPatch(draftFrom(value, catalog))),
207
+ [value, draft, catalog],
189
208
  )
190
209
  const invalid = useMemo(
191
210
  () => draft !== null && Object.values(draft.rows).some((row) =>
@@ -193,12 +212,11 @@ export function SubagentModelCard(props: CardProps): ReactElement | null {
193
212
  [draft],
194
213
  )
195
214
 
196
- if (loadFailed) return null
197
- if (wire === null || draft === null) {
215
+ if (value === undefined || draft === null) {
198
216
  return <li className="dsm-card"><p className="dsm-empty">{t('loading')}</p></li>
199
217
  }
200
- const edit = <K extends keyof Draft>(key: K, value: Draft[K]): void => {
201
- setDraft({ ...draft, [key]: value })
218
+ const edit = <K extends keyof Draft>(key: K, editValue: Draft[K]): void => {
219
+ setDraft({ ...draft, [key]: editValue })
202
220
  setStatus(IDLE_STATUS)
203
221
  }
204
222
  const editRow = (name: string, row: DraftRow): void => {
@@ -207,10 +225,19 @@ export function SubagentModelCard(props: CardProps): ReactElement | null {
207
225
  }
208
226
  const onSave = (): void => {
209
227
  setSaving(true)
210
- saveConfig(toPatch(draft))
211
- .then((saved) => {
212
- setWire(saved)
213
- setDraft(draftFromWire(saved))
228
+ const revision = scope.getSnapshot().revision
229
+ api.update({
230
+ ns: SETTINGS_NS,
231
+ patch: toPatch(draft),
232
+ ...(revision !== undefined ? { expectedRevision: revision } : {}),
233
+ })
234
+ .then(async (response) => {
235
+ if (!response.result.ok) {
236
+ throw new Error(response.result.error?.message ?? t('saveFailed'))
237
+ }
238
+ await scope.load()
239
+ const next = scope.getSnapshot().value as ConfigValue | undefined
240
+ if (next !== undefined) setDraft(draftFrom(next, catalog))
214
241
  setStatus(IDLE_STATUS)
215
242
  })
216
243
  .catch((error: unknown) => {
@@ -223,8 +250,8 @@ export function SubagentModelCard(props: CardProps): ReactElement | null {
223
250
  setCatalogFailed(false)
224
251
  }
225
252
 
226
- const disabled = !wire.writable
227
- const rowNames = rowNamesOf(wire, draft)
253
+ const disabled = !snapshot.writable
254
+ const rowNames = Object.keys(draft.rows).sort()
228
255
  const providerCount = (catalog?.providers ?? []).length
229
256
  return (
230
257
  <li className={`dsm-card${open ? ' dsm-cardOpen' : ''}`}>
@@ -276,7 +303,7 @@ export function SubagentModelCard(props: CardProps): ReactElement | null {
276
303
  </p>
277
304
  ) : null}
278
305
  <button type="button" className="dsm-btn dsm-btnGhost" disabled={!dirty || saving}
279
- onClick={() => { setDraft(draftFromWire(wire)); setStatus(IDLE_STATUS) }}>
306
+ onClick={() => { setDraft(draftFrom(value, catalog)); setStatus(IDLE_STATUS) }}>
280
307
  {t('discard')}
281
308
  </button>
282
309
  <button type="button" className="dsm-btn dsm-btnPrimary" disabled={!dirty || invalid || saving || disabled}
@@ -3,12 +3,15 @@
3
3
  * 构建产物为 window.__ModuleLoader__.load({id, factory}) 形式的 CJS factory
4
4
  * (包装见 tsdown.config.ts);样式沿用官方 data-plugin-css 约定,HMR 据此卸载。
5
5
  *
6
- * 类型说明:浏览器半只用到 slots/locale 的很窄一面,此处以最小本地接口声明,
7
- * 避免为构建期类型引入整条官方 client 依赖树;运行时契约以官方
8
- * dsh-client-ui-settings-plugins 的 settings.plugin.item 插槽为准。
6
+ * 类型说明:浏览器半只用到 slots/locale/settingsScope/connection 的很窄一面,
7
+ * 此处以最小本地接口声明(见 ./scope.ts),避免为构建期类型引入整条官方
8
+ * client 依赖树;运行时契约以官方 dsh-client-ui-settings-plugins 的
9
+ * settings.plugin.item 插槽与 dsh-client-ui-settings 的 settingsScope 服务为准。
9
10
  * rc7 起该插槽为 keyed 槽位:key 必须是卡片编辑的 settings 命名空间
10
11
  * (即服务端 settingsNamespace() 注册的同一字面量,见 ../ns.ts),
11
12
  * 官方配置页按此 key 与 Host 已注册命名空间配对分发。
13
+ * 配置读写经官方 settingsScope 传输(rc7 起第三方命名空间对 settings RPC
14
+ * 全量开放);自定义端点仅剩「模型目录」(api.ts)。
12
15
  * @module @dsh-plus/subagent-model/client
13
16
  */
14
17
  import type { Context } from '@deepseek-ai/cordis'
@@ -16,12 +19,13 @@ import type { Context } from '@deepseek-ai/cordis'
16
19
  import { SETTINGS_NS } from '../ns.ts'
17
20
  import { SubagentModelCard } from './card.tsx'
18
21
  import { en, NS, zh } from './i18n.ts'
22
+ import type { Scope, SettingsApi } from './scope.ts'
19
23
  import { injectStyle } from './styles.ts'
20
24
 
21
25
  export const name = 'dsh-plus-subagent-model'
22
26
 
23
27
  /** 浏览器半需要的 cordis 服务 key(loader 据此注入;package.json 的 dsh.client.inject 管包加载顺序)。 */
24
- export const inject = ['slots', 'locale'] as const
28
+ export const inject = ['slots', 'locale', 'settingsScope', 'connection'] as const
25
29
 
26
30
  interface SlotsLike {
27
31
  inject(key: string, callback: () => unknown): unknown
@@ -33,9 +37,19 @@ interface LocaleLike {
33
37
  bind(ns: string): (key: string) => string
34
38
  }
35
39
 
40
+ interface SettingsScopeServiceLike {
41
+ bind(spec: { namespace: string }): Scope
42
+ }
43
+
44
+ interface ConnectionLike {
45
+ settings: SettingsApi
46
+ }
47
+
36
48
  interface ClientContext {
37
49
  slots: SlotsLike
38
50
  locale: LocaleLike
51
+ settingsScope: SettingsScopeServiceLike
52
+ get(key: 'connection'): ConnectionLike
39
53
  effect(execute: () => () => void, label?: string): unknown
40
54
  }
41
55
 
@@ -52,14 +66,15 @@ export function apply(ctx: Context): void {
52
66
  () => c.locale.register(NS, { zh, en }),
53
67
  'subagent-model: locale',
54
68
  )
69
+ const scope = c.settingsScope.bind({ namespace: SETTINGS_NS })
70
+ const api = c.get('connection').api.settings
55
71
  c.slots.inject('settings.plugin.item', () =>
56
72
  c.slots.register(
57
73
  {
58
74
  name: 'settings.plugin.item',
59
75
  key: SETTINGS_NS,
60
- order: 100,
61
76
  locale: NS,
62
- inject: () => ({ t: c.locale.bind(NS) }),
77
+ inject: () => ({ t: c.locale.bind(NS), scope, api }),
63
78
  },
64
79
  SubagentModelCard,
65
80
  ),
@@ -0,0 +1,33 @@
1
+ /**
2
+ * 浏览器半 settingsScope/connection 的最小本地接口声明。
3
+ * 只声明本插件用到的窄面,避免为构建期类型引入整条官方 client 依赖树;
4
+ * 运行时契约以官方 dsh-client-ui-settings 的 settingsScope 服务与
5
+ * dsh-client-connection 的 settings RPC 面为准(rc7:白名单已移除,
6
+ * 任何已注册命名空间均可 describe/update/replace)。
7
+ * @module subagent-model/client/scope
8
+ */
9
+
10
+ /** settings 命名空间 scope 的快照(官方 SettingsScopeSnapshot 的最小投影)。 */
11
+ export interface ScopeSnapshot {
12
+ status: 'loading' | 'ready' | 'unavailable'
13
+ /** schema 解析后的配置值(secret 字段已脱敏剥除)。 */
14
+ value: unknown
15
+ /** 命名空间 revision,写操作 fencing 用;首次 Host 应答前为 undefined。 */
16
+ revision: number | undefined
17
+ /** Host 文档是否可写。 */
18
+ writable: boolean
19
+ }
20
+
21
+ /** settingsScope.bind({namespace}) 返回的控制器面。 */
22
+ export interface Scope {
23
+ getSnapshot(): ScopeSnapshot
24
+ subscribe(listener: () => void): () => void
25
+ load(): Promise<void>
26
+ }
27
+
28
+ /** connection api.settings 的 RPC 面(本插件用到的三个方法)。 */
29
+ export interface SettingsApi {
30
+ describe(): Promise<{ result: { ok: boolean; value?: { namespaces: Array<{ ns: string; secrets: Array<{ path: string[]; set: boolean }> }> }; error?: { message?: string } } }>
31
+ update(request: { ns: string; patch: Record<string, unknown>; expectedRevision?: number }): Promise<{ result: { ok: boolean; error?: { message?: string } } }>
32
+ replace(request: { ns: string; section: Record<string, unknown>; expectedRevision?: number }): Promise<{ result: { ok: boolean; error?: { message?: string } } }>
33
+ }
package/src/config-api.ts CHANGED
@@ -1,8 +1,6 @@
1
1
  /**
2
- * 配置 HTTP 通道:webui 插件配置卡片的后端。
3
- * 背景:官方 apiproxy 的 settings.* RPC 对 namespace 有硬编码白名单,第三方
4
- * namespace 一律 settings-not-exposed;故卡片数据走自建 webServer 路由,
5
- * 读写仍经 ctx.settings 用户层(持久化 $DSH_HOME/settings.yaml)。
2
+ * 自定义端点 HTTP 通道:配置读写已迁移到官方 settings RPC(rc7 起第三方
3
+ * 命名空间全量开放),这里只保留官方传输没有的「模型目录」端点。
6
4
  * 仅监听 dsh web 同源(webserver 默认 127.0.0.1),与 GUI 其余面同等暴露面。
7
5
  * @module subagent-model/config-api
8
6
  */
@@ -11,94 +9,21 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
11
9
  import type { Context } from '@deepseek-ai/cordis'
12
10
 
13
11
  import { buildModelCatalog, type ModelCatalog } from './catalog.ts'
14
- import {
15
- SETTINGS_NS, toUserPatch, toWire, validateEntry, WirePatch, type WirePatchOutput,
16
- } from './config.ts'
17
- import type { SubagentModelService } from './service.ts'
18
12
 
19
- const ROUTE_CONFIG = '/dsh-plus/subagent-model/config'
20
13
  const ROUTE_CATALOG = '/dsh-plus/subagent-model/catalog'
21
- const MAX_BODY_BYTES = 256 * 1024
22
14
 
23
15
  function sendJson(res: ServerResponse, status: number, body: unknown): void {
24
16
  res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
25
17
  res.end(JSON.stringify(body))
26
18
  }
27
19
 
28
- function readBody(req: IncomingMessage): Promise<string> {
29
- return new Promise((resolve, reject) => {
30
- const chunks: Buffer[] = []
31
- let size = 0
32
- req.on('data', (chunk: Buffer) => {
33
- size += chunk.length
34
- if (size > MAX_BODY_BYTES) {
35
- reject(new Error('request body too large'))
36
- req.destroy()
37
- return
38
- }
39
- chunks.push(chunk)
40
- })
41
- req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
42
- req.on('error', reject)
43
- })
44
- }
45
-
46
- async function readPatch(req: IncomingMessage): Promise<WirePatchOutput> {
47
- const raw = await readBody(req)
48
- let parsed: unknown
49
- try {
50
- parsed = JSON.parse(raw)
51
- } catch {
52
- throw new Error('request body is not valid JSON')
53
- }
54
- return WirePatch(parsed)
55
- }
56
-
57
- /** 校验用户提交的 entries(model 不能脱离 provider 等);返回首个错误消息或 null。 */
58
- function firstEntryError(patch: WirePatchOutput): string | null {
59
- for (const [name, entry] of Object.entries(patch.entries)) {
60
- const error = validateEntry(entry)
61
- if (error !== null) return `条目 ${name}: ${error}`
62
- }
63
- return null
64
- }
65
-
66
- async function handleConfig(
67
- ctx: Context,
68
- service: SubagentModelService,
69
- req: IncomingMessage,
70
- res: ServerResponse,
71
- ): Promise<void> {
72
- const settings = ctx.get('settings')
73
- if (req.method === 'GET') {
74
- sendJson(res, 200, toWire(service.currentConfig(), service.subagentProviders(), settings !== undefined))
75
- return
76
- }
77
- if (req.method !== 'PUT') {
78
- sendJson(res, 405, { error: 'method not allowed' })
79
- return
80
- }
81
- if (settings === undefined) {
82
- sendJson(res, 503, { error: 'settings provider 不可用,无法在线保存;请编辑 settings.yaml' })
83
- return
84
- }
85
- const patch = await readPatch(req)
86
- const entryError = firstEntryError(patch)
87
- if (entryError !== null) {
88
- sendJson(res, 400, { error: entryError })
89
- return
90
- }
91
- await settings.update(SETTINGS_NS, toUserPatch(patch))
92
- sendJson(res, 200, toWire(service.currentConfig(), service.subagentProviders(), true))
93
- }
94
-
95
20
  async function handleCatalog(ctx: Context, res: ServerResponse): Promise<void> {
96
21
  const catalog: ModelCatalog = await buildModelCatalog(ctx)
97
22
  sendJson(res, 200, catalog)
98
23
  }
99
24
 
100
- /** 注册配置读写与目录路由;返回是否注册成功(webServer 缺失时由调用方保证不调用)。 */
101
- export function registerConfigApi(ctx: Context, service: SubagentModelService): void {
25
+ /** 注册目录路由;返回是否注册成功(webServer 缺失时由调用方保证不调用)。 */
26
+ export function registerCatalogApi(ctx: Context): void {
102
27
  const logger = ctx.logger('subagent-model')
103
28
  const guard = (
104
29
  handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>,
@@ -108,17 +33,12 @@ export function registerConfigApi(ctx: Context, service: SubagentModelService):
108
33
  await handler(req, res)
109
34
  } catch (error) {
110
35
  const message = error instanceof Error ? error.message : String(error)
111
- logger.warn(`config api ${req.method ?? '?'} ${req.url ?? '?'} failed: ${message}`)
36
+ logger.warn(`catalog api ${req.method ?? '?'} ${req.url ?? '?'} failed: ${message}`)
112
37
  if (!res.headersSent) sendJson(res, 400, { error: message })
113
38
  else res.end()
114
39
  }
115
40
  }
116
41
  }
117
- ctx.webServer.register({
118
- kind: 'exact',
119
- path: ROUTE_CONFIG,
120
- handler: guard((req, res) => handleConfig(ctx, service, req, res)),
121
- })
122
42
  ctx.webServer.register({
123
43
  kind: 'exact',
124
44
  path: ROUTE_CATALOG,
package/src/config.ts CHANGED
@@ -144,58 +144,11 @@ export function validateEntry(entry: EntryValidationInput): string | null {
144
144
  return null
145
145
  }
146
146
 
147
- /** 配置卡片读取用的传输对象(writable 表示是否存在可写的 settings provider)。 */
148
- export interface WireEntry {
149
- enabled: boolean
150
- provider: string
151
- model: string
152
- reasoningEffort: string
153
- }
154
-
155
- export interface WireConfig {
156
- enabled: boolean
157
- entries: Record<string, WireEntry>
158
- /** 当前已注册的子代理 provider 名(spawn/fork/…),供卡片合成空行。 */
159
- subagentProviders: string[]
160
- writable: boolean
161
- }
162
-
163
- export function toWire(cfg: SubagentModelConfig, subagentProviders: string[], writable: boolean): WireConfig {
164
- const entries: Record<string, WireEntry> = {}
165
- for (const [name, entry] of Object.entries(cfg.entries)) {
166
- entries[name] = {
167
- enabled: entry.enabled,
168
- provider: entry.provider,
169
- model: entry.model,
170
- reasoningEffort: entry.reasoningEffort,
171
- }
147
+ /** 校验整组条目(settings 写入的 validate 钩子用);返回首个错误消息,合法时返回 null。 */
148
+ export function validateEntries(entries: Record<string, EntryValidationInput>): string | null {
149
+ for (const [name, entry] of Object.entries(entries)) {
150
+ const error = validateEntry(entry)
151
+ if (error !== null) return `条目 ${name}: ${error}`
172
152
  }
173
- return { enabled: cfg.enabled, entries, subagentProviders: [...subagentProviders], writable }
174
- }
175
-
176
- /** 配置卡片写回用的宽松输入形态(字段全部可选,无默认值,深合并进用户层)。 */
177
- export interface WirePatchInput {
178
- enabled?: boolean | null
179
- entries?: Record<string, {
180
- enabled?: boolean | null
181
- provider?: string | null
182
- model?: string | null
183
- reasoningEffort?: string | null
184
- }> | null
185
- }
186
-
187
- /** 配置卡片写回用的规范输出形态(校验后条目已填默认)。 */
188
- export interface WirePatchOutput {
189
- enabled: boolean
190
- entries: Record<string, EntryConfig>
191
- }
192
-
193
- export const WirePatch: z<WirePatchInput, WirePatchOutput> = z.object({
194
- enabled: z.boolean(),
195
- entries: z.dict(EntrySchema),
196
- })
197
-
198
- /** 把卡片提交的 patch 规整为 settings.update 的用户层 patch(entries 整体替换)。 */
199
- export function toUserPatch(input: WirePatchInput): Record<string, unknown> {
200
- return { ...input }
153
+ return null
201
154
  }
package/src/service.ts CHANGED
@@ -1,19 +1,19 @@
1
1
  /**
2
2
  * SubagentModelService:子代理模型配置中枢。
3
3
  * - 配置:installSettingsSection 接入 settings 用户层($DSH_HOME/settings.yaml,热生效),
4
- * 无 settings provider 时退化为 cordis 行级 config。
4
+ * 无 settings provider 时退化为 cordis 行级 config;写入经 validate 钩子
5
+ * 走 validateEntries 规则(model 不能脱离 provider 等),非法即拒。
5
6
  * - 委托挂钩:包装 ctx.subagents.start/startContinuable,按 provider 名注入
6
7
  * agentOptions(provider/model/effort 私有标记),未命中条目完全直通。
7
8
  * - 子代理路由:根 ctx 的 agent/request 瀑布监听,把条目 effort 应用到子代理请求。
8
- * - 配置通道:ctx.webServer 注册 config/catalog 同源路由(官方 settings RPC
9
- * 白名单不含第三方 namespace)。
9
+ * - 自定义端点:ctx.webServer 仅注册模型目录路由(配置读写已走官方 settings RPC)。
10
10
  * @module @dsh-plus/subagent-model
11
11
  */
12
12
  import { Context, Service } from '@deepseek-ai/cordis'
13
13
  import { installSettingsSection } from '@deepseek-ai/dsh-settings'
14
14
 
15
- import { registerConfigApi } from './config-api.ts'
16
- import { Config, SETTINGS_NS, type SubagentModelConfig } from './config.ts'
15
+ import { registerCatalogApi } from './config-api.ts'
16
+ import { Config, SETTINGS_NS, validateEntries, type SubagentModelConfig } from './config.ts'
17
17
  import { installChildRoute, installDelegationHook } from './delegation.ts'
18
18
 
19
19
  declare module '@deepseek-ai/cordis' {
@@ -31,6 +31,10 @@ export class SubagentModelService extends Service {
31
31
  super(ctx, 'subagentModel')
32
32
  this.current = () => config
33
33
  installSettingsSection(ctx, SETTINGS_NS, Config, config, {
34
+ validate: (cfg: SubagentModelConfig) => {
35
+ const error = validateEntries(cfg.entries)
36
+ if (error !== null) throw new Error(`subagent-model: ${error}`)
37
+ },
34
38
  setSource: (source) => {
35
39
  this.current = source
36
40
  },
@@ -39,7 +43,7 @@ export class SubagentModelService extends Service {
39
43
  installDelegationHook(ctx, () => this.current())
40
44
  installChildRoute(ctx)
41
45
  ctx.inject(['webServer'], (webCtx) => {
42
- registerConfigApi(webCtx, this)
46
+ registerCatalogApi(webCtx)
43
47
  })
44
48
  }
45
49