@dsh-plus/subagent-model 0.1.1 → 0.1.3
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 +128 -50
- package/lib/index.js +15 -91
- package/package.json +4 -2
- package/src/client/api.ts +2 -37
- package/src/client/card.tsx +76 -49
- package/src/client/client.ts +85 -6
- package/src/client/scope.ts +37 -0
- package/src/config-api.ts +5 -85
- package/src/config.ts +6 -53
- package/src/service.ts +10 -6
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
|
|
122
|
+
function draftFrom(value, catalog) {
|
|
135
123
|
const rows = {};
|
|
136
|
-
const names =
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
if (
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
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)(() =>
|
|
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 (
|
|
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,
|
|
316
|
+
const edit = (key, editValue) => {
|
|
325
317
|
setDraft({
|
|
326
318
|
...draft,
|
|
327
|
-
[key]:
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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 = !
|
|
359
|
-
const rowNames =
|
|
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(
|
|
448
|
+
setDraft(draftFrom(value, catalog));
|
|
450
449
|
setStatus(IDLE_STATUS);
|
|
451
450
|
},
|
|
452
451
|
children: t("discard")
|
|
@@ -609,7 +608,81 @@ 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 = [
|
|
611
|
+
const inject = [
|
|
612
|
+
"slots",
|
|
613
|
+
"locale",
|
|
614
|
+
"connection",
|
|
615
|
+
"remote"
|
|
616
|
+
];
|
|
617
|
+
/**
|
|
618
|
+
* api 直连实现的命名空间 scope(官方配置页 tab 同款模式)。
|
|
619
|
+
* 读:describe 取命名空间视图(脱敏 value/revision + 顶层 writable);
|
|
620
|
+
* 刷新:remote `settings/document-updated`(按命名空间过滤)与
|
|
621
|
+
* `connection/reset`;generation 防旧读覆盖新发布。任何页面 origin
|
|
622
|
+
* (loopback 或 tailnet 信任域名)下行为一致。
|
|
623
|
+
*/
|
|
624
|
+
function createApiScope(api, ns, c) {
|
|
625
|
+
let state = {
|
|
626
|
+
status: "loading",
|
|
627
|
+
value: void 0,
|
|
628
|
+
revision: void 0,
|
|
629
|
+
writable: false
|
|
630
|
+
};
|
|
631
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
632
|
+
let generation = 0;
|
|
633
|
+
const publish = (next) => {
|
|
634
|
+
state = next;
|
|
635
|
+
for (const listener of [...listeners]) listener();
|
|
636
|
+
};
|
|
637
|
+
const load = async () => {
|
|
638
|
+
const gen = ++generation;
|
|
639
|
+
let response;
|
|
640
|
+
try {
|
|
641
|
+
response = await api.describe({});
|
|
642
|
+
} catch {
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
if (gen !== generation || !response.result.ok) return;
|
|
646
|
+
const writable = response.result.value?.writable ?? false;
|
|
647
|
+
const view = response.result.value?.namespaces.find((candidate) => candidate.ns === ns);
|
|
648
|
+
if (view === void 0) {
|
|
649
|
+
publish({
|
|
650
|
+
status: "unavailable",
|
|
651
|
+
value: void 0,
|
|
652
|
+
revision: void 0,
|
|
653
|
+
writable
|
|
654
|
+
});
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
publish({
|
|
658
|
+
status: "ready",
|
|
659
|
+
value: view.value,
|
|
660
|
+
revision: view.revision,
|
|
661
|
+
writable
|
|
662
|
+
});
|
|
663
|
+
};
|
|
664
|
+
const refresh = (namespace) => {
|
|
665
|
+
if (namespace !== void 0 && namespace !== ns) return;
|
|
666
|
+
load();
|
|
667
|
+
};
|
|
668
|
+
const disposers = [c.get("remote").$on("settings/document-updated", refresh), c.on("connection/reset", () => {
|
|
669
|
+
load();
|
|
670
|
+
})];
|
|
671
|
+
c.effect(() => () => {
|
|
672
|
+
for (const dispose of disposers) dispose();
|
|
673
|
+
}, "subagent-model: settings scope");
|
|
674
|
+
load();
|
|
675
|
+
return {
|
|
676
|
+
getSnapshot: () => state,
|
|
677
|
+
subscribe: (listener) => {
|
|
678
|
+
listeners.add(listener);
|
|
679
|
+
return () => {
|
|
680
|
+
listeners.delete(listener);
|
|
681
|
+
};
|
|
682
|
+
},
|
|
683
|
+
load
|
|
684
|
+
};
|
|
685
|
+
}
|
|
613
686
|
function apply(ctx) {
|
|
614
687
|
const c = ctx;
|
|
615
688
|
const tag = injectStyle();
|
|
@@ -620,12 +693,17 @@ function apply(ctx) {
|
|
|
620
693
|
zh,
|
|
621
694
|
en
|
|
622
695
|
}), "subagent-model: locale");
|
|
696
|
+
const api = c.get("connection").api.settings;
|
|
697
|
+
const scope = createApiScope(api, SETTINGS_NS, c);
|
|
623
698
|
c.slots.inject("settings.plugin.item", () => c.slots.register({
|
|
624
699
|
name: "settings.plugin.item",
|
|
625
700
|
key: SETTINGS_NS,
|
|
626
|
-
order: 100,
|
|
627
701
|
locale: NS,
|
|
628
|
-
inject: () => ({
|
|
702
|
+
inject: () => ({
|
|
703
|
+
t: c.locale.bind(NS),
|
|
704
|
+
scope,
|
|
705
|
+
api
|
|
706
|
+
})
|
|
629
707
|
}, SubagentModelCard));
|
|
630
708
|
}
|
|
631
709
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
for (const [name$1, entry] of Object.entries(
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
/**
|
|
244
|
-
function
|
|
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(`
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.1.3",
|
|
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-api-remotes"
|
|
28
30
|
],
|
|
29
31
|
"platform": "web"
|
|
30
32
|
}
|
package/src/client/api.ts
CHANGED
|
@@ -1,28 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
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
|
}
|
package/src/client/card.tsx
CHANGED
|
@@ -1,20 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 「子代理模型配置」配置卡片:注册进 settings.plugin.item 插槽(官方插件配置页)。
|
|
3
|
-
* 交互对齐官方卡片:折叠/展开、staged draft
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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,
|
|
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
|
|
60
|
+
function draftFrom(value: ConfigValue, catalog: ModelCatalog | null): Draft {
|
|
43
61
|
const rows: Record<string, DraftRow> = {}
|
|
44
|
-
const names =
|
|
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 =
|
|
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:
|
|
76
|
+
return { enabled: value.enabled, rows }
|
|
57
77
|
}
|
|
58
78
|
|
|
59
|
-
function toPatch(draft: Draft):
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
() =>
|
|
187
|
-
|
|
188
|
-
|
|
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 (
|
|
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,
|
|
201
|
-
setDraft({ ...draft, [key]:
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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 = !
|
|
227
|
-
const rowNames =
|
|
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(
|
|
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}
|
package/src/client/client.ts
CHANGED
|
@@ -3,12 +3,16 @@
|
|
|
3
3
|
* 构建产物为 window.__ModuleLoader__.load({id, factory}) 形式的 CJS factory
|
|
4
4
|
* (包装见 tsdown.config.ts);样式沿用官方 data-plugin-css 约定,HMR 据此卸载。
|
|
5
5
|
*
|
|
6
|
-
* 类型说明:浏览器半只用到 slots/locale
|
|
7
|
-
*
|
|
8
|
-
* dsh-client-ui-settings-plugins 的
|
|
6
|
+
* 类型说明:浏览器半只用到 slots/locale/connection/remote 的很窄一面,
|
|
7
|
+
* 此处以最小本地接口声明(见 ./scope.ts),避免为构建期类型引入整条官方
|
|
8
|
+
* client 依赖树;运行时契约以官方 dsh-client-ui-settings-plugins 的
|
|
9
|
+
* settings.plugin.item 插槽与 dsh-client-connection 的 settings RPC 面为准。
|
|
9
10
|
* rc7 起该插槽为 keyed 槽位:key 必须是卡片编辑的 settings 命名空间
|
|
10
11
|
* (即服务端 settingsNamespace() 注册的同一字面量,见 ../ns.ts),
|
|
11
12
|
* 官方配置页按此 key 与 Host 已注册命名空间配对分发。
|
|
13
|
+
* 配置读写经官方 settings RPC 直连(rc7 起第三方命名空间全量开放;
|
|
14
|
+
* 不复用 settingsScope 服务——非 loopback 页面下它无数据,见 scope.ts);
|
|
15
|
+
* 自定义端点仅剩「模型目录」(api.ts)。
|
|
12
16
|
* @module @dsh-plus/subagent-model/client
|
|
13
17
|
*/
|
|
14
18
|
import type { Context } from '@deepseek-ai/cordis'
|
|
@@ -16,12 +20,13 @@ import type { Context } from '@deepseek-ai/cordis'
|
|
|
16
20
|
import { SETTINGS_NS } from '../ns.ts'
|
|
17
21
|
import { SubagentModelCard } from './card.tsx'
|
|
18
22
|
import { en, NS, zh } from './i18n.ts'
|
|
23
|
+
import type { Scope, SettingsApi } from './scope.ts'
|
|
19
24
|
import { injectStyle } from './styles.ts'
|
|
20
25
|
|
|
21
26
|
export const name = 'dsh-plus-subagent-model'
|
|
22
27
|
|
|
23
28
|
/** 浏览器半需要的 cordis 服务 key(loader 据此注入;package.json 的 dsh.client.inject 管包加载顺序)。 */
|
|
24
|
-
export const inject = ['slots', 'locale'] as const
|
|
29
|
+
export const inject = ['slots', 'locale', 'connection', 'remote'] as const
|
|
25
30
|
|
|
26
31
|
interface SlotsLike {
|
|
27
32
|
inject(key: string, callback: () => unknown): unknown
|
|
@@ -33,13 +38,86 @@ interface LocaleLike {
|
|
|
33
38
|
bind(ns: string): (key: string) => string
|
|
34
39
|
}
|
|
35
40
|
|
|
41
|
+
interface RemoteLike {
|
|
42
|
+
$on(event: string, listener: (payload?: unknown) => void): () => void
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface ConnectionLike {
|
|
46
|
+
api: { settings: SettingsApi }
|
|
47
|
+
}
|
|
48
|
+
|
|
36
49
|
interface ClientContext {
|
|
37
50
|
slots: SlotsLike
|
|
38
51
|
locale: LocaleLike
|
|
52
|
+
get(key: 'connection'): ConnectionLike
|
|
53
|
+
get(key: 'remote'): RemoteLike
|
|
54
|
+
on(event: string, listener: () => void): () => void
|
|
39
55
|
effect(execute: () => () => void, label?: string): unknown
|
|
40
56
|
}
|
|
41
57
|
|
|
58
|
+
/**
|
|
59
|
+
* api 直连实现的命名空间 scope(官方配置页 tab 同款模式)。
|
|
60
|
+
* 读:describe 取命名空间视图(脱敏 value/revision + 顶层 writable);
|
|
61
|
+
* 刷新:remote `settings/document-updated`(按命名空间过滤)与
|
|
62
|
+
* `connection/reset`;generation 防旧读覆盖新发布。任何页面 origin
|
|
63
|
+
* (loopback 或 tailnet 信任域名)下行为一致。
|
|
64
|
+
*/
|
|
65
|
+
function createApiScope(api: SettingsApi, ns: string, c: ClientContext): Scope {
|
|
66
|
+
let state: ScopeSnapshot = { status: 'loading', value: undefined, revision: undefined, writable: false }
|
|
67
|
+
const listeners = new Set<() => void>()
|
|
68
|
+
let generation = 0
|
|
69
|
+
const publish = (next: ScopeSnapshot): void => {
|
|
70
|
+
state = next
|
|
71
|
+
for (const listener of [...listeners]) listener()
|
|
72
|
+
}
|
|
73
|
+
const load = async (): Promise<void> => {
|
|
74
|
+
const gen = ++generation
|
|
75
|
+
let response
|
|
76
|
+
try {
|
|
77
|
+
response = await api.describe({})
|
|
78
|
+
} catch {
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
if (gen !== generation || !response.result.ok) return
|
|
82
|
+
const writable = response.result.value?.writable ?? false
|
|
83
|
+
const view = response.result.value?.namespaces.find((candidate) => candidate.ns === ns)
|
|
84
|
+
if (view === undefined) {
|
|
85
|
+
publish({ status: 'unavailable', value: undefined, revision: undefined, writable })
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
publish({ status: 'ready', value: view.value, revision: view.revision, writable })
|
|
89
|
+
}
|
|
90
|
+
const refresh = (namespace?: unknown): void => {
|
|
91
|
+
if (namespace !== undefined && namespace !== ns) return
|
|
92
|
+
void load()
|
|
93
|
+
}
|
|
94
|
+
const disposers = [
|
|
95
|
+
c.get('remote').$on('settings/document-updated', refresh),
|
|
96
|
+
c.on('connection/reset', () => {
|
|
97
|
+
void load()
|
|
98
|
+
}),
|
|
99
|
+
]
|
|
100
|
+
c.effect(
|
|
101
|
+
() => () => {
|
|
102
|
+
for (const dispose of disposers) dispose()
|
|
103
|
+
},
|
|
104
|
+
'subagent-model: settings scope',
|
|
105
|
+
)
|
|
106
|
+
void load()
|
|
107
|
+
return {
|
|
108
|
+
getSnapshot: () => state,
|
|
109
|
+
subscribe: (listener: () => void) => {
|
|
110
|
+
listeners.add(listener)
|
|
111
|
+
return () => {
|
|
112
|
+
listeners.delete(listener)
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
load,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
42
119
|
export function apply(ctx: Context): void {
|
|
120
|
+
|
|
43
121
|
const c = ctx as unknown as ClientContext
|
|
44
122
|
const tag = injectStyle()
|
|
45
123
|
c.effect(
|
|
@@ -52,14 +130,15 @@ export function apply(ctx: Context): void {
|
|
|
52
130
|
() => c.locale.register(NS, { zh, en }),
|
|
53
131
|
'subagent-model: locale',
|
|
54
132
|
)
|
|
133
|
+
const api = c.get('connection').api.settings
|
|
134
|
+
const scope = createApiScope(api, SETTINGS_NS, c)
|
|
55
135
|
c.slots.inject('settings.plugin.item', () =>
|
|
56
136
|
c.slots.register(
|
|
57
137
|
{
|
|
58
138
|
name: 'settings.plugin.item',
|
|
59
139
|
key: SETTINGS_NS,
|
|
60
|
-
order: 100,
|
|
61
140
|
locale: NS,
|
|
62
|
-
inject: () => ({ t: c.locale.bind(NS) }),
|
|
141
|
+
inject: () => ({ t: c.locale.bind(NS), scope, api }),
|
|
63
142
|
},
|
|
64
143
|
SubagentModelCard,
|
|
65
144
|
),
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 浏览器半 settingsScope/connection 的最小本地接口声明。
|
|
3
|
+
* 只声明本插件用到的窄面,避免为构建期类型引入整条官方 client 依赖树;
|
|
4
|
+
* 运行时契约以官方 dsh-client-connection 的 settings RPC 面为准
|
|
5
|
+
* (rc7:白名单已移除,任何已注册命名空间均可 describe/update/replace)。
|
|
6
|
+
*
|
|
7
|
+
* 实现说明:本插件不复用 settingsScope 服务的 bind()——它在非 loopback
|
|
8
|
+
* 页面(如经 tailnet 域名访问)下走 memory 模式、永远无数据;这里按官方
|
|
9
|
+
* 配置页 tab 的同款模式用 `api.settings` 直连实现 Scope(describe 读 +
|
|
10
|
+
* remote `settings/document-updated` / `connection/reset` 刷新,见 client.ts)。
|
|
11
|
+
* @module {pkg}/client/scope
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** settings 命名空间 scope 的快照(官方 SettingsScopeSnapshot 的最小投影)。 */
|
|
15
|
+
export interface ScopeSnapshot {
|
|
16
|
+
status: 'loading' | 'ready' | 'unavailable'
|
|
17
|
+
/** schema 解析后的配置值(secret 字段已脱敏剥除)。 */
|
|
18
|
+
value: unknown
|
|
19
|
+
/** 命名空间 revision,写操作 fencing 用;首次 Host 应答前为 undefined。 */
|
|
20
|
+
revision: number | undefined
|
|
21
|
+
/** Host 文档是否可写。 */
|
|
22
|
+
writable: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** settingsScope.bind({namespace}) 返回的控制器面。 */
|
|
26
|
+
export interface Scope {
|
|
27
|
+
getSnapshot(): ScopeSnapshot
|
|
28
|
+
subscribe(listener: () => void): () => void
|
|
29
|
+
load(): Promise<void>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** connection api.settings 的 RPC 面(本插件用到的三个方法)。 */
|
|
33
|
+
export interface SettingsApi {
|
|
34
|
+
describe(payload?: Record<string, never>): Promise<{ result: { ok: boolean; value?: { namespaces: Array<{ ns: string; secrets: Array<{ path: string[]; set: boolean }> }> }; error?: { message?: string } } }>
|
|
35
|
+
update(request: { ns: string; patch: Record<string, unknown>; expectedRevision?: number }): Promise<{ result: { ok: boolean; error?: { message?: string } } }>
|
|
36
|
+
replace(request: { ns: string; section: Record<string, unknown>; expectedRevision?: number }): Promise<{ result: { ok: boolean; error?: { message?: string } } }>
|
|
37
|
+
}
|
package/src/config-api.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
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
|
-
/**
|
|
101
|
-
export function
|
|
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(`
|
|
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
|
-
/**
|
|
148
|
-
export
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
|
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
|
-
* -
|
|
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 {
|
|
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
|
-
|
|
46
|
+
registerCatalogApi(webCtx)
|
|
43
47
|
})
|
|
44
48
|
}
|
|
45
49
|
|