dsh-plugin-effort-declare 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -12,9 +12,11 @@ window.__ModuleLoader__.load({
12
12
  *
13
13
  * Levels match `@deepseek-ai/dsh-llm-pi-ai` catalog.ts `THINKING_LEVELS`.
14
14
  * Formats match `SUPPORTED_THINKING_FORMATS` in the same file (rc.8).
15
- * Runtime UI prefers the live settings schema union; these lists are the
16
- * fallback plus a test pin so a silent drift is a failing test, not a
17
- * second hand-maintained copy nobody notices.
15
+ * Tests pin these lists against a checked-in schema fixture
16
+ * (`tests/fixtures/pi-ai-thinking-format-union.ts`) and the local level
17
+ * whitelist. The settings page never offers the handwritten thinkingFormat
18
+ * list as writable choices — only the live schema union, plus a stored
19
+ * value that the union omitted.
18
20
  */
19
21
  /** Selectable reasoning levels, in pi-ai escalation order. */
20
22
  const THINKING_LEVELS = [
@@ -28,22 +30,6 @@ window.__ModuleLoader__.load({
28
30
  ];
29
31
  /** Thinking levels other than Off; Off has its own tri-state control. */
30
32
  const THINKING_LEVELS_WITHOUT_OFF = THINKING_LEVELS.filter((level) => level !== "off");
31
- /**
32
- * openai-completions thinkingFormat values from llm-pi-ai catalog.ts rc.8.
33
- * Tests pin this list; the settings page prefers schema union choices.
34
- */
35
- const FALLBACK_THINKING_FORMATS = [
36
- "openai",
37
- "deepseek",
38
- "openrouter",
39
- "together",
40
- "zai",
41
- "qwen",
42
- "chat-template",
43
- "qwen-chat-template",
44
- "string-thinking",
45
- "ant-ling"
46
- ];
47
33
  /** Wire protocol this plugin's v1 editor supports. */
48
34
  const OPENAI_COMPLETIONS = "openai-completions";
49
35
  /** Settings namespace this plugin writes. */
@@ -172,6 +158,14 @@ window.__ModuleLoader__.load({
172
158
  * Route-card draft merge: user-layer slices, namespace revision, dirty preserve.
173
159
  * No React — settings UI and tests share these helpers.
174
160
  */
161
+ /** JSON-stable equality matching pathOps (key order included). */
162
+ function sliceEqual(left, right) {
163
+ return JSON.stringify(left) === JSON.stringify(right);
164
+ }
165
+ /** Whether two settings slices differ. */
166
+ function sliceChanged(before, after) {
167
+ return !sliceEqual(before, after);
168
+ }
175
169
  /** Build a draft from the stored user subtree (never from effective `value`). */
176
170
  function routeDraftFromUserProfile(args) {
177
171
  const { provider, displayName, settingsPath, revision, userProfile } = args;
@@ -226,9 +220,103 @@ window.__ModuleLoader__.load({
226
220
  });
227
221
  });
228
222
  }
223
+ function modelRowId(row) {
224
+ return String(row.id);
225
+ }
226
+ function indexById(rows) {
227
+ const map = /* @__PURE__ */ new Map();
228
+ for (const row of rows) {
229
+ const id = modelRowId(row);
230
+ if (!map.has(id)) map.set(id, row);
231
+ }
232
+ return map;
233
+ }
234
+ function effortsPresence(row) {
235
+ if (row === void 0 || !Object.hasOwn(row, "reasoningEfforts")) return {
236
+ present: false,
237
+ value: void 0
238
+ };
239
+ return {
240
+ present: true,
241
+ value: row.reasoningEfforts
242
+ };
243
+ }
244
+ function effortsEqual(left, right) {
245
+ if (left.present !== right.present) return false;
246
+ if (!left.present) return true;
247
+ return sliceEqual(left.value, right.value);
248
+ }
249
+ function overlayLocalEfforts(incomingRow, prevRow) {
250
+ const next = structuredClone(incomingRow);
251
+ if (Object.hasOwn(prevRow, "reasoningEfforts")) next.reasoningEfforts = structuredClone(prevRow.reasoningEfforts);
252
+ else delete next.reasoningEfforts;
253
+ return next;
254
+ }
255
+ function objectKeyChanged(left, right, key) {
256
+ const leftHas = Object.hasOwn(left, key);
257
+ if (leftHas !== Object.hasOwn(right, key)) return true;
258
+ if (!leftHas) return false;
259
+ return sliceChanged(left[key], right[key]);
260
+ }
229
261
  /**
230
- * Apply a freshly loaded table. Dirty cards keep models/compat; originals and
231
- * revision follow the incoming snapshot so a later save is against the new user layer.
262
+ * Membership follows the latest user-layer models list (Models page add/delete).
263
+ * Local unsaved `reasoningEfforts` (including a cleared key) overlay by id.
264
+ */
265
+ function mergeModelsById(args) {
266
+ const prevById = indexById(args.prevModels);
267
+ const prevOrigById = indexById(args.prevOriginal);
268
+ const incomingOrigById = indexById(args.incomingOriginal);
269
+ const incomingIds = new Set(args.incomingModels.map(modelRowId));
270
+ let conflicted = false;
271
+ const models = args.incomingModels.map((incomingRow) => {
272
+ const id = modelRowId(incomingRow);
273
+ const prevRow = prevById.get(id);
274
+ if (prevRow === void 0) return structuredClone(incomingRow);
275
+ const prevOrig = prevOrigById.get(id);
276
+ if (!!effortsEqual(effortsPresence(prevRow), effortsPresence(prevOrig))) return structuredClone(incomingRow);
277
+ const incomingOrig = incomingOrigById.get(id);
278
+ if (!effortsEqual(effortsPresence(prevOrig), effortsPresence(incomingOrig))) conflicted = true;
279
+ return overlayLocalEfforts(incomingRow, prevRow);
280
+ });
281
+ for (const [id, prevRow] of prevById) {
282
+ if (incomingIds.has(id)) continue;
283
+ const prevOrig = prevOrigById.get(id);
284
+ if (effortsEqual(effortsPresence(prevRow), effortsPresence(prevOrig))) continue;
285
+ if (!effortsEqual(effortsPresence(prevOrig), effortsPresence(incomingOrigById.get(id)))) conflicted = true;
286
+ }
287
+ return {
288
+ models,
289
+ conflicted
290
+ };
291
+ }
292
+ /**
293
+ * Three-way compat merge: locally changed keys stay local; everything else
294
+ * follows incoming. Conflict only when a locally dirty key also moved in originals.
295
+ */
296
+ function mergeCompat(args) {
297
+ if (!sliceChanged(args.prev, args.prevOriginal)) return {
298
+ compat: cloneObject(args.incoming),
299
+ conflicted: false
300
+ };
301
+ const compat = cloneObject(args.incoming);
302
+ let conflicted = false;
303
+ const keys = /* @__PURE__ */ new Set([...Object.keys(args.prev), ...Object.keys(args.prevOriginal)]);
304
+ for (const key of keys) {
305
+ if (!objectKeyChanged(args.prev, args.prevOriginal, key)) continue;
306
+ if (objectKeyChanged(args.prevOriginal, args.incomingOriginal, key)) conflicted = true;
307
+ if (Object.hasOwn(args.prev, key)) compat[key] = structuredClone(args.prev[key]);
308
+ else delete compat[key];
309
+ }
310
+ return {
311
+ compat,
312
+ conflicted
313
+ };
314
+ }
315
+ /**
316
+ * Apply a freshly loaded table. Membership and metadata follow incoming;
317
+ * unsaved reasoningEfforts / dirty compat keys overlay by id. Conflict only
318
+ * when a locally dirty field also changed in originals (revision-only bumps
319
+ * and sibling-card saves do not warn).
232
320
  */
233
321
  function mergeLoadedDrafts(current, incoming, options) {
234
322
  const currentByProvider = new Map(current.map((draft) => [draft.provider, draft]));
@@ -237,13 +325,27 @@ window.__ModuleLoader__.load({
237
325
  drafts: incoming.map((next) => {
238
326
  const prev = currentByProvider.get(next.provider);
239
327
  if (prev === void 0 || !options.preserveDirty || !draftDirty(prev)) return next;
240
- conflicted.push(next.provider);
328
+ const modelsMerge = mergeModelsById({
329
+ prevModels: prev.models,
330
+ prevOriginal: prev.originalModels,
331
+ incomingModels: next.models,
332
+ incomingOriginal: next.originalModels
333
+ });
334
+ const compatMerge = mergeCompat({
335
+ prev: prev.compat,
336
+ prevOriginal: prev.originalCompat,
337
+ incoming: next.compat,
338
+ incomingOriginal: next.originalCompat
339
+ });
340
+ if (modelsMerge.conflicted || compatMerge.conflicted) conflicted.push(next.provider);
241
341
  return {
242
- ...prev,
342
+ provider: next.provider,
243
343
  displayName: next.displayName,
244
344
  settingsPath: next.settingsPath,
245
345
  revision: next.revision,
346
+ models: modelsMerge.models,
246
347
  originalModels: cloneModels(next.originalModels),
348
+ compat: compatMerge.compat,
247
349
  originalCompat: cloneObject(next.originalCompat),
248
350
  compatPresent: next.compatPresent
249
351
  };
@@ -434,25 +536,28 @@ window.__ModuleLoader__.load({
434
536
  /**
435
537
  * First paint: `ensure()` (reads only from idle). Never treat ensure as refresh.
436
538
  * Callers that must not apply a stale settlement compare generation themselves.
539
+ *
540
+ * `formats` is the live schema union only. Empty means the dropdown has no
541
+ * writable choices (stored values stay visible via `thinkingFormatChoices`).
437
542
  */
438
543
  async function loadDrafts(api, describe, schema) {
439
544
  await describe.ensure();
440
545
  const mirrored = describe.getSnapshot();
441
546
  if (mirrored.view === void 0) return {
442
547
  writable: false,
443
- formats: [...FALLBACK_THINKING_FORMATS],
548
+ formats: [],
444
549
  drafts: [],
445
550
  error: mirrored.error ?? void 0
446
551
  };
447
552
  const providersResponse = await api.llm.providers({});
448
553
  if (!providersResponse.result.ok) return {
449
554
  writable: mirrored.view.writable,
450
- formats: [...FALLBACK_THINKING_FORMATS],
555
+ formats: [],
451
556
  drafts: [],
452
557
  error: providersResponse.result.error.message
453
558
  };
454
559
  const pi = new Map(mirrored.view.namespaces.map((view) => [view.ns, view])).get(LLM_PI_AI_NS);
455
- let formats = [...FALLBACK_THINKING_FORMATS];
560
+ let formats = [];
456
561
  let schemaDefaultApi;
457
562
  if (pi !== void 0) try {
458
563
  const root = schema.rehydrate(pi.schema);
@@ -488,6 +593,36 @@ window.__ModuleLoader__.load({
488
593
  };
489
594
  }
490
595
  //#endregion
596
+ //#region src/client/schema-ops.ts
597
+ /** Wrap a live settingsSchema service as plain callbacks. */
598
+ function bindSchema(service) {
599
+ return {
600
+ rehydrate: (serialized) => service.rehydrate(serialized),
601
+ nodeAtPath: (root, path) => service.nodeAtPath(root, path),
602
+ getPath: (value, path) => service.getPath(value, path),
603
+ hasPath: (value, path) => service.hasPath(value, path),
604
+ validate: (node, draft) => service.validate(node, draft)
605
+ };
606
+ }
607
+ /**
608
+ * Pre-mutate schema check used by the settings page.
609
+ * A returned string means do not call `settings.mutate`.
610
+ */
611
+ function validateSaveDraft(schema, root, settingsPath, afterModels, afterCompat, willWriteCompat) {
612
+ const modelsNode = schema.nodeAtPath(root, [...settingsPath, "models"]);
613
+ if (modelsNode !== void 0) {
614
+ const error = schema.validate(modelsNode, afterModels);
615
+ if (error !== void 0) return error;
616
+ }
617
+ if (willWriteCompat) {
618
+ const compatNode = schema.nodeAtPath(root, [...settingsPath, "compat"]);
619
+ if (compatNode !== void 0) {
620
+ const error = schema.validate(compatNode, afterCompat);
621
+ if (error !== void 0) return error;
622
+ }
623
+ }
624
+ }
625
+ //#endregion
491
626
  //#region \0dsh-css:C:\Users\zimo\AppData\Roaming\io.github.hairyf.deepseek-harness-desktop\data\dsh\临时目录\dsh-plugin-effort-declare\src\client\effort-declare.module.css.mjs
492
627
  const cssText = ".hYEBUa_section{max-width:720px;color:var(--dsw-alias-label-primary);flex-direction:column;gap:12px;display:flex}.hYEBUa_title{color:var(--dsw-alias-label-primary);margin:0;font-size:16px;font-weight:500;line-height:24px}.hYEBUa_intro{color:var(--dsw-alias-label-tertiary);margin:0;font-size:14px;line-height:22px}.hYEBUa_notice{color:var(--dsw-alias-state-warn-label);margin:0;font-size:12px;line-height:18px}.hYEBUa_savedNotice{color:var(--dsw-alias-state-success-primary);margin:0;font-size:12px;line-height:18px}.hYEBUa_error{color:var(--dsw-alias-state-error-primary);margin:0;font-size:12px;line-height:18px}.hYEBUa_rows{flex-direction:column;gap:8px;margin:12px 0 0;padding:0;list-style:none;display:flex}.hYEBUa_rowCard{border:1px solid var(--dsw-alias-border-l2);border-radius:12px;flex-direction:column;gap:12px;padding:12px 14px;display:flex}.hYEBUa_rowHead{align-items:baseline;gap:8px;display:flex}.hYEBUa_rowName{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:500;line-height:22px}.hYEBUa_rowTag{border:1px solid var(--dsw-alias-border-l3);color:var(--dsw-alias-label-secondary);border-radius:4px;flex:none;padding:1px 6px;font-size:11px;line-height:16px}.hYEBUa_compatSummary{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:18px}.hYEBUa_presetRow,.hYEBUa_actions{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.hYEBUa_fieldLabel{color:var(--dsw-alias-label-secondary);font-size:12px;font-weight:500;line-height:18px}.hYEBUa_primaryButton,.hYEBUa_secondaryButton{box-sizing:border-box;height:36px;font:inherit;cursor:pointer;border-radius:18px;justify-content:center;align-items:center;padding:0 14px;font-size:14px;line-height:22px;display:inline-flex}.hYEBUa_primaryButton{background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground);border:none}.hYEBUa_primaryButton:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover)}.hYEBUa_secondaryButton{border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);background:0 0}.hYEBUa_secondaryButton:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-solid)}.hYEBUa_primaryButton:disabled,.hYEBUa_secondaryButton:disabled,.hYEBUa_linkButton:disabled,.hYEBUa_input:disabled{opacity:.4;cursor:default}.hYEBUa_primaryButton:focus-visible,.hYEBUa_secondaryButton:focus-visible,.hYEBUa_linkButton:focus-visible,.hYEBUa_input:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-border-l3);outline:none}.hYEBUa_linkButton{box-sizing:border-box;height:28px;color:var(--dsw-alias-label-tertiary);font:inherit;cursor:pointer;background:0 0;border:none;border-radius:14px;align-items:center;padding:0 10px;font-size:12px;line-height:18px;display:inline-flex}.hYEBUa_linkButton:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}.hYEBUa_modelEntry{border-top:1px solid var(--dsw-alias-border-l2);flex-direction:column;gap:8px;padding:10px 0;display:flex}.hYEBUa_modelHead{flex-wrap:wrap;align-items:baseline;gap:8px;display:flex}.hYEBUa_modelId{font-size:13px;font-weight:500;line-height:20px}.hYEBUa_modelName{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}.hYEBUa_levels{flex-wrap:wrap;gap:10px 14px;display:flex}.hYEBUa_level{align-items:center;gap:6px;font-size:12px;line-height:18px;display:inline-flex}.hYEBUa_wireRow{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.hYEBUa_input,.hYEBUa_selectInput{box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-module-platform);height:32px;color:var(--dsw-alias-label-primary);font:inherit;border-radius:8px;padding:0 10px;font-size:13px}.hYEBUa_wireInput{width:7em}.hYEBUa_offGroup{flex-direction:column;gap:6px;display:flex}.hYEBUa_advanced{border-top:1px solid var(--dsw-alias-border-l2);padding-top:8px}.hYEBUa_advanced summary{cursor:pointer;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}.hYEBUa_advancedBody{flex-direction:column;gap:10px;padding-top:10px;display:flex}.hYEBUa_check{align-items:flex-start;gap:8px;font-size:12px;line-height:18px;display:flex}";
493
628
  const cssTagId = "dsh-plugin-effort-declare/effort-declare.module.css";
@@ -653,10 +788,10 @@ window.__ModuleLoader__.load({
653
788
  });
654
789
  }
655
790
  function RouteCard(props) {
656
- const { draft, writable, busy, reloading, t, onChange } = props;
791
+ const { draft, writable, busy, saveLocked, t, onChange } = props;
657
792
  const noModels = draft.models.length === 0;
658
793
  const editDisabled = !writable || busy || noModels;
659
- const saveLocked = editDisabled || reloading;
794
+ const saveDisabled = saveLocked || noModels;
660
795
  const formats = thinkingFormatChoices(props.formats, draft.compat.thinkingFormat);
661
796
  const summary = compatSummary(draft.compat);
662
797
  const sameWire = draft.compat.supportsReasoningEffort === false;
@@ -840,7 +975,7 @@ window.__ModuleLoader__.load({
840
975
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
841
976
  type: "button",
842
977
  className: effort_declare_module_css_default.primaryButton,
843
- disabled: saveLocked || !dirty || clientError !== void 0,
978
+ disabled: saveDisabled || !dirty || clientError !== void 0,
844
979
  onClick: () => {
845
980
  props.onSave(draft);
846
981
  },
@@ -872,12 +1007,12 @@ window.__ModuleLoader__.load({
872
1007
  return;
873
1008
  }
874
1009
  const generation = nextGeneration(generationRef);
875
- setStatus("loading");
1010
+ if (draftsRef.current.length === 0) setStatus("loading");
876
1011
  setError("");
877
1012
  loadDrafts(api, describe, schema).then((result) => {
878
1013
  if (!generationIsCurrent(generationRef, generation)) return;
879
1014
  setWritable(result.writable);
880
- if (result.formats.length > 0) setFormats(result.formats);
1015
+ setFormats(result.formats);
881
1016
  if (result.error !== void 0) {
882
1017
  setStatus("error");
883
1018
  setError(result.error);
@@ -885,10 +1020,14 @@ window.__ModuleLoader__.load({
885
1020
  }
886
1021
  const merged = mergeLoadedDrafts(draftsRef.current, result.drafts, { preserveDirty });
887
1022
  setDrafts(merged.drafts);
888
- if (merged.conflicted.length > 0) setNotices(Object.fromEntries(merged.conflicted.map((provider) => [provider, {
889
- kind: "conflict",
890
- text: t("dirtyConflict")
891
- }])));
1023
+ if (merged.conflicted.length > 0) setNotices((current) => {
1024
+ const next = { ...current };
1025
+ for (const provider of merged.conflicted) next[provider] = {
1026
+ kind: "conflict",
1027
+ text: t("dirtyConflict")
1028
+ };
1029
+ return next;
1030
+ });
892
1031
  setStatus("ready");
893
1032
  }, (failure) => {
894
1033
  if (!generationIsCurrent(generationRef, generation)) return;
@@ -906,23 +1045,46 @@ window.__ModuleLoader__.load({
906
1045
  }, [reload]);
907
1046
  (0, react.useEffect)(() => {
908
1047
  if (props.subscribeInvalidate === void 0) return void 0;
909
- return props.subscribeInvalidate(() => {
910
- reload(true);
1048
+ return props.subscribeInvalidate((source) => {
1049
+ if (source === "writable") {
1050
+ const view = describe?.getSnapshot().view;
1051
+ if (view !== void 0) setWritable(view.writable);
1052
+ return;
1053
+ }
1054
+ if (source === "settings" || source === "directory") reload(true);
1055
+ });
1056
+ }, [
1057
+ describe,
1058
+ props.subscribeInvalidate,
1059
+ reload
1060
+ ]);
1061
+ const patchNotice = (provider, notice) => {
1062
+ setNotices((current) => {
1063
+ const copy = { ...current };
1064
+ if (notice === void 0) delete copy[provider];
1065
+ else copy[provider] = notice;
1066
+ return copy;
911
1067
  });
912
- }, [props.subscribeInvalidate, reload]);
1068
+ };
913
1069
  const save = async (draft) => {
914
- if (api === void 0 || describe === void 0) return;
915
- if (status === "loading" || busyRoute !== null) return;
1070
+ if (api === void 0 || describe === void 0 || schema === void 0) return;
1071
+ if (status === "loading" || busyRoute !== null) {
1072
+ patchNotice(draft.provider, {
1073
+ kind: "error",
1074
+ text: t("saveBusy")
1075
+ });
1076
+ return;
1077
+ }
916
1078
  const blocking = draft.models.map((row) => errorText(modelEffortError(row), t)).find((text) => text !== void 0);
917
1079
  if (blocking !== void 0) {
918
- setNotices({ [draft.provider]: {
1080
+ patchNotice(draft.provider, {
919
1081
  kind: "error",
920
1082
  text: blocking
921
- } });
1083
+ });
922
1084
  return;
923
1085
  }
924
1086
  setBusyRoute(draft.provider);
925
- setNotices({});
1087
+ patchNotice(draft.provider, void 0);
926
1088
  try {
927
1089
  const ops = buildSaveOps({
928
1090
  settingsPath: draft.settingsPath,
@@ -935,16 +1097,38 @@ window.__ModuleLoader__.load({
935
1097
  setDrafts((current) => current.map((row) => row.provider === draft.provider ? alignDraft(row) : row));
936
1098
  return;
937
1099
  }
1100
+ const willWriteCompat = ops.some((op) => op.path.length > draft.settingsPath.length && op.path[draft.settingsPath.length] === "compat");
1101
+ const pi = describe.getSnapshot().view?.namespaces.find((view) => view.ns === LLM_PI_AI_NS);
1102
+ if (pi !== void 0) {
1103
+ let root;
1104
+ try {
1105
+ root = schema.rehydrate(pi.schema);
1106
+ } catch {
1107
+ root = void 0;
1108
+ }
1109
+ if (root !== void 0) {
1110
+ const schemaError = validateSaveDraft(schema, root, draft.settingsPath, draft.models, draft.compat, willWriteCompat);
1111
+ if (schemaError !== void 0) {
1112
+ patchNotice(draft.provider, {
1113
+ kind: "error",
1114
+ text: schemaError
1115
+ });
1116
+ return;
1117
+ }
1118
+ }
1119
+ }
938
1120
  const response = await api.settings.mutate({
939
1121
  ns: LLM_PI_AI_NS,
940
1122
  ops,
941
1123
  expectedRevision: draft.revision
942
1124
  });
943
1125
  if (!response.result.ok) {
944
- setNotices({ [draft.provider]: {
945
- kind: "error",
946
- text: response.result.error.code === "settings-conflict" ? t("conflict") : response.result.error.message
947
- } });
1126
+ const conflict = response.result.error.code === "settings-conflict";
1127
+ patchNotice(draft.provider, {
1128
+ kind: conflict ? "conflict" : "error",
1129
+ text: conflict ? t("conflict") : response.result.error.message
1130
+ });
1131
+ if (conflict) reload(true);
948
1132
  return;
949
1133
  }
950
1134
  const view = response.result.value;
@@ -953,15 +1137,15 @@ window.__ModuleLoader__.load({
953
1137
  user: view.user ?? {},
954
1138
  revision: view.revision
955
1139
  }));
956
- setNotices({ [draft.provider]: {
1140
+ patchNotice(draft.provider, {
957
1141
  kind: "saved",
958
1142
  text: t("saved")
959
- } });
1143
+ });
960
1144
  } catch (failure) {
961
- setNotices({ [draft.provider]: {
1145
+ patchNotice(draft.provider, {
962
1146
  kind: "error",
963
1147
  text: failure instanceof Error ? failure.message : t("loadError")
964
- } });
1148
+ });
965
1149
  } finally {
966
1150
  setBusyRoute(null);
967
1151
  }
@@ -969,6 +1153,8 @@ window.__ModuleLoader__.load({
969
1153
  const showLoading = status === "loading" && drafts.length === 0;
970
1154
  const showEmpty = status === "ready" && drafts.length === 0;
971
1155
  const showList = drafts.length > 0;
1156
+ const hasCardFailure = Object.values(notices).some((notice) => notice.kind === "conflict" || notice.kind === "error");
1157
+ const showReload = status === "error" || hasCardFailure || showEmpty || !writable || status === "loading" || showList;
972
1158
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
973
1159
  className: effort_declare_module_css_default.section,
974
1160
  children: [
@@ -992,7 +1178,7 @@ window.__ModuleLoader__.load({
992
1178
  className: effort_declare_module_css_default.error,
993
1179
  children: error
994
1180
  }) : null,
995
- status === "error" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1181
+ showReload ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
996
1182
  type: "button",
997
1183
  className: effort_declare_module_css_default.secondaryButton,
998
1184
  onClick: () => {
@@ -1014,26 +1200,18 @@ window.__ModuleLoader__.load({
1014
1200
  formats: formats.length > 0 ? formats : [],
1015
1201
  writable,
1016
1202
  busy: busyRoute === draft.provider,
1017
- reloading: status === "loading",
1203
+ saveLocked: !writable || busyRoute !== null || status === "loading",
1018
1204
  notice: notices[draft.provider],
1019
1205
  t,
1020
1206
  onChange: (next) => {
1021
- setNotices((current) => {
1022
- const copy = { ...current };
1023
- delete copy[next.provider];
1024
- return copy;
1025
- });
1207
+ patchNotice(next.provider, void 0);
1026
1208
  setDrafts((current) => current.map((row) => row.provider === next.provider ? next : row));
1027
1209
  },
1028
1210
  onSave: (next) => {
1029
1211
  save(next);
1030
1212
  },
1031
1213
  onCancel: (next) => {
1032
- setNotices((current) => {
1033
- const copy = { ...current };
1034
- delete copy[next.provider];
1035
- return copy;
1036
- });
1214
+ patchNotice(next.provider, void 0);
1037
1215
  setDrafts((current) => current.map((row) => row.provider === next.provider ? {
1038
1216
  ...row,
1039
1217
  models: cloneModels(row.originalModels),
@@ -1046,17 +1224,6 @@ window.__ModuleLoader__.load({
1046
1224
  });
1047
1225
  }
1048
1226
  //#endregion
1049
- //#region src/client/schema-ops.ts
1050
- /** Wrap a live settingsSchema service as plain callbacks. */
1051
- function bindSchema(service) {
1052
- return {
1053
- rehydrate: (serialized) => service.rehydrate(serialized),
1054
- nodeAtPath: (root, path) => service.nodeAtPath(root, path),
1055
- getPath: (value, path) => service.getPath(value, path),
1056
- hasPath: (value, path) => service.hasPath(value, path)
1057
- };
1058
- }
1059
- //#endregion
1060
1227
  //#region src/client/locales.ts
1061
1228
  const NS = "plugin-effort-declare";
1062
1229
  const zh = {
@@ -1070,6 +1237,7 @@ window.__ModuleLoader__.load({
1070
1237
  readOnly: "当前设置为只读,无法保存。",
1071
1238
  save: "保存",
1072
1239
  saving: "保存中…",
1240
+ saveBusy: "另有路由正在保存,请稍候。",
1073
1241
  cancel: "取消",
1074
1242
  saved: "已保存。对话选择器会按新的能力声明显示 Effort 行。",
1075
1243
  conflict: "设置已被其他地方改过,请重新加载后再保存。",
@@ -1090,7 +1258,7 @@ window.__ModuleLoader__.load({
1090
1258
  clear: "清除本模型声明",
1091
1259
  advanced: "高级:协议方言",
1092
1260
  thinkingFormat: "thinkingFormat",
1093
- thinkingFormatDefault: "默认(openai)",
1261
+ thinkingFormatDefault: "默认(省略该键)",
1094
1262
  supportsDeveloperRole: "系统提示走 system 而不是 developer(supportsDeveloperRole: false)",
1095
1263
  supportsReasoningEffort: "不发 reasoning_effort,只发开关(supportsReasoningEffort: false)",
1096
1264
  developerTrueHint: "当前文档是 supportsDeveloperRole: true。v1 只能强制 false 或缺席;勾选会写成 false,取消勾选会删除该键。",
@@ -1112,6 +1280,7 @@ window.__ModuleLoader__.load({
1112
1280
  readOnly: "Settings are read-only; saving is disabled.",
1113
1281
  save: "Save",
1114
1282
  saving: "Saving…",
1283
+ saveBusy: "Another route is saving. Wait, then save this card.",
1115
1284
  cancel: "Cancel",
1116
1285
  saved: "Saved. The composer Effort row follows this capability declaration.",
1117
1286
  conflict: "Settings changed elsewhere. Reload, then save again.",
@@ -1132,7 +1301,7 @@ window.__ModuleLoader__.load({
1132
1301
  clear: "Clear this model’s declaration",
1133
1302
  advanced: "Advanced: protocol dialect",
1134
1303
  thinkingFormat: "thinkingFormat",
1135
- thinkingFormatDefault: "Default (openai)",
1304
+ thinkingFormatDefault: "Default (omit the key)",
1136
1305
  supportsDeveloperRole: "Send system prompts as system, not developer (supportsDeveloperRole: false)",
1137
1306
  supportsReasoningEffort: "Do not send reasoning_effort; switch only (supportsReasoningEffort: false)",
1138
1307
  developerTrueHint: "The document has supportsDeveloperRole: true. v1 can only force false or omit the key; checking writes false, unchecking deletes the key.",
@@ -1181,7 +1350,8 @@ window.__ModuleLoader__.load({
1181
1350
  rehydrate: (serialized) => settingsSchema.rehydrate(serialized),
1182
1351
  nodeAtPath: (root, path) => settingsSchema.nodeAtPath(root, path),
1183
1352
  getPath: (value, path) => settingsSchema.getPath(value, path),
1184
- hasPath: (value, path) => settingsSchema.hasPath(value, path)
1353
+ hasPath: (value, path) => settingsSchema.hasPath(value, path),
1354
+ validate: (node, draft) => settingsSchema.validate(node, draft)
1185
1355
  });
1186
1356
  const t = ctx.locale.bind(NS);
1187
1357
  const describe = ctx.settingsScope.describe();
@@ -1191,6 +1361,9 @@ window.__ModuleLoader__.load({
1191
1361
  for (const listener of invalidation) listener(source);
1192
1362
  };
1193
1363
  const disposers = [
1364
+ describe.subscribe(() => {
1365
+ emit("writable");
1366
+ }),
1194
1367
  ctx.remote.$on("settings/document-updated", (ns) => {
1195
1368
  if (ns !== "llm-pi-ai") return;
1196
1369
  emit("settings");
@@ -1215,14 +1388,13 @@ window.__ModuleLoader__.load({
1215
1388
  ctx.slots.inject("settings.section", () => ctx.slots.register({
1216
1389
  name: "settings.section",
1217
1390
  id: "effort-declare",
1218
- order: 15,
1391
+ order: 12,
1219
1392
  label: () => t("nav"),
1220
1393
  locale: NS,
1221
1394
  inject: () => ({
1222
1395
  api: connection.api,
1223
1396
  describe,
1224
1397
  schema,
1225
- t,
1226
1398
  subscribeInvalidate
1227
1399
  })
1228
1400
  }, EffortDeclareSection));