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 +5 -1
- package/client.js +184 -85
- package/http-route.js +178 -9
- package/index.js +150 -17
- package/memory-ops.js +159 -55
- package/package.json +2 -1
- package/panel-ops.js +2936 -2356
- package/persona-memory.js +232 -40
- package/personas/README.md +1 -1
- package/scripts/run-suites.mjs +2 -1
- package/serialize.js +133 -0
- package/team-memory.js +227 -17
- package/team-runtime.js +47 -26
- package/team-simulate.js +61 -16
- package/team.js +14 -3
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
|
|
1851
|
-
*
|
|
1852
|
-
*
|
|
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
|
-
|
|
1859
|
-
{
|
|
1860
|
-
|
|
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
|
-
|
|
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(
|
|
2636
|
-
|
|
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
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
3686
|
-
|
|
3687
|
-
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
|
|
3691
|
-
|
|
3692
|
-
|
|
3693
|
-
|
|
3694
|
-
|
|
3695
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3708
|
-
{
|
|
3709
|
-
|
|
3710
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
});
|