dsh-expert-group 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/LICENSE +38 -0
  2. package/README.md +355 -0
  3. package/THIRD_PARTY_NOTICES.md +35 -0
  4. package/assets/notification-whoosh.wav +0 -0
  5. package/bin/prompt-inspector.mjs +63 -0
  6. package/client-bundle.js +14891 -0
  7. package/client-expert-group-model.js +426 -0
  8. package/client-expert-group-sidebar.js +5401 -0
  9. package/client-freshness.js +364 -0
  10. package/client-model-config.js +2155 -0
  11. package/client-notifications.js +38 -0
  12. package/client.js +6487 -0
  13. package/cordis.patch.yml +10 -0
  14. package/fanout-host.js +1507 -0
  15. package/fanout.js +2981 -0
  16. package/host/composition.js +155 -0
  17. package/host/expert-group-runtime.js +822 -0
  18. package/host/expert-group-snapshot.js +450 -0
  19. package/host/fanout-runtime.js +822 -0
  20. package/host/handoff.js +80 -0
  21. package/host/lineage.js +274 -0
  22. package/host/notifications.js +189 -0
  23. package/host/persistence-health.js +389 -0
  24. package/host/recovery.js +242 -0
  25. package/host/rpc-transport.js +573 -0
  26. package/host/settings-runtime.js +168 -0
  27. package/host/settings-source.js +265 -0
  28. package/host/storage-binding.js +94 -0
  29. package/host/system-toast.js +916 -0
  30. package/image-routing.js +263 -0
  31. package/index.js +9327 -0
  32. package/install.ps1 +219 -0
  33. package/orchestrator.js +239 -0
  34. package/package.json +142 -0
  35. package/prompt-contracts.js +148 -0
  36. package/prompt-inspector.js +584 -0
  37. package/prompt-sidecar.js +610 -0
  38. package/resource-budget.js +540 -0
  39. package/role-registry.js +286 -0
  40. package/rpc-host.js +310 -0
  41. package/scripts/assemble-client.mjs +605 -0
  42. package/scripts/build.mjs +124 -0
  43. package/shared.js +4003 -0
  44. package/storage.js +190 -0
  45. package/uninstall.ps1 +47 -0
@@ -0,0 +1,2155 @@
1
+ (function installModelConfig(global) {
2
+ "use strict";
3
+
4
+ /**
5
+ * 「模型配置」区域的**唯一纯数据流实现**(浏览器安全的 classic IIFE)。
6
+ *
7
+ * 这里只放纯逻辑,不放 React 组件、不发请求、不读写 DOM:
8
+ * - provider 目录装配(`llm/listProviders` × `llm/listConfigurableProviders`);
9
+ * - credentials 引用推导(与 DSH 内置模型设置页同一条 `<ROUTE>_API_KEY` 规则);
10
+ * - settings 命名空间路径操作(用户覆盖 materialize / 恢复默认 / 应用草稿);
11
+ * - 模型目录草稿(模型 ID、显示名、上下文窗口、最大输出 token、输入类型);
12
+ * - 七个可多选思考档位与 `reasoningEfforts` 的双向映射;
13
+ * - provider 卡片的应用/取消草稿、revision 冲突判定与角色联动守卫。
14
+ *
15
+ * client.js 只从 `globalThis[Symbol.for("dsh-expert-group/client-model-config")]`
16
+ * 取回这份实现并负责渲染,因此同一份判定既被设置页使用,也被 node:test 直接调用,
17
+ * 不存在"测试里一份、界面里另一份"的平行实现。
18
+ */
19
+
20
+ /** classic 片段发布本模块导出面用的 globalThis Symbol 键(与相邻侧车模块约定一致)。 */
21
+ const SYMBOL = Symbol.for("dsh-expert-group/client-model-config");
22
+
23
+ /**
24
+ * 七个可多选思考档位,顺序固定为 DSH 的 pi-ai 词表顺序
25
+ * (`packages/llm/llm-pi-ai/src/catalog.ts` 的 THINKING_LEVEL_GATE)。
26
+ * 界面永远按这个顺序渲染七个复选框,与当前目录是否声明支持无关。
27
+ */
28
+ const REASONING_EFFORTS = Object.freeze([
29
+ "off", "minimal", "low", "medium", "high", "xhigh", "max",
30
+ ]);
31
+
32
+ /** `off` 档写入的**空字符串值**:DSH 的 schemastery 联合把 `off` 表达为"无 dispatch 值"。 */
33
+ const REASONING_EFFORT_EMPTY = "";
34
+
35
+ /** 目录条目的输入类型词表(text / image)。 */
36
+ const INPUT_MODALITIES = Object.freeze(["text", "image"]);
37
+
38
+ /** 模型目录未声明输入类型时的回退(与 DSH 的 `defaultInput` 默认值一致)。 */
39
+ const DEFAULT_INPUT_MODALITIES = Object.freeze(["text"]);
40
+
41
+ /** 容量字段:K/M 后缀词表与十进制换算(1K = 1000,1M = 1000000)。 */
42
+ const CAPACITY_PATTERN = /^(\d+(?:\.\d+)?)([km])?$/i;
43
+ const CAPACITY_SCALE = Object.freeze({ k: 1000, m: 1000000 });
44
+
45
+ /** 草稿里必须被显式删除的"空值":空字符串与 undefined 都不应写进用户层。 */
46
+ function isBlankDraftValue(value) {
47
+ return value === undefined || value === null || value === "";
48
+ }
49
+
50
+ function isPlainObject(value) {
51
+ return value !== null && typeof value === "object" && !Array.isArray(value);
52
+ }
53
+
54
+ /**
55
+ * schema 节点判定。
56
+ *
57
+ * schemastery 的节点是**可调用对象**(`typeof node === "function"`),因此不能只接受
58
+ * plain object:`isPlainObject(schemaNode)` 恒为 false,会让协议词表、默认值等
59
+ * schema 派生的读取全部静默退化成空。这里同时接受可调用节点与普通对象字面量,
60
+ * 这样测试里手写的 schema 描述对象也仍然可用。
61
+ */
62
+ function isSchemaNode(value) {
63
+ return isPlainObject(value) || typeof value === "function";
64
+ }
65
+
66
+ function isNonEmptyString(value) {
67
+ return typeof value === "string" && value.trim().length > 0;
68
+ }
69
+
70
+ /** JSON 等值判定:草稿比较、pathOps 去重与冲突判定共用同一套语义。 */
71
+ function jsonEqual(left, right) {
72
+ if (Object.is(left, right)) return true;
73
+ if (left === undefined || right === undefined) return left === right;
74
+ if (typeof left !== typeof right) return false;
75
+ if (left === null || right === null) return false;
76
+ if (typeof left !== "object") return false;
77
+ try {
78
+ return JSON.stringify(left) === JSON.stringify(right);
79
+ } catch {
80
+ return false;
81
+ }
82
+ }
83
+
84
+ /** 纯 JSON 深拷贝:草稿必须与 settings 快照解耦,绝不就地修改宿主对象。 */
85
+ function deepClone(value) {
86
+ if (value === null || typeof value !== "object") return value;
87
+ if (Array.isArray(value)) return value.map((item) => deepClone(item));
88
+ const clone = {};
89
+ for (const [key, item] of Object.entries(value)) clone[key] = deepClone(item);
90
+ return clone;
91
+ }
92
+
93
+ /**
94
+ * 按路径读取嵌套值;数组下标同样支持。
95
+ * @param {unknown} value
96
+ * @param {readonly string[]} path
97
+ * @returns {unknown}
98
+ */
99
+ function getPath(value, path) {
100
+ let current = value;
101
+ for (const key of Array.isArray(path) ? path : []) {
102
+ if (Array.isArray(current)) {
103
+ current = current[Number(key)];
104
+ continue;
105
+ }
106
+ if (current === null || typeof current !== "object") return undefined;
107
+ current = current[key];
108
+ }
109
+ return current;
110
+ }
111
+
112
+ /**
113
+ * 按路径判断键是否存在(`undefined` 值也算存在:这是用户覆盖的判定依据)。
114
+ * @param {unknown} value
115
+ * @param {readonly string[]} path
116
+ * @returns {boolean}
117
+ */
118
+ function hasPath(value, path) {
119
+ const segments = Array.isArray(path) ? path : [];
120
+ if (segments.length === 0) {
121
+ return isPlainObject(value) && Object.keys(value).length > 0;
122
+ }
123
+ const parent = getPath(value, segments.slice(0, -1));
124
+ const key = segments[segments.length - 1];
125
+ if (Array.isArray(parent)) return Number.isSafeInteger(Number(key)) && Number(key) >= 0 && Number(key) < parent.length;
126
+ if (parent === null || typeof parent !== "object") return false;
127
+ return Object.prototype.hasOwnProperty.call(parent, key);
128
+ }
129
+
130
+ /**
131
+ * 不可变写入:复制路径上的每一层容器,绝不修改入参。
132
+ * @param {Record<string, unknown>} root
133
+ * @param {readonly string[]} path
134
+ * @param {unknown} value
135
+ * @returns {Record<string, unknown>}
136
+ */
137
+ function setPath(root, path, value) {
138
+ const segments = Array.isArray(path) ? path : [];
139
+ if (segments.length === 0) throw new Error("setPath 需要非空路径");
140
+ if (Array.isArray(root)) {
141
+ const next = [...root];
142
+ next[Number(segments[0])] = segments.length === 1 ? value : setPath(next[Number(segments[0])], segments.slice(1), value);
143
+ return next;
144
+ }
145
+ const source = isPlainObject(root) ? root : {};
146
+ const next = { ...source };
147
+ const [head, ...rest] = segments;
148
+ next[head] = rest.length === 0 ? value : setPath(source[head], rest, value);
149
+ return next;
150
+ }
151
+
152
+ /**
153
+ * 不可变删除:路径末端的容器类型与原值一致;路径不存在时原样返回。
154
+ * @param {Record<string, unknown>} root
155
+ * @param {readonly string[]} path
156
+ * @returns {Record<string, unknown>}
157
+ */
158
+ function deletePath(root, path) {
159
+ const segments = Array.isArray(path) ? path : [];
160
+ if (segments.length === 0) return isPlainObject(root) ? {} : root;
161
+ if (!hasPath(root, segments)) return root;
162
+ if (Array.isArray(root)) {
163
+ const next = [...root];
164
+ const [head, ...rest] = segments;
165
+ if (rest.length === 0) next.splice(Number(head), 1);
166
+ else next[Number(head)] = deletePath(next[Number(head)], rest);
167
+ return next;
168
+ }
169
+ const next = { ...root };
170
+ const [head, ...rest] = segments;
171
+ if (rest.length === 0) Reflect.deleteProperty(next, head);
172
+ else next[head] = deletePath(next[head], rest);
173
+ return next;
174
+ }
175
+
176
+ function getSubtree(root, path) {
177
+ const value = getPath(root, path);
178
+ return isPlainObject(value) ? value : {};
179
+ }
180
+
181
+ /**
182
+ * 从 provider 路由 id 推导凭据引用:与 DSH 内置模型设置页完全同一条规则。
183
+ * @param {string} provider
184
+ * @returns {string}
185
+ */
186
+ function deriveKeyRef(provider) {
187
+ const text = isNonEmptyString(provider) ? provider : "";
188
+ return text.toUpperCase().replace(/[^A-Z0-9]+/g, "_") + "_API_KEY";
189
+ }
190
+
191
+ /** 模型显示名的归一读取:空显示名回退到模型 ID。 */
192
+ function modelDisplayNameOf(model) {
193
+ if (!isPlainObject(model)) return "";
194
+ if (isNonEmptyString(model.name)) return model.name;
195
+ return typeof model.id === "string" ? model.id : "";
196
+ }
197
+
198
+ /**
199
+ * 读一条已存储模型条目的输入类型:数组优先,未声明时返回回退值。
200
+ * @param {unknown} model
201
+ * @param {readonly string[]} [fallback]
202
+ * @returns {string[]}
203
+ */
204
+ function modelInputOf(model, fallback) {
205
+ const declared = isPlainObject(model) ? model.input : undefined;
206
+ if (Array.isArray(declared) && declared.length > 0) {
207
+ return declared.filter((value) => typeof value === "string");
208
+ }
209
+ if (Array.isArray(fallback) && fallback.length > 0) return [...fallback];
210
+ return [...DEFAULT_INPUT_MODALITIES];
211
+ }
212
+
213
+ /** 一行模型草稿的浅拷贝读取;非对象按空行处理。 */
214
+ function modelRowOf(value) {
215
+ return isPlainObject(value) ? { ...value } : {};
216
+ }
217
+
218
+ /** 把任意数组归一为模型草稿数组(逐行浅拷贝,隐藏字段原样保留)。 */
219
+ function modelDraftsOf(value) {
220
+ return Array.isArray(value) ? value.map((entry) => modelRowOf(entry)) : [];
221
+ }
222
+
223
+ /** 描述输入类型字段是否存在用户覆盖。 */
224
+ function inputOverrideOf(model) {
225
+ const declared = isPlainObject(model) ? model.input : undefined;
226
+ return Array.isArray(declared) && declared.length > 0;
227
+ }
228
+
229
+ /** 思考档位多选集合的空值:七档全不勾选。 */
230
+ function emptyEffortSelection() {
231
+ const selection = {};
232
+ for (const level of REASONING_EFFORTS) selection[level] = false;
233
+ return selection;
234
+ }
235
+
236
+ /**
237
+ * 从模型条目的 `reasoningEfforts` 映射恢复勾选状态。
238
+ *
239
+ * 已有条目:只有"键存在且值不是 false"才算勾选(`off` 的空字符串 / null 都是勾选)。
240
+ * 新条目(字段缺失)与显式 `false`:七档全部不勾选。
241
+ * `reasoningEfforts: false` 表示"非推理模型",不作为任何档位的勾选来源。
242
+ * @param {unknown} model
243
+ * @returns {Record<string, boolean>}
244
+ */
245
+ function effortSelectionFor(model) {
246
+ const selection = emptyEffortSelection();
247
+ if (!isPlainObject(model)) return selection;
248
+ const declared = model.reasoningEfforts;
249
+ if (declared === false || !isPlainObject(declared)) return selection;
250
+ for (const level of REASONING_EFFORTS) {
251
+ if (!Object.prototype.hasOwnProperty.call(declared, level)) continue;
252
+ selection[level] = declared[level] !== false;
253
+ }
254
+ return selection;
255
+ }
256
+
257
+ /** 两份勾选状态是否等价(只比较七个可见档位)。 */
258
+ function effortSelectionEqual(left, right) {
259
+ const a = isPlainObject(left) ? left : {};
260
+ const b = isPlainObject(right) ? right : {};
261
+ return REASONING_EFFORTS.every((level) => (a[level] === true) === (b[level] === true));
262
+ }
263
+
264
+ /** `reasoningEfforts` 里未被界面展示的键(未知键必须原样保留)。 */
265
+ function unknownEffortKeysOf(declared) {
266
+ if (!isPlainObject(declared)) return [];
267
+ return Object.keys(declared).filter((key) => !REASONING_EFFORTS.includes(key));
268
+ }
269
+
270
+ /** 判断一份映射在"七档可见键"之外是否还有内容。 */
271
+ function hasUnknownEffortKeys(declared) {
272
+ return unknownEffortKeysOf(declared).length > 0;
273
+ }
274
+
275
+ /** 记录两条模型路径的 diff 行(纯函数,供侧栏/测试断言使用)。 */
276
+ function reasoningDiffLines(before, after) {
277
+ const lines = [];
278
+ const beforePaths = new Map();
279
+ const afterPaths = new Map();
280
+ const collect = (map, rows) => {
281
+ (Array.isArray(rows) ? rows : []).forEach((row, index) => {
282
+ if (!isPlainObject(row) || !isNonEmptyString(row.id)) return;
283
+ map.set(row.id.trim(), { index, efforts: jsonEqual(row.reasoningEfforts, undefined) ? undefined : row.reasoningEfforts });
284
+ });
285
+ };
286
+ collect(beforePaths, before);
287
+ collect(afterPaths, after);
288
+ const ids = [...new Set([...beforePaths.keys(), ...afterPaths.keys()])];
289
+ for (const id of ids) {
290
+ const previous = beforePaths.get(id);
291
+ const next = afterPaths.get(id);
292
+ if (previous === undefined) {
293
+ lines.push({ id, kind: "added", levels: selectedLevelsOf(next?.efforts) });
294
+ continue;
295
+ }
296
+ if (next === undefined) {
297
+ lines.push({ id, kind: "removed", levels: selectedLevelsOf(previous.efforts) });
298
+ continue;
299
+ }
300
+ const beforeLevels = selectedLevelsOf(previous.efforts);
301
+ const afterLevels = selectedLevelsOf(next.efforts);
302
+ const added = afterLevels.filter((level) => !beforeLevels.includes(level));
303
+ const removed = beforeLevels.filter((level) => !afterLevels.includes(level));
304
+ if (added.length > 0 || removed.length > 0) lines.push({ id, kind: "changed", added, removed });
305
+ }
306
+ return lines;
307
+ }
308
+
309
+ /** 一份 `reasoningEfforts` 映射当前勾选的档位(用于 diff 展示)。 */
310
+ function selectedLevelsOf(declared) {
311
+ if (!isPlainObject(declared)) return [];
312
+ return REASONING_EFFORTS.filter((level) => Object.prototype.hasOwnProperty.call(declared, level) && declared[level] !== false);
313
+ }
314
+
315
+ /**
316
+ * 把勾选状态写回一份 `reasoningEfforts` 映射。
317
+ *
318
+ * - `off` 勾选写空字符串值,其余档位写同名小写字符串;
319
+ * - 只增删七个可见键,未展示的未知键原样保留;
320
+ * - 七档全不勾选且没有未知键时返回 `undefined`(调用方据此删除该字段)。
321
+ *
322
+ * @param {Record<string, boolean>} selection
323
+ * @param {unknown} previous
324
+ * @returns {Record<string, unknown> | undefined}
325
+ */
326
+ function applyEffortSelection(selection, previous) {
327
+ const source = isPlainObject(selection) ? selection : {};
328
+ const next = isPlainObject(previous) ? { ...previous } : {};
329
+ for (const level of REASONING_EFFORTS) {
330
+ if (source[level] === true) next[level] = level === "off" ? REASONING_EFFORT_EMPTY : level;
331
+ else Reflect.deleteProperty(next, level);
332
+ }
333
+ if (Object.keys(next).length === 0) return undefined;
334
+ return next;
335
+ }
336
+
337
+ /**
338
+ * 归一一条模型草稿里的 `reasoningEfforts`:
339
+ * 界面写入的键都指向档位名(或 `off` 的空字符串),因此直接复用七档映射规则。
340
+ * @param {Record<string, unknown>} model
341
+ * @returns {Record<string, unknown>}
342
+ */
343
+ function normalizeEffortsEntry(model) {
344
+ const row = modelRowOf(model);
345
+ const declared = row.reasoningEfforts;
346
+ if (!isPlainObject(declared)) {
347
+ if (declared === false) Reflect.deleteProperty(row, "reasoningEfforts");
348
+ return row;
349
+ }
350
+ const selection = {};
351
+ for (const level of REASONING_EFFORTS) {
352
+ selection[level] = Object.prototype.hasOwnProperty.call(declared, level) && declared[level] !== false;
353
+ }
354
+ const next = applyEffortSelection(selection, declared);
355
+ if (next === undefined) Reflect.deleteProperty(row, "reasoningEfforts");
356
+ else row.reasoningEfforts = next;
357
+ return row;
358
+ }
359
+
360
+ /** 构建一条新模型草稿:新模型七档全不勾选,输入类型留空(继承目录/默认值)。 */
361
+ function createModelRow(id) {
362
+ return { id: typeof id === "string" ? id : "" };
363
+ }
364
+
365
+ /** 读取一行草稿的输入类型勾选(未声明时回退到目录/默认值)。 */
366
+ function inputSelectionFor(model, fallback) {
367
+ return modelInputOf(model, fallback);
368
+ }
369
+
370
+ /**
371
+ * 应用一条模型行的编辑。
372
+ *
373
+ * `patch` 里的 `undefined` / `''` / `null` 都表示清空该字段(从用户层删除),
374
+ * 其余字段原样保留,因此隐藏字段与未知键都不会被重建丢失。
375
+ *
376
+ * @param {Record<string, unknown>} model
377
+ * @param {Record<string, unknown>} patch
378
+ * @returns {Record<string, unknown>}
379
+ */
380
+ function patchModelDraft(model, patch) {
381
+ const row = modelRowOf(model);
382
+ const next = { ...row };
383
+ for (const [key, value] of Object.entries(isPlainObject(patch) ? patch : {})) {
384
+ if (isBlankDraftValue(value)) Reflect.deleteProperty(next, key);
385
+ else next[key] = value;
386
+ }
387
+ return normalizeEffortsEntry(next);
388
+ }
389
+
390
+ /**
391
+ * 整表替换一条模型行的 `reasoningEfforts`(卡片草稿路径)。
392
+ * @param {readonly unknown[]} models
393
+ * @param {number} index
394
+ * @param {Record<string, boolean>} selection
395
+ * @returns {Record<string, unknown>[]}
396
+ */
397
+ function setModelEffortSelection(models, index, selection) {
398
+ const rows = modelDraftsOf(models);
399
+ return rows.map((row, at) => {
400
+ if (at !== index) return row;
401
+ const next = { ...row };
402
+ const efforts = applyEffortSelection(selection, row.reasoningEfforts);
403
+ if (efforts === undefined) Reflect.deleteProperty(next, "reasoningEfforts");
404
+ else next.reasoningEfforts = efforts;
405
+ return next;
406
+ });
407
+ }
408
+
409
+ /**
410
+ * 整表替换一条模型行的输入类型(卡片草稿路径)。
411
+ * @param {readonly unknown[]} models
412
+ * @param {number} index
413
+ * @param {readonly string[] | undefined} modalities
414
+ * @returns {Record<string, unknown>[]}
415
+ */
416
+ function setModelInputSelection(models, index, modalities) {
417
+ const rows = modelDraftsOf(models);
418
+ return rows.map((row, at) => {
419
+ if (at !== index) return row;
420
+ const next = { ...row };
421
+ const declared = Array.isArray(modalities)
422
+ ? modalities.filter((value) => INPUT_MODALITIES.includes(value))
423
+ : [];
424
+ if (declared.length === 0) Reflect.deleteProperty(next, "input");
425
+ else next.input = declared;
426
+ return next;
427
+ });
428
+ }
429
+
430
+ /** 删除一条模型行。 */
431
+ function removeModelRow(models, index) {
432
+ return modelDraftsOf(models).filter((_row, at) => at !== index);
433
+ }
434
+
435
+ /** 追加一条空模型行(新模型七档全不勾选)。 */
436
+ function appendModelRow(models, id) {
437
+ return [...modelDraftsOf(models), createModelRow(id)];
438
+ }
439
+
440
+ /**
441
+ * 容量字段文本 -> 数值:空文本表示继承(`undefined`),不可读文本为 `NaN`。
442
+ * @param {string} text
443
+ * @returns {number | undefined}
444
+ */
445
+ function parseCapacity(text) {
446
+ const trimmed = typeof text === "string" ? text.trim() : "";
447
+ if (trimmed.length === 0) return undefined;
448
+ const match = CAPACITY_PATTERN.exec(trimmed);
449
+ if (match === null) return Number.NaN;
450
+ const suffix = match[2] === undefined ? undefined : match[2].toLowerCase();
451
+ const scale = suffix === undefined ? 1 : CAPACITY_SCALE[suffix];
452
+ const scaled = Number(match[1]) * scale;
453
+ const rounded = Math.round(scaled);
454
+ return Math.abs(scaled - rounded) < 1e-6 ? rounded : scaled;
455
+ }
456
+
457
+ /** 容量数值 -> 最短文本拼写(K/M 后缀,整千整百万才缩写)。 */
458
+ function formatCapacity(value) {
459
+ if (typeof value !== "number" || !Number.isFinite(value)) return "";
460
+ if (!Number.isInteger(value) || value <= 0) return String(value);
461
+ if (value % CAPACITY_SCALE.m === 0) return String(value / CAPACITY_SCALE.m) + "M";
462
+ if (value % CAPACITY_SCALE.k === 0) return String(value / CAPACITY_SCALE.k) + "K";
463
+ return String(value);
464
+ }
465
+
466
+ /**
467
+ * 归一并校验模型草稿数组:schema 之前先拒绝适配器无法表达的行。
468
+ * @param {unknown} value
469
+ * @returns {{ ok: boolean, rows: Record<string, unknown>[], error: string | null }}
470
+ */
471
+ function validateModelRows(value) {
472
+ const rows = modelDraftsOf(value);
473
+ const seen = new Set();
474
+ for (const [index, row] of rows.entries()) {
475
+ const position = index + 1;
476
+ const id = typeof row.id === "string" ? row.id.trim() : "";
477
+ if (id.length === 0) return { ok: false, rows, error: "第 " + position + " 行:模型 ID 不能为空" };
478
+ if (seen.has(id)) return { ok: false, rows, error: "第 " + position + " 行:模型 ID 重复(" + id + ")" };
479
+ seen.add(id);
480
+ if (row.name !== undefined && (typeof row.name !== "string" || row.name.length === 0)) {
481
+ return { ok: false, rows, error: "第 " + position + " 行:显示名必须是非空文本" };
482
+ }
483
+ for (const field of ["contextWindow", "maxTokens"]) {
484
+ const raw = row[field];
485
+ if (raw === undefined) continue;
486
+ if (typeof raw !== "number" || !Number.isInteger(raw) || raw <= 0) {
487
+ return {
488
+ ok: false,
489
+ rows,
490
+ error: "第 " + position + " 行:" + (field === "contextWindow" ? "上下文窗口" : "最大输出 token") + "必须是正整数",
491
+ };
492
+ }
493
+ }
494
+ const declared = row.reasoningEfforts;
495
+ if (declared !== undefined && declared !== false && !isPlainObject(declared)) {
496
+ return { ok: false, rows, error: "第 " + position + " 行:思考档位映射必须是对象(或 false)" };
497
+ }
498
+ }
499
+ return { ok: true, rows, error: null };
500
+ }
501
+
502
+ /**
503
+ * 归一模型草稿:去掉纯空白字段、把 ID/显示名去首尾空白、重建七档映射。
504
+ * @param {unknown} value
505
+ * @returns {Record<string, unknown>[]}
506
+ */
507
+ function normalizeModelRows(value) {
508
+ return modelDraftsOf(value)
509
+ .map((row) => {
510
+ const next = { ...row };
511
+ if (typeof next.id === "string") next.id = next.id.trim();
512
+ if (typeof next.name === "string") {
513
+ const trimmed = next.name.trim();
514
+ if (trimmed.length === 0) Reflect.deleteProperty(next, "name");
515
+ else next.name = trimmed;
516
+ }
517
+ return normalizeEffortsEntry(next);
518
+ })
519
+ .filter((row) => {
520
+ // 完全空白的行(用户在"添加模型"后没填 ID)不写进用户层。
521
+ const onlyIdentity = Object.keys(row).length <= 1;
522
+ return !(onlyIdentity && (row.id === undefined || row.id === ""));
523
+ });
524
+ }
525
+
526
+ /**
527
+ * 计算从 `before` 到 `after` 的最小路径操作序列。
528
+ * 只命名两边观察到的键:只在一侧出现的键产生 set/unset,值相等的键不产生操作。
529
+ *
530
+ * @param {readonly string[]} base
531
+ * @param {unknown} before
532
+ * @param {unknown} after
533
+ * @param {readonly string[]} [allowedKeys]
534
+ * @returns {Array<{ op: "set" | "unset", path: string[], value?: unknown }>}
535
+ */
536
+ function pathOps(base, before, after, allowedKeys) {
537
+ const prefix = Array.isArray(base) ? base : [];
538
+ const previous = isPlainObject(before) ? before : {};
539
+ const next = isPlainObject(after) ? after : {};
540
+ const allowed = Array.isArray(allowedKeys) ? new Set(allowedKeys) : null;
541
+ const ops = [];
542
+ for (const [key, value] of Object.entries(next)) {
543
+ if (allowed !== null && !allowed.has(key)) continue;
544
+ if (jsonEqual(previous[key], value)) continue;
545
+ ops.push({ op: "set", path: [...prefix, key], value: deepClone(value) });
546
+ }
547
+ for (const key of Object.keys(previous)) {
548
+ if (allowed !== null && !allowed.has(key)) continue;
549
+ if (Object.prototype.hasOwnProperty.call(next, key)) continue;
550
+ ops.push({ op: "unset", path: [...prefix, key] });
551
+ }
552
+ return ops;
553
+ }
554
+
555
+ /** 是否有可提交的路径操作。 */
556
+ function hasOps(ops) {
557
+ return Array.isArray(ops) && ops.length > 0;
558
+ }
559
+
560
+ /**
561
+ * namespace revision 冲突判定:提交前复核卡片读到的 revision 是否仍是当前值。
562
+ * @param {number} expectedRevision
563
+ * @param {number} currentRevision
564
+ * @returns {boolean}
565
+ */
566
+ function revisionConflictOf(expectedRevision, currentRevision) {
567
+ if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) return false;
568
+ if (!Number.isSafeInteger(currentRevision) || currentRevision < 0) return true;
569
+ return expectedRevision !== currentRevision;
570
+ }
571
+
572
+ /**
573
+ * 卡片进入编辑态时固定的基线:草稿基底 + revision 栅栏 + 继承来源。
574
+ * @param {{ revision?: number, user?: unknown, base?: unknown, value?: unknown }} snapshot
575
+ * @param {readonly string[]} path
576
+ * @returns {{ revision: number | null, userSubtree: Record<string, unknown>, effectiveSubtree: Record<string, unknown>, inherited: Record<string, unknown> }}
577
+ */
578
+ function createEditorBaseline(snapshot, path) {
579
+ const source = isPlainObject(snapshot) ? snapshot : {};
580
+ const revision = Number.isSafeInteger(source.revision) && source.revision >= 0 ? source.revision : null;
581
+ const user = source.user;
582
+ const pinned = getPath(source.base, path);
583
+ return {
584
+ revision,
585
+ userSubtree: getSubtree(user, path),
586
+ effectiveSubtree: getSubtree(source.value, path),
587
+ inherited: isPlainObject(pinned) ? deepClone(pinned) : {},
588
+ };
589
+ }
590
+
591
+ /** 卡片当前是否持有用户覆盖(`user` 层存在该路径)。 */
592
+ function modelCatalogOverridden(baseline) {
593
+ if (!isPlainObject(baseline) || !isPlainObject(baseline.userSubtree)) return false;
594
+ return Object.prototype.hasOwnProperty.call(baseline.userSubtree, "models")
595
+ && Array.isArray(baseline.userSubtree.models);
596
+ }
597
+
598
+ function isOverridden(baseline) {
599
+ if (!isPlainObject(baseline)) return false;
600
+ return Object.keys(baseline.userSubtree ?? {}).length > 0;
601
+ }
602
+
603
+ /**
604
+ * 模型目录的显示内容:有用户覆盖时用覆盖,否则用"适配器/组合层默认"的继承值。
605
+ * 继承值优先取组合层(`base`)钉住的行,缺失时回退到已解析生效值里的行。
606
+ *
607
+ * @param {ReturnType<typeof createEditorBaseline>} baseline
608
+ * @returns {{ overridden: boolean, models: Record<string, unknown>[], inherited: Record<string, unknown>[] }}
609
+ */
610
+ function inheritedModelsOf(baseline) {
611
+ const source = isPlainObject(baseline) ? baseline : {};
612
+ const overridden = modelCatalogOverridden(source);
613
+ if (overridden) {
614
+ return { overridden: true, models: modelDraftsOf(source.userSubtree?.models), inherited: [] };
615
+ }
616
+ const baseRows = Array.isArray(source.inherited?.models) ? source.inherited.models : undefined;
617
+ const effectiveRows = Array.isArray(source.effectiveSubtree?.models) ? source.effectiveSubtree.models : [];
618
+ return { overridden: false, models: modelDraftsOf(baseRows ?? effectiveRows), inherited: modelDraftsOf(baseRows ?? effectiveRows) };
619
+ }
620
+
621
+ /**
622
+ * 卡片会话在删除用户覆盖后使用的默认模型目录。
623
+ * 用户覆盖存在时不能从 effectiveSubtree 回退,因为 effectiveSubtree 仍包含覆盖本身。
624
+ * @param {ReturnType<typeof createEditorBaseline>} baseline
625
+ * @returns {Record<string, unknown>[]}
626
+ */
627
+ function fallbackModelsOf(baseline) {
628
+ const source = isPlainObject(baseline) ? baseline : {};
629
+ const baseRows = Array.isArray(source.inherited?.models) ? source.inherited.models : undefined;
630
+ if (baseRows !== undefined) return modelDraftsOf(baseRows);
631
+ if (modelCatalogOverridden(source)) return [];
632
+ return modelDraftsOf(source.effectiveSubtree?.models);
633
+ }
634
+
635
+ /**
636
+ * 首次编辑时 materialize 用户覆盖:把草稿整体写到用户层(settings.mutate 的 set 操作)。
637
+ * @param {readonly string[]} path
638
+ * @param {readonly unknown[]} models
639
+ * @returns {Array<{ op: "set", path: string[], value: unknown }>}
640
+ */
641
+ function materializeOverrideOps(path, models) {
642
+ return [{
643
+ op: "set",
644
+ path: [...(Array.isArray(path) ? path : []), "models"],
645
+ value: modelDraftsOf(models),
646
+ }];
647
+ }
648
+
649
+ /**
650
+ * 恢复默认:删除用户覆盖(settings.mutate 的 unset 操作),继承值随即生效。
651
+ * @param {readonly string[]} path
652
+ * @returns {Array<{ op: "unset", path: string[] }>}
653
+ */
654
+ function restoreDefaultOps(path) {
655
+ return [{ op: "unset", path: [...(Array.isArray(path) ? path : []), "models"] }];
656
+ }
657
+
658
+ /**
659
+ * 卡片提交的路径操作:相对进入编辑态时的 user 基线做 diff。
660
+ * 未覆盖 + 草稿没有任何内容时不产生任何操作(不 materialize 空覆盖)。
661
+ *
662
+ * `profilePath` 是 **profile 子树**在命名空间内的路径(整段即 profile 时为空数组),
663
+ * `draft` 是编辑后的 profile 子树(含 `models`、`baseURL`、`apiKeyEnv` 等卡片可见字段)。
664
+ * diff 在 profile 层级进行,因此只命名卡片看到的键,未展示的字段绝不重建或删除。
665
+ *
666
+ * @param {{ profilePath: readonly string[], baseline: unknown, draft: Record<string, unknown>, catalogTouched?: boolean }} options
667
+ * @returns {Array<{ op: "set" | "unset", path: string[], value?: unknown }>}
668
+ */
669
+ function modelCatalogOps({ profilePath, baseline, draft, catalogTouched }) {
670
+ const overridden = isOverridden(baseline);
671
+ const rawDraft = isPlainObject(draft) ? draft : {};
672
+ const includeModels = catalogTouched !== false;
673
+ const sourceNext = includeModels
674
+ ? rawDraft
675
+ : (isPlainObject(baseline?.userSubtree) && Object.prototype.hasOwnProperty.call(baseline.userSubtree, "models")
676
+ ? { ...rawDraft, models: deepClone(baseline.userSubtree.models) }
677
+ : deletePath(rawDraft, ["models"]));
678
+ const next = Array.isArray(sourceNext.models) && sourceNext.models.length === 0 && includeModels
679
+ ? deletePath(sourceNext, ["models"])
680
+ : sourceNext;
681
+ // 未覆盖时只允许 materialize 真正有内容的草稿:直接点"应用"不能凭空写一份空 profile。
682
+ if (!overridden && Object.keys(next).length === 0) return [];
683
+ return pathOps(profilePath, overridden ? baseline.userSubtree : undefined, next, EDITABLE_PROFILE_FIELDS);
684
+ }
685
+
686
+ /**
687
+ * 相对 *已存储* 用户层的最小子树操作(provider 资料编辑用):
688
+ * 只命名卡片能看到的字段,未展示的字段绝不重建。
689
+ *
690
+ * @param {unknown} stored - 已存储的用户子树(可能不存在)。
691
+ * @param {Record<string, unknown>} draft - 卡片编辑后的子树。
692
+ * @returns {Array<{ op: "set" | "unset", path: string[], value?: unknown }>}
693
+ */
694
+ function providerSubtreeOps(stored, draft) {
695
+ return pathOps([], stored, draft);
696
+ }
697
+
698
+ /**
699
+ * provider 卡片有权编辑的 profile 字段。
700
+ *
701
+ * 只命名卡片看到的字段是刻意的:settings.yaml 里的隐藏高级字段(transport、retryPolicy、
702
+ * compat…)不进入草稿,因此既不会被显示,也不会在提交时被重建或删除(与 DSH 内置模型
703
+ * 设置页"卡片只写自己的字段"同一条原则)。
704
+ */
705
+ const EDITABLE_PROFILE_FIELDS = Object.freeze(["displayName", "apiKeyEnv", "baseURL", "api", "models"]);
706
+
707
+ /**
708
+ * 把一份 profile 收敛到卡片有权编辑的字段。`models` 只在确实是数组时保留(空数组表示
709
+ * "已清空目录",等同恢复默认)。
710
+ *
711
+ * @param {unknown} source
712
+ * @returns {Record<string, unknown>}
713
+ */
714
+ function modelEditableProfileOf(source) {
715
+ if (!isPlainObject(source)) return {};
716
+ const next = {};
717
+ for (const field of EDITABLE_PROFILE_FIELDS) {
718
+ if (!Object.prototype.hasOwnProperty.call(source, field)) continue;
719
+ const value = source[field];
720
+ if (value === undefined || value === null) continue;
721
+ if (field === "models" && !Array.isArray(value)) continue;
722
+ next[field] = deepClone(value);
723
+ }
724
+ return next;
725
+ }
726
+
727
+ /**
728
+ * 「写入前 materialize」:把当前生效值(effective value)里卡片能看到的字段导入草稿。
729
+ *
730
+ * 这是"首次编辑才 materialize 用户覆盖"的实现方式:卡片在用户真正编辑时先导入,
731
+ * 提交时再与 **已存储的用户层** diff,因此
732
+ * - 没有用户覆盖时,导入值 + 编辑值就是这份覆盖的全部内容(等价于写入用户层);
733
+ * - 已有用户覆盖时,只有实际改动才会变成 set,未被改动的字段不会重复写入;
734
+ * - 卡片编辑期间到达的外部提交不会让"已存在的用户覆盖"被误判成删除。
735
+ *
736
+ * @param {unknown} source - 已解析生效值里的 profile 子树(`namespace.value` 的路径值)。
737
+ * @returns {Record<string, unknown>}
738
+ */
739
+ function importNamespace(source) {
740
+ return modelEditableProfileOf(source);
741
+ }
742
+
743
+ /**
744
+ * 把一份完整的模型目录草稿换算成提交用的路径操作。
745
+ *
746
+ * @param {readonly string[]} modelsPath - 模型数组在命名空间内的路径。
747
+ * @param {readonly {}[]} models - 归一后的模型目录(空数组表示"沿用适配器默认")。
748
+ * @returns {Array<{ op: "set" | "unset", path: string[], value?: unknown }>}
749
+ */
750
+ function catalogOpsFor(modelsPath, models) {
751
+ const path = Array.isArray(modelsPath) ? modelsPath : [];
752
+ const rows = modelDraftsOf(models);
753
+ if (rows.length === 0) return [{ op: "unset", path: [...path] }];
754
+ return [{ op: "set", path: [...path], value: rows }];
755
+ }
756
+
757
+ /**
758
+ * 把「用户覆盖 materialize / 恢复默认」判定收敛成一次决策,供 UI 与测试共用。
759
+ * @param {{ overridden: boolean, dirty: boolean, models: readonly unknown[] }} input
760
+ * @returns {"reset" | "materialize" | "replace" | "noop"}
761
+ */
762
+ function modelOverrideDecision({ overridden, dirty, models }) {
763
+ if (!dirty) return "noop";
764
+ const empty = modelDraftsOf(models).length === 0;
765
+ if (overridden && empty) return "reset";
766
+ if (!overridden) return "materialize";
767
+ return "replace";
768
+ }
769
+
770
+ /** 供 UI 显示的选择:把已存储行的模型选项归一为 `{ id, name }` 列表。 */
771
+ function modelChoicesOf(rows) {
772
+ return modelDraftsOf(rows)
773
+ .filter((row) => isNonEmptyString(row.id))
774
+ .map((row) => ({ id: row.id.trim(), name: modelDisplayNameOf(row) }));
775
+ }
776
+
777
+ /** 使目录行按 ID 索引,便于"取模型"时保留用户已调整的容量字段。 */
778
+ function indexModelsById(rows) {
779
+ const index = new Map();
780
+ for (const row of modelDraftsOf(rows)) {
781
+ if (isNonEmptyString(row.id)) index.set(row.id.trim(), row);
782
+ }
783
+ return index;
784
+ }
785
+
786
+ /**
787
+ * 采纳端点/目录返回的候选模型:已存在的行(用户调过的数值)优先,其余新增。
788
+ * 新采纳的行七档全不勾选(`effortSelectionFor` 对缺失字段即全不勾选)。
789
+ *
790
+ * @param {readonly unknown[]} rows
791
+ * @param {readonly { id?: unknown, name?: unknown, contextWindow?: unknown, maxTokens?: unknown, inputModalities?: unknown }[]} candidates
792
+ * @returns {Record<string, unknown>[]}
793
+ */
794
+ function adoptModels(rows, candidates) {
795
+ const index = indexModelsById(rows);
796
+ const next = modelDraftsOf(rows).map((row) => {
797
+ const id = isNonEmptyString(row.id) ? row.id.trim() : "";
798
+ return id.length === 0 ? row : index.get(id);
799
+ });
800
+ for (const candidate of Array.isArray(candidates) ? candidates : []) {
801
+ if (!isPlainObject(candidate) || !isNonEmptyString(candidate.id)) continue;
802
+ const id = candidate.id.trim();
803
+ if (index.has(id)) continue;
804
+ const row = { id };
805
+ if (isNonEmptyString(candidate.name)) row.name = candidate.name;
806
+ if (typeof candidate.contextWindow === "number") row.contextWindow = candidate.contextWindow;
807
+ if (typeof candidate.maxTokens === "number") row.maxTokens = candidate.maxTokens;
808
+ if (Array.isArray(candidate.inputModalities) && candidate.inputModalities.length > 0) {
809
+ row.input = candidate.inputModalities.filter((value) => typeof value === "string");
810
+ }
811
+ index.set(id, row);
812
+ next.push(row);
813
+ }
814
+ return next;
815
+ }
816
+
817
+ /**
818
+ * 合并 provider 目录与已注册路由:声明顺序优先,未声明的活动路由追加在后。
819
+ * @param {readonly { id?: unknown, name?: unknown }[]} registered
820
+ * @param {readonly Record<string, unknown>[]} directory
821
+ * @returns {Record<string, unknown>[]}
822
+ */
823
+ function joinProviderDirectory(registered, directory) {
824
+ const active = new Set();
825
+ for (const provider of Array.isArray(registered) ? registered : []) {
826
+ if (isPlainObject(provider) && isNonEmptyString(provider.id)) active.add(provider.id);
827
+ }
828
+ const declared = new Set();
829
+ const rows = [];
830
+ for (const entry of Array.isArray(directory) ? directory : []) {
831
+ if (!isPlainObject(entry) || !isNonEmptyString(entry.provider)) continue;
832
+ declared.add(entry.provider);
833
+ rows.push({
834
+ provider: entry.provider,
835
+ displayName: isNonEmptyString(entry.displayName) ? entry.displayName : entry.provider,
836
+ settingsNs: typeof entry.settingsNs === "string" ? entry.settingsNs : "",
837
+ settingsPath: Array.isArray(entry.settingsPath) ? [...entry.settingsPath] : [],
838
+ active: active.has(entry.provider),
839
+ declared: entry.declared === true,
840
+ error: typeof entry.error === "string" && entry.error.length > 0 ? entry.error : undefined,
841
+ });
842
+ }
843
+ for (const provider of Array.isArray(registered) ? registered : []) {
844
+ if (!isPlainObject(provider) || !isNonEmptyString(provider.id) || declared.has(provider.id)) continue;
845
+ rows.push({
846
+ provider: provider.id,
847
+ displayName: isNonEmptyString(provider.name) ? provider.name : provider.id,
848
+ settingsNs: "",
849
+ settingsPath: [],
850
+ active: true,
851
+ declared: false,
852
+ error: undefined,
853
+ });
854
+ }
855
+ return rows;
856
+ }
857
+
858
+ /** provider 卡片是否可编辑(有可写 settings 命名空间与 profile 路径)。 */
859
+ function providerEditable(row, namespace) {
860
+ return isPlainObject(row) && isNonEmptyString(row.settingsNs) && isPlainObject(namespace);
861
+ }
862
+
863
+ /** provider 卡片是否可删除(用户层存在 profile)。 */
864
+ function providerRemovable(row, namespace) {
865
+ if (!isPlainObject(row) || !isPlainObject(namespace)) return false;
866
+ if (row.settingsPath.length === 0 && row.settingsNs !== "") {
867
+ // 整段即 profile:用户层存在任意键即视为可删除。
868
+ return hasPath(namespace.user, row.settingsPath);
869
+ }
870
+ return hasPath(namespace.user, row.settingsPath);
871
+ }
872
+
873
+ /** 从有效配置与组合层读出 provider 的 profile(草稿基底)。 */
874
+ function providerProfileOf(row, namespace) {
875
+ if (!isPlainObject(row) || !isPlainObject(namespace)) return {};
876
+ return getSubtree(namespace.value, row.settingsPath);
877
+ }
878
+
879
+ /** provider profile 的 apiKeyEnv(未声明时为空字符串)。 */
880
+ function apiKeyEnvOf(row, namespace) {
881
+ const profile = providerProfileOf(row, namespace);
882
+ return isNonEmptyString(profile.apiKeyEnv) ? profile.apiKeyEnv : "";
883
+ }
884
+
885
+ /** provider 卡片的凭据引用:profile 的 apiKeyEnv 优先,否则用同一条推导规则。 */
886
+ function credentialRefOf(row, namespace) {
887
+ const named = apiKeyEnvOf(row, namespace);
888
+ return named.length > 0 ? named : deriveKeyRef(isPlainObject(row) ? row.provider : "");
889
+ }
890
+
891
+ /**
892
+ * 从命名空间自己的 schema 读出可选协议列表(手写 route 的 `api` 字段)。
893
+ *
894
+ * 显式给出非空 `settingsPath` 时按该路径查询(provider 的 profile 基路径不一定是
895
+ * `providers`);省略或给空数组时按 pi-ai profile 的默认形状 `providers.<route>.api`
896
+ * 查询。`nodeAtPath` 自己会穿过 dict 的 inner 节点,因此这里不需要知道中间层是 dict
897
+ * 还是 object。
898
+ *
899
+ * @param {unknown} root - 已 rehydrate 的 schema 节点。
900
+ * @param {string} provider
901
+ * @param {{ nodeAtPath?: Function }} operations
902
+ * @param {readonly string[]} [settingsPath]
903
+ * @returns {string[]}
904
+ */
905
+ function protocolChoices(root, provider, operations, settingsPath) {
906
+ if (!isPlainObject(operations) || typeof operations.nodeAtPath !== "function") return [];
907
+ if (!isNonEmptyString(provider)) return [];
908
+ const profilePath = Array.isArray(settingsPath) && settingsPath.length > 0
909
+ ? settingsPath
910
+ : ["providers", provider];
911
+ let node;
912
+ try {
913
+ node = operations.nodeAtPath(root, [...profilePath, "api"]);
914
+ } catch {
915
+ return [];
916
+ }
917
+ if (!isSchemaNode(node) || node.type !== "union" || !Array.isArray(node.list)) return [];
918
+ return node.list
919
+ .map((entry) => (isSchemaNode(entry) ? entry.value : undefined))
920
+ .filter((value) => typeof value === "string" && value.length > 0);
921
+ }
922
+
923
+ /**
924
+ * 创建一个基于 describe 视图的同步 schema 适配器。
925
+ *
926
+ * 只使用设置服务自己的公开面:schema 来自 describe 返回的 `SettingsNamespaceView.schema`,
927
+ * rehydrate/validate/nodeAtPath 优先用注入的公开服务(DSH 的 `settingsSchema`);宿主没有
928
+ * 该服务时退化为"直接按 schemastery 的 `new Schema(信封)` 语义读取 schema 信封",
929
+ * 再没有就只能不校验(把拒绝留给正式 settings API)。因此这里既不碰 DSH 核心文件,
930
+ * 也不复制 schemastery 的实现。
931
+ *
932
+ * @param {{ rehydrate?: Function, validate?: Function, nodeAtPath?: Function }} operations
933
+ * @param {{ Schema?: Function }} [fallback] - 可选的结构化兜底(测试注入 schemastery 用)。
934
+ */
935
+ function createSchemaAdapter(operations, fallback) {
936
+ const ops = isPlainObject(operations) ? operations : {};
937
+ const schemaConstructor = isPlainObject(fallback) && typeof fallback.Schema === "function" ? fallback.Schema : null;
938
+ const rehydrate = typeof ops.rehydrate === "function" ? ops.rehydrate : null;
939
+ const validate = typeof ops.validate === "function" ? ops.validate : null;
940
+ const nodeAtPath = typeof ops.nodeAtPath === "function" ? ops.nodeAtPath : null;
941
+ /** 结构化兜底实现:与 DSH 公开服务同语义的遍历/校验最小集合。 */
942
+ const structural = {
943
+ rehydrate(serialized) {
944
+ if (schemaConstructor === null) return undefined;
945
+ try {
946
+ return new schemaConstructor(serialized);
947
+ } catch {
948
+ return undefined;
949
+ }
950
+ },
951
+ /**
952
+ * 用 schema 节点自身做一次真实校验(DSH 的 schema 服务同语义:校验语义在节点上,
953
+ * 服务只负责把抛错转成文本)。宿主没有 schmastery 构造器时退化为"不本地校验",
954
+ * 拒绝仍然由正式 settings API 负责。
955
+ */
956
+ validateNode(schema, draft) {
957
+ if (schema === undefined || schema === null || typeof schema !== "function") return undefined;
958
+ try {
959
+ schema(draft);
960
+ return undefined;
961
+ } catch (error) {
962
+ return error instanceof Error ? error.message : String(error);
963
+ }
964
+ },
965
+ nodeAtPath(root, path) {
966
+ let node = root;
967
+ for (const key of Array.isArray(path) ? path : []) {
968
+ if (node === null || node === undefined || typeof node !== "object") return undefined;
969
+ if (node.type === "object") node = isPlainObject(node.dict) ? node.dict[key] : undefined;
970
+ else if (node.type === "dict" || node.type === "array") node = node.inner;
971
+ else return undefined;
972
+ }
973
+ return node;
974
+ },
975
+ };
976
+ return {
977
+ available: rehydrate !== null || schemaConstructor !== null,
978
+ rehydrate(serialized) {
979
+ if (rehydrate !== null) {
980
+ try {
981
+ return rehydrate(serialized);
982
+ } catch {
983
+ // 无法 rehydrate 的 schema 信封不能证明任何草稿合法:按"无 schema"处理。
984
+ return undefined;
985
+ }
986
+ }
987
+ return structural.rehydrate(serialized);
988
+ },
989
+ /**
990
+ * 校验草稿;没有 schema 能力时返回 `undefined`(交给正式的 settings API 拒绝)。
991
+ * @returns {string | undefined}
992
+ */
993
+ validate(schema, draft) {
994
+ if (schema === undefined || schema === null) return undefined;
995
+ if (validate !== null) {
996
+ try {
997
+ const outcome = validate(schema, draft);
998
+ if (outcome !== undefined && outcome !== null) return String(outcome);
999
+ // 注入的公开服务返回"通过"时仍需结构化复核:DSH 的 schema 服务只把
1000
+ // 抛错转成文本,真实校验语义始终在 schema 节点自身。
1001
+ return structural.validateNode(schema, draft);
1002
+ } catch (error) {
1003
+ return error instanceof Error ? error.message : String(error);
1004
+ }
1005
+ }
1006
+ return structural.validateNode(schema, draft);
1007
+ },
1008
+ nodeAtPath(root, path) {
1009
+ if (root === undefined || root === null) return undefined;
1010
+ if (nodeAtPath !== null) {
1011
+ try {
1012
+ return nodeAtPath(root, path);
1013
+ } catch {
1014
+ return undefined;
1015
+ }
1016
+ }
1017
+ return structural.nodeAtPath(root, path);
1018
+ },
1019
+ };
1020
+ }
1021
+
1022
+ /**
1023
+ * 读取一个命名空间的 schema 节点与默认值(模型目录的继承来源)。
1024
+ * @param {{ schema?: unknown, value?: unknown, base?: unknown }} namespace
1025
+ * @param {ReturnType<typeof createSchemaAdapter>} adapter
1026
+ */
1027
+ function namespaceSchemaInfo(namespace, adapter) {
1028
+ const view = isPlainObject(namespace) ? namespace : {};
1029
+ const root = adapter === undefined ? undefined : adapter.rehydrate(view.schema);
1030
+ return {
1031
+ root,
1032
+ modelsNode: adapter === undefined ? undefined : adapter.nodeAtPath(root, ["models"]),
1033
+ defaultInput: (() => {
1034
+ const node = adapter === undefined ? undefined : adapter.nodeAtPath(root, ["defaultInput"]);
1035
+ const value = isSchemaNode(node) ? node.meta?.default : undefined;
1036
+ return Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : undefined;
1037
+ })(),
1038
+ };
1039
+ }
1040
+
1041
+ /**
1042
+ * 卡片保存前的本地校验:只校验卡片有权编辑的字段。
1043
+ *
1044
+ * 未展示的未知字段不参与判定(它们也不产生路径操作),因此这里不会因为
1045
+ * 结构外字段而拒绝一次合法编辑。
1046
+ *
1047
+ * @param {Record<string, unknown>} draft - 卡片草稿(profile 子树)。
1048
+ * @returns {string | null} 失败文案,或 null 表示通过。
1049
+ */
1050
+ function validateProfileDraft(draft) {
1051
+ if (!isPlainObject(draft)) return null;
1052
+ for (const field of ["displayName", "baseURL", "api", "apiKeyEnv"]) {
1053
+ const value = draft[field];
1054
+ if (value === undefined || value === null) continue;
1055
+ if (typeof value !== "string") return field + " 必须是文本";
1056
+ }
1057
+ if (draft.models !== undefined) {
1058
+ const checked = validateModelRows(draft.models);
1059
+ if (!checked.ok) return checked.error;
1060
+ }
1061
+ return null;
1062
+ }
1063
+
1064
+ // ── 角色联动:将要删除/取消的档位是否正被某些角色默认使用 ────────────────────
1065
+
1066
+ /**
1067
+ * 计算受影响角色:provider 的某一档位将被删除(或该 provider 整体被删除)时,
1068
+ * `dsh-expert-group.defaults.<role>.reasoningEffort` 仍指向这一档的角色必须被列出。
1069
+ *
1070
+ * 绝不自动改写角色配置:调用方据此阻止保存并提示用户先去角色配置里改。
1071
+ *
1072
+ * @param {unknown} defaults - settings 里的 `defaults` 映射。
1073
+ * @param {{ provider: string, removedLevels?: readonly string[], removeProvider?: boolean }} change
1074
+ * @returns {Array<{ role: string, reasoningEffort: string }>}
1075
+ */
1076
+ function rolesAffectedByChange(defaults, change) {
1077
+ const source = isPlainObject(defaults) ? defaults : {};
1078
+ const provider = isNonEmptyString(change?.provider) ? change.provider : "";
1079
+ if (provider.length === 0) return [];
1080
+ const removed = new Set(
1081
+ (Array.isArray(change?.removedLevels) ? change.removedLevels : [])
1082
+ .filter((level) => typeof level === "string" && level.length > 0),
1083
+ );
1084
+ const removeProvider = change?.removeProvider === true;
1085
+ const affected = [];
1086
+ for (const [role, selection] of Object.entries(source)) {
1087
+ if (!isPlainObject(selection)) continue;
1088
+ if (selection.provider !== provider) continue;
1089
+ const effort = selection.reasoningEffort;
1090
+ if (typeof effort !== "string" || effort.length === 0) continue;
1091
+ if (removeProvider || removed.has(effort)) affected.push({ role, reasoningEffort: effort });
1092
+ }
1093
+ return affected;
1094
+ }
1095
+
1096
+ /**
1097
+ * 角色联动拦截提示文案(中文,列出受影响角色)。
1098
+ * @param {readonly string[]} levels
1099
+ * @param {readonly { role: string, reasoningEffort: string }[]} affected
1100
+ * @param {(role: string) => string} [labelOf]
1101
+ * @returns {string}
1102
+ */
1103
+ function reasoningGuardMessage(levels, affected, labelOf) {
1104
+ const label = typeof labelOf === "function" ? labelOf : (role) => role;
1105
+ const parts = affected.map((entry) => label(entry.role) + "(" + entry.reasoningEffort + ")");
1106
+ return "无法保存:将删除的思考档位 " + levels.join("、") + " 正被以下专家角色使用:" + parts.join("、")
1107
+ + "。请先在这些角色的配置中改用其它档位,本页不会自动改写角色配置。";
1108
+ }
1109
+
1110
+ /**
1111
+ * 从两份模型草稿算出"将被删除的档位",只在保存前用于角色联动守卫。
1112
+ * @param {readonly unknown[]} before
1113
+ * @param {readonly unknown[]} after
1114
+ * @param {{ includeRemovedRows?: boolean }} [options]
1115
+ * @returns {string[]}
1116
+ */
1117
+ function removedLevelsOf(before, after, options) {
1118
+ const levels = new Set();
1119
+ const afterIndex = indexModelsById(after);
1120
+ const includeRemovedRows = isPlainObject(options) && options.includeRemovedRows === true;
1121
+ for (const row of modelDraftsOf(before)) {
1122
+ if (!isNonEmptyString(row.id)) continue;
1123
+ const id = row.id.trim();
1124
+ const nextRow = afterIndex.get(id);
1125
+ const previousLevels = selectedLevelsOf(row.reasoningEfforts);
1126
+ if (nextRow === undefined) {
1127
+ if (includeRemovedRows) for (const level of previousLevels) levels.add(level);
1128
+ continue;
1129
+ }
1130
+ const nextLevels = selectedLevelsOf(nextRow.reasoningEfforts);
1131
+ for (const level of previousLevels) if (!nextLevels.includes(level)) levels.add(level);
1132
+ }
1133
+ return [...levels];
1134
+ }
1135
+
1136
+ // ── 设置文档读取(沿用公开 settings API 的视图形状) ───────────────────────
1137
+
1138
+ /**
1139
+ * 从 describe 视图取出命名空间视图。
1140
+ * @param {unknown} view
1141
+ * @param {string} ns
1142
+ * @returns {Record<string, unknown> | undefined}
1143
+ */
1144
+ function namespaceViewOf(view, ns) {
1145
+ const namespaces = isPlainObject(view) && Array.isArray(view.namespaces) ? view.namespaces : [];
1146
+ return namespaces.find((entry) => isPlainObject(entry) && entry.ns === ns);
1147
+ }
1148
+
1149
+ /**
1150
+ * 从 settings 快照读角色默认选择(角色联动的数据源)。
1151
+ * @param {unknown} settingsValue
1152
+ * @returns {Record<string, unknown>}
1153
+ */
1154
+ function roleDefaultsOf(settingsValue) {
1155
+ const defaults = isPlainObject(settingsValue) ? settingsValue.defaults : undefined;
1156
+ return isPlainObject(defaults) ? defaults : {};
1157
+ }
1158
+
1159
+ /**
1160
+ * 决定一次提交要写什么:路径操作、将被取消的档位、以及角色的守卫结论。
1161
+ *
1162
+ * 卡片只把"自己的草稿 + 进入编辑态时已存储的用户层"交给这里,因此
1163
+ * - 未覆盖时:materialize 用户覆盖(写入卡片可见的全部字段);
1164
+ * - 已覆盖时:相对权威用户层的最小 diff,外部提交的字段不会被误删;
1165
+ * - 空档位行 / 空白显示名等在草稿校验阶段就被拒绝,不进入这里。
1166
+ *
1167
+ * @param {{
1168
+ * provider: string,
1169
+ * profilePath: readonly string[],
1170
+ * storedUser: unknown,
1171
+ * draft: Record<string, unknown>,
1172
+ * inheritedModels: readonly unknown[],
1173
+ * roleDefaults: unknown,
1174
+ * catalogTouched?: boolean,
1175
+ * }} input
1176
+ * @returns {{
1177
+ * ops: Array<{ op: "set" | "unset", path: string[], value?: unknown }>,
1178
+ * removedLevels: string[],
1179
+ * affectedRoles: Array<{ role: string, reasoningEffort: string }>,
1180
+ * }}
1181
+ */
1182
+ function modelCardApply(input) {
1183
+ const source = isPlainObject(input) ? input : {};
1184
+ const catalogTouched = source.catalogTouched === true;
1185
+ const profilePath = Array.isArray(source.profilePath) ? source.profilePath : [];
1186
+ const draft = isPlainObject(source.draft) ? source.draft : {};
1187
+ const storedUser = source.storedUser;
1188
+ const baseline = { userSubtree: getSubtree(storedUser, profilePath) };
1189
+ const ops = modelCatalogOps({ profilePath, baseline, draft, catalogTouched });
1190
+ const rows = catalogTouched
1191
+ ? (Array.isArray(draft.models) ? draft.models : [])
1192
+ : [];
1193
+ const removedLevels = catalogTouched
1194
+ ? removedLevelsOf(source.inheritedModels, rows, { includeRemovedRows: source.catalogReset === true })
1195
+ : [];
1196
+ const affectedRoles = removedLevels.length === 0
1197
+ ? []
1198
+ : rolesAffectedByChange(source.roleDefaults, { provider: source.provider, removedLevels });
1199
+ return { ops, removedLevels, affectedRoles, rows };
1200
+ }
1201
+
1202
+ /**
1203
+ * provider 目录行的渲染面:与 DSH 内置模型设置页同一份 join 结果,只补上"是否已由用户配置"。
1204
+ *
1205
+ * @param {readonly Record<string, unknown>[]} rows
1206
+ * @param {(ns: string) => Record<string, unknown> | undefined} namespaceOf
1207
+ * @returns {{ configured: Record<string, unknown>[], addable: Record<string, unknown>[] }}
1208
+ */
1209
+ function providerListSections(rows, namespaceOf) {
1210
+ const lookup = typeof namespaceOf === "function" ? namespaceOf : () => undefined;
1211
+ const sections = [];
1212
+ for (const row of Array.isArray(rows) ? rows : []) {
1213
+ if (!isPlainObject(row)) continue;
1214
+ const namespace = lookup(row.settingsNs);
1215
+ if (!isPlainObject(namespace)) continue;
1216
+ sections.push({
1217
+ row,
1218
+ namespace,
1219
+ // 用户层存在该 profile 才算"已配置":值等于默认值也仍是覆盖,只看键是否存在。
1220
+ configured: hasPath(namespace.user, row.settingsPath),
1221
+ });
1222
+ }
1223
+ return {
1224
+ configured: sections.filter((entry) => entry.configured),
1225
+ addable: sections.filter((entry) => !entry.configured),
1226
+ };
1227
+ }
1228
+
1229
+ /**
1230
+ * API Key 输入框的渲染面:只写不读明文、可按状态禁用、并给出引用提示。
1231
+ *
1232
+ * @param {{ provider: string, credentialRef: string, credential?: { configured?: boolean, writable?: boolean }, typed?: string, readOnly?: boolean }} input
1233
+ * @returns {{ ref: string, value: string, type: "password", autoComplete: "off", disabled: boolean, placeholder: string, hint: string }}
1234
+ */
1235
+ function apiKeyField(input) {
1236
+ const source = isPlainObject(input) ? input : {};
1237
+ const configured = isPlainObject(source.credential) && source.credential.configured === true;
1238
+ const writable = !isPlainObject(source.credential) || source.credential.writable !== false;
1239
+ const ref = isNonEmptyString(source.credentialRef) ? source.credentialRef : "";
1240
+ return {
1241
+ ref,
1242
+ value: typeof source.typed === "string" ? source.typed : "",
1243
+ type: "password",
1244
+ autoComplete: "off",
1245
+ disabled: source.readOnly === true || !writable,
1246
+ placeholder: writable ? (configured ? "已配置(留空表示不修改)" : "粘贴 API Key 后点应用") : "当前凭据只读",
1247
+ hint: ref.length === 0 ? "未设置凭据引用" : "凭据引用:" + ref + "(API Key 只写入 credentials 服务,保存后不回显明文)",
1248
+ };
1249
+ }
1250
+
1251
+ /**
1252
+ * provider 卡片的渲染面:把"卡片看到的数据 + 可执行动作"收敛成一个可断言的对象。
1253
+ *
1254
+ * 界面按它渲染,测试按它断言,因此"模型配置区有哪些控件、哪些默认值、哪些禁用条件"
1255
+ * 不需要依赖 React 渲染器也能被验证。
1256
+ *
1257
+ * @param {{
1258
+ * row: Record<string, unknown>,
1259
+ * namespace: Record<string, unknown>,
1260
+ * draft: Record<string, unknown>,
1261
+ * expandedRows?: ReadonlySet<number> | readonly number[],
1262
+ * defaultInput?: readonly string[],
1263
+ * protocols?: readonly string[],
1264
+ * credentialRef?: string,
1265
+ * credential?: { configured?: boolean, writable?: boolean },
1266
+ * typed?: string,
1267
+ * readOnly?: boolean,
1268
+ * fallbackModels?: readonly unknown[],
1269
+ * }} input
1270
+ */
1271
+ function buildModelCardModel(input) {
1272
+ const source = isPlainObject(input) ? input : {};
1273
+ const row = isPlainObject(source.row) ? source.row : {};
1274
+ const profilePath = Array.isArray(row.settingsPath) ? row.settingsPath : [];
1275
+ const draft = isPlainObject(source.draft) ? source.draft : {};
1276
+ const inherited = modelDraftsOf(source.fallbackModels);
1277
+ const models = Array.isArray(draft.models) ? modelDraftsOf(draft.models) : inherited;
1278
+ // 「已按用户覆盖」= 草稿里有一份**非空**的模型目录。清空目录(恢复默认的草稿态)
1279
+ // 意味着回到适配器默认,而不是一份空覆盖。
1280
+ const overridden = typeof source.catalogOverridden === "boolean"
1281
+ ? source.catalogOverridden
1282
+ : Array.isArray(draft.models) && draft.models.length > 0;
1283
+ const expanded = new Set(
1284
+ source.expandedRows instanceof Set
1285
+ ? [...source.expandedRows]
1286
+ : (Array.isArray(source.expandedRows) ? source.expandedRows : []),
1287
+ );
1288
+ const defaultInput = Array.isArray(source.defaultInput) ? source.defaultInput : undefined;
1289
+ const credential = isPlainObject(source.credential) ? source.credential : undefined;
1290
+ const apiKey = apiKeyField({
1291
+ provider: row.provider,
1292
+ credentialRef: source.credentialRef ?? credentialRefOf(row, source.namespace),
1293
+ credential,
1294
+ typed: typeof source.typed === "string" ? source.typed : "",
1295
+ readOnly: source.readOnly === true,
1296
+ });
1297
+ const nameOf = (field, model) => {
1298
+ if (field === "id") return typeof model.id === "string" ? model.id : "";
1299
+ return typeof model.name === "string" ? model.name : "";
1300
+ };
1301
+ return {
1302
+ provider: row.provider,
1303
+ displayName: isNonEmptyString(row.displayName) ? row.displayName : row.provider,
1304
+ profilePath,
1305
+ readOnly: source.readOnly === true,
1306
+ apiKey,
1307
+ baseUrl: {
1308
+ value: typeof draft.baseURL === "string" ? draft.baseURL : "",
1309
+ placeholder: "留空表示使用适配器默认端点",
1310
+ },
1311
+ protocol: {
1312
+ value: typeof draft.api === "string" ? draft.api : "",
1313
+ options: [{
1314
+ value: "",
1315
+ label: "适配器默认",
1316
+ }, ...(Array.isArray(source.protocols) ? source.protocols : [])
1317
+ .filter((choice) => typeof choice === "string" && choice.length > 0)
1318
+ .map((choice) => ({ value: choice, label: choice }))],
1319
+ },
1320
+ models: models.map((model, index) => {
1321
+ const selection = effortSelectionFor(model);
1322
+ const inputSelected = modelInputOf(model, defaultInput);
1323
+ return {
1324
+ index,
1325
+ position: index + 1,
1326
+ id: { value: nameOf("id", model), editable: true },
1327
+ name: { value: nameOf("name", model), editable: true },
1328
+ contextWindow: {
1329
+ value: typeof model.contextWindow === "number" ? formatCapacity(model.contextWindow) : "",
1330
+ placeholder: "继承适配器默认",
1331
+ },
1332
+ maxTokens: {
1333
+ value: typeof model.maxTokens === "number" ? formatCapacity(model.maxTokens) : "",
1334
+ placeholder: "继承适配器默认",
1335
+ },
1336
+ reasoningDefaults: { value: "模型默认", editable: false },
1337
+ efforts: REASONING_EFFORTS.map((level) => ({ level, checked: selection[level] === true })),
1338
+ inputs: INPUT_MODALITIES.map((modality) => ({
1339
+ modality,
1340
+ checked: inputSelected.includes(modality),
1341
+ // 至少保留一个输入类型:只剩一个已勾选项时该项不可再取消。
1342
+ locked: inputSelected.length === 1 && inputSelected.includes(modality),
1343
+ })),
1344
+ advancedOpen: expanded.has(index),
1345
+ };
1346
+ }),
1347
+ modelCatalog: {
1348
+ overridden,
1349
+ label: overridden ? "已按用户覆盖" : "适配器默认(首次编辑时写入用户覆盖)",
1350
+ emptyHint: "没有模型:留空表示沿用适配器内置目录",
1351
+ canReset: overridden,
1352
+ },
1353
+ actions: {
1354
+ apply: source.readOnly === true ? "disabled" : "enabled",
1355
+ cancel: "enabled",
1356
+ fetchModels: source.readOnly === true ? "disabled" : "enabled",
1357
+ addModel: source.readOnly === true ? "disabled" : "enabled",
1358
+ addProvider: source.readOnly === true ? "disabled" : "enabled",
1359
+ removeProvider: source.readOnly === true ? "disabled" : "enabled",
1360
+ edit: "enabled",
1361
+ },
1362
+ };
1363
+ }
1364
+
1365
+ /**
1366
+ * provider 卡片的一次编辑会话(纯状态机,界面与测试共用)。
1367
+ *
1368
+ * 它只做与渲染无关的判定:合并草稿、算动作、校验、以及一次成功/失败提交的结论,
1369
+ * 因此"每个卡片独立应用/取消 + revision 冲突保护 + 角色联动守卫"可以在没有 React
1370
+ * 渲染器的情况下被完整验证。
1371
+ *
1372
+ * @param {{ provider: string, settingsNs?: string, profilePath: readonly string[], storedUser: unknown, value: unknown, revision: number, roleDefaults?: unknown }} input
1373
+ */
1374
+ function createCardSession(input) {
1375
+ const source = isPlainObject(input) ? input : {};
1376
+ const provider = isNonEmptyString(source.provider) ? source.provider : "";
1377
+ const settingsNs = typeof source.settingsNs === "string" ? source.settingsNs : "";
1378
+ const profilePath = Array.isArray(source.profilePath) ? source.profilePath : [];
1379
+ const startingUser = isPlainObject(source.storedUser) ? source.storedUser : {};
1380
+ let appliedUser = deepClone(startingUser);
1381
+ let currentEffective = source.value;
1382
+ let baseline = createEditorBaseline({
1383
+ revision: source.revision,
1384
+ user: appliedUser,
1385
+ base: source.base,
1386
+ value: currentEffective,
1387
+ }, profilePath);
1388
+ let fallbackModels = fallbackModelsOf(baseline);
1389
+ let draft = importNamespace(
1390
+ !isPlainObject(currentEffective) || profilePath.length === 0
1391
+ ? currentEffective
1392
+ : getPath(currentEffective, profilePath),
1393
+ );
1394
+ let catalogTouched = false;
1395
+ let catalogReset = false;
1396
+ let keyDraft = "";
1397
+ let closed = false;
1398
+ return {
1399
+ provider,
1400
+ settingsNs,
1401
+ profilePath,
1402
+ get draft() { return draft; },
1403
+ get revision() { return baseline.revision; },
1404
+ get closed() { return closed; },
1405
+ get overridden() {
1406
+ return catalogTouched
1407
+ ? Array.isArray(draft.models) && draft.models.length > 0
1408
+ : modelCatalogOverridden(baseline);
1409
+ },
1410
+ get modelRows() {
1411
+ return Array.isArray(draft.models) ? modelDraftsOf(draft.models) : modelDraftsOf(fallbackModels);
1412
+ },
1413
+ /** 交给渲染面的当前模型(与界面渲染用的是同一份数据)。 */
1414
+ cardModel(extra = {}) {
1415
+ return buildModelCardModel({
1416
+ row: { provider, settingsPath: profilePath },
1417
+ draft,
1418
+ fallbackModels: fallbackModels,
1419
+ catalogOverridden: catalogTouched
1420
+ ? Array.isArray(draft.models) && draft.models.length > 0
1421
+ : modelCatalogOverridden(baseline),
1422
+ ...extra,
1423
+ });
1424
+ },
1425
+ /** 用户开始编辑:首次编辑才把生效值导入草稿(等价于 materialize 用户覆盖)。 */
1426
+ edit(update) {
1427
+ if (closed) return draft;
1428
+ const imported = catalogReset || Array.isArray(draft.models) || fallbackModels.length === 0
1429
+ ? draft
1430
+ : { ...draft, models: deepClone(fallbackModels) };
1431
+ const beforeModels = isPlainObject(imported) && Object.prototype.hasOwnProperty.call(imported, "models")
1432
+ ? imported.models
1433
+ : undefined;
1434
+ const next = update(imported);
1435
+ const afterModels = isPlainObject(next) && Object.prototype.hasOwnProperty.call(next, "models")
1436
+ ? next.models
1437
+ : undefined;
1438
+ if (!jsonEqual(beforeModels, afterModels)) catalogTouched = true;
1439
+ draft = next;
1440
+ return draft;
1441
+ },
1442
+ /** 恢复默认:删除用户覆盖(草稿层)。 */
1443
+ resetModels() {
1444
+ catalogTouched = true;
1445
+ catalogReset = true;
1446
+ draft = deletePath(draft, ["models"]);
1447
+ return draft;
1448
+ },
1449
+ setFallbackModels(models) {
1450
+ fallbackModels = modelDraftsOf(models);
1451
+ return fallbackModels;
1452
+ },
1453
+ typeKey(value) {
1454
+ keyDraft = typeof value === "string" ? value : "";
1455
+ return keyDraft;
1456
+ },
1457
+ /** 尚未提交的 API Key 明文(只存在于卡片内存里,用于"获取模型"探测)。 */
1458
+ get keyValue() { return keyDraft.trim(); },
1459
+ /** 取消:丢弃草稿与凭据输入,回到当前已存储状态(含已接受的权威视图)。 */
1460
+ cancel() {
1461
+ draft = importNamespace(
1462
+ !isPlainObject(currentEffective) || profilePath.length === 0
1463
+ ? currentEffective
1464
+ : getPath(currentEffective, profilePath),
1465
+ );
1466
+ catalogTouched = false;
1467
+ catalogReset = false;
1468
+ keyDraft = "";
1469
+ return draft;
1470
+ },
1471
+ /**
1472
+ * 计算一次"应用"的结果,但**不落盘**:返回要提交的路径操作与凭据输入,
1473
+ * 由宿主面负责真正写入(正式 settings API + credentials 服务)。
1474
+ */
1475
+ plan() {
1476
+ const key = keyDraft.trim();
1477
+ // pi-ai only names the conventional credential reference when a key is
1478
+ // actually supplied. Without this field the credential would be stored
1479
+ // successfully but the provider profile would not point at it.
1480
+ const draftForSave = key.length > 0 && settingsNs === "llm-pi-ai"
1481
+ && !isNonEmptyString(draft.apiKeyEnv)
1482
+ ? { ...draft, apiKeyEnv: deriveKeyRef(provider) }
1483
+ : draft;
1484
+ const validation = validateProfileDraft(draftForSave);
1485
+ if (validation !== null) return { ok: false, error: validation };
1486
+ if (Array.isArray(draftForSave.models)) {
1487
+ const rows = validateModelRows(draftForSave.models);
1488
+ if (!rows.ok) return { ok: false, error: rows.error };
1489
+ }
1490
+ const result = modelCardApply({
1491
+ provider,
1492
+ profilePath,
1493
+ storedUser: appliedUser,
1494
+ draft: draftForSave,
1495
+ inheritedModels: inheritedModelsOf(baseline).models,
1496
+ roleDefaults: source.roleDefaults,
1497
+ catalogTouched,
1498
+ catalogReset,
1499
+ });
1500
+ if (result.affectedRoles.length > 0) {
1501
+ return {
1502
+ ok: false,
1503
+ guard: true,
1504
+ removedLevels: result.removedLevels,
1505
+ affectedRoles: result.affectedRoles,
1506
+ error: reasoningGuardMessage(result.removedLevels, result.affectedRoles),
1507
+ };
1508
+ }
1509
+ const settingsDraft = deletePath(draftForSave, ["models"]);
1510
+ const ops = result.ops.length === 0 && key.length > 0 && !hasPath(appliedUser, profilePath)
1511
+ && Object.keys(settingsDraft).length === 0
1512
+ ? [{ op: "set", path: [...profilePath], value: {} }]
1513
+ : result.ops;
1514
+ return {
1515
+ ok: true,
1516
+ ops,
1517
+ expectedRevision: baseline.revision,
1518
+ keyValue: key.length === 0 ? undefined : key,
1519
+ };
1520
+ },
1521
+ /**
1522
+ * 提交成功后的收口:接受权威用户层与 revision 作为新基线,草稿回到已存储状态。
1523
+ *
1524
+ * 只接受卡片有权编辑的字段(隐藏高级字段继续留在用户层里,由宿主视图负责),
1525
+ * 因此接受之后的 plan() 恒为"无待提交操作"。
1526
+ */
1527
+ accept(view) {
1528
+ if (isPlainObject(view)) {
1529
+ appliedUser = isPlainObject(view.user) ? deepClone(view.user) : {};
1530
+ currentEffective = view.value;
1531
+ baseline = createEditorBaseline({
1532
+ revision: view.revision,
1533
+ user: appliedUser,
1534
+ base: view.base,
1535
+ value: view.value,
1536
+ }, profilePath);
1537
+ fallbackModels = fallbackModelsOf(baseline);
1538
+ draft = importNamespace(
1539
+ !isPlainObject(view.value) || profilePath.length === 0
1540
+ ? view.value
1541
+ : getPath(view.value, profilePath),
1542
+ );
1543
+ }
1544
+ catalogTouched = false;
1545
+ catalogReset = false;
1546
+ return draft;
1547
+ },
1548
+ close() {
1549
+ closed = true;
1550
+ return draft;
1551
+ },
1552
+ };
1553
+ }
1554
+
1555
+ /**
1556
+ * 「模型配置」折叠区的渲染面:区域默认折叠、标题中文、展开后才读取目录。
1557
+ *
1558
+ * 两个 provider 入口与原生模型设置页同级:
1559
+ * - 添加提供方:从 DSH 已知但用户层尚未配置的 provider 中选择;
1560
+ * - 添加自定义提供方:声明 DSH 目录中不存在的新 route,需要 `llm-pi-ai` namespace
1561
+ * 提供至少一个合法协议,并且 settings 文档可写。
1562
+ *
1563
+ * @param {{
1564
+ * open?: boolean,
1565
+ * status?: string,
1566
+ * configured?: readonly unknown[],
1567
+ * addable?: readonly unknown[],
1568
+ * error?: string | null,
1569
+ * writable?: boolean,
1570
+ * protocols?: readonly unknown[],
1571
+ * canCreateCustom?: boolean,
1572
+ * }} input
1573
+ */
1574
+ function buildSectionModel(input) {
1575
+ const source = isPlainObject(input) ? input : {};
1576
+ const open = source.open === true;
1577
+ const status = typeof source.status === "string" ? source.status : "idle";
1578
+ const readOnly = source.writable === false;
1579
+ const addableCount = Array.isArray(source.addable) ? source.addable.length : 0;
1580
+ // 自定义入口的可用性独立判定:schema 能给出合法协议才可能提交,否则入口禁用而不是
1581
+ // 打开一张永远无法提交的卡片。
1582
+ const customAllowed = source.canCreateCustom === true;
1583
+ return {
1584
+ title: "模型配置",
1585
+ open,
1586
+ // 默认折叠是需求本身:折叠时既不渲染卡片,也不读取 provider 目录。
1587
+ disclosure: { expanded: open, bodyId: "deg-model-config-body", label: open ? "收起" : "展开" },
1588
+ status,
1589
+ loading: status === "loading",
1590
+ error: typeof source.error === "string" && source.error.length > 0 ? source.error : null,
1591
+ readOnly,
1592
+ configuredCount: Array.isArray(source.configured) ? source.configured.length : 0,
1593
+ addableCount,
1594
+ entries: {
1595
+ addProvider: { label: "添加提供方" },
1596
+ addCustom: { label: "添加自定义提供方", protocols: Array.isArray(source.protocols) ? source.protocols.length : 0 },
1597
+ },
1598
+ actions: {
1599
+ toggle: "enabled",
1600
+ addProvider: readOnly || status !== "ready" || addableCount === 0 ? "disabled" : "enabled",
1601
+ addCustom: readOnly || status !== "ready" || !customAllowed ? "disabled" : "enabled",
1602
+ reload: status === "loading" ? "disabled" : "enabled",
1603
+ },
1604
+ hint: "与 DSH 内置模型设置页同一份数据流:provider 目录、凭据状态与模型目录都来自宿主公开接口,保存走正式 settings API 与 schema 校验。",
1605
+ };
1606
+ }
1607
+
1608
+ // ── 自定义 provider 创建(route id / Base URL / 协议 / 模型 校验) ─────────────
1609
+
1610
+ /**
1611
+ * 自定义 provider 的 route id 词表:小写字母开头,只含小写字母、数字与连字符。
1612
+ *
1613
+ * 点号、下划线与其它标点一律排除,因为凭据引用由 route id 推导
1614
+ * (`deriveKeyRef` 把每段非字母数字折叠成一个下划线),允许这类字符会让
1615
+ * `a.b`、`a-b`、`a_b` 全部折叠到同一个引用而产生凭据冲突。这条规则同时是原生
1616
+ * provider 路由使用的字符集(字母/数字/连字符)。
1617
+ */
1618
+ const ROUTE_ID_PATTERN = /^[a-z][a-z0-9-]{0,62}[a-z0-9]$/;
1619
+
1620
+ /** 没有 `URL` 构造器时的降级检查(只做形状校验,不解析端口与 IPv6)。 */
1621
+ const BASE_URL_FALLBACK_PATTERN = /^https?:\/\/[^\s/?#]+[^\s]*$/i;
1622
+
1623
+ /**
1624
+ * 校验自定义 provider 的 Base URL。
1625
+ *
1626
+ * 用 `URL` 构造器而不是正则:端口、IPv6 方括号、百分号编码与主机名存在性都由平台
1627
+ * 解析器判定,正则无法覆盖(例如 `https://[::1` 未闭合、`https://host:99999` 非法端口、
1628
+ * `https://` 缺失主机都必须是错误)。降级路径只在宿主环境没有 `URL` 时使用。
1629
+ *
1630
+ * @param {unknown} value
1631
+ * @returns {string | null} 失败文案,或 null 表示通过。
1632
+ */
1633
+ function validateBaseUrl(value) {
1634
+ if (typeof value !== "string" || value.trim().length === 0) return "请填写 Base URL(HTTP 或 HTTPS)";
1635
+ const trimmed = value.trim();
1636
+ const UrlConstructor = typeof URL === "function" ? URL : undefined;
1637
+ if (UrlConstructor === undefined) {
1638
+ return BASE_URL_FALLBACK_PATTERN.test(trimmed) ? null : "Base URL 必须以 http:// 或 https:// 开头,并包含主机名";
1639
+ }
1640
+ let parsed;
1641
+ try {
1642
+ parsed = new UrlConstructor(trimmed);
1643
+ } catch {
1644
+ // 未闭合的 IPv6 字面量、非法端口、缺少 scheme 等都会走到这里。
1645
+ return "Base URL 不是合法的 URL(请检查协议、主机、端口与括号)";
1646
+ }
1647
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
1648
+ return "Base URL 只支持 http:// 或 https://";
1649
+ }
1650
+ if (typeof parsed.hostname !== "string" || parsed.hostname.length === 0) {
1651
+ return "Base URL 必须包含主机名";
1652
+ }
1653
+ if (parsed.port !== "") {
1654
+ const port = Number(parsed.port);
1655
+ if (!Number.isSafeInteger(port) || port < 1 || port > 65535) return "Base URL 的端口必须在 1-65535 之间";
1656
+ }
1657
+ return null;
1658
+ }
1659
+
1660
+ /**
1661
+ * 校验自定义 provider 的 route id。
1662
+ *
1663
+ * 去重同时按原始 route 与推导出的凭据引用判定:`a-b` 与 `ab` 这类不同 route 只要折叠出
1664
+ * 同一个 credential ref 就必须拒绝,否则第二个 provider 会静默复用第一个的 API Key。
1665
+ *
1666
+ * @param {unknown} value
1667
+ * @param {readonly string[]} [taken] - 已经存在的 route id(DSH 目录与已配置 provider)。
1668
+ * @returns {string | null} 失败文案,或 null 表示通过。
1669
+ */
1670
+ function validateRouteId(value, taken) {
1671
+ if (typeof value !== "string") return "请填写 provider 路由 ID";
1672
+ const trimmed = value.trim();
1673
+ if (trimmed.length === 0) return "请填写 provider 路由 ID";
1674
+ if (trimmed.length < 2) return "provider 路由 ID 至少 2 个字符";
1675
+ if (trimmed.length > 64) return "provider 路由 ID 最多 64 个字符";
1676
+ if (!ROUTE_ID_PATTERN.test(trimmed)) {
1677
+ return "provider 路由 ID 只允许小写字母、数字与连字符,且必须以字母开头、字母或数字结尾";
1678
+ }
1679
+ const existing = (Array.isArray(taken) ? taken : []).filter((id) => isNonEmptyString(id));
1680
+ if (existing.includes(trimmed)) return "provider「" + trimmed + "」已存在,请改用其它路由 ID";
1681
+ const wantedRef = deriveKeyRef(trimmed);
1682
+ const collided = existing.find((id) => id !== trimmed && deriveKeyRef(id) === wantedRef);
1683
+ if (collided !== undefined) {
1684
+ return "provider「" + trimmed + "」与「" + collided + "」会使用同一个凭据引用("
1685
+ + wantedRef + "),请改用其它路由 ID";
1686
+ }
1687
+ return null;
1688
+ }
1689
+
1690
+ /**
1691
+ * 校验自定义 provider 的协议:必须是宿主 schema 暴露的合法协议之一。
1692
+ * @param {unknown} value
1693
+ * @param {readonly string[]} [protocols]
1694
+ * @returns {string | null}
1695
+ */
1696
+ function validateProtocol(value, protocols) {
1697
+ const allowed = (Array.isArray(protocols) ? protocols : []).filter((entry) => isNonEmptyString(entry));
1698
+ if (allowed.length === 0) return "当前宿主没有提供可用的协议选项";
1699
+ if (!isNonEmptyString(value)) return "请选择协议";
1700
+ return allowed.includes(value) ? null : "协议「" + value + "」不在宿主支持的协议列表中";
1701
+ }
1702
+
1703
+ /**
1704
+ * 校验自定义 provider 的完整草稿。
1705
+ *
1706
+ * 字段级错误同时返回,便于卡片逐字段提示;`ok` 只在整个草稿都能提交时为 true。
1707
+ *
1708
+ * @param {{
1709
+ * routeId?: unknown,
1710
+ * displayName?: unknown,
1711
+ * baseURL?: unknown,
1712
+ * api?: unknown,
1713
+ * models?: unknown,
1714
+ * apiKey?: unknown,
1715
+ * takenRoutes?: readonly string[],
1716
+ * protocols?: readonly string[],
1717
+ * }} input
1718
+ * @returns {{ ok: boolean, errors: Record<string, string>, error: string | null, routeId: string, profile: Record<string, unknown>, models: Record<string, unknown>[], apiKey: string }}
1719
+ */
1720
+ function validateCustomProviderDraft(input) {
1721
+ const source = isPlainObject(input) ? input : {};
1722
+ const errors = {};
1723
+ const routeId = typeof source.routeId === "string" ? source.routeId.trim() : "";
1724
+ const displayName = typeof source.displayName === "string" ? source.displayName.trim() : "";
1725
+ const baseURL = typeof source.baseURL === "string" ? source.baseURL.trim() : "";
1726
+ const apiKey = typeof source.apiKey === "string" ? source.apiKey.trim() : "";
1727
+ const protocols = (Array.isArray(source.protocols) ? source.protocols : [])
1728
+ .filter((entry) => isNonEmptyString(entry));
1729
+
1730
+ const routeFailure = validateRouteId(routeId, source.takenRoutes);
1731
+ if (routeFailure !== null) errors.routeId = routeFailure;
1732
+ const urlFailure = validateBaseUrl(baseURL);
1733
+ if (urlFailure !== null) errors.baseURL = urlFailure;
1734
+ const protocolFailure = validateProtocol(source.api, protocols);
1735
+ if (protocolFailure !== null) errors.api = protocolFailure;
1736
+ if (displayName.length > 0 && displayName.length > 200) errors.displayName = "显示名最多 200 个字符";
1737
+
1738
+ const rows = normalizeModelRows(source.models);
1739
+ if (rows.length === 0) errors.models = "至少配置一个模型";
1740
+ else {
1741
+ const checked = validateModelRows(rows);
1742
+ if (!checked.ok) errors.models = checked.error;
1743
+ }
1744
+
1745
+ const profile = {};
1746
+ if (routeFailure === null && urlFailure === null && protocolFailure === null && Object.keys(errors).length === 0) {
1747
+ // 只有真正要写凭据时才声明凭据引用:留空 API Key 表示使用 provider 原生/环境凭据
1748
+ // (ambient authentication),此时写入 `apiKeyEnv` 会让宿主去找一条永远不存在的密钥。
1749
+ if (apiKey.length > 0) profile.apiKeyEnv = deriveKeyRef(routeId);
1750
+ if (displayName.length > 0) profile.displayName = displayName;
1751
+ profile.baseURL = baseURL;
1752
+ profile.api = source.api;
1753
+ profile.models = rows;
1754
+ }
1755
+ const firstError = Object.keys(errors).length === 0 ? null : errors[Object.keys(errors)[0]];
1756
+ return {
1757
+ ok: Object.keys(errors).length === 0,
1758
+ errors,
1759
+ error: firstError,
1760
+ routeId,
1761
+ profile,
1762
+ models: rows,
1763
+ apiKey,
1764
+ };
1765
+ }
1766
+
1767
+ /**
1768
+ * 自定义 provider 创建卡片的**状态机**(纯状态机,界面与测试共用)。
1769
+ *
1770
+ * 二阶段提交是这里最重要的语义:settings(profile)先写,API Key 再单独写
1771
+ * credentials。settings 成功后把 `settingsCommitted` 固定下来,因此 credentials
1772
+ * 写入失败后的重试只重试 credentials,绝不会重复 mutate 已经写好的 profile。
1773
+ *
1774
+ * @param {{
1775
+ * settingsNs: string,
1776
+ * profileBasePath: readonly string[],
1777
+ * revision: number,
1778
+ * protocols?: readonly string[],
1779
+ * takenRoutes?: readonly string[],
1780
+ * displayName?: string,
1781
+ * models?: readonly unknown[],
1782
+ * }} input
1783
+ */
1784
+ function createCustomProviderDraft(input) {
1785
+ const source = isPlainObject(input) ? input : {};
1786
+ const settingsNs = typeof source.settingsNs === "string" ? source.settingsNs : "";
1787
+ const profileBasePath = Array.isArray(source.profileBasePath) ? source.profileBasePath : [];
1788
+ const protocols = (Array.isArray(source.protocols) ? source.protocols : [])
1789
+ .filter((entry) => isNonEmptyString(entry));
1790
+ let takenRoutes = (Array.isArray(source.takenRoutes) ? source.takenRoutes : [])
1791
+ .filter((entry) => isNonEmptyString(entry));
1792
+ // 栅栏是否已经定稿:定稿后 rebase 不再改写 revision(只为冲突语义保留)。
1793
+ let fenceFrozen = false;
1794
+ let draft = {
1795
+ routeId: "",
1796
+ displayName: typeof source.displayName === "string" ? source.displayName : "",
1797
+ baseURL: "",
1798
+ api: protocols.length === 1 ? protocols[0] : "",
1799
+ apiKey: "",
1800
+ models: modelDraftsOf(source.models ?? [createModelRow()]),
1801
+ };
1802
+ let settingsCommitted = false;
1803
+ let committedRevision = null;
1804
+ let committedProfilePath = null;
1805
+ let committedCredentialRef = null;
1806
+ let expectedRevision = Number.isSafeInteger(source.revision) ? source.revision : undefined;
1807
+
1808
+ /** 已提交后的凭据引用:固定成提交那一刻推导出的值,绝不再随 route 编辑漂移。 */
1809
+ const committedRef = () => (
1810
+ committedCredentialRef !== null
1811
+ ? committedCredentialRef
1812
+ : deriveKeyRef(typeof draft.routeId === "string" ? draft.routeId.trim() : "")
1813
+ );
1814
+ return {
1815
+ settingsNs,
1816
+ get draft() { return draft; },
1817
+ get settingsCommitted() { return settingsCommitted; },
1818
+ get committedProfilePath() { return committedProfilePath; },
1819
+ /** 已提交时为固定引用,未提交时按当前 route 推导(便于卡片展示预期引用)。 */
1820
+ get credentialRef() { return committedRef(); },
1821
+ get revision() { return committedRevision === null ? expectedRevision : committedRevision; },
1822
+ /**
1823
+ * 同步候选 route 词表(任何时候都同步),并在**尚未写盘前**接受一次 revision 纠正。
1824
+ *
1825
+ * revision 语义:
1826
+ * - 用户开始编辑、以及 settings 写成功之后,栅栏完全冻结。此后 mirror/load 推进
1827
+ * revision 时自动跟随会让 CAS 失效,从而可能覆盖别人在这段时间里的写入;这种情况
1828
+ * 必须交给宿主按 revision 语义拒绝,界面再提示用户重新打开。
1829
+ * - 在这之前(刚打开卡片、还没输入任何字符)允许用最新 revision 纠正:卡片可能在
1830
+ * Describe 视图还没读到/还过期时打开,用陈旧栅栏会把每一次正常提交都误判成冲突。
1831
+ *
1832
+ * `takenRoutes` 不受上述限制:route 占用情况任何时候都应该是最新的。
1833
+ *
1834
+ * @param {{ revision?: number, takenRoutes?: readonly string[], frozen?: boolean }} next
1835
+ */
1836
+ rebase(next) {
1837
+ if (settingsCommitted) return draft;
1838
+ const snapshot = isPlainObject(next) ? next : {};
1839
+ // 定稿这一次仍允许贴上 revision,之后就只读。
1840
+ if (!fenceFrozen && Number.isSafeInteger(snapshot.revision)) {
1841
+ expectedRevision = snapshot.revision;
1842
+ }
1843
+ if (snapshot.frozen === true) fenceFrozen = true;
1844
+ if (Array.isArray(snapshot.takenRoutes)) {
1845
+ takenRoutes = snapshot.takenRoutes.filter((entry) => isNonEmptyString(entry));
1846
+ }
1847
+ return draft;
1848
+ },
1849
+ /**
1850
+ * 编辑草稿:`patch` 里出现的键整体替换,未出现的键原样保留。
1851
+ *
1852
+ * `settingsCommitted` 之后只允许修改 API Key:profile 已经按当时的 route/字段落盘,
1853
+ * 再改 route、URL、协议或模型就会让界面描述的东西与实际写入的 provider 不一致,
1854
+ * 凭据也会被写到另一条引用上。这里的拒绝是状态机级的不变式,界面同时会禁用这些字段。
1855
+ */
1856
+ edit(patch) {
1857
+ const next = { ...draft };
1858
+ for (const [key, value] of Object.entries(isPlainObject(patch) ? patch : {})) {
1859
+ if (settingsCommitted && key !== "apiKey") continue;
1860
+ if (key === "models") next.models = modelDraftsOf(value);
1861
+ else if (isBlankDraftValue(value)) next[key] = "";
1862
+ else next[key] = value;
1863
+ }
1864
+ draft = next;
1865
+ return draft;
1866
+ },
1867
+ /** 校验草稿;返回逐字段错误与将要写入的 profile。 */
1868
+ validate() {
1869
+ return validateCustomProviderDraft({
1870
+ routeId: draft.routeId,
1871
+ displayName: draft.displayName,
1872
+ baseURL: draft.baseURL,
1873
+ api: draft.api,
1874
+ apiKey: draft.apiKey,
1875
+ models: draft.models,
1876
+ takenRoutes,
1877
+ protocols,
1878
+ });
1879
+ },
1880
+ /** profile 在命名空间内的完整路径(route id 是路径最后一段)。 */
1881
+ profilePath() {
1882
+ const routeId = typeof draft.routeId === "string" ? draft.routeId.trim() : "";
1883
+ return routeId.length === 0 ? null : [...profileBasePath, routeId];
1884
+ },
1885
+ /**
1886
+ * 一次提交的结论。
1887
+ *
1888
+ * 已经写成功 profile 时只返回凭据阶段(`ops` 为 undefined),因此重试不会重复
1889
+ * settings mutate。凭据阶段使用**提交时固定**的 credential ref,即使卡片上的
1890
+ * route 字段后来被改动过,也不会出现"旧 profile + 新 route 的凭据"错配。
1891
+ *
1892
+ * @returns {{ ok: boolean, error?: string, errors?: Record<string, string>, phase?: "settings" | "credentials" | "done", ops?: Array<{ op: "set", path: string[], value: unknown }>, credentialRef?: string, keyValue?: string, expectedRevision?: number }}
1893
+ */
1894
+ plan() {
1895
+ const keyValue = typeof draft.apiKey === "string" ? draft.apiKey.trim() : "";
1896
+ if (settingsCommitted) {
1897
+ const credentialRef = committedRef();
1898
+ return keyValue.length === 0
1899
+ ? { ok: true, phase: "done", credentialRef, keyValue: undefined }
1900
+ : { ok: true, phase: "credentials", credentialRef, keyValue };
1901
+ }
1902
+ const routeId = typeof draft.routeId === "string" ? draft.routeId.trim() : "";
1903
+ const credentialRef = routeId.length === 0 ? "" : deriveKeyRef(routeId);
1904
+ const checked = this.validate();
1905
+ if (!checked.ok) {
1906
+ return { ok: false, error: checked.error, errors: checked.errors };
1907
+ }
1908
+ const profilePath = this.profilePath();
1909
+ return {
1910
+ ok: true,
1911
+ phase: "settings",
1912
+ credentialRef,
1913
+ keyValue: keyValue.length === 0 ? undefined : keyValue,
1914
+ expectedRevision,
1915
+ ops: [{ op: "set", path: profilePath, value: deepClone(checked.profile) }],
1916
+ };
1917
+ },
1918
+ /**
1919
+ * settings 阶段成功:固定 profile 已写入的事实、新的 revision 与凭据引用。
1920
+ *
1921
+ * **保留** API Key 明文输入:profile 已经落盘,但凭据可能还没写成功(credentials
1922
+ * 服务失败、用户中断)。只要明文还在卡片内存里,重复点击提交就只会重试凭据写入,
1923
+ * 绝不会重复 mutate settings。明文不进入 settings、不回显在输入框以外的地方,
1924
+ * 并在凭据写入成功(`acceptCredential`)或卡片关闭时被丢弃。
1925
+ */
1926
+ acceptSettings(view, credentialRef) {
1927
+ const routeId = typeof draft.routeId === "string" ? draft.routeId.trim() : "";
1928
+ settingsCommitted = true;
1929
+ committedProfilePath = [...profileBasePath, routeId];
1930
+ committedCredentialRef = isNonEmptyString(credentialRef) ? credentialRef : deriveKeyRef(routeId);
1931
+ if (isPlainObject(view) && Number.isSafeInteger(view.revision)) committedRevision = view.revision;
1932
+ return draft;
1933
+ },
1934
+ /** credentials 阶段成功:清空明文输入。 */
1935
+ acceptCredential() {
1936
+ draft = { ...draft, apiKey: "" };
1937
+ return draft;
1938
+ },
1939
+ };
1940
+ }
1941
+
1942
+ /** 自定义创建入口的可用性:`llm-pi-ai` namespace 存在 + 至少一个协议 + settings 可写。 */
1943
+ function canCreateCustomProvider({ namespace, protocols, writable } = {}) {
1944
+ if (!isPlainObject(namespace)) return false;
1945
+ if (writable === false) return false;
1946
+ return (Array.isArray(protocols) ? protocols : []).some((entry) => isNonEmptyString(entry));
1947
+ }
1948
+
1949
+ /**
1950
+ * 自定义 provider 创建卡片的渲染面:与 provider 卡片同层的可断言对象。
1951
+ *
1952
+ * @param {{
1953
+ * draft: Record<string, unknown>,
1954
+ * protocols?: readonly string[],
1955
+ * errors?: Record<string, string>,
1956
+ * error?: string | null,
1957
+ * readOnly?: boolean,
1958
+ * saving?: boolean,
1959
+ * submitted?: boolean,
1960
+ * credentialRef?: string,
1961
+ * defaultInput?: readonly string[],
1962
+ * expandedRows?: ReadonlySet<number> | readonly number[],
1963
+ * }} input
1964
+ */
1965
+ function buildCustomCardModel(input) {
1966
+ const source = isPlainObject(input) ? input : {};
1967
+ const draft = isPlainObject(source.draft) ? source.draft : {};
1968
+ const errors = isPlainObject(source.errors) ? source.errors : {};
1969
+ const readOnly = source.readOnly === true;
1970
+ const saving = source.saving === true;
1971
+ const submitted = source.submitted === true;
1972
+ const expanded = new Set(
1973
+ source.expandedRows instanceof Set
1974
+ ? [...source.expandedRows]
1975
+ : (Array.isArray(source.expandedRows) ? source.expandedRows : []),
1976
+ );
1977
+ const defaultInput = Array.isArray(source.defaultInput) ? source.defaultInput : undefined;
1978
+ const rows = modelDraftsOf(draft.models);
1979
+ const routeId = typeof draft.routeId === "string" ? draft.routeId.trim() : "";
1980
+ const protocols = (Array.isArray(source.protocols) ? source.protocols : [])
1981
+ .filter((entry) => isNonEmptyString(entry));
1982
+ const disabled = readOnly || saving;
1983
+ // profile 已经写盘之后只允许重试 API Key:route 与 credential ref 都可能已经落盘,
1984
+ // 继续编辑它们会让"旧 profile + 新 route 的凭据"错配。固定字段在这里统一禁用。
1985
+ const profileLocked = submitted || readOnly;
1986
+ const profileDisabled = profileLocked || saving;
1987
+ const credentialRef = isNonEmptyString(source.credentialRef)
1988
+ ? source.credentialRef
1989
+ : (routeId.length === 0 ? "" : deriveKeyRef(routeId));
1990
+ return {
1991
+ title: "添加自定义提供方",
1992
+ readOnly,
1993
+ saving,
1994
+ submitted,
1995
+ profileLocked,
1996
+ credentialRef,
1997
+ error: isNonEmptyString(source.error) ? source.error : null,
1998
+ routeId: {
1999
+ value: routeId,
2000
+ error: isNonEmptyString(errors.routeId) ? errors.routeId : null,
2001
+ credentialRef,
2002
+ disabled: profileDisabled,
2003
+ },
2004
+ displayName: {
2005
+ value: typeof draft.displayName === "string" ? draft.displayName : "",
2006
+ error: isNonEmptyString(errors.displayName) ? errors.displayName : null,
2007
+ disabled: profileDisabled,
2008
+ },
2009
+ baseUrl: {
2010
+ value: typeof draft.baseURL === "string" ? draft.baseURL : "",
2011
+ error: isNonEmptyString(errors.baseURL) ? errors.baseURL : null,
2012
+ placeholder: "https://api.example.com/v1",
2013
+ disabled: profileDisabled,
2014
+ },
2015
+ protocol: {
2016
+ value: typeof draft.api === "string" ? draft.api : "",
2017
+ error: isNonEmptyString(errors.api) ? errors.api : null,
2018
+ disabled: profileDisabled,
2019
+ options: protocols.map((choice) => ({ value: choice, label: choice })),
2020
+ },
2021
+ apiKey: {
2022
+ value: typeof draft.apiKey === "string" ? draft.apiKey : "",
2023
+ type: "password",
2024
+ autoComplete: "off",
2025
+ disabled,
2026
+ placeholder: "粘贴 API Key(可留空,沿用 provider 原生/环境凭据)",
2027
+ },
2028
+ models: rows.map((model, index) => {
2029
+ const selection = effortSelectionFor(model);
2030
+ const inputSelected = modelInputOf(model, defaultInput);
2031
+ return {
2032
+ index,
2033
+ position: index + 1,
2034
+ id: { value: typeof model.id === "string" ? model.id : "" },
2035
+ name: { value: typeof model.name === "string" ? model.name : "" },
2036
+ contextWindow: {
2037
+ value: typeof model.contextWindow === "number" ? formatCapacity(model.contextWindow) : "",
2038
+ placeholder: "继承适配器默认",
2039
+ },
2040
+ maxTokens: {
2041
+ value: typeof model.maxTokens === "number" ? formatCapacity(model.maxTokens) : "",
2042
+ placeholder: "继承适配器默认",
2043
+ },
2044
+ efforts: REASONING_EFFORTS.map((level) => ({ level, checked: selection[level] === true })),
2045
+ inputs: INPUT_MODALITIES.map((modality) => ({
2046
+ modality,
2047
+ checked: inputSelected.includes(modality),
2048
+ locked: inputSelected.length === 1 && inputSelected.includes(modality),
2049
+ })),
2050
+ advancedOpen: expanded.has(index),
2051
+ };
2052
+ }),
2053
+ modelsError: isNonEmptyString(errors.models) ? errors.models : null,
2054
+ actions: {
2055
+ // 提交后在 settings 阶段成功前仍可提交(用于重试凭据写入);route 固定后依然满足。
2056
+ submit: disabled || routeId.length === 0 ? "disabled" : "enabled",
2057
+ cancel: saving ? "disabled" : "enabled",
2058
+ addModel: profileLocked ? "disabled" : (disabled ? "disabled" : "enabled"),
2059
+ },
2060
+ };
2061
+ }
2062
+
2063
+ const api = Object.freeze({
2064
+ SYMBOL,
2065
+ REASONING_EFFORTS,
2066
+ REASONING_EFFORT_EMPTY,
2067
+ INPUT_MODALITIES,
2068
+ DEFAULT_INPUT_MODALITIES,
2069
+ isPlainObject,
2070
+ isSchemaNode,
2071
+ isNonEmptyString,
2072
+ jsonEqual,
2073
+ deepClone,
2074
+ getPath,
2075
+ hasPath,
2076
+ setPath,
2077
+ deletePath,
2078
+ getSubtree,
2079
+ deriveKeyRef,
2080
+ modelDisplayNameOf,
2081
+ modelInputOf,
2082
+ modelDraftsOf,
2083
+ inputOverrideOf,
2084
+ emptyEffortSelection,
2085
+ effortSelectionFor,
2086
+ effortSelectionEqual,
2087
+ unknownEffortKeysOf,
2088
+ hasUnknownEffortKeys,
2089
+ selectedLevelsOf,
2090
+ reasoningDiffLines,
2091
+ applyEffortSelection,
2092
+ normalizeEffortsEntry,
2093
+ createModelRow,
2094
+ inputSelectionFor,
2095
+ patchModelDraft,
2096
+ setModelEffortSelection,
2097
+ setModelInputSelection,
2098
+ removeModelRow,
2099
+ appendModelRow,
2100
+ parseCapacity,
2101
+ formatCapacity,
2102
+ validateModelRows,
2103
+ normalizeModelRows,
2104
+ pathOps,
2105
+ hasOps,
2106
+ revisionConflictOf,
2107
+ createEditorBaseline,
2108
+ isOverridden,
2109
+ inheritedModelsOf,
2110
+ materializeOverrideOps,
2111
+ restoreDefaultOps,
2112
+ modelCatalogOps,
2113
+ providerSubtreeOps,
2114
+ importNamespace,
2115
+ catalogOpsFor,
2116
+ modelCardApply,
2117
+ providerListSections,
2118
+ apiKeyField,
2119
+ buildModelCardModel,
2120
+ createCardSession,
2121
+ modelEditableProfileOf,
2122
+ EDITABLE_PROFILE_FIELDS,
2123
+ buildSectionModel,
2124
+ ROUTE_ID_PATTERN,
2125
+ BASE_URL_FALLBACK_PATTERN,
2126
+ validateRouteId,
2127
+ validateBaseUrl,
2128
+ validateProtocol,
2129
+ validateCustomProviderDraft,
2130
+ createCustomProviderDraft,
2131
+ canCreateCustomProvider,
2132
+ buildCustomCardModel,
2133
+ modelOverrideDecision,
2134
+ modelChoicesOf,
2135
+ indexModelsById,
2136
+ adoptModels,
2137
+ joinProviderDirectory,
2138
+ providerEditable,
2139
+ providerRemovable,
2140
+ providerProfileOf,
2141
+ apiKeyEnvOf,
2142
+ credentialRefOf,
2143
+ protocolChoices,
2144
+ createSchemaAdapter,
2145
+ namespaceSchemaInfo,
2146
+ validateProfileDraft,
2147
+ rolesAffectedByChange,
2148
+ reasoningGuardMessage,
2149
+ removedLevelsOf,
2150
+ namespaceViewOf,
2151
+ roleDefaultsOf,
2152
+ });
2153
+
2154
+ global[SYMBOL] = api;
2155
+ })(typeof globalThis !== "undefined" ? globalThis : this);