dsh-messager 0.3.0 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -5,7 +5,7 @@ window.__ModuleLoader__.load({
5
5
  var exports = module.exports;
6
6
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
7
  let react_jsx_runtime = require("react/jsx-runtime");
8
- //#region node_modules/.pnpm/@deepseek-ai+cosmokit@1.8.3/node_modules/@deepseek-ai/cosmokit/lib/index.js
8
+ //#region node_modules/.pnpm/@deepseek-ai+cosmokit@1.8.4/node_modules/@deepseek-ai/cosmokit/lib/index.js
9
9
  /** Return true when a value is `null` or `undefined`. */
10
10
  function isNullable(value) {
11
11
  return value === null || value === void 0;
@@ -29,6 +29,43 @@ window.__ModuleLoader__.load({
29
29
  for (const key of keys) if (forced || source[key] !== void 0) result[key] = source[key];
30
30
  return result;
31
31
  }
32
+ /** Shared config references used by schema validators and plugin runtimes. */
33
+ const write = Symbol.for("cosmokit.volatile.write");
34
+ function snapshot(value, ancestors = /* @__PURE__ */ new Set()) {
35
+ if (typeof value === "function") throw new TypeError("volatile config cannot contain functions");
36
+ if (value === null || typeof value !== "object") return value;
37
+ if (ancestors.has(value)) throw new TypeError("volatile config cannot contain cycles");
38
+ ancestors.add(value);
39
+ try {
40
+ if (Array.isArray(value)) return Object.freeze(value.map((item) => snapshot(item, ancestors)));
41
+ if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) throw new TypeError("volatile config objects must be plain objects or arrays");
42
+ return Object.freeze(Object.fromEntries(Object.entries(value).map(([key, item]) => [key, snapshot(item, ancestors)])));
43
+ } finally {
44
+ ancestors.delete(value);
45
+ }
46
+ }
47
+ /**
48
+ * Create a detached reference containing an immutable copy of the supplied data.
49
+ * @param value - validated config data; class instances and functions are unsupported.
50
+ * @returns a reference whose value is updated only by its owning runtime.
51
+ */
52
+ function createVolatile(value) {
53
+ let current = snapshot(value);
54
+ return Object.freeze({
55
+ get: () => current,
56
+ [write]: (value) => {
57
+ current = value;
58
+ }
59
+ });
60
+ }
61
+ /**
62
+ * Identify references across ESM/CJS copies of the shared library.
63
+ * @param value - a parsed config value.
64
+ * @returns whether the value implements the shared reference protocol.
65
+ */
66
+ function isVolatile(value) {
67
+ return typeof value === "object" && value !== null && write in value;
68
+ }
32
69
  /** Test values using `instanceof` with a `toStringTag` fallback. */
33
70
  function is(type, value) {
34
71
  if (arguments.length === 1) return (value) => is(type, value);
@@ -109,26 +146,47 @@ window.__ModuleLoader__.load({
109
146
  }
110
147
  return result;
111
148
  }
112
- /** Deeply compare arrays, dates, regexps, buffers, and plain object fields. */
149
+ /**
150
+ * Compare values recursively, treating two volatile references as equal regardless of value.
151
+ * Strict comparison distinguishes null/undefined, treats opaque objects by identity,
152
+ * compares URLs by normalized href, treats array holes as undefined, and considers distinct cyclic structures unequal.
153
+ * @param a - first value.
154
+ * @param b - second value.
155
+ * @param strict - whether to require strict data equality outside volatile references.
156
+ * @returns whether the values compare equal.
157
+ */
113
158
  function deepEqual(a, b, strict) {
114
- if (a === b) return true;
115
- if (!strict && isNullable(a) && isNullable(b)) return true;
116
- if (typeof a !== typeof b) return false;
117
- if (typeof a !== "object") return false;
118
- if (!a || !b) return false;
119
- function check(test, then) {
120
- return test(a) ? test(b) ? then(a, b) : false : test(b) ? false : void 0;
121
- }
122
- return check(Array.isArray, (a, b) => a.length === b.length && a.every((item, index) => deepEqual(item, b[index]))) ?? check(is("Date"), (a, b) => a.valueOf() === b.valueOf()) ?? check(is("RegExp"), (a, b) => a.source === b.source && a.flags === b.flags) ?? check(isArrayBufferLike, (a, b) => {
123
- if (a.byteLength !== b.byteLength) return false;
124
- const viewA = new Uint8Array(a);
125
- const viewB = new Uint8Array(b);
126
- for (let i = 0; i < viewA.length; i++) if (viewA[i] !== viewB[i]) return false;
127
- return true;
128
- }) ?? Object.keys({
129
- ...a,
130
- ...b
131
- }).every((key) => deepEqual(a[key], b[key], strict));
159
+ const ancestors = /* @__PURE__ */ new Set();
160
+ function compare(a, b) {
161
+ if (a === b) return true;
162
+ if (isVolatile(a) || isVolatile(b)) return isVolatile(a) && isVolatile(b);
163
+ if (!strict && isNullable(a) && isNullable(b)) return true;
164
+ if (typeof a !== typeof b || typeof a !== "object" || !a || !b) return false;
165
+ if (ancestors.has(a)) return false;
166
+ function check(test, then) {
167
+ return test(a) ? test(b) ? then(a, b) : false : test(b) ? false : void 0;
168
+ }
169
+ ancestors.add(a);
170
+ try {
171
+ return check(Array.isArray, (a, b) => {
172
+ if (a.length !== b.length) return false;
173
+ for (let index = 0; index < a.length; index++) if (!compare(a[index], b[index])) return false;
174
+ return true;
175
+ }) ?? check(is("Date"), (a, b) => a.valueOf() === b.valueOf()) ?? check(is("URL"), (a, b) => a.href === b.href) ?? check(is("RegExp"), (a, b) => a.source === b.source && a.flags === b.flags) ?? check(isArrayBufferLike, (a, b) => {
176
+ if (a.byteLength !== b.byteLength) return false;
177
+ const viewA = new Uint8Array(a);
178
+ const viewB = new Uint8Array(b);
179
+ for (let i = 0; i < viewA.length; i++) if (viewA[i] !== viewB[i]) return false;
180
+ return true;
181
+ }) ?? ((!strict || [a, b].every((value) => Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)) && Object.keys({
182
+ ...a,
183
+ ...b
184
+ }).every((key) => compare(a[key], b[key])));
185
+ } finally {
186
+ ancestors.delete(a);
187
+ }
188
+ }
189
+ return compare(a, b);
132
190
  }
133
191
  /** Time constants plus parsing and formatting helpers. */
134
192
  var Time;
@@ -201,7 +259,7 @@ window.__ModuleLoader__.load({
201
259
  Time.template = template;
202
260
  })(Time || (Time = {}));
203
261
  //#endregion
204
- //#region node_modules/.pnpm/@deepseek-ai+schemastery@3.18.2/node_modules/@deepseek-ai/schemastery/lib/index.mjs
262
+ //#region node_modules/.pnpm/@deepseek-ai+schemastery@3.18.3/node_modules/@deepseek-ai/schemastery/lib/index.mjs
205
263
  const kSchema = Symbol.for("schemastery");
206
264
  const kValidationError = Symbol.for("ValidationError");
207
265
  globalThis.__schemastery_index__ ??= 0;
@@ -377,6 +435,7 @@ window.__ModuleLoader__.load({
377
435
  return schema;
378
436
  };
379
437
  Schema.prototype.simplify = function simplify(value) {
438
+ if (isVolatile(value)) value = value.get();
380
439
  if (deepEqual(value, this.meta.default, this.type === "dict")) return null;
381
440
  if (isNullable(value)) return value;
382
441
  if (this.type === "object" || this.type === "dict") {
@@ -433,12 +492,49 @@ window.__ModuleLoader__.load({
433
492
  };
434
493
  return schema;
435
494
  } });
495
+ Schema.prototype.volatile = function volatile() {
496
+ if (this.meta.volatile) throw new TypeError("volatile schema is already wrapped");
497
+ return this.extra("volatile", true);
498
+ };
436
499
  const resolvers = {};
500
+ const checkedVolatile = Symbol("checked-volatile-schema");
501
+ function validateVolatileSchema(schema, path = [], blocked = false, seen = /* @__PURE__ */ new Map()) {
502
+ const states = seen.get(schema) ?? /* @__PURE__ */ new Set();
503
+ if (states.has(blocked)) return;
504
+ states.add(blocked);
505
+ seen.set(schema, states);
506
+ if (schema.meta?.volatile && blocked) throw new ValidationError("volatile fields require a fixed object path without an enclosing volatile field", { path });
507
+ const nested = blocked || !!schema.meta?.volatile;
508
+ if (schema.dict) for (const [key, child] of Object.entries(schema.dict)) validateVolatileSchema(child, [...path, key], nested, seen);
509
+ if (schema.sKey) validateVolatileSchema(schema.sKey, [...path, "<key>"], true, seen);
510
+ if (schema.inner && (schema.type !== "lazy" || schema.inner[kSchema])) validateVolatileSchema(schema.inner, [...path, "*"], true, seen);
511
+ if (schema.list) for (let index = 0; index < schema.list.length; index++) validateVolatileSchema(schema.list[index], [...path, String(index)], true, seen);
512
+ }
437
513
  Schema.extend = function extend(type, resolve) {
438
514
  resolvers[type] = resolve;
439
515
  };
440
516
  Schema.resolve = function resolve(data, schema, options = {}, strict = false) {
441
517
  if (!schema) return [data];
518
+ if (!options[checkedVolatile]) {
519
+ validateVolatileSchema(schema, options.path);
520
+ options = {
521
+ ...options,
522
+ [checkedVolatile]: true
523
+ };
524
+ }
525
+ if (schema.meta?.volatile) {
526
+ const inner = Schema(schema);
527
+ inner.meta = {
528
+ ...schema.meta,
529
+ volatile: false
530
+ };
531
+ const [value, adapted] = Schema.resolve(data, inner, options, strict);
532
+ try {
533
+ return [createVolatile(value), adapted];
534
+ } catch (error) {
535
+ throw new ValidationError(error instanceof Error ? error.message : String(error), options);
536
+ }
537
+ }
442
538
  if (options.ignore?.(data, schema)) return [data];
443
539
  if (isNullable(data) && schema.type !== "lazy") {
444
540
  if (schema.meta.required) throw new ValidationError(`missing required value`, options);
@@ -541,6 +637,7 @@ window.__ModuleLoader__.load({
541
637
  ...schema.meta,
542
638
  ...schema.inner.meta
543
639
  };
640
+ validateVolatileSchema(schema.inner, options.path, true);
544
641
  }
545
642
  return Schema.resolve(data, schema.inner, options, strict);
546
643
  });
@@ -640,7 +737,7 @@ window.__ModuleLoader__.load({
640
737
  } catch (e) {
641
738
  if (!options?.autofix) throw e;
642
739
  delete data[key];
643
- return schema.meta.default;
740
+ return schema.meta.volatile ? createVolatile(schema.meta.default) : schema.meta.default;
644
741
  }
645
742
  }
646
743
  Schema.extend("array", (data, { inner, meta }, options) => {
@@ -799,12 +896,18 @@ window.__ModuleLoader__.load({
799
896
  /**
800
897
  * dsh-messager 配置模型。
801
898
  *
802
- * 本 schema 同时承担两个角色:
803
- * - host 端的 Loader config(cordis.yml 中该插件行的 `config:`,成为 settings 命名空间的 base 层);
804
- * - `ctx.settings.register('messager', Config, { base })` 的命名空间 schema,
805
- * 因此 Web 设置页会自动渲染配置表单,用户层可覆盖 base。
899
+ * DSH 0.1.7 起,本 schema 由 **Loader 自动接管**:插件模块导出的 `Config` 即
900
+ * settings 表单的 schema,命名空间 = profile 中该插件条目的 id(本仓库的
901
+ * `cordis.patch.yml` / `cordis.yml` 都声明为 `messager`)。
902
+ * 因此**不再有** `ctx.settings.register(...)` 这一步。
806
903
  *
807
- * 有效值优先级:schema 默认值 → base(cordis.yml)→ 用户层(设置页)。
904
+ * ⚠️ 所有可编辑字段必须标记 `.volatile()`:DSH 用 `isVolatilePath` 门控写入,
905
+ * 非 volatile 路径一律被 `settings.mutate` 拒绝("Config field ... is not volatile"),
906
+ * 且没有任何 volatile 字段时整个命名空间会被 `volatileForm` 判为不可配置
907
+ * ("Plugin entry ... has no volatile fields"),设置页直接报废。
908
+ * 这是 0.1.7 的硬契约,不是可选项。
909
+ *
910
+ * 有效值优先级:schema 默认值 → base(Loader config)→ 用户层(设置页)。
808
911
  */
809
912
  const verbosity = Schema.union([
810
913
  "minimal",
@@ -813,72 +916,87 @@ window.__ModuleLoader__.load({
813
916
  ]);
814
917
  const Config = Schema.object({
815
918
  triggers: Schema.object({
816
- interaction: Schema.boolean().default(true),
817
- completed: Schema.boolean().default(true),
818
- error: Schema.boolean().default(true)
919
+ interaction: Schema.boolean().default(true).volatile(),
920
+ completed: Schema.boolean().default(true).volatile(),
921
+ error: Schema.boolean().default(true).volatile()
819
922
  }),
820
923
  system: Schema.object({
821
- enabled: Schema.boolean().default(true),
822
- icon: Schema.string(),
823
- verbosity: verbosity.default("normal")
924
+ enabled: Schema.boolean().default(true).volatile(),
925
+ icon: Schema.string().volatile(),
926
+ verbosity: verbosity.default("normal").volatile()
824
927
  }),
825
928
  browser: Schema.object({
826
- enabled: Schema.boolean().default(true),
827
- icon: Schema.string(),
828
- onlyWhenHidden: Schema.boolean().default(true),
829
- verbosity: verbosity.default("normal")
929
+ enabled: Schema.boolean().default(true).volatile(),
930
+ icon: Schema.string().volatile(),
931
+ onlyWhenHidden: Schema.boolean().default(true).volatile(),
932
+ verbosity: verbosity.default("normal").volatile()
830
933
  }),
831
934
  feishu: Schema.object({
832
- enabled: Schema.boolean().default(false),
833
- webhookUrl: Schema.string(),
834
- secret: Schema.string().role("secret"),
835
- timeoutMs: Schema.number().default(5e3),
836
- verbosity: verbosity.default("normal")
935
+ enabled: Schema.boolean().default(false).volatile(),
936
+ webhookUrl: Schema.string().volatile(),
937
+ secret: Schema.string().role("secret").volatile(),
938
+ timeoutMs: Schema.number().default(5e3).volatile(),
939
+ verbosity: verbosity.default("normal").volatile()
837
940
  }),
838
941
  wecom: Schema.object({
839
- enabled: Schema.boolean().default(false),
840
- webhookUrl: Schema.string(),
841
- secret: Schema.string().role("secret"),
842
- timeoutMs: Schema.number().default(5e3),
843
- verbosity: verbosity.default("normal")
942
+ enabled: Schema.boolean().default(false).volatile(),
943
+ webhookUrl: Schema.string().volatile(),
944
+ secret: Schema.string().role("secret").volatile(),
945
+ timeoutMs: Schema.number().default(5e3).volatile(),
946
+ verbosity: verbosity.default("normal").volatile()
844
947
  }),
845
948
  discord: Schema.object({
846
- enabled: Schema.boolean().default(false),
847
- webhookUrl: Schema.string(),
848
- timeoutMs: Schema.number().default(5e3),
849
- verbosity: verbosity.default("normal")
949
+ enabled: Schema.boolean().default(false).volatile(),
950
+ webhookUrl: Schema.string().volatile(),
951
+ timeoutMs: Schema.number().default(5e3).volatile(),
952
+ verbosity: verbosity.default("normal").volatile()
850
953
  }),
851
954
  dingtalk: Schema.object({
852
- enabled: Schema.boolean().default(false),
853
- webhookUrl: Schema.string(),
854
- secret: Schema.string().role("secret"),
855
- timeoutMs: Schema.number().default(5e3),
856
- verbosity: verbosity.default("normal")
955
+ enabled: Schema.boolean().default(false).volatile(),
956
+ webhookUrl: Schema.string().volatile(),
957
+ secret: Schema.string().role("secret").volatile(),
958
+ timeoutMs: Schema.number().default(5e3).volatile(),
959
+ verbosity: verbosity.default("normal").volatile()
857
960
  }),
858
961
  telegram: Schema.object({
859
- enabled: Schema.boolean().default(false),
860
- botToken: Schema.string().role("secret"),
861
- chatId: Schema.string(),
862
- timeoutMs: Schema.number().default(5e3),
863
- verbosity: verbosity.default("normal")
962
+ enabled: Schema.boolean().default(false).volatile(),
963
+ botToken: Schema.string().role("secret").volatile(),
964
+ chatId: Schema.string().volatile(),
965
+ timeoutMs: Schema.number().default(5e3).volatile(),
966
+ verbosity: verbosity.default("normal").volatile()
864
967
  }),
865
968
  dedup: Schema.object({
866
- interactionCooldownMs: Schema.number().default(1e4),
867
- completedDebounceMs: Schema.number().default(1e3),
868
- perChannelPerMinute: Schema.number().default(20)
969
+ interactionCooldownMs: Schema.number().default(1e4).volatile(),
970
+ completedDebounceMs: Schema.number().default(1e3).volatile(),
971
+ perChannelPerMinute: Schema.number().default(20).volatile()
869
972
  }),
870
973
  message: Schema.object({
871
- titlePrefix: Schema.string(),
872
- includeSessionTitle: Schema.boolean().default(true),
873
- guiUrl: Schema.string().default("http://127.0.0.1:3080")
974
+ titlePrefix: Schema.string().volatile(),
975
+ includeSessionTitle: Schema.boolean().default(true).volatile(),
976
+ guiUrl: Schema.string().default("http://127.0.0.1:3080").volatile()
874
977
  })
875
978
  });
876
979
  /**
980
+ * 递归解开 volatile 引用,得到纯值。
981
+ *
982
+ * volatile 引用在配置热更新时**原地变更**(这正是无需重挂载的原因),
983
+ * 所以引用本身要保留;本插件的内部逻辑(通道构建/调度/模板)只需要某一时刻的
984
+ * 纯值,因此在边界处统一取值。
985
+ */
986
+ function unwrapVolatile(value) {
987
+ if (isVolatile(value)) return unwrapVolatile(value.get());
988
+ if (Array.isArray(value)) return value.map(unwrapVolatile);
989
+ if (value !== null && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, unwrapVolatile(child)]));
990
+ return value;
991
+ }
992
+ /**
877
993
  * 用 schema 默认值解析一份配置(输入可省略任意字段)。
878
- * 与 cordis Loader 的校验同源,保证“默认值 → base → 用户层”的解析一致。
994
+ *
995
+ * 输入既可以是纯值(Loader config / settings 视图),也可以是 Loader 传入的
996
+ * volatile 形态 —— 两者都先解包再校验,保证「默认值 → base → 用户层」解析一致。
879
997
  */
880
998
  function resolveConfig(input = {}) {
881
- return Config(input);
999
+ return unwrapVolatile(Config(unwrapVolatile(input)));
882
1000
  }
883
1001
  //#endregion
884
1002
  //#region src/config-shared.ts
@@ -893,10 +1011,19 @@ window.__ModuleLoader__.load({
893
1011
  /**
894
1012
  * 客户端配置读取:经 host 配置路由(GET /dsh-messager/config)拉取 messager
895
1013
  * 命名空间有效值,并通过 `settings/document-updated` 失效后重拉。
896
- * 路由不受 Web 设置白名单门控,发行版同样可用;拉取失败回退 schema 默认值。
1014
+ * 路由不受 Web 设置白名单门控,发行版同样可用。
1015
+ *
1016
+ * ⚠️ 拉取失败时的兜底值必须**关闭**通知(见 FAIL_SAFE),不能用 schema 默认值:
1017
+ * `browser.enabled` 的 schema 默认值是 `true`,用它兜底意味着任何一次拉取失败
1018
+ * 都会在用户已关闭通知的情况下静默开启推送 —— 表现为「我明明关了还弹」。
897
1019
  */
1020
+ /**
1021
+ * 拉取成功前的失效安全值:**显式关闭浏览器通知**。
1022
+ * 通知类插件的正确取向是「宁可漏报,不可误报」:未确认用户意愿前一律不推。
1023
+ */
1024
+ const FAIL_SAFE = resolveConfig({ browser: { enabled: false } });
898
1025
  var ClientConfig = class {
899
- current = resolveConfig({});
1026
+ current = FAIL_SAFE;
900
1027
  listeners = /* @__PURE__ */ new Set();
901
1028
  get() {
902
1029
  return this.current;
@@ -907,7 +1034,12 @@ window.__ModuleLoader__.load({
907
1034
  this.listeners.delete(listener);
908
1035
  };
909
1036
  }
910
- /** 拉取 host 配置视图(首次与收到 document-updated 后调用)。 */
1037
+ /**
1038
+ * 拉取 host 配置视图。
1039
+ *
1040
+ * 失败(路由未挂载 / 网络问题 / 视图非 ready)时**保持上一次的已知值**;
1041
+ * 若从未成功过,则保持 FAIL_SAFE(通知关闭)。
1042
+ */
911
1043
  async refresh() {
912
1044
  try {
913
1045
  const response = await fetch(CONFIG_PATH, { headers: { accept: "application/json" } });
@@ -922,20 +1054,16 @@ window.__ModuleLoader__.load({
922
1054
  //#endregion
923
1055
  //#region src/client/diff.ts
924
1056
  /**
925
- * 对比两次列表快照。
926
- * 首次出现的会话只建立基线、不通知;被移除的会话不通知。
927
- * @param previous - 上一次快照的 byId。
928
- * @param next - 本次快照的 byId。
929
- * @param current - 本次快照的当前选中会话(选中会话的完成不打扰)。
1057
+ * 对比两次列表快照。首次出现的会话只建立基线、不通知;被移除的会话不通知。
930
1058
  */
931
- function diffSessionSummaries(previous, next, current) {
1059
+ function diffSessionSummaries(previous, next) {
932
1060
  const notices = [];
933
1061
  for (const id of Object.keys(next)) {
934
1062
  const summary = next[id];
935
1063
  if (summary === void 0) continue;
936
1064
  const before = previous[id];
937
1065
  if (before === void 0) continue;
938
- if (before.running && !summary.running && id !== current) notices.push({
1066
+ if (before.running && !summary.running && (summary.retainedBy.mainView ?? 0) === 0) notices.push({
939
1067
  kind: "completed",
940
1068
  sessionId: id,
941
1069
  title: summary.displayTitle
@@ -953,13 +1081,20 @@ window.__ModuleLoader__.load({
953
1081
  }
954
1082
  }
955
1083
  /**
956
- * 对比两次 uiSession 待交互快照。
957
- * 仅从无到有时通知;首次订阅由调用方把当前快照设为 previous,因此不会补发历史通知。
1084
+ * 触发开关门控:与 host 端 `triggers` 语义对齐。
1085
+ *
1086
+ * 此前浏览器通道完全没有这层判断,导致「关闭某类触发」对浏览器通知无效 ——
1087
+ * 用户关掉后仍会收到(Windows 把浏览器通知渲染成系统 toast,难以分辨来源)。
958
1088
  */
1089
+ function triggerAllows(notice, triggers) {
1090
+ return notice.kind === "interaction" ? triggers.interaction : triggers.completed;
1091
+ }
1092
+ /** 对比两次 uiSession 统一状态快照。 */
959
1093
  function diffPendingInteractions(previous, next, summaries) {
960
1094
  const notices = [];
961
- for (const [sessionId, pending] of next) {
962
- if (previous.has(sessionId)) continue;
1095
+ for (const [sessionId, status] of next) {
1096
+ const pending = status.pendingInteraction;
1097
+ if (previous.get(sessionId)?.pendingInteraction !== void 0 || pending === void 0) continue;
963
1098
  const interaction = clientInteractionKindOf(pending.kind);
964
1099
  if (interaction === void 0) continue;
965
1100
  const title = summaries[sessionId]?.displayTitle;
@@ -1092,8 +1227,9 @@ window.__ModuleLoader__.load({
1092
1227
  status: snapshot.status,
1093
1228
  mode: snapshot.mode ?? "",
1094
1229
  writable: snapshot.writable,
1095
- dirty: plan.length > 0,
1096
- invalid: plan.some((item) => item.run === void 0),
1230
+ dirty: plan.ops.length > 0 || plan.invalid,
1231
+ dirtyCount: plan.ops.length,
1232
+ invalid: plan.invalid,
1097
1233
  saving: this.saving,
1098
1234
  failed: this.failed,
1099
1235
  fields
@@ -1154,7 +1290,11 @@ window.__ModuleLoader__.load({
1154
1290
  continue;
1155
1291
  }
1156
1292
  const desired = parseDraft(spec, staged.text);
1157
- if (desired === void 0) return [{ run: void 0 }];
1293
+ if (desired === void 0) return {
1294
+ ops: [],
1295
+ run: void 0,
1296
+ invalid: true
1297
+ };
1158
1298
  if (spec.secret !== true && deepEqualJson(desired, this.effectiveValue(spec))) continue;
1159
1299
  ops.push({
1160
1300
  op: "set",
@@ -1162,11 +1302,19 @@ window.__ModuleLoader__.load({
1162
1302
  value: desired
1163
1303
  });
1164
1304
  }
1165
- if (ops.length === 0) return [];
1305
+ if (ops.length === 0) return {
1306
+ ops: [],
1307
+ run: void 0,
1308
+ invalid: false
1309
+ };
1166
1310
  const scope = this.scope;
1167
- return [{ run: async () => {
1168
- return await scope.writeOps(ops);
1169
- } }];
1311
+ return {
1312
+ ops,
1313
+ invalid: false,
1314
+ run: async () => {
1315
+ return await scope.writeOps(ops);
1316
+ }
1317
+ };
1170
1318
  }
1171
1319
  /**
1172
1320
  * 门控判断:hiddenUnless 指定的开关字段当前(含草稿覆盖)不为 true 时,
@@ -1185,15 +1333,11 @@ window.__ModuleLoader__.load({
1185
1333
  }
1186
1334
  async save() {
1187
1335
  const plan = this.plan();
1188
- if (plan.length === 0 || this.saving || plan.some((item) => item.run === void 0)) return;
1336
+ if (plan.ops.length === 0 || this.saving || plan.run === void 0) return;
1189
1337
  this.saving = true;
1190
1338
  this.failed = false;
1191
1339
  this.invalidate();
1192
- let landed = true;
1193
- for (const item of plan) {
1194
- if (item.run === void 0) continue;
1195
- landed = await item.run() && landed;
1196
- }
1340
+ const landed = await plan.run();
1197
1341
  if (landed) this.staged.clear();
1198
1342
  this.saving = false;
1199
1343
  this.failed = !landed;
@@ -1752,9 +1896,32 @@ window.__ModuleLoader__.load({
1752
1896
  alignItems: "center",
1753
1897
  justifyContent: "flex-end",
1754
1898
  gap: 8,
1755
- padding: "12px 0 4px",
1899
+ padding: "12px 0",
1756
1900
  borderTop: "1px solid var(--dsw-alias-border-l2)",
1757
- marginTop: 12
1901
+ marginTop: 12,
1902
+ position: "sticky",
1903
+ bottom: 0,
1904
+ zIndex: 1,
1905
+ background: "var(--dsw-alias-bg-layer-2)"
1906
+ };
1907
+ /** 顶部「有未保存的修改」提示条(滚到顶部时可见,与底部粘性操作栏互补)。 */
1908
+ const UNSAVED_BAR_STYLE = {
1909
+ display: "flex",
1910
+ alignItems: "center",
1911
+ justifyContent: "space-between",
1912
+ gap: 12,
1913
+ margin: "12px 0 0",
1914
+ padding: "8px 12px",
1915
+ border: "1px solid var(--dsw-alias-border-l2)",
1916
+ borderRadius: 8,
1917
+ background: "var(--dsw-alias-bg-module-platform)"
1918
+ };
1919
+ const UNSAVED_TEXT_STYLE = {
1920
+ margin: 0,
1921
+ fontSize: 13,
1922
+ lineHeight: 1.5,
1923
+ fontWeight: 500,
1924
+ color: "var(--dsw-alias-label-primary)"
1758
1925
  };
1759
1926
  const FAILED_STYLE = {
1760
1927
  flex: 1,
@@ -1952,6 +2119,26 @@ window.__ModuleLoader__.load({
1952
2119
  role: "status",
1953
2120
  children: t("status.readOnly")
1954
2121
  }),
2122
+ state.dirty && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2123
+ style: UNSAVED_BAR_STYLE,
2124
+ role: "status",
2125
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
2126
+ style: UNSAVED_TEXT_STYLE,
2127
+ children: [state.invalid ? t("status.invalidInput") : t("status.unsaved"), !state.invalid && state.dirtyCount > 0 ? ` (${state.dirtyCount})` : ""]
2128
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2129
+ type: "button",
2130
+ style: {
2131
+ ...SAVE_STYLE,
2132
+ ...state.invalid || state.saving ? {
2133
+ opacity: .4,
2134
+ cursor: "default"
2135
+ } : {}
2136
+ },
2137
+ disabled: state.invalid || state.saving,
2138
+ onClick: actions.save,
2139
+ children: state.saving ? t("action.saving") : t("action.save")
2140
+ })]
2141
+ }),
1955
2142
  groups.map((group) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1956
2143
  style: GROUP_HEADING_STYLE,
1957
2144
  children: t(GROUP_TITLE_KEYS[group] ?? group)
@@ -1981,6 +2168,14 @@ window.__ModuleLoader__.load({
1981
2168
  role: "status",
1982
2169
  children: t("status.invalidInput")
1983
2170
  }),
2171
+ state.dirty && !state.failed && !state.invalid && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
2172
+ style: {
2173
+ ...UNSAVED_TEXT_STYLE,
2174
+ flex: 1
2175
+ },
2176
+ role: "status",
2177
+ children: [t("status.unsaved"), state.dirtyCount > 0 ? ` (${state.dirtyCount})` : ""]
2178
+ }),
1984
2179
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1985
2180
  type: "button",
1986
2181
  style: {
@@ -2128,6 +2323,7 @@ window.__ModuleLoader__.load({
2128
2323
  "action.discard": "放弃修改",
2129
2324
  "action.reset": "重置",
2130
2325
  "badge.overridden": "已覆盖",
2326
+ "status.unsaved": "有未保存的修改",
2131
2327
  "status.readOnly": "设置文档当前为只读。",
2132
2328
  "status.saveFailed": "保存失败,请重试。",
2133
2329
  "status.invalidInput": "存在无效输入,请修正后再保存。",
@@ -2191,6 +2387,7 @@ window.__ModuleLoader__.load({
2191
2387
  "action.discard": "Discard",
2192
2388
  "action.reset": "Reset",
2193
2389
  "badge.overridden": "Overridden",
2390
+ "status.unsaved": "Unsaved changes",
2194
2391
  "status.readOnly": "The settings document is read-only.",
2195
2392
  "status.saveFailed": "Save failed, please retry.",
2196
2393
  "status.invalidInput": "There are invalid inputs, fix them before saving.",
@@ -2225,9 +2422,9 @@ window.__ModuleLoader__.load({
2225
2422
  }
2226
2423
  /** 列表快照变化 → 通知。 */
2227
2424
  onListChange(previous, next) {
2228
- this.onNotices(diffSessionSummaries(previous.byId, next.byId, next.current));
2425
+ this.onNotices(diffSessionSummaries(previous.byId, next.byId));
2229
2426
  }
2230
- /** 待交互快照变化 → 通知。 */
2427
+ /** 待交互状态变化 → 通知。 */
2231
2428
  onPendingChange(previous, next, sessions) {
2232
2429
  this.onNotices(diffPendingInteractions(previous, next, sessions.byId));
2233
2430
  }
@@ -2238,6 +2435,7 @@ window.__ModuleLoader__.load({
2238
2435
  if (Notification.permission !== "granted") return;
2239
2436
  if (config.browser.onlyWhenHidden && document.visibilityState !== "hidden") return;
2240
2437
  for (const notice of notices) {
2438
+ if (!triggerAllows(notice, config.triggers)) continue;
2241
2439
  if (!this.allow(notice, config)) continue;
2242
2440
  this.show(notice, config);
2243
2441
  }
@@ -2320,6 +2518,13 @@ window.__ModuleLoader__.load({
2320
2518
  fetchScope.refresh();
2321
2519
  });
2322
2520
  ctx.effect(() => () => offRemote?.(), "dsh-messager: settings invalidation");
2521
+ const onVisibilityChange = () => {
2522
+ if (document.visibilityState !== "visible") return;
2523
+ config.refresh();
2524
+ fetchScope.refresh();
2525
+ };
2526
+ document.addEventListener("visibilitychange", onVisibilityChange);
2527
+ ctx.effect(() => () => document.removeEventListener("visibilitychange", onVisibilityChange), "dsh-messager: visibility refresh");
2323
2528
  const notifier = new BrowserNotifier(config);
2324
2529
  ctx.effect(() => () => notifier.dispose(), "dsh-messager: browser notifier");
2325
2530
  let previous = ctx.sessions.list.getSnapshot();
@@ -2329,13 +2534,13 @@ window.__ModuleLoader__.load({
2329
2534
  previous = next;
2330
2535
  });
2331
2536
  ctx.effect(() => () => offList(), "dsh-messager: sessions subscription");
2332
- let previousPending = ctx.uiSession.pendingInteractions.getSnapshot();
2333
- const offPending = ctx.uiSession.pendingInteractions.subscribe(() => {
2334
- const next = ctx.uiSession.pendingInteractions.getSnapshot();
2335
- notifier.onPendingChange(previousPending, next, ctx.sessions.list.getSnapshot());
2336
- previousPending = next;
2537
+ let previousStatus = ctx.uiSession.sessionStatus.getSnapshot();
2538
+ const offStatus = ctx.uiSession.sessionStatus.subscribe(() => {
2539
+ const next = ctx.uiSession.sessionStatus.getSnapshot();
2540
+ notifier.onPendingChange(previousStatus, next, ctx.sessions.list.getSnapshot());
2541
+ previousStatus = next;
2337
2542
  });
2338
- ctx.effect(() => () => offPending(), "dsh-messager: pending interactions subscription");
2543
+ ctx.effect(() => () => offStatus(), "dsh-messager: session status subscription");
2339
2544
  const controller = new MessagerCardController(fetchScope.scope, CARD_FIELDS);
2340
2545
  ctx.effect(() => ctx.locale.register(LOCALE_NS, {
2341
2546
  zh,