dsh-waifu-agents 0.3.0 → 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.
package/README.md CHANGED
@@ -84,11 +84,13 @@ group: 组名
84
84
  几条习惯:
85
85
 
86
86
  - 编辑是**离开输入框自动保存**,没有保存按钮。
87
- - 只有行首的 **▸** 会展开编辑器,点名字不会。
87
+ - 新增与编辑都开在**窗口**里(团队编辑器同样):关掉窗口就是保存完,名册本身不会被挤走。
88
+ - 只有行首的 **▸** 会打开编辑窗口,点名字不会。
88
89
  - 行尾的**眼睛**看组装后的成品(模型真正收到的那份)。
89
90
  - 改名字时**文件名自动跟着变**;改名不会丢正文。
90
91
  - 隐藏是改名成 `.md.off`,正文还在,随时取消隐藏。
91
92
  - 会动数据的事都会先弹一次确认。
93
+ - 新建任务时那一列人设的顺序是:**默认人设 → 有分组的人设(按组名)→ 没分组的人设**。组内仍按「拖拽排序」的位置。
92
94
 
93
95
  ## 聊天模式与工作模式
94
96
 
@@ -180,6 +182,8 @@ group: 组名
180
182
 
181
183
  **某一行提示「激活失败」「ID 占用冲突」?** 多半是热重载时新旧实例并存,**完全退出 DSH 再启动**,提示消失就没事。
182
184
 
185
+ **改完插件后整个面板打不开(或一片空白)?** 热重载时新旧两个实例会抢同一条接口地址,新版有可能一直没抢到。插件现在会自己重试半秒多,还失败就在日志里留一句「面板路由没注册上」。真的看到那句,**完全退出 DSH 再启动**就能恢复——人设、记忆、团队都没受影响,丢的只是那一版的面板。
186
+
183
187
  **保存被拒,说「磁盘上变过」?** 这行在别处被改过了,重新打开载入最新内容再改一次(被拒时磁盘上什么都没写)。
184
188
 
185
189
  **拖不动行?** 「拖拽排序」每次打开面板都是关的,在工具条里打开;开着时行内按钮会一起锁住。
package/client.js CHANGED
@@ -95,6 +95,9 @@ window.__ModuleLoader__.load({
95
95
  tabMemory: '记忆',
96
96
  search: '搜索名字、简述或正文…',
97
97
  add: '新增',
98
+ /* 两个编辑器的窗口标题:'新增人设' / '编辑人设'。 */
99
+ personaNew: '新增人设',
100
+ personaEdit: '编辑人设',
98
101
  import: '导入 md',
99
102
  exportAll: '导出全部',
100
103
  exportOne: '导出这一条',
@@ -196,7 +199,8 @@ window.__ModuleLoader__.load({
196
199
  name: '名字',
197
200
  description: '简述',
198
201
  bodyPlaceholder: '只写她独有的部分;公共条款在骨架里',
199
- autosave: '改动会在离开输入框时自动保存',
202
+ save: '保存',
203
+ saveHint: '改动要点「保存」才会写入',
200
204
  nameDrivesFile: '文件名会跟着「名字」走',
201
205
  saving: '保存中…',
202
206
  saved: '已保存',
@@ -433,6 +437,8 @@ window.__ModuleLoader__.load({
433
437
  tabMemory: 'Memory',
434
438
  search: 'Search name, summary or body…',
435
439
  add: 'New',
440
+ personaNew: 'New persona',
441
+ personaEdit: 'Edit persona',
436
442
  import: 'Import md',
437
443
  exportAll: 'Export all',
438
444
  exportOne: 'Export this file',
@@ -528,7 +534,8 @@ window.__ModuleLoader__.load({
528
534
  name: 'Name',
529
535
  description: 'Summary',
530
536
  bodyPlaceholder: 'Only her own part; the shared clauses live in the skeleton',
531
- autosave: 'Edits are written when the field loses focus',
537
+ save: 'save',
538
+ saveHint: 'Changes are written when you press Save',
532
539
  nameDrivesFile: 'The file name follows this name',
533
540
  saving: 'Saving…',
534
541
  saved: 'Saved',
@@ -1036,6 +1043,26 @@ window.__ModuleLoader__.load({
1036
1043
  borderRadius: 'var(--dsw-radius-panel, 12px)',
1037
1044
  background: 'var(--dsw-alias-bg-layer-2)',
1038
1045
  },
1046
+ /*
1047
+ * The wide dialog behind `Sheet`. Same surface as `dialog`, but its height
1048
+ * is capped and its body scrolls: a persona body can be arbitrarily long,
1049
+ * and a window taller than the viewport puts the close button off screen.
1050
+ */
1051
+ sheet: {
1052
+ display: 'flex',
1053
+ flexDirection: 'column',
1054
+ gap: '12px',
1055
+ maxWidth: 'calc(100vw - 48px)',
1056
+ maxHeight: '86vh',
1057
+ padding: '16px',
1058
+ border: '1px solid var(--dsw-alias-border-l2)',
1059
+ borderRadius: 'var(--dsw-radius-panel, 12px)',
1060
+ background: 'var(--dsw-alias-bg-layer-2)',
1061
+ boxSizing: 'border-box',
1062
+ boxShadow: '0 18px 48px rgba(0, 0, 0, 0.28)',
1063
+ },
1064
+ sheetHead: { display: 'flex', alignItems: 'flex-start', gap: '8px' },
1065
+ sheetBody: { display: 'flex', flexDirection: 'column', gap: '10px', overflow: 'auto', minHeight: 0 },
1039
1066
  dragging: { opacity: 0.45 },
1040
1067
  reorderRow: { cursor: 'grab', userSelect: 'none' },
1041
1068
  dropTarget: { borderColor: 'var(--dsw-alias-state-business-primary)', borderStyle: 'dashed' },
@@ -1569,6 +1596,75 @@ window.__ModuleLoader__.load({
1569
1596
  );
1570
1597
  }
1571
1598
 
1599
+ /**
1600
+ * 一块从中间升起的面板:新增/编辑人设、新建/编辑团队、查看组装结果都用它。
1601
+ *
1602
+ * 为什么不用平台的 `Modal`:它的宽度不在本运行时的契约里(面板拿到的
1603
+ * 只有 `Button`/`Tag`/`MarkdownText` 的已核实形状),而这三样恰恰需要
1604
+ * 比一句确认宽得多的版面——整段正文、一列成员勾选、一整份组装后的
1605
+ * 提示词。`Confirm` / `Alert` 那种一句话对话框仍然走平台,两者不冲突:
1606
+ * 这里只借用同一层遮罩色和同一套主题 token。
1607
+ *
1608
+ * 收尾路径分两档。未锁定时三条都要有:右上角的关闭、点遮罩、Esc——少一
1609
+ * 条,用户就会在某一次编辑后发现"关不掉",而那正是弹窗最容易招人烦的地
1610
+ * 方。锁定(`locked`)时只剩显式按钮:编辑器正攥着一份未保存的草稿,一
1611
+ * 次走神的点击落在遮罩上、或者一个惯性 Esc,就把整段正文无声丢掉——这
1612
+ * 种"没有确认、没有恢复"的丢失比关不掉恶劣得多,所以两个编辑器都锁。
1613
+ */
1614
+ function Sheet({ open, title, hint, width, t, onClose, footer, children, locked }) {
1615
+ React.useEffect(() => {
1616
+ if (open !== true) return undefined;
1617
+ if (locked === true) return undefined;
1618
+ const onKey = (event) => {
1619
+ if (event.key === 'Escape') onClose();
1620
+ };
1621
+ document.addEventListener('keydown', onKey);
1622
+ return () => document.removeEventListener('keydown', onKey);
1623
+ }, [open, onClose, locked]);
1624
+ if (open !== true) return null;
1625
+ return h(
1626
+ 'div',
1627
+ {
1628
+ style: { ...L.overlay, zIndex: 60 },
1629
+ // 锁定时遮罩不再接 onClose:点外面什么也不发生,关窗只能走按钮。
1630
+ onClick: locked === true ? undefined : onClose,
1631
+ },
1632
+ h(
1633
+ 'div',
1634
+ {
1635
+ style: { ...L.sheet, width: width ?? '760px' },
1636
+ role: 'dialog',
1637
+ 'aria-modal': 'true',
1638
+ 'aria-label': title,
1639
+ // A click that landed on the window itself — typing, selecting
1640
+ // text, dragging the scrollbar — must not close it.
1641
+ onClick: (event) => event.stopPropagation(),
1642
+ },
1643
+ h(
1644
+ 'div',
1645
+ { style: L.sheetHead },
1646
+ h(
1647
+ 'div',
1648
+ { style: L.rowMain },
1649
+ h('span', { style: L.rowTitle }, title),
1650
+ hint === undefined || hint === null || hint === '' ? null : h('span', { style: L.hint }, hint),
1651
+ ),
1652
+ h(Btn, {
1653
+ variant: 'toolbar',
1654
+ size: 'sm',
1655
+ icon: h(Icon, { name: 'close', size: 16 }),
1656
+ title: t('close'),
1657
+ 'aria-label': t('close'),
1658
+ style: { padding: '0 6px' },
1659
+ onClick: onClose,
1660
+ }),
1661
+ ),
1662
+ h('div', { style: L.sheetBody }, children),
1663
+ footer ?? null,
1664
+ ),
1665
+ );
1666
+ }
1667
+
1572
1668
  /**
1573
1669
  * A v4 UUID, minted by the PANEL rather than the Host.
1574
1670
  *
@@ -1831,8 +1927,8 @@ window.__ModuleLoader__.load({
1831
1927
  * a draft with unsaved keystrokes in it, and a preview that reads from the
1832
1928
  * draft can never be what the model sees. This one is fetched from the Host
1833
1929
  * (`get` again), so it shows the file as it is on disk with the skeleton
1834
- * poured in — and `openPreview` waits for the autosave the click itself
1835
- * 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.
1836
1932
  */
1837
1933
  function PreviewWindow({ open, title, text, loading, error, t, onClose }) {
1838
1934
  if (open !== true) return null;
@@ -1847,41 +1943,21 @@ window.__ModuleLoader__.load({
1847
1943
  h(Markdown, { text: text ?? '' }),
1848
1944
  );
1849
1945
  /*
1850
- * The platform's own dialog, like the confirmations next to it — the same
1851
- * mask, the same close button, the same Escape handling. A hand-rolled
1852
- * overlay next to a platform dialog is two dialogs wearing one panel.
1946
+ * The same wide window the two editors use, for two reasons.
1947
+ *
1948
+ * Width: this shows the assembled prompt — the thing the model actually
1949
+ * receives — and it was being read through a 560px column, which is
1950
+ * narrower than the editor that produced it. Reading the result should
1951
+ * not be harder than writing it.
1952
+ *
1953
+ * Consistency: the editor that this opens from is a Sheet, so a
1954
+ * different dialog shape one click later would read as two different
1955
+ * products sharing a page.
1853
1956
  */
1854
- if (primitives !== null && typeof primitives.Modal === 'function') {
1855
- return h(primitives.Modal, { open: true, onClose, title, closeLabel: t('close'), children: content });
1856
- }
1857
1957
  return h(
1858
- 'div',
1859
- { style: L.overlay, onClick: onClose },
1860
- h(
1861
- 'div',
1862
- {
1863
- style: { ...L.dialog, maxWidth: '560px', width: '100%' },
1864
- // The backdrop closes it; a click that landed on the window itself
1865
- // — reading, selecting text — must not.
1866
- onClick: (event) => event.stopPropagation(),
1867
- },
1868
- h(
1869
- 'div',
1870
- { style: L.bar },
1871
- h('span', { style: L.rowTitle }, title),
1872
- h('div', { style: L.push }),
1873
- h(Btn, {
1874
- variant: 'toolbar',
1875
- size: 'sm',
1876
- icon: h(Icon, { name: 'close', size: 16 }),
1877
- title: t('close'),
1878
- 'aria-label': t('close'),
1879
- style: { padding: '0 6px' },
1880
- onClick: onClose,
1881
- }),
1882
- ),
1883
- content,
1884
- ),
1958
+ Sheet,
1959
+ { open: true, title, width: '980px', t, onClose },
1960
+ content,
1885
1961
  );
1886
1962
  }
1887
1963
 
@@ -1932,7 +2008,20 @@ window.__ModuleLoader__.load({
1932
2008
  );
1933
2009
  }
1934
2010
 
1935
- function Editor({ draft, t, busy, onPatch, onCommit }) {
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 }) {
1936
2025
  const loading = draft.loading === true;
1937
2026
  const field = (label, ...controls) => h('div', { style: L.field }, h('span', { style: L.label }, label), ...controls);
1938
2027
  const text = draft.body ?? '';
@@ -1951,7 +2040,6 @@ window.__ModuleLoader__.load({
1951
2040
  value: draft.name ?? '',
1952
2041
  disabled: busy,
1953
2042
  onChange: (event) => onPatch({ name: event.target.value }),
1954
- onBlur: onCommit,
1955
2043
  }),
1956
2044
  ),
1957
2045
  field(
@@ -1961,7 +2049,6 @@ window.__ModuleLoader__.load({
1961
2049
  value: draft.description ?? '',
1962
2050
  disabled: busy,
1963
2051
  onChange: (event) => onPatch({ description: event.target.value }),
1964
- onBlur: onCommit,
1965
2052
  }),
1966
2053
  ),
1967
2054
  field(
@@ -1971,7 +2058,6 @@ window.__ModuleLoader__.load({
1971
2058
  value: draft.group ?? '',
1972
2059
  disabled: busy,
1973
2060
  onChange: (event) => onPatch({ group: event.target.value }),
1974
- onBlur: onCommit,
1975
2061
  }),
1976
2062
  ),
1977
2063
  field(
@@ -1980,10 +2066,7 @@ window.__ModuleLoader__.load({
1980
2066
  checked: (draft.template ?? '') === SKELETON_FILE,
1981
2067
  disabled: busy,
1982
2068
  title: t('useSkeleton'),
1983
- onChange: (next) => {
1984
- onPatch({ template: next ? SKELETON_FILE : '' });
1985
- onCommit();
1986
- },
2069
+ onChange: (next) => onPatch({ template: next ? SKELETON_FILE : '' }),
1987
2070
  }),
1988
2071
  ),
1989
2072
  /*
@@ -1993,12 +2076,11 @@ window.__ModuleLoader__.load({
1993
2076
  * exactly one mode and no state to remember.
1994
2077
  */
1995
2078
  h('textarea', {
1996
- style: L.textarea,
2079
+ style: tall === true ? { ...L.textarea, minHeight: '44vh' } : L.textarea,
1997
2080
  value: text,
1998
2081
  disabled: busy,
1999
2082
  placeholder: t('bodyPlaceholder'),
2000
2083
  onChange: (event) => onPatch({ body: event.target.value }),
2001
- onBlur: onCommit,
2002
2084
  }),
2003
2085
  (draft.problems ?? []).length > 0
2004
2086
  ? h(
@@ -2008,7 +2090,17 @@ window.__ModuleLoader__.load({
2008
2090
  ...(draft.problems ?? []).map((line, index) => h('div', { key: index, style: L.error }, line)),
2009
2091
  )
2010
2092
  : null,
2011
- 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
+ ),
2012
2104
  ),
2013
2105
  );
2014
2106
  }
@@ -2225,9 +2317,9 @@ window.__ModuleLoader__.load({
2225
2317
  'aria-label': t('preview'),
2226
2318
  /*
2227
2319
  * Not disabled while `busy`. Reading does not fight the writer, and
2228
- * the click that opened this also triggered the autosave (blur
2229
- * fires first) — disabling here would swallow exactly that click.
2230
- * `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.
2231
2323
  */
2232
2324
  disabled: locked,
2233
2325
  style: { padding: '0 6px' },
@@ -2627,27 +2719,50 @@ window.__ModuleLoader__.load({
2627
2719
  }
2628
2720
  const problem =
2629
2721
  team.problem === null || team.problem === undefined ? null : h('span', { style: L.error }, team.problem);
2630
- if (draft !== null) {
2722
+ /*
2723
+ * The team editor is a window over the list, the same shape as the
2724
+ * persona editor. It used to replace the list outright: opening one team
2725
+ * hid every other team and the count above them, so the answer to
2726
+ * "is that the one I already have?" was behind the dialog being edited.
2727
+ */
2728
+ const editor =
2729
+ draft === null
2730
+ ? null
2731
+ : h(
2732
+ Sheet,
2733
+ {
2734
+ open: true,
2735
+ title:
2736
+ draft.name === undefined || draft.name === ''
2737
+ ? t('teamNew')
2738
+ : `${t('teamEdit')} · ${draft.name}`,
2739
+ width: '860px',
2740
+ t,
2741
+ onClose: onCancelDraft,
2742
+ // 与人设编辑器同一档:草稿只认显式的取消/保存。
2743
+ locked: true,
2744
+ },
2745
+ h(TeamDraftCard, {
2746
+ draft,
2747
+ t,
2748
+ busy,
2749
+ rows: team.rows,
2750
+ groups: team.groups,
2751
+ onPatch,
2752
+ onSave: onSaveDraft,
2753
+ onCancel: onCancelDraft,
2754
+ problem: fieldProblem,
2755
+ }),
2756
+ );
2757
+ if (team.teams.length === 0) {
2631
2758
  return h(
2632
2759
  'div',
2633
2760
  { style: L.list },
2634
2761
  problem,
2635
- h(TeamDraftCard, {
2636
- draft,
2637
- t,
2638
- busy,
2639
- rows: team.rows,
2640
- groups: team.groups,
2641
- onPatch,
2642
- onSave: onSaveDraft,
2643
- onCancel: onCancelDraft,
2644
- problem: fieldProblem,
2645
- }),
2762
+ h('div', { style: L.card }, h('span', { style: L.hint }, t('teamEmpty'))),
2763
+ editor,
2646
2764
  );
2647
2765
  }
2648
- if (team.teams.length === 0) {
2649
- return h('div', { style: L.list }, problem, h('div', { style: L.card }, h('span', { style: L.hint }, t('teamEmpty'))));
2650
- }
2651
2766
  return h(
2652
2767
  'div',
2653
2768
  { style: L.list },
@@ -2742,6 +2857,7 @@ window.__ModuleLoader__.load({
2742
2857
  h(TeamPreview, { preview: entry.preview, t }),
2743
2858
  );
2744
2859
  }),
2860
+ editor,
2745
2861
  );
2746
2862
  }
2747
2863
 
@@ -3547,7 +3663,9 @@ window.__ModuleLoader__.load({
3547
3663
  onImportClick,
3548
3664
  onDownloadTemplate,
3549
3665
  onPatchDraft,
3550
- onCommit,
3666
+ onSave,
3667
+ dirty,
3668
+ onCloseDraft,
3551
3669
  page,
3552
3670
  pageSize,
3553
3671
  onPage,
@@ -3669,45 +3787,55 @@ window.__ModuleLoader__.load({
3669
3787
  groupFolded(group.key)
3670
3788
  ? null
3671
3789
  : group.rows.map((item) =>
3672
- h(
3673
- React.Fragment,
3674
- { key: item.id },
3675
- h(WifeRow, {
3676
- item,
3677
- t,
3678
- open: openId === item.id,
3679
- busy,
3680
- isDefault: data !== null && data.defaultId === item.id,
3681
- broken: brokenById === undefined ? null : (brokenById[item.id] ?? null),
3682
- selected: batches.includes(item.id),
3683
- batchMode,
3684
- onToggle: () => onOpenRow(item),
3685
- onSelect: () => onSelect(item.id),
3686
- onPreview: () => onPreviewOne(item),
3687
- onExport: () => onExportOne(item.id),
3688
- onHide: () => onHideOne(item),
3689
- onDefault: (next) => onSetDefault(item, next),
3690
- dragging,
3691
- dropTarget,
3692
- reorder: reorderMode === true,
3693
- onDragStart,
3694
- onDragOver,
3695
- onDrop,
3696
- onDragEnd,
3697
- }),
3698
- openId === item.id && draft !== null && draft.id === item.id
3699
- ? h(Editor, { draft, t, busy, onPatch: onPatchDraft, onCommit })
3700
- : null,
3701
- ),
3790
+ h(WifeRow, {
3791
+ key: item.id,
3792
+ item,
3793
+ t,
3794
+ open: openId === item.id,
3795
+ busy,
3796
+ isDefault: data !== null && data.defaultId === item.id,
3797
+ broken: brokenById === undefined ? null : (brokenById[item.id] ?? null),
3798
+ selected: batches.includes(item.id),
3799
+ batchMode,
3800
+ onToggle: () => onOpenRow(item),
3801
+ onSelect: () => onSelect(item.id),
3802
+ onPreview: () => onPreviewOne(item),
3803
+ onExport: () => onExportOne(item.id),
3804
+ onHide: () => onHideOne(item),
3805
+ onDefault: (next) => onSetDefault(item, next),
3806
+ dragging,
3807
+ dropTarget,
3808
+ reorder: reorderMode === true,
3809
+ onDragStart,
3810
+ onDragOver,
3811
+ onDrop,
3812
+ onDragEnd,
3813
+ }),
3702
3814
  ),
3703
3815
  ),
3704
3816
  ),
3705
- openId === NEW && draft !== null
3817
+ /*
3818
+ * The editor is a window now, not a row that grew.
3819
+ *
3820
+ * A row sits inside the scrolling list, so opening one pushed everything
3821
+ * below it down, and the body was read through a window four rows tall.
3822
+ * One window, sized for the work, opened over the list: the roster stays
3823
+ * where it was, and closing it cannot leave a half-open row behind.
3824
+ */
3825
+ draft !== null && openId !== null
3706
3826
  ? h(
3707
- 'div',
3708
- { style: L.list },
3709
- h('div', { style: { ...L.item, ...L.skeletonRow } }, h('span', { style: L.name }, t('add'))),
3710
- h(Editor, { draft, t, busy, onPatch: onPatchDraft, onCommit }),
3827
+ Sheet,
3828
+ {
3829
+ open: true,
3830
+ // A new persona has no name yet; the edit draft names itself.
3831
+ title: draft.mode === 'new' ? t('personaNew') : `${t('personaEdit')} · ${draft.name || draft.id}`,
3832
+ width: '820px',
3833
+ t,
3834
+ onClose: onCloseDraft,
3835
+ // 点遮罩、Esc 都不收:草稿只认显式的取消/保存。
3836
+ locked: true,
3837
+ },
3838
+ h(Editor, { draft, t, busy, onPatch: onPatchDraft, onSave, onCancel: onCloseDraft, dirty, tall: true }),
3711
3839
  )
3712
3840
  : null,
3713
3841
  pageCount > 1
@@ -3838,12 +3966,12 @@ window.__ModuleLoader__.load({
3838
3966
  const idAlias = React.useRef(new Map());
3839
3967
  const fileInput = React.useRef(null);
3840
3968
  /**
3841
- * The autosave currently in flight, if any.
3969
+ * The explicit save currently in flight, if any.
3842
3970
  *
3843
- * Clicking the eye blurs the textarea first, and blur is what starts the
3844
- * save — so without this the preview races the write it just triggered
3845
- * and shows the file one keystroke behind. Preview is a read of the disk,
3846
- * 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.
3847
3975
  */
3848
3976
  const savingRef = React.useRef(null);
3849
3977
 
@@ -4416,6 +4544,13 @@ window.__ModuleLoader__.load({
4416
4544
  savedKey: written,
4417
4545
  });
4418
4546
  }
4547
+ /*
4548
+ * 保存成功即收窗。写盘已经完成,草稿没有值得留下的东西——这与
4549
+ * 团队编辑器的「成功即 `setTeamDraft(null)`」是同一条约定;留窗
4550
+ * 是失焦自动保存时代的习惯,那时窗口必须开着等下一次失焦。
4551
+ * 失败路径不动:弹窗留在原地,错误贴在窗内。
4552
+ */
4553
+ putDraft(null);
4419
4554
  await load();
4420
4555
  await loadRoster();
4421
4556
  } catch (failure) {
@@ -4430,7 +4565,13 @@ window.__ModuleLoader__.load({
4430
4565
  }
4431
4566
  }
4432
4567
 
4433
- /** Blur, row switch and unmount all funnel through here. */
4568
+ /**
4569
+ * 编辑器「保存」按钮的唯一入口。
4570
+ *
4571
+ * 曾经失焦也会走到这里(blur → commit),如今唯一的调用方是按钮:
4572
+ * 写盘必须是人按下去的那个动作。`save` 自己做去重——草稿与上一次
4573
+ * 保存一致时不发请求——所以连点两下、或者没改就点,都是空操作。
4574
+ */
4434
4575
  const commit = React.useCallback(() => {
4435
4576
  const current = draftRef.current;
4436
4577
  if (current === null || current.loading === true) return;
@@ -4443,8 +4584,8 @@ window.__ModuleLoader__.load({
4443
4584
  });
4444
4585
  }, []);
4445
4586
 
4587
+ /* 关窗即放弃:没有点「保存」的改动一个字都不落盘。 */
4446
4588
  function closeRow() {
4447
- commit();
4448
4589
  showRow(null);
4449
4590
  putDraft(null);
4450
4591
  setDialog(null);
@@ -4463,8 +4604,8 @@ window.__ModuleLoader__.load({
4463
4604
  const id = item.id;
4464
4605
  setPeek({ id, name: item.name ?? id, loading: true, text: '', error: '' });
4465
4606
  try {
4466
- // The blur that opened this window already kicked off a save; reading
4467
- // 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.
4468
4609
  if (savingRef.current !== null) await savingRef.current;
4469
4610
  const payload = await post({ op: 'get', id, assembled: true });
4470
4611
  setPeek((prev) =>
@@ -4486,7 +4627,6 @@ window.__ModuleLoader__.load({
4486
4627
  closeRow();
4487
4628
  return;
4488
4629
  }
4489
- commit();
4490
4630
  setDialog(null);
4491
4631
  setNote(null);
4492
4632
  showRow(item.id);
@@ -4534,7 +4674,6 @@ window.__ModuleLoader__.load({
4534
4674
  closeRow();
4535
4675
  return;
4536
4676
  }
4537
- commit();
4538
4677
  setDialog(null);
4539
4678
  setNote(null);
4540
4679
  showRow(NEW);
@@ -4561,7 +4700,6 @@ window.__ModuleLoader__.load({
4561
4700
  closeRow();
4562
4701
  return;
4563
4702
  }
4564
- commit();
4565
4703
  showRow(SKELETON);
4566
4704
  setNote(null);
4567
4705
  putSkeletonDraft({ text: '', slots: 0, nameSlots: 0, problems: [], rev: null, loading: true });
@@ -5472,7 +5610,11 @@ window.__ModuleLoader__.load({
5472
5610
  onImportClick: () => fileInput.current?.click(),
5473
5611
  onDownloadTemplate: downloadTemplate,
5474
5612
  onPatchDraft: patchDraft,
5475
- onCommit: commit,
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.
5617
+ onCloseDraft: closeRow,
5476
5618
  onOpenSkeleton: openSkeleton,
5477
5619
  skeletonDraft,
5478
5620
  onPatchSkeleton: patchSkeletonDraft,