dsh-waifu-agents 0.3.1 → 0.3.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.
Files changed (3) hide show
  1. package/client.js +81 -38
  2. package/package.json +1 -1
  3. package/panel-ops.js +26 -4
package/client.js CHANGED
@@ -199,7 +199,8 @@ window.__ModuleLoader__.load({
199
199
  name: '名字',
200
200
  description: '简述',
201
201
  bodyPlaceholder: '只写她独有的部分;公共条款在骨架里',
202
- autosave: '改动会在离开输入框时自动保存',
202
+ save: '保存',
203
+ saveHint: '改动要点「保存」才会写入',
203
204
  nameDrivesFile: '文件名会跟着「名字」走',
204
205
  saving: '保存中…',
205
206
  saved: '已保存',
@@ -533,7 +534,8 @@ window.__ModuleLoader__.load({
533
534
  name: 'Name',
534
535
  description: 'Summary',
535
536
  bodyPlaceholder: 'Only her own part; the shared clauses live in the skeleton',
536
- autosave: 'Edits are written when the field loses focus',
537
+ save: 'save',
538
+ saveHint: 'Changes are written when you press Save',
537
539
  nameDrivesFile: 'The file name follows this name',
538
540
  saving: 'Saving…',
539
541
  saved: 'Saved',
@@ -1603,22 +1605,30 @@ window.__ModuleLoader__.load({
1603
1605
  * 提示词。`Confirm` / `Alert` 那种一句话对话框仍然走平台,两者不冲突:
1604
1606
  * 这里只借用同一层遮罩色和同一套主题 token。
1605
1607
  *
1606
- * 三条收尾路径都要有:右上角的关闭、点遮罩、Esc。少一条,用户就会在
1607
- * 某一次编辑后发现"关不掉",而那正是弹窗最容易招人烦的地方。
1608
+ * 收尾路径分两档。未锁定时三条都要有:右上角的关闭、点遮罩、Esc——少一
1609
+ * 条,用户就会在某一次编辑后发现"关不掉",而那正是弹窗最容易招人烦的地
1610
+ * 方。锁定(`locked`)时只剩显式按钮:编辑器正攥着一份未保存的草稿,一
1611
+ * 次走神的点击落在遮罩上、或者一个惯性 Esc,就把整段正文无声丢掉——这
1612
+ * 种"没有确认、没有恢复"的丢失比关不掉恶劣得多,所以两个编辑器都锁。
1608
1613
  */
1609
- function Sheet({ open, title, hint, width, t, onClose, footer, children }) {
1614
+ function Sheet({ open, title, hint, width, t, onClose, footer, children, locked }) {
1610
1615
  React.useEffect(() => {
1611
1616
  if (open !== true) return undefined;
1617
+ if (locked === true) return undefined;
1612
1618
  const onKey = (event) => {
1613
1619
  if (event.key === 'Escape') onClose();
1614
1620
  };
1615
1621
  document.addEventListener('keydown', onKey);
1616
1622
  return () => document.removeEventListener('keydown', onKey);
1617
- }, [open, onClose]);
1623
+ }, [open, onClose, locked]);
1618
1624
  if (open !== true) return null;
1619
1625
  return h(
1620
1626
  'div',
1621
- { style: { ...L.overlay, zIndex: 60 }, onClick: onClose },
1627
+ {
1628
+ style: { ...L.overlay, zIndex: 60 },
1629
+ // 锁定时遮罩不再接 onClose:点外面什么也不发生,关窗只能走按钮。
1630
+ onClick: locked === true ? undefined : onClose,
1631
+ },
1622
1632
  h(
1623
1633
  'div',
1624
1634
  {
@@ -1917,8 +1927,8 @@ window.__ModuleLoader__.load({
1917
1927
  * a draft with unsaved keystrokes in it, and a preview that reads from the
1918
1928
  * draft can never be what the model sees. This one is fetched from the Host
1919
1929
  * (`get` again), so it shows the file as it is on disk with the skeleton
1920
- * poured in — and `openPreview` waits for the autosave the click itself
1921
- * triggered, so "on disk" means "including what you just typed".
1930
+ * poured in — "on disk" now means "the last version you pressed Save on";
1931
+ * edits still in the editor are deliberately not in it.
1922
1932
  */
1923
1933
  function PreviewWindow({ open, title, text, loading, error, t, onClose }) {
1924
1934
  if (open !== true) return null;
@@ -1998,7 +2008,20 @@ window.__ModuleLoader__.load({
1998
2008
  );
1999
2009
  }
2000
2010
 
2001
- function Editor({ draft, t, busy, onPatch, onCommit, tall }) {
2011
+ /*
2012
+ * 人设编辑器:弹窗里的一张表,改动**不**自动保存。
2013
+ *
2014
+ * 曾经每个输入框失焦就写一次盘("改动会在离开输入框时自动保存")。
2015
+ * 那意味着"保存"这个动作不可见:点谁、路过谁都会触发一次写入,而
2016
+ * 写一半想去看看别人、再回来的路上一句话已经被存成了半句。现在写入
2017
+ * 只有一个入口——下面的「保存」按钮;关掉窗口(× 或「取消」)丢掉
2018
+ * 没保存的改动,文件保持上一次保存的样子。
2019
+ *
2020
+ * `dirty` 由调用方算(`draftKeyOf(draft) !== draft.savedKey`):它让
2021
+ * 「保存」在没有改动时是灰的,替代旧模型里"没改动就不发请求"的
2022
+ * 静默去重——去重还在(`save` 里),只是用户现在也看得见。
2023
+ */
2024
+ function Editor({ draft, t, busy, onPatch, onSave, onCancel, dirty, tall }) {
2002
2025
  const loading = draft.loading === true;
2003
2026
  const field = (label, ...controls) => h('div', { style: L.field }, h('span', { style: L.label }, label), ...controls);
2004
2027
  const text = draft.body ?? '';
@@ -2017,7 +2040,6 @@ window.__ModuleLoader__.load({
2017
2040
  value: draft.name ?? '',
2018
2041
  disabled: busy,
2019
2042
  onChange: (event) => onPatch({ name: event.target.value }),
2020
- onBlur: onCommit,
2021
2043
  }),
2022
2044
  ),
2023
2045
  field(
@@ -2027,7 +2049,6 @@ window.__ModuleLoader__.load({
2027
2049
  value: draft.description ?? '',
2028
2050
  disabled: busy,
2029
2051
  onChange: (event) => onPatch({ description: event.target.value }),
2030
- onBlur: onCommit,
2031
2052
  }),
2032
2053
  ),
2033
2054
  field(
@@ -2037,7 +2058,6 @@ window.__ModuleLoader__.load({
2037
2058
  value: draft.group ?? '',
2038
2059
  disabled: busy,
2039
2060
  onChange: (event) => onPatch({ group: event.target.value }),
2040
- onBlur: onCommit,
2041
2061
  }),
2042
2062
  ),
2043
2063
  field(
@@ -2046,10 +2066,7 @@ window.__ModuleLoader__.load({
2046
2066
  checked: (draft.template ?? '') === SKELETON_FILE,
2047
2067
  disabled: busy,
2048
2068
  title: t('useSkeleton'),
2049
- onChange: (next) => {
2050
- onPatch({ template: next ? SKELETON_FILE : '' });
2051
- onCommit();
2052
- },
2069
+ onChange: (next) => onPatch({ template: next ? SKELETON_FILE : '' }),
2053
2070
  }),
2054
2071
  ),
2055
2072
  /*
@@ -2064,7 +2081,6 @@ window.__ModuleLoader__.load({
2064
2081
  disabled: busy,
2065
2082
  placeholder: t('bodyPlaceholder'),
2066
2083
  onChange: (event) => onPatch({ body: event.target.value }),
2067
- onBlur: onCommit,
2068
2084
  }),
2069
2085
  (draft.problems ?? []).length > 0
2070
2086
  ? h(
@@ -2074,7 +2090,17 @@ window.__ModuleLoader__.load({
2074
2090
  ...(draft.problems ?? []).map((line, index) => h('div', { key: index, style: L.error }, line)),
2075
2091
  )
2076
2092
  : null,
2077
- h('span', { style: L.hint }, `${t('autosave')} · ${t('nameDrivesFile')}`),
2093
+ h(
2094
+ 'div',
2095
+ { style: { ...L.bar, justifyContent: 'space-between', marginTop: '4px' } },
2096
+ h('span', { style: L.hint }, `${t('saveHint')} · ${t('nameDrivesFile')}`),
2097
+ h(
2098
+ 'div',
2099
+ { style: L.bar },
2100
+ h(Btn, { variant: 'toolbar', disabled: busy, onClick: onCancel }, t('cancel')),
2101
+ h(Btn, { variant: 'primary', disabled: busy || dirty === false, onClick: onSave }, t('save')),
2102
+ ),
2103
+ ),
2078
2104
  ),
2079
2105
  );
2080
2106
  }
@@ -2291,9 +2317,9 @@ window.__ModuleLoader__.load({
2291
2317
  'aria-label': t('preview'),
2292
2318
  /*
2293
2319
  * Not disabled while `busy`. Reading does not fight the writer, and
2294
- * the click that opened this also triggered the autosave (blur
2295
- * fires first) — disabling here would swallow exactly that click.
2296
- * `openPreview` waits for the write instead.
2320
+ * an explicit-save editor means the click never starts a write —
2321
+ * the preview shows the last saved version, unsaved keystrokes
2322
+ * stay in the editor where they belong.
2297
2323
  */
2298
2324
  disabled: locked,
2299
2325
  style: { padding: '0 6px' },
@@ -2713,6 +2739,8 @@ window.__ModuleLoader__.load({
2713
2739
  width: '860px',
2714
2740
  t,
2715
2741
  onClose: onCancelDraft,
2742
+ // 与人设编辑器同一档:草稿只认显式的取消/保存。
2743
+ locked: true,
2716
2744
  },
2717
2745
  h(TeamDraftCard, {
2718
2746
  draft,
@@ -3635,7 +3663,8 @@ window.__ModuleLoader__.load({
3635
3663
  onImportClick,
3636
3664
  onDownloadTemplate,
3637
3665
  onPatchDraft,
3638
- onCommit,
3666
+ onSave,
3667
+ dirty,
3639
3668
  onCloseDraft,
3640
3669
  page,
3641
3670
  pageSize,
@@ -3803,8 +3832,10 @@ window.__ModuleLoader__.load({
3803
3832
  width: '820px',
3804
3833
  t,
3805
3834
  onClose: onCloseDraft,
3835
+ // 点遮罩、Esc 都不收:草稿只认显式的取消/保存。
3836
+ locked: true,
3806
3837
  },
3807
- h(Editor, { draft, t, busy, onPatch: onPatchDraft, onCommit, tall: true }),
3838
+ h(Editor, { draft, t, busy, onPatch: onPatchDraft, onSave, onCancel: onCloseDraft, dirty, tall: true }),
3808
3839
  )
3809
3840
  : null,
3810
3841
  pageCount > 1
@@ -3935,12 +3966,12 @@ window.__ModuleLoader__.load({
3935
3966
  const idAlias = React.useRef(new Map());
3936
3967
  const fileInput = React.useRef(null);
3937
3968
  /**
3938
- * The autosave currently in flight, if any.
3969
+ * The explicit save currently in flight, if any.
3939
3970
  *
3940
- * Clicking the eye blurs the textarea first, and blur is what starts the
3941
- * save — so without this the preview races the write it just triggered
3942
- * and shows the file one keystroke behind. Preview is a read of the disk,
3943
- * and a read has to wait for the write in front of it.
3971
+ * Saves start only from the editor's Save button now, so there is no
3972
+ * blur race to lose — but a second click on Save while one write is
3973
+ * still running must not start a duplicate, and `openPreview` keeps
3974
+ * waiting on this slot so a save and a read can never interleave.
3944
3975
  */
3945
3976
  const savingRef = React.useRef(null);
3946
3977
 
@@ -4513,6 +4544,13 @@ window.__ModuleLoader__.load({
4513
4544
  savedKey: written,
4514
4545
  });
4515
4546
  }
4547
+ /*
4548
+ * 保存成功即收窗。写盘已经完成,草稿没有值得留下的东西——这与
4549
+ * 团队编辑器的「成功即 `setTeamDraft(null)`」是同一条约定;留窗
4550
+ * 是失焦自动保存时代的习惯,那时窗口必须开着等下一次失焦。
4551
+ * 失败路径不动:弹窗留在原地,错误贴在窗内。
4552
+ */
4553
+ putDraft(null);
4516
4554
  await load();
4517
4555
  await loadRoster();
4518
4556
  } catch (failure) {
@@ -4527,7 +4565,13 @@ window.__ModuleLoader__.load({
4527
4565
  }
4528
4566
  }
4529
4567
 
4530
- /** Blur, row switch and unmount all funnel through here. */
4568
+ /**
4569
+ * 编辑器「保存」按钮的唯一入口。
4570
+ *
4571
+ * 曾经失焦也会走到这里(blur → commit),如今唯一的调用方是按钮:
4572
+ * 写盘必须是人按下去的那个动作。`save` 自己做去重——草稿与上一次
4573
+ * 保存一致时不发请求——所以连点两下、或者没改就点,都是空操作。
4574
+ */
4531
4575
  const commit = React.useCallback(() => {
4532
4576
  const current = draftRef.current;
4533
4577
  if (current === null || current.loading === true) return;
@@ -4540,8 +4584,8 @@ window.__ModuleLoader__.load({
4540
4584
  });
4541
4585
  }, []);
4542
4586
 
4587
+ /* 关窗即放弃:没有点「保存」的改动一个字都不落盘。 */
4543
4588
  function closeRow() {
4544
- commit();
4545
4589
  showRow(null);
4546
4590
  putDraft(null);
4547
4591
  setDialog(null);
@@ -4560,8 +4604,8 @@ window.__ModuleLoader__.load({
4560
4604
  const id = item.id;
4561
4605
  setPeek({ id, name: item.name ?? id, loading: true, text: '', error: '' });
4562
4606
  try {
4563
- // The blur that opened this window already kicked off a save; reading
4564
- // first would answer with the previous version of the file.
4607
+ // A save pressed moments ago may still be in flight; reading first
4608
+ // would answer with the previous version of the file.
4565
4609
  if (savingRef.current !== null) await savingRef.current;
4566
4610
  const payload = await post({ op: 'get', id, assembled: true });
4567
4611
  setPeek((prev) =>
@@ -4583,7 +4627,6 @@ window.__ModuleLoader__.load({
4583
4627
  closeRow();
4584
4628
  return;
4585
4629
  }
4586
- commit();
4587
4630
  setDialog(null);
4588
4631
  setNote(null);
4589
4632
  showRow(item.id);
@@ -4631,7 +4674,6 @@ window.__ModuleLoader__.load({
4631
4674
  closeRow();
4632
4675
  return;
4633
4676
  }
4634
- commit();
4635
4677
  setDialog(null);
4636
4678
  setNote(null);
4637
4679
  showRow(NEW);
@@ -4658,7 +4700,6 @@ window.__ModuleLoader__.load({
4658
4700
  closeRow();
4659
4701
  return;
4660
4702
  }
4661
- commit();
4662
4703
  showRow(SKELETON);
4663
4704
  setNote(null);
4664
4705
  putSkeletonDraft({ text: '', slots: 0, nameSlots: 0, problems: [], rev: null, loading: true });
@@ -5569,8 +5610,10 @@ window.__ModuleLoader__.load({
5569
5610
  onImportClick: () => fileInput.current?.click(),
5570
5611
  onDownloadTemplate: downloadTemplate,
5571
5612
  onPatchDraft: patchDraft,
5572
- onCommit: commit,
5573
- // Closing the editor window: commit what was typed, then drop the draft.
5613
+ // 显式保存:只有按钮会写盘。关窗(onCloseDraft)丢掉未保存的改动。
5614
+ onSave: commit,
5615
+ dirty: draft !== null && draft.savedKey !== undefined && draftKeyOf(draft) !== draft.savedKey,
5616
+ // Closing the editor window: drop the draft without writing.
5574
5617
  onCloseDraft: closeRow,
5575
5618
  onOpenSkeleton: openSkeleton,
5576
5619
  skeletonDraft,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-waifu-agents",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "type": "module",
5
5
  "description": "Registers every *.md in a persona directory as an agent preset, and manages them from the Plugins settings page.",
6
6
  "license": "MIT",
package/panel-ops.js CHANGED
@@ -171,13 +171,34 @@ export function installPanelOps(ctx, deps) {
171
171
  * 回答的问题——`registryToken` 保持指向我们登记时的那一个,复核时身份对不上
172
172
  * 就一律回 `unreadable`。
173
173
  *
174
- * 解析时连续读两次,是因为服务面可能只是个别名 getter:两次读到不同对象
175
- * 就说明这里根本给不出稳定身份,令牌留空,此后所有复核都是 `unreadable`。
176
- * 宁可全部看不清,也不要拿一个每次访问都变的引用去当证据。
174
+ * 解析时连续读两次,是为了验证服务面给得出稳定身份。这里有个真宿主
175
+ * (2026-11-01 诊断落盘实锤)教会我们的坑:cordis 对带 tracker 的服务,
176
+ * 每次属性访问都现包一层 traceable 代理(`createTraceable`)——裸比对象
177
+ * 身份,两次**永远**不相等,令牌会被永久置空,此后所有「确认摘除」都是
178
+ * `unreadable`,改一个人设就弹一次「注销失败」。所以身份比的是解包后的
179
+ * 底层服务对象(cordis 在代理上留了官方开瓶器,见 {@link registryIdentity});
180
+ * 底层两次都同一个,才发令牌。宁可全部看不清,也不要拿一个每次访问都
181
+ * 换底的引用去当证据。
177
182
  */
178
183
  let registryRef = null;
179
184
  let registryToken = null;
180
185
 
186
+ /**
187
+ * 解到 cordis traceable 包装下的底层服务对象。
188
+ *
189
+ * cordis 的 traceable 代理专门留了这个读口:读
190
+ * `Symbol.for("cordis.original")` 返回代理目标(真正的服务对象);不是
191
+ * 包装就是普通对象,符号读出来是 `undefined`,原样返回。用全局注册表
192
+ * 符号而不是 import,是为了不对宿主的模块布局做硬依赖。
193
+ *
194
+ * @param {object} value 属性访问刚拿到的(可能是包装的)服务对象
195
+ * @returns {object} 底层服务对象
196
+ */
197
+ function registryIdentity(value) {
198
+ const inner = value[Symbol.for("cordis.original")];
199
+ return inner === undefined || inner === null ? value : inner;
200
+ }
201
+
181
202
  /**
182
203
  * @returns {object | null} 本实例登记用的 registry(可能还没就绪)
183
204
  */
@@ -186,8 +207,9 @@ export function installPanelOps(ctx, deps) {
186
207
  const first = ctx.agentPresets;
187
208
  if (first === undefined || first === null) return null;
188
209
  const second = ctx.agentPresets;
210
+ const identity = registryIdentity(first);
189
211
  registryRef = first;
190
- registryToken = first === second ? registryTokenOf(first) : null;
212
+ registryToken = identity === registryIdentity(second) ? registryTokenOf(identity) : null;
191
213
  return registryRef;
192
214
  }
193
215