dsh-waifu-agents 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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: '导出这一条',
@@ -433,6 +436,8 @@ window.__ModuleLoader__.load({
433
436
  tabMemory: 'Memory',
434
437
  search: 'Search name, summary or body…',
435
438
  add: 'New',
439
+ personaNew: 'New persona',
440
+ personaEdit: 'Edit persona',
436
441
  import: 'Import md',
437
442
  exportAll: 'Export all',
438
443
  exportOne: 'Export this file',
@@ -1036,6 +1041,26 @@ window.__ModuleLoader__.load({
1036
1041
  borderRadius: 'var(--dsw-radius-panel, 12px)',
1037
1042
  background: 'var(--dsw-alias-bg-layer-2)',
1038
1043
  },
1044
+ /*
1045
+ * The wide dialog behind `Sheet`. Same surface as `dialog`, but its height
1046
+ * is capped and its body scrolls: a persona body can be arbitrarily long,
1047
+ * and a window taller than the viewport puts the close button off screen.
1048
+ */
1049
+ sheet: {
1050
+ display: 'flex',
1051
+ flexDirection: 'column',
1052
+ gap: '12px',
1053
+ maxWidth: 'calc(100vw - 48px)',
1054
+ maxHeight: '86vh',
1055
+ padding: '16px',
1056
+ border: '1px solid var(--dsw-alias-border-l2)',
1057
+ borderRadius: 'var(--dsw-radius-panel, 12px)',
1058
+ background: 'var(--dsw-alias-bg-layer-2)',
1059
+ boxSizing: 'border-box',
1060
+ boxShadow: '0 18px 48px rgba(0, 0, 0, 0.28)',
1061
+ },
1062
+ sheetHead: { display: 'flex', alignItems: 'flex-start', gap: '8px' },
1063
+ sheetBody: { display: 'flex', flexDirection: 'column', gap: '10px', overflow: 'auto', minHeight: 0 },
1039
1064
  dragging: { opacity: 0.45 },
1040
1065
  reorderRow: { cursor: 'grab', userSelect: 'none' },
1041
1066
  dropTarget: { borderColor: 'var(--dsw-alias-state-business-primary)', borderStyle: 'dashed' },
@@ -1569,6 +1594,67 @@ window.__ModuleLoader__.load({
1569
1594
  );
1570
1595
  }
1571
1596
 
1597
+ /**
1598
+ * 一块从中间升起的面板:新增/编辑人设、新建/编辑团队、查看组装结果都用它。
1599
+ *
1600
+ * 为什么不用平台的 `Modal`:它的宽度不在本运行时的契约里(面板拿到的
1601
+ * 只有 `Button`/`Tag`/`MarkdownText` 的已核实形状),而这三样恰恰需要
1602
+ * 比一句确认宽得多的版面——整段正文、一列成员勾选、一整份组装后的
1603
+ * 提示词。`Confirm` / `Alert` 那种一句话对话框仍然走平台,两者不冲突:
1604
+ * 这里只借用同一层遮罩色和同一套主题 token。
1605
+ *
1606
+ * 三条收尾路径都要有:右上角的关闭、点遮罩、Esc。少一条,用户就会在
1607
+ * 某一次编辑后发现"关不掉",而那正是弹窗最容易招人烦的地方。
1608
+ */
1609
+ function Sheet({ open, title, hint, width, t, onClose, footer, children }) {
1610
+ React.useEffect(() => {
1611
+ if (open !== true) return undefined;
1612
+ const onKey = (event) => {
1613
+ if (event.key === 'Escape') onClose();
1614
+ };
1615
+ document.addEventListener('keydown', onKey);
1616
+ return () => document.removeEventListener('keydown', onKey);
1617
+ }, [open, onClose]);
1618
+ if (open !== true) return null;
1619
+ return h(
1620
+ 'div',
1621
+ { style: { ...L.overlay, zIndex: 60 }, onClick: onClose },
1622
+ h(
1623
+ 'div',
1624
+ {
1625
+ style: { ...L.sheet, width: width ?? '760px' },
1626
+ role: 'dialog',
1627
+ 'aria-modal': 'true',
1628
+ 'aria-label': title,
1629
+ // A click that landed on the window itself — typing, selecting
1630
+ // text, dragging the scrollbar — must not close it.
1631
+ onClick: (event) => event.stopPropagation(),
1632
+ },
1633
+ h(
1634
+ 'div',
1635
+ { style: L.sheetHead },
1636
+ h(
1637
+ 'div',
1638
+ { style: L.rowMain },
1639
+ h('span', { style: L.rowTitle }, title),
1640
+ hint === undefined || hint === null || hint === '' ? null : h('span', { style: L.hint }, hint),
1641
+ ),
1642
+ h(Btn, {
1643
+ variant: 'toolbar',
1644
+ size: 'sm',
1645
+ icon: h(Icon, { name: 'close', size: 16 }),
1646
+ title: t('close'),
1647
+ 'aria-label': t('close'),
1648
+ style: { padding: '0 6px' },
1649
+ onClick: onClose,
1650
+ }),
1651
+ ),
1652
+ h('div', { style: L.sheetBody }, children),
1653
+ footer ?? null,
1654
+ ),
1655
+ );
1656
+ }
1657
+
1572
1658
  /**
1573
1659
  * A v4 UUID, minted by the PANEL rather than the Host.
1574
1660
  *
@@ -1847,41 +1933,21 @@ window.__ModuleLoader__.load({
1847
1933
  h(Markdown, { text: text ?? '' }),
1848
1934
  );
1849
1935
  /*
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.
1936
+ * The same wide window the two editors use, for two reasons.
1937
+ *
1938
+ * Width: this shows the assembled prompt — the thing the model actually
1939
+ * receives — and it was being read through a 560px column, which is
1940
+ * narrower than the editor that produced it. Reading the result should
1941
+ * not be harder than writing it.
1942
+ *
1943
+ * Consistency: the editor that this opens from is a Sheet, so a
1944
+ * different dialog shape one click later would read as two different
1945
+ * products sharing a page.
1853
1946
  */
1854
- if (primitives !== null && typeof primitives.Modal === 'function') {
1855
- return h(primitives.Modal, { open: true, onClose, title, closeLabel: t('close'), children: content });
1856
- }
1857
1947
  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
- ),
1948
+ Sheet,
1949
+ { open: true, title, width: '980px', t, onClose },
1950
+ content,
1885
1951
  );
1886
1952
  }
1887
1953
 
@@ -1932,7 +1998,7 @@ window.__ModuleLoader__.load({
1932
1998
  );
1933
1999
  }
1934
2000
 
1935
- function Editor({ draft, t, busy, onPatch, onCommit }) {
2001
+ function Editor({ draft, t, busy, onPatch, onCommit, tall }) {
1936
2002
  const loading = draft.loading === true;
1937
2003
  const field = (label, ...controls) => h('div', { style: L.field }, h('span', { style: L.label }, label), ...controls);
1938
2004
  const text = draft.body ?? '';
@@ -1993,7 +2059,7 @@ window.__ModuleLoader__.load({
1993
2059
  * exactly one mode and no state to remember.
1994
2060
  */
1995
2061
  h('textarea', {
1996
- style: L.textarea,
2062
+ style: tall === true ? { ...L.textarea, minHeight: '44vh' } : L.textarea,
1997
2063
  value: text,
1998
2064
  disabled: busy,
1999
2065
  placeholder: t('bodyPlaceholder'),
@@ -2627,27 +2693,48 @@ window.__ModuleLoader__.load({
2627
2693
  }
2628
2694
  const problem =
2629
2695
  team.problem === null || team.problem === undefined ? null : h('span', { style: L.error }, team.problem);
2630
- if (draft !== null) {
2696
+ /*
2697
+ * The team editor is a window over the list, the same shape as the
2698
+ * persona editor. It used to replace the list outright: opening one team
2699
+ * hid every other team and the count above them, so the answer to
2700
+ * "is that the one I already have?" was behind the dialog being edited.
2701
+ */
2702
+ const editor =
2703
+ draft === null
2704
+ ? null
2705
+ : h(
2706
+ Sheet,
2707
+ {
2708
+ open: true,
2709
+ title:
2710
+ draft.name === undefined || draft.name === ''
2711
+ ? t('teamNew')
2712
+ : `${t('teamEdit')} · ${draft.name}`,
2713
+ width: '860px',
2714
+ t,
2715
+ onClose: onCancelDraft,
2716
+ },
2717
+ h(TeamDraftCard, {
2718
+ draft,
2719
+ t,
2720
+ busy,
2721
+ rows: team.rows,
2722
+ groups: team.groups,
2723
+ onPatch,
2724
+ onSave: onSaveDraft,
2725
+ onCancel: onCancelDraft,
2726
+ problem: fieldProblem,
2727
+ }),
2728
+ );
2729
+ if (team.teams.length === 0) {
2631
2730
  return h(
2632
2731
  'div',
2633
2732
  { style: L.list },
2634
2733
  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
- }),
2734
+ h('div', { style: L.card }, h('span', { style: L.hint }, t('teamEmpty'))),
2735
+ editor,
2646
2736
  );
2647
2737
  }
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
2738
  return h(
2652
2739
  'div',
2653
2740
  { style: L.list },
@@ -2742,6 +2829,7 @@ window.__ModuleLoader__.load({
2742
2829
  h(TeamPreview, { preview: entry.preview, t }),
2743
2830
  );
2744
2831
  }),
2832
+ editor,
2745
2833
  );
2746
2834
  }
2747
2835
 
@@ -3548,6 +3636,7 @@ window.__ModuleLoader__.load({
3548
3636
  onDownloadTemplate,
3549
3637
  onPatchDraft,
3550
3638
  onCommit,
3639
+ onCloseDraft,
3551
3640
  page,
3552
3641
  pageSize,
3553
3642
  onPage,
@@ -3669,45 +3758,53 @@ window.__ModuleLoader__.load({
3669
3758
  groupFolded(group.key)
3670
3759
  ? null
3671
3760
  : 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
- ),
3761
+ h(WifeRow, {
3762
+ key: item.id,
3763
+ item,
3764
+ t,
3765
+ open: openId === item.id,
3766
+ busy,
3767
+ isDefault: data !== null && data.defaultId === item.id,
3768
+ broken: brokenById === undefined ? null : (brokenById[item.id] ?? null),
3769
+ selected: batches.includes(item.id),
3770
+ batchMode,
3771
+ onToggle: () => onOpenRow(item),
3772
+ onSelect: () => onSelect(item.id),
3773
+ onPreview: () => onPreviewOne(item),
3774
+ onExport: () => onExportOne(item.id),
3775
+ onHide: () => onHideOne(item),
3776
+ onDefault: (next) => onSetDefault(item, next),
3777
+ dragging,
3778
+ dropTarget,
3779
+ reorder: reorderMode === true,
3780
+ onDragStart,
3781
+ onDragOver,
3782
+ onDrop,
3783
+ onDragEnd,
3784
+ }),
3702
3785
  ),
3703
3786
  ),
3704
3787
  ),
3705
- openId === NEW && draft !== null
3788
+ /*
3789
+ * The editor is a window now, not a row that grew.
3790
+ *
3791
+ * A row sits inside the scrolling list, so opening one pushed everything
3792
+ * below it down, and the body was read through a window four rows tall.
3793
+ * One window, sized for the work, opened over the list: the roster stays
3794
+ * where it was, and closing it cannot leave a half-open row behind.
3795
+ */
3796
+ draft !== null && openId !== null
3706
3797
  ? 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 }),
3798
+ Sheet,
3799
+ {
3800
+ open: true,
3801
+ // A new persona has no name yet; the edit draft names itself.
3802
+ title: draft.mode === 'new' ? t('personaNew') : `${t('personaEdit')} · ${draft.name || draft.id}`,
3803
+ width: '820px',
3804
+ t,
3805
+ onClose: onCloseDraft,
3806
+ },
3807
+ h(Editor, { draft, t, busy, onPatch: onPatchDraft, onCommit, tall: true }),
3711
3808
  )
3712
3809
  : null,
3713
3810
  pageCount > 1
@@ -5473,6 +5570,8 @@ window.__ModuleLoader__.load({
5473
5570
  onDownloadTemplate: downloadTemplate,
5474
5571
  onPatchDraft: patchDraft,
5475
5572
  onCommit: commit,
5573
+ // Closing the editor window: commit what was typed, then drop the draft.
5574
+ onCloseDraft: closeRow,
5476
5575
  onOpenSkeleton: openSkeleton,
5477
5576
  skeletonDraft,
5478
5577
  onPatchSkeleton: patchSkeletonDraft,
package/http-route.js CHANGED
@@ -21,6 +21,69 @@
21
21
  * 启动这个独立 fiber。它有意待在预设注册之外:万一 webserver 缺席,丢的是
22
22
  * 面板,而不是人设。
23
23
  */
24
+ /**
25
+ * 面板路由。是 `prefix`,所以子路径也能匹配。
26
+ *
27
+ * 提到模块级是为了让重试的告警文案和错误识别都能引用同一个字符串 —— 用户
28
+ * 在日志里看到的路径,必须和代码里查找的那条一字不差。
29
+ */
30
+ const ROUTE_PATH = "/waifu-agents/api";
31
+
32
+ /**
33
+ * 注册被占用时的重试间隔(毫秒)。
34
+ *
35
+ * ## 为什么要重试
36
+ *
37
+ * `webServer.register` 对重复 path **直接抛错**,从不 upsert。热重载时新实例
38
+ * 的 `apply` 常常早于旧实例的 `dispose`,于是新实例第一次注册必然撞上旧的那
39
+ * 条路由;而 cordis 不会重试一个抛过错的 fiber —— 结果就是新实例**一直没有**
40
+ * 面板路由,直到下一次重载。用户看到的是「面板整个消失,以为插件坏了」。
41
+ *
42
+ * 旧实例真正让出路由通常在几十毫秒内(同一次重载的收尾),所以短间隔的三次
43
+ * 试探足以覆盖正常重载;试探失败不重试到底,是因为「旧实例什么时候死」不是
44
+ * 我们能观测的事 —— 无限重试只会把一个已知失败变成后台噪声。
45
+ *
46
+ * 间隔总长 **600 ms**,和告警文案是一对:用户从「面板空白」到「看见一句人话」
47
+ * 最多等这么久,所以文案里不能写「立刻恢复」——那句话说的是恢复怎么最快,但
48
+ * 读的人会拿它比对刚才那一段空白。宁可把预算压短、把话写实。
49
+ */
50
+ const RETRY_DELAYS_MS = [120, 200, 280];
51
+
52
+ /**
53
+ * 这条错误是不是「path 已经被占了」。
54
+ *
55
+ * 只在文案上认,因为宿主没有给出错误码。宽松到 `duplicate` / `already` 两个
56
+ * 词:认错(把别的错误当占用)的代价是多试三次仍然失败、最后仍会告警;认漏
57
+ * 的代价是**当场放弃**,那正是这次要修的那个观感问题。所以宁可认宽。
58
+ */
59
+ function looksOccupied(error) {
60
+ const text = String(error?.message ?? error ?? "");
61
+ return /duplicate|already|exists/i.test(text);
62
+ }
63
+
64
+ /**
65
+ * 一次注册尝试的结果,只分三态:拿到 disposer、注册了但没有 disposer、
66
+ * 或者抛了。
67
+ *
68
+ * 「注册了但没有 disposer」不能当失败:路由已经进表、面板已经能用,缺的只是
69
+ * 我们没法主动注销它 —— 当失败去重试反而会撞上自己刚注册的那条。
70
+ */
71
+ function adoptRegistration(outcome, onDisposer, onFailure) {
72
+ if (typeof outcome === "function") {
73
+ onDisposer(outcome);
74
+ return;
75
+ }
76
+ if (outcome !== null && typeof outcome === "object" && typeof outcome.then === "function") {
77
+ /* 万一宿主将来改成异步注册:接住 reject,别让它变成未处理的 Promise。 */
78
+ outcome.then(
79
+ (fn) => onDisposer(typeof fn === "function" ? fn : null),
80
+ (error) => onFailure(error),
81
+ );
82
+ return;
83
+ }
84
+ onDisposer(null);
85
+ }
86
+
24
87
  /**
25
88
  * @param {object} ctx 插件自己的上下文
26
89
  * @param {{ run: (request: object) => Promise<object> }} deps op 分发器
@@ -29,9 +92,6 @@
29
92
  export function installHttpRoute(ctx, deps) {
30
93
  const { run } = deps;
31
94
 
32
- /** 面板路由。是 `prefix`,所以子路径也能匹配。 */
33
- const ROUTE_PATH = "/waifu-agents/api";
34
-
35
95
  /** 只有回环(loopback)对端可以驱动这条路由。 */
36
96
  function isLoopbackPeer(req) {
37
97
  const address = req.socket?.remoteAddress ?? "";
@@ -113,13 +173,122 @@ export function installHttpRoute(ctx, deps) {
113
173
  * 在上面的预设注册之外:万一 webserver 缺席,丢的是面板,而不是人设。
114
174
  */
115
175
  ctx.inject(["webServer"], (ctx) => {
176
+ /*
177
+ * 一次注册的完整生命周期都在这个闭包里:重试计时器、拿到的 disposer、
178
+ * 以及「已经注销了」这个事实。三者必须一起管,否则会留下「注销之后又把
179
+ * 路由注册回来」的悬空计时器。
180
+ */
116
181
  ctx.effect(
117
- () =>
118
- ctx.webServer.register({
119
- kind: "prefix",
120
- path: ROUTE_PATH,
121
- handler: (req, res) => handleHttp(req, res),
122
- }),
182
+ () => {
183
+ let disposed = false;
184
+ /** 当前这次成功注册拿到的注销函数;没有就是 `null`。 */
185
+ let release = null;
186
+ /** 尚未触发的重试;`null` 表示没有在等。 */
187
+ let timer = null;
188
+
189
+ const warn = (message) => {
190
+ try {
191
+ ctx.logger?.warn?.(message);
192
+ } catch {
193
+ /* 日志服务缺席不该让面板路由这条线再抛一次 */
194
+ }
195
+ };
196
+
197
+ const detach = () => {
198
+ const fn = release;
199
+ release = null;
200
+ if (typeof fn !== "function") return;
201
+ try {
202
+ fn();
203
+ } catch {
204
+ /* 旧实例可能已经自行注销;重复注销不是本函数要处理的失败 */
205
+ }
206
+ };
207
+
208
+ /** 放弃注册时的唯一出口:说人话,且只说一次。 */
209
+ const giveUp = (error, permanent) => {
210
+ if (permanent) {
211
+ warn(
212
+ `面板路由没注册上:另一个 waifu-agents 实例还占着 ${ROUTE_PATH}(热重载时常见)。` +
213
+ "设置面板可能到下次重载前都打不开——完全退出 DSH 再启动就能恢复;人设、记忆、团队不受影响。",
214
+ );
215
+ return;
216
+ }
217
+ warn(
218
+ `面板路由没注册上(${ROUTE_PATH}):${String(error?.message ?? error)}。` +
219
+ "设置面板可能打不开;人设、记忆、团队不受影响。",
220
+ );
221
+ };
222
+
223
+ const attempt = (index) => {
224
+ timer = null;
225
+ if (disposed) return;
226
+ let outcome;
227
+ try {
228
+ outcome = ctx.webServer.register({
229
+ kind: "prefix",
230
+ path: ROUTE_PATH,
231
+ handler: (req, res) => handleHttp(req, res),
232
+ });
233
+ } catch (error) {
234
+ const occupied = looksOccupied(error);
235
+ if (occupied && index < RETRY_DELAYS_MS.length) {
236
+ timer = setTimeout(() => attempt(index + 1), RETRY_DELAYS_MS[index]);
237
+ /* 计时器不该把进程留着不退出:重试是背景行为,不是待办工作。 */
238
+ timer?.unref?.();
239
+ return;
240
+ }
241
+ giveUp(error, occupied);
242
+ return;
243
+ }
244
+ adoptRegistration(
245
+ outcome,
246
+ (fn) => {
247
+ /*
248
+ * 先把 disposer 交给 `release`,再决定要不要立刻用掉:
249
+ * `detach()` 自己会把它置空,所以「这里调一次、之后注销又调一次」
250
+ * 也只会真正归还一次 —— 幂等性由 `detach` 的 `release = null`
251
+ * 保证,而不是靠这个分支记得提前 return。
252
+ *
253
+ * 这条路径只在注册是异步的(`register` 返回 Promise)时才会走到:
254
+ * resolve 与 dispose 撞在一起,`disposed` 已经是 true。
255
+ */
256
+ release = fn;
257
+ if (disposed) {
258
+ detach();
259
+ return;
260
+ }
261
+ if (index > 0) {
262
+ try {
263
+ ctx.logger?.debug?.(`waifu-agents: 面板路由在重试 ${index} 次后注册上了`);
264
+ } catch {
265
+ /* 日志服务缺席不影响已经注册上的路由 */
266
+ }
267
+ }
268
+ },
269
+ (error) => {
270
+ const occupied = looksOccupied(error);
271
+ if (occupied && index < RETRY_DELAYS_MS.length) {
272
+ timer = setTimeout(() => attempt(index + 1), RETRY_DELAYS_MS[index]);
273
+ timer?.unref?.();
274
+ return;
275
+ }
276
+ giveUp(error, occupied);
277
+ },
278
+ );
279
+ };
280
+
281
+ attempt(0);
282
+
283
+ return () => {
284
+ disposed = true;
285
+ if (timer !== null) {
286
+ clearTimeout(timer);
287
+ timer = null;
288
+ }
289
+ detach();
290
+ };
291
+ },
123
292
  "waifu-agents: http route",
124
293
  );
125
294
  });