@mzzsfy/dsh-session-manager 0.9.7 → 0.10.0
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/package.json +9 -3
- package/src/client.js +63 -17
- package/src/index.js +87 -13
- package/src/list-slim.mjs +106 -0
- package/test/client-row-actions.test.mjs +20 -0
- package/test/client-switch.test.mjs +49 -0
- package/test/index.test.mjs +79 -0
- package/test/list-slim.test.mjs +174 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mzzsfy/dsh-session-manager",
|
|
3
3
|
"description": "会话自动归档与管理:超期自动归档、归档面板、删除进系统回收站、还原后一键重挂载",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.10.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"./package.json": "./package.json"
|
|
11
11
|
},
|
|
12
12
|
"dsh": {
|
|
13
|
+
"hostMin": "0.2.1-rc.2",
|
|
13
14
|
"bundle": {
|
|
14
15
|
"patch": "./cordis.patch.yml"
|
|
15
16
|
},
|
|
@@ -20,12 +21,13 @@
|
|
|
20
21
|
]
|
|
21
22
|
},
|
|
22
23
|
"compatibility": {
|
|
23
|
-
"hostMin": "0.2.1-rc.2",
|
|
24
24
|
"dshReleases": {
|
|
25
25
|
"0.2.0-rc.1": "unknown",
|
|
26
26
|
"0.2.0-rc.2": "unknown",
|
|
27
27
|
"0.2.1-alpha.1": "unknown",
|
|
28
|
-
"0.2.1-rc.2": "compatible"
|
|
28
|
+
"0.2.1-rc.2": "compatible",
|
|
29
|
+
"0.2.1-rc.5": "compatible",
|
|
30
|
+
"0.2.2-rc.1": "compatible"
|
|
29
31
|
}
|
|
30
32
|
}
|
|
31
33
|
},
|
|
@@ -33,6 +35,10 @@
|
|
|
33
35
|
"test": "node --test \"test/*.test.mjs\"",
|
|
34
36
|
"npmPublish": "npm publish --access public --provenance=false --registry=https://registry.npmjs.org"
|
|
35
37
|
},
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public",
|
|
40
|
+
"registry": "https://registry.npmjs.org"
|
|
41
|
+
},
|
|
36
42
|
"license": "MIT",
|
|
37
43
|
"repository": {
|
|
38
44
|
"type": "git",
|
package/src/client.js
CHANGED
|
@@ -123,6 +123,20 @@ const CSS = [
|
|
|
123
123
|
' border:1px solid var(--dsw-alias-border-l2);',
|
|
124
124
|
' background:transparent; color:inherit; font:inherit; }',
|
|
125
125
|
'.sm-cfg__field input:focus-visible { outline:2px solid var(--dsw-alias-state-business-primary); outline-offset:0; }',
|
|
126
|
+
// 列表瘦身开关:原生 checkbox 保语义与可访问性,视觉由 track/thumb 呈现
|
|
127
|
+
'.sm-switch { display:inline-flex; align-items:center; gap:8px; min-height:28px; cursor:pointer;',
|
|
128
|
+
' font:var(--dsw-font-xxs-12, 12px/18px sans-serif); color:var(--dsw-alias-label-caption); }',
|
|
129
|
+
'.sm-switch input[type="checkbox"] { position:absolute; opacity:0; width:0; height:0; }',
|
|
130
|
+
'.sm-switch__track { position:relative; flex-shrink:0; width:34px; height:19px; border-radius:999px; box-sizing:border-box;',
|
|
131
|
+
' background:transparent; border:1px solid var(--dsw-alias-border-l2); transition:background 0.15s,border-color 0.15s; }',
|
|
132
|
+
'.sm-switch__thumb { position:absolute; top:50%; left:2px; width:13px; height:13px; border-radius:50%;',
|
|
133
|
+
' background:var(--dsw-alias-label-caption); transform:translateY(-50%); transition:left 0.15s,background 0.15s; }',
|
|
134
|
+
'.sm-switch:hover .sm-switch__track { border-color:var(--dsw-alias-state-business-primary); }',
|
|
135
|
+
'.sm-switch input[type="checkbox"]:checked + .sm-switch__track { background:var(--dsw-alias-state-business-primary); border-color:var(--dsw-alias-state-business-primary); }',
|
|
136
|
+
'.sm-switch input[type="checkbox"]:checked + .sm-switch__track .sm-switch__thumb { left:17px; background:#fff; }',
|
|
137
|
+
'.sm-switch input[type="checkbox"]:focus-visible + .sm-switch__track { outline:2px solid var(--dsw-alias-state-business-primary); outline-offset:2px; }',
|
|
138
|
+
'.sm-switch:has(input[type="checkbox"]:disabled) { opacity:0.5; cursor:not-allowed; }',
|
|
139
|
+
'@media (prefers-reduced-motion: reduce) { .sm-switch__track, .sm-switch__thumb { transition:none; } }',
|
|
126
140
|
].join('\n')
|
|
127
141
|
|
|
128
142
|
const UNARCHIVE_URL = '/api/session-manager/unarchive'
|
|
@@ -136,6 +150,11 @@ const STATUS_URL = '/api/session-manager/status'
|
|
|
136
150
|
// 删除确认链文案:两击行内确认后弹窗强提示,弹窗内确认才发请求。
|
|
137
151
|
// 删除产物不可再生,其他插件(用量统计等)依赖会话产物,删后其会话级数据一并消失
|
|
138
152
|
const DELETE_BUTTON_TITLE = '删除会话产物会影响其他插件的工作(用量统计等依赖会话产物);若无特殊需求,请不要删除。删除需再次行内确认与弹窗确认。'
|
|
153
|
+
// 行操作按钮悬停说明:文案与行为同源维护,由源码契约测试锁定
|
|
154
|
+
const RESTORE_BUTTON_TITLE = '把该会话从归档移回会话列表;恢复后 10 分钟内暂停自动归档,保护「恢复 → 首条消息」窗口,期间发言即恢复正常活跃计时。'
|
|
155
|
+
const REMOUNT_BUTTON_TITLE = '把这条已删除记录对应的会话重新挂回列表:产物仍在原位置时直接挂载,产物已进系统回收站时先还原再挂载。'
|
|
156
|
+
const FORGET_BUTTON_TITLE = '只移除这条已删除记录,不动磁盘上的会话产物;用于产物已手动找回、或不再需要追踪的情况。'
|
|
157
|
+
const MORE_BUTTON_TITLE = '展开该工作区剩余的归档会话;只改变面板显示条数,不改变任何会话的归档状态。'
|
|
139
158
|
const DELETE_DIALOG_TITLE = '确认删除该会话?'
|
|
140
159
|
const DELETE_DIALOG_BODY_PREFIX = '即将删除会话「'
|
|
141
160
|
const DELETE_DIALOG_BODY_SUFFIX = '」。'
|
|
@@ -347,7 +366,7 @@ function ArchiveRow(props) {
|
|
|
347
366
|
]
|
|
348
367
|
: [
|
|
349
368
|
h('button', { key: 'delete', className: 'sm-btn sm-btn--danger', disabled: busy, title: DELETE_BUTTON_TITLE, onClick: props.onDelete }, '删除'),
|
|
350
|
-
h('button', { key: 'restore', className: 'sm-btn sm-btn--restore', disabled: busy, onClick: props.onUnarchive }, '恢复'),
|
|
369
|
+
h('button', { key: 'restore', className: 'sm-btn sm-btn--restore', disabled: busy, title: RESTORE_BUTTON_TITLE, onClick: props.onUnarchive }, '恢复'),
|
|
351
370
|
]
|
|
352
371
|
return h('div', {
|
|
353
372
|
className: 'sm-row' + (armed ? ' sm-row--armed' : '') + (busy ? ' sm-row--busy' : ''),
|
|
@@ -402,8 +421,8 @@ function DeletedRow(props) {
|
|
|
402
421
|
h('span', { key: 'meta', className: 'sm-row__time', title: new Date(row.deletedAt).toLocaleString() }, fmtTime(row.deletedAt)),
|
|
403
422
|
h('span', { className: 'sm-row__title', title: row.title }, row.title),
|
|
404
423
|
h('div', { className: 'sm-row__actions' },
|
|
405
|
-
h('button', { key: 'remount', className: 'sm-btn sm-btn--restore', disabled: busy, onClick: props.onRemount }, '重新挂载'),
|
|
406
|
-
h('button', { key: 'forget', className: 'sm-btn', disabled: busy, onClick: props.onForget }, '移除记录'),
|
|
424
|
+
h('button', { key: 'remount', className: 'sm-btn sm-btn--restore', disabled: busy, title: REMOUNT_BUTTON_TITLE, onClick: props.onRemount }, '重新挂载'),
|
|
425
|
+
h('button', { key: 'forget', className: 'sm-btn', disabled: busy, title: FORGET_BUTTON_TITLE, onClick: props.onForget }, '移除记录'),
|
|
407
426
|
),
|
|
408
427
|
h('span', { className: 'sm-row__path', title: row.path }, '原位置 ' + row.path),
|
|
409
428
|
)
|
|
@@ -554,7 +573,7 @@ function SessionManagerApp(props) {
|
|
|
554
573
|
// 免得数组子节点内无 key 元素随页扩位触发不必要的卸载重挂
|
|
555
574
|
const expandMore = (remaining, onMore) => remaining <= 0
|
|
556
575
|
? null
|
|
557
|
-
: h('button', { key: 'more', className: 'sm-more', onClick: onMore }, '展开更多(剩 ' + remaining + ' 条)')
|
|
576
|
+
: h('button', { key: 'more', className: 'sm-more', title: MORE_BUTTON_TITLE, onClick: onMore }, '展开更多(剩 ' + remaining + ' 条)')
|
|
558
577
|
|
|
559
578
|
// 主列表(最近归档):固定首页不翻页,尾部「按工作区浏览」入口跳转二级视图看全部
|
|
560
579
|
const recentPage = pageArchiveRows(rows, ARCHIVE_PAGE_SIZE)
|
|
@@ -668,11 +687,13 @@ function SessionManagerApp(props) {
|
|
|
668
687
|
const ARCHIVE_OVERVIEW_TITLE = '超过阈值天数未活跃的会话自动移入归档;触发时机为新会话创建、插件启动与周期检查;归档会话在本面板管理,可恢复或删除;恢复后 10 分钟内暂停自动归档,期间发言即恢复正常活跃计时。'
|
|
669
688
|
const ARCHIVE_DAYS_TITLE = '会话超过该天数未活跃(产物无更新)即自动归档,空白(无消息)会话不参与归档;0 = 关闭自动归档。仅对产物可读的会话评估,产物不可读的跳过以免误归档,已归档的会话不会重复处理。'
|
|
670
689
|
const ARCHIVE_INTERVAL_TITLE = '周期检查的间隔小时数,到期会话在下一轮检查时归档;0 = 关闭周期检查(新会话创建与插件启动时的检查不受影响);调小最迟等新间隔即可提前触发,调大自下一轮排期起生效。'
|
|
690
|
+
const LIST_SLIM_TITLE = '会话列表瘦身:列表接口原本把每个会话的完整投影快照(上下文请求头、回合大纲等)一并下发,会话多时单次响应数十 MB、服务端重建要数十秒,每次打开页面都重来一遍。开启后只下发列表真正用到的字段并缓存结果,重复打开不再重建,响应体积下降九成以上;被裁掉的字段在会话详情里照旧完整返回。关闭即恢复原始行为,不影响归档、删除等其它功能。'
|
|
671
691
|
|
|
672
692
|
// 自动归档配置:阈值天数与检查周期小时数,数值语义,失焦或 Enter 单字段即时提交;
|
|
673
693
|
// 值存宿主 settings(与评估逻辑同源,0 = 关闭),非法输入前端拒绝不发包,
|
|
674
694
|
// 服务端失败后回读生效值回滚显示
|
|
675
695
|
const AUTO_ARCHIVE_URL = '/api/session-manager/auto-archive'
|
|
696
|
+
const LIST_SLIM_URL = '/api/session-manager/list-slim'
|
|
676
697
|
|
|
677
698
|
// 提交判定三态(镜像 core.mjs classifyAutoArchiveInput):noop 值未变 /
|
|
678
699
|
// invalid 非非负整数 / post 可提交;受控输入下显示值随击键同步,提交守卫
|
|
@@ -685,29 +706,30 @@ function classifyAutoArchiveInput(committedText, text) {
|
|
|
685
706
|
return { action: 'post', value }
|
|
686
707
|
}
|
|
687
708
|
|
|
709
|
+
// 面板状态:数值项取字符串供受控输入,开关项取布尔
|
|
710
|
+
const configStateOf = (archive, slim) => ({
|
|
711
|
+
days: String(archive.days),
|
|
712
|
+
intervalHours: String(archive.intervalHours),
|
|
713
|
+
enabled: slim.enabled !== false,
|
|
714
|
+
})
|
|
715
|
+
|
|
688
716
|
function AutoArchiveConfig() {
|
|
689
717
|
const [state, setState] = useState(null)
|
|
690
718
|
// 已提交值快照:非法输入与保存失败的回滚基准,与编辑中的显示值分离
|
|
691
719
|
const committedRef = React.useRef(null)
|
|
692
720
|
useEffect(() => {
|
|
693
|
-
|
|
694
|
-
.then((payload) => {
|
|
695
|
-
if (!payload) return
|
|
696
|
-
const next = { days: String(payload.days), intervalHours: String(payload.intervalHours) }
|
|
697
|
-
committedRef.current = next
|
|
698
|
-
setState(next)
|
|
699
|
-
})
|
|
700
|
-
.catch((error) => { console.warn('[session-manager] 自动归档配置读取失败', error) })
|
|
721
|
+
void refresh()
|
|
701
722
|
}, [])
|
|
723
|
+
// 两个接口合并成一个面板状态:数值项与开关项一次读齐,提交后各自回读
|
|
702
724
|
function refresh() {
|
|
703
|
-
return api(AUTO_ARCHIVE_URL)
|
|
704
|
-
.then((
|
|
705
|
-
if (!
|
|
706
|
-
const next =
|
|
725
|
+
return Promise.all([api(AUTO_ARCHIVE_URL), api(LIST_SLIM_URL)])
|
|
726
|
+
.then(([archive, slim]) => {
|
|
727
|
+
if (!archive || !slim) return
|
|
728
|
+
const next = configStateOf(archive, slim)
|
|
707
729
|
committedRef.current = next
|
|
708
730
|
setState(next)
|
|
709
731
|
})
|
|
710
|
-
.catch((error) => { console.warn('[session-manager]
|
|
732
|
+
.catch((error) => { console.warn('[session-manager] 面板配置回读失败', error) })
|
|
711
733
|
}
|
|
712
734
|
function commit(field, text) {
|
|
713
735
|
const committed = committedRef.current
|
|
@@ -735,6 +757,20 @@ function AutoArchiveConfig() {
|
|
|
735
757
|
void refresh()
|
|
736
758
|
})
|
|
737
759
|
}
|
|
760
|
+
// 开关提交:布尔值直接落 settings,成功后按回显值收敛,失败回读生效值
|
|
761
|
+
function commitEnabled(next) {
|
|
762
|
+
api(LIST_SLIM_URL, { method: 'POST', body: JSON.stringify({ enabled: next }) })
|
|
763
|
+
.then((payload) => {
|
|
764
|
+
const saved = payload.enabled !== false
|
|
765
|
+
committedRef.current = { ...committedRef.current, enabled: saved }
|
|
766
|
+
setState((prev) => (prev === null ? prev : { ...prev, enabled: saved }))
|
|
767
|
+
toast(saved ? '会话列表瘦身已开启' : '会话列表瘦身已关闭')
|
|
768
|
+
})
|
|
769
|
+
.catch(() => {
|
|
770
|
+
toast('会话列表瘦身开关保存失败', { kind: 'error' })
|
|
771
|
+
void refresh()
|
|
772
|
+
})
|
|
773
|
+
}
|
|
738
774
|
if (state === null) return null
|
|
739
775
|
const numberInput = (field) => h('input', {
|
|
740
776
|
type: 'number', min: 0, step: 1, value: state[field],
|
|
@@ -747,6 +783,16 @@ function AutoArchiveConfig() {
|
|
|
747
783
|
h('span', { title: ARCHIVE_OVERVIEW_TITLE }, '自动归档'),
|
|
748
784
|
h('label', { className: 'sm-cfg__field', title: ARCHIVE_DAYS_TITLE }, '阈值', numberInput('days'), '天未活跃(0 关闭)'),
|
|
749
785
|
h('label', { className: 'sm-cfg__field', title: ARCHIVE_INTERVAL_TITLE }, '检查周期', numberInput('intervalHours'), '小时(0 关闭)'),
|
|
786
|
+
h('label', { className: 'sm-switch', title: LIST_SLIM_TITLE },
|
|
787
|
+
h('input', {
|
|
788
|
+
type: 'checkbox',
|
|
789
|
+
checked: state.enabled,
|
|
790
|
+
'aria-label': '会话列表瘦身',
|
|
791
|
+
onChange: (event) => commitEnabled(event.target.checked),
|
|
792
|
+
}),
|
|
793
|
+
h('span', { className: 'sm-switch__track' }, h('span', { className: 'sm-switch__thumb' })),
|
|
794
|
+
h('span', { title: LIST_SLIM_TITLE }, '会话列表瘦身'),
|
|
795
|
+
),
|
|
750
796
|
)
|
|
751
797
|
}
|
|
752
798
|
|
package/src/index.js
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
updatedAtOf,
|
|
32
32
|
} from './core.mjs'
|
|
33
33
|
import { moveToQuarantine, restoreFromQuarantine, trashPath } from './trash.mjs'
|
|
34
|
+
import { installListSlim } from './list-slim.mjs'
|
|
34
35
|
|
|
35
36
|
export const name = 'dsh-session-manager'
|
|
36
37
|
|
|
@@ -97,6 +98,8 @@ const SETTINGS_SCHEMA = schemastery.object({
|
|
|
97
98
|
.description('自动归档阈值天数,0 表示关闭;新会话创建、启动与周期评估时生效'),
|
|
98
99
|
autoArchiveIntervalHours: schemastery.number().min(0).step(1).default(DEFAULT_AUTO_ARCHIVE_INTERVAL_HOURS)
|
|
99
100
|
.description('周期评估间隔小时数,0 表示关闭周期评估;变更经周期 tick 对账,关闭即时暂停、重启用最迟下个 tick 生效'),
|
|
101
|
+
listSlimEnabled: schemastery.boolean().default(true)
|
|
102
|
+
.description('会话列表瘦身:裁剪 session/list 的投影并缓存,降低列表响应体积与重建耗时;上游结构变更时自动降级为原行为'),
|
|
100
103
|
})
|
|
101
104
|
|
|
102
105
|
// 0.1.7 宿主以静态 Config 导出生成设置节表单(maintain 同构);legacy 宿主经
|
|
@@ -141,6 +144,7 @@ function readSettings(ctx, config) {
|
|
|
141
144
|
return {
|
|
142
145
|
days: Number.isInteger(days) && days >= 0 ? days : DEFAULT_AUTO_ARCHIVE_DAYS,
|
|
143
146
|
intervalHours: Number.isInteger(hours) && hours >= 0 ? hours : DEFAULT_AUTO_ARCHIVE_INTERVAL_HOURS,
|
|
147
|
+
listSlimEnabled: value?.listSlimEnabled !== false,
|
|
144
148
|
}
|
|
145
149
|
}
|
|
146
150
|
|
|
@@ -160,6 +164,21 @@ function readAutoArchivePatch(body) {
|
|
|
160
164
|
return patch
|
|
161
165
|
}
|
|
162
166
|
|
|
167
|
+
// settings 双形写(maintain 同构,判定延迟到使用点):legacy 方法面以 register 为准
|
|
168
|
+
// (0.1.7 的 settings.update 名义在场但按命名空间找条目必败,不可作判据);
|
|
169
|
+
// 0.1.7+ 走 configEditor.edit 落 profile 条目(fiber.entry,与命名空间解耦)
|
|
170
|
+
async function persistSettings(ctx, patch) {
|
|
171
|
+
const settings = ctx.get('settings')
|
|
172
|
+
if (typeof settings?.register === 'function' && typeof settings?.update === 'function') {
|
|
173
|
+
await settings.update(NAMESPACE, patch)
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
const editor = ctx.get('configEditor')
|
|
177
|
+
const entry = ctx.fiber?.entry
|
|
178
|
+
if (!editor || typeof editor.edit !== 'function' || !entry) throw new Error('宿主设置服务不可用')
|
|
179
|
+
await editor.edit(entry, (current) => ({ ...current, ...patch }))
|
|
180
|
+
}
|
|
181
|
+
|
|
163
182
|
function readBody(req) {
|
|
164
183
|
const BODY_MAX_BYTES = 64 * 1024
|
|
165
184
|
return new Promise((resolve, reject) => {
|
|
@@ -215,9 +234,44 @@ function rejectMethod(req, res, method) {
|
|
|
215
234
|
return false
|
|
216
235
|
}
|
|
217
236
|
|
|
237
|
+
// 写路由守卫(全仓同构):浏览器跨站表单可发 text/plain 且载荷为合法 JSON,
|
|
238
|
+
// 仅方法守卫不足以阻断 drive-by 写,补同源比对与 JSON 内容类型双校验。
|
|
239
|
+
// 无 Origin 的非浏览器调用放行(本地 CLI/脚本);无 headers 的直调桩同样放行。
|
|
240
|
+
function rejectCrossOrigin(req, res) {
|
|
241
|
+
const origin = req.headers ? req.headers.origin : undefined
|
|
242
|
+
if (!origin) return false
|
|
243
|
+
let sameOrigin = false
|
|
244
|
+
try {
|
|
245
|
+
sameOrigin = new URL(origin).host === req.headers.host
|
|
246
|
+
} catch {
|
|
247
|
+
sameOrigin = false
|
|
248
|
+
}
|
|
249
|
+
if (sameOrigin) return false
|
|
250
|
+
sendJson(res, 403, { error: '跨源请求被拒绝' })
|
|
251
|
+
return true
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// 内容类型守卫:浏览器发起的跨站请求(表单/fetch 字符串体)必带 text/plain 等类型,
|
|
255
|
+
// 借此阻断 drive-by 写;完全不带 content-type 的调用(本地 CLI/无头脚本)放行,
|
|
256
|
+
// 与"无 Origin 放行"同口径
|
|
257
|
+
function rejectNonJson(req, res) {
|
|
258
|
+
const type = req.headers ? req.headers['content-type'] : undefined
|
|
259
|
+
if (typeof type !== 'string' || type.trim() === '') return false
|
|
260
|
+
if (type.split(';')[0].trim().toLowerCase() === 'application/json') return false
|
|
261
|
+
sendJson(res, 415, { error: '仅接受 application/json 请求体' })
|
|
262
|
+
return true
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function guardWrite(req, res) {
|
|
266
|
+
return rejectCrossOrigin(req, res) || rejectNonJson(req, res)
|
|
267
|
+
}
|
|
268
|
+
|
|
218
269
|
/** @param {import('@deepseek-ai/cordis').Context} ctx */
|
|
219
270
|
export function apply(ctx, config) {
|
|
220
271
|
let evaluating = false
|
|
272
|
+
// 列表瘦身:开关从设置实时读取,设置页改动即时生效;装载失败只关闭该能力,
|
|
273
|
+
// 归档与删除等其它功能不受影响
|
|
274
|
+
installListSlim(ctx, { isEnabled: () => readSettings(ctx, config).listSlimEnabled })
|
|
221
275
|
// 取消归档全局宽限起点(进程内,重启失效可接受):恢复会话到用户发出首条
|
|
222
276
|
// 消息之间的评估窗口保护,首条消息更新产物 mtime 后由阈值自然接管
|
|
223
277
|
let unarchivedAtMs = 0
|
|
@@ -445,6 +499,7 @@ export function apply(ctx, config) {
|
|
|
445
499
|
path: '/api/session-manager/unarchive',
|
|
446
500
|
handler: async (req, res) => {
|
|
447
501
|
if (!rejectMethod(req, res, 'POST')) return
|
|
502
|
+
if (guardWrite(req, res)) return
|
|
448
503
|
try {
|
|
449
504
|
const sessionId = await requireSessionId(req)
|
|
450
505
|
await removeArchivedId(sessionId)
|
|
@@ -459,6 +514,7 @@ export function apply(ctx, config) {
|
|
|
459
514
|
path: '/api/session-manager/info',
|
|
460
515
|
handler: async (req, res) => {
|
|
461
516
|
if (!rejectMethod(req, res, 'POST')) return
|
|
517
|
+
if (guardWrite(req, res)) return
|
|
462
518
|
try {
|
|
463
519
|
const sessionId = await requireSessionId(req)
|
|
464
520
|
const header = await findHeader(ctx, sessionId, headerCache)
|
|
@@ -493,6 +549,7 @@ export function apply(ctx, config) {
|
|
|
493
549
|
path: '/api/session-manager/delete',
|
|
494
550
|
handler: async (req, res) => {
|
|
495
551
|
if (!rejectMethod(req, res, 'POST')) return
|
|
552
|
+
if (guardWrite(req, res)) return
|
|
496
553
|
const sessionId = await requireSessionId(req).catch((error) => {
|
|
497
554
|
respondError(ctx, res, error)
|
|
498
555
|
return undefined
|
|
@@ -644,6 +701,7 @@ export function apply(ctx, config) {
|
|
|
644
701
|
return
|
|
645
702
|
}
|
|
646
703
|
if (!rejectMethod(req, res, 'POST')) return
|
|
704
|
+
if (guardWrite(req, res)) return
|
|
647
705
|
let body
|
|
648
706
|
try {
|
|
649
707
|
body = JSON.parse(await readBody(req))
|
|
@@ -651,19 +709,7 @@ export function apply(ctx, config) {
|
|
|
651
709
|
throw new Error(MESSAGES.badJsonBody)
|
|
652
710
|
}
|
|
653
711
|
const patch = readAutoArchivePatch(body)
|
|
654
|
-
|
|
655
|
-
// (0.1.7 的 settings.update 名义在场但按命名空间找条目必败,不可作判据);
|
|
656
|
-
// 0.1.7+ 走 configEditor.edit 落 profile 条目(fiber.entry,与命名空间解耦)。
|
|
657
|
-
// await 保证回显为新值,persist 失败走 respondError
|
|
658
|
-
const settings = ctx.get('settings')
|
|
659
|
-
if (typeof settings?.register === 'function' && typeof settings?.update === 'function') {
|
|
660
|
-
await settings.update(NAMESPACE, patch)
|
|
661
|
-
} else {
|
|
662
|
-
const editor = ctx.get('configEditor')
|
|
663
|
-
const entry = ctx.fiber?.entry
|
|
664
|
-
if (!editor || typeof editor.edit !== 'function' || !entry) throw new Error('宿主设置服务不可用')
|
|
665
|
-
await editor.edit(entry, (current) => ({ ...current, ...patch }))
|
|
666
|
-
}
|
|
712
|
+
await persistSettings(ctx, patch)
|
|
667
713
|
const current = readSettings(ctx, config)
|
|
668
714
|
sendJson(res, 200, { days: current.days, intervalHours: current.intervalHours })
|
|
669
715
|
} catch (error) {
|
|
@@ -671,6 +717,32 @@ export function apply(ctx, config) {
|
|
|
671
717
|
}
|
|
672
718
|
},
|
|
673
719
|
},
|
|
720
|
+
{
|
|
721
|
+
// 会话列表瘦身开关:读回生效值,写经 settings 落 profile 条目;
|
|
722
|
+
// 关闭即恢复原始列表语义(不裁剪、不缓存),无需卸载插件
|
|
723
|
+
path: '/api/session-manager/list-slim',
|
|
724
|
+
handler: async (req, res) => {
|
|
725
|
+
try {
|
|
726
|
+
if (req.method === 'GET') {
|
|
727
|
+
sendJson(res, 200, { enabled: readSettings(ctx, config).listSlimEnabled })
|
|
728
|
+
return
|
|
729
|
+
}
|
|
730
|
+
if (!rejectMethod(req, res, 'POST')) return
|
|
731
|
+
if (guardWrite(req, res)) return
|
|
732
|
+
let body
|
|
733
|
+
try {
|
|
734
|
+
body = JSON.parse(await readBody(req))
|
|
735
|
+
} catch {
|
|
736
|
+
throw new Error(MESSAGES.badJsonBody)
|
|
737
|
+
}
|
|
738
|
+
if (typeof body?.enabled !== 'boolean') throw new Error('列表瘦身开关须为布尔值')
|
|
739
|
+
await persistSettings(ctx, { listSlimEnabled: body.enabled })
|
|
740
|
+
sendJson(res, 200, { enabled: readSettings(ctx, config).listSlimEnabled })
|
|
741
|
+
} catch (error) {
|
|
742
|
+
respondError(ctx, res, error)
|
|
743
|
+
}
|
|
744
|
+
},
|
|
745
|
+
},
|
|
674
746
|
{
|
|
675
747
|
path: '/api/session-manager/deleted',
|
|
676
748
|
handler: async (req, res) => {
|
|
@@ -687,6 +759,7 @@ export function apply(ctx, config) {
|
|
|
687
759
|
path: '/api/session-manager/remount',
|
|
688
760
|
handler: async (req, res) => {
|
|
689
761
|
if (!rejectMethod(req, res, 'POST')) return
|
|
762
|
+
if (guardWrite(req, res)) return
|
|
690
763
|
try {
|
|
691
764
|
const sessionId = await requireSessionId(req)
|
|
692
765
|
// 移回依据是台账条目,台账域不可用时整个重挂载拒绝:
|
|
@@ -730,6 +803,7 @@ export function apply(ctx, config) {
|
|
|
730
803
|
path: '/api/session-manager/forget',
|
|
731
804
|
handler: async (req, res) => {
|
|
732
805
|
if (!rejectMethod(req, res, 'POST')) return
|
|
806
|
+
if (guardWrite(req, res)) return
|
|
733
807
|
try {
|
|
734
808
|
await removeLedgerEntry(await requireSessionId(req))
|
|
735
809
|
sendJson(res, 200, { ok: true })
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// 会话列表瘦身:session/list 为每个会话下发完整投影快照,列表行只消费其中少数几项。
|
|
2
|
+
// 本模块在 remote 方法返回前按白名单裁剪并缓存结果。任何失败路径都降级为原行为:
|
|
3
|
+
// 未装载、形状不符、原方法抛错一律透传,不影响本插件的其它功能。
|
|
4
|
+
// sessionController 经 ctx.inject 可选依赖取得,宿主缺该服务时本能力静默关闭。
|
|
5
|
+
|
|
6
|
+
// 列表行消费的投影;其余键不下发。被裁掉的投影仍由会话详情接口完整返回。
|
|
7
|
+
export const KEEP_PROJECTIONS = ['title', 'sessionListMetadata', 'inbox', 'modelSelection', 'tokenUsage']
|
|
8
|
+
|
|
9
|
+
// 缓存有效期:会话增删立即失效,TTL 只兜底标题与运行时状态的漂移。
|
|
10
|
+
export const TTL_MS = 15 * 1000
|
|
11
|
+
|
|
12
|
+
// 启动后预热延迟:让宿主先完成装载,再让出一次列表重建。
|
|
13
|
+
export const WARMUP_DELAY_MS = 10 * 1000
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 裁剪一条列表项的投影,只保留白名单里的键。
|
|
17
|
+
* @param item - 宿主返回的列表项。
|
|
18
|
+
* @param keep - 保留的投影键。
|
|
19
|
+
* @returns 投影已裁剪的列表项;形状不符时原样返回。
|
|
20
|
+
*/
|
|
21
|
+
export function slimItem(item, keep = KEEP_PROJECTIONS) {
|
|
22
|
+
const values = item?.projections?.values
|
|
23
|
+
if (values === null || typeof values !== 'object' || Array.isArray(values)) return item
|
|
24
|
+
const picked = {}
|
|
25
|
+
for (const key of keep) if (key in values) picked[key] = values[key]
|
|
26
|
+
return { ...item, projections: { ...item.projections, values: picked } }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 给 sessionController.list 装上裁剪与缓存。
|
|
31
|
+
* @param ctx - 宿主上下文。
|
|
32
|
+
* @param options - 开关、白名单、缓存有效期与预热延迟。
|
|
33
|
+
* @returns 是否提交了装载回调;sessionController 缺席时回调不执行,宿主行为不变。
|
|
34
|
+
*/
|
|
35
|
+
export function installListSlim(ctx, options = {}) {
|
|
36
|
+
const {
|
|
37
|
+
enabled = true,
|
|
38
|
+
isEnabled,
|
|
39
|
+
keep = KEEP_PROJECTIONS,
|
|
40
|
+
ttlMs = TTL_MS,
|
|
41
|
+
warmupDelayMs = WARMUP_DELAY_MS,
|
|
42
|
+
} = options
|
|
43
|
+
if (!enabled) return false
|
|
44
|
+
const warn = (message) => ctx.logger?.warn?.('session-manager: 列表瘦身 ' + message)
|
|
45
|
+
try {
|
|
46
|
+
ctx.inject(['sessionController'], (scope) => {
|
|
47
|
+
try {
|
|
48
|
+
const controller = scope.sessionController
|
|
49
|
+
const original = controller?.list
|
|
50
|
+
if (typeof original !== 'function') return
|
|
51
|
+
let cache
|
|
52
|
+
let cachedAt = 0
|
|
53
|
+
let inflight
|
|
54
|
+
|
|
55
|
+
const fresh = () => cache !== undefined && Date.now() - cachedAt < ttlMs
|
|
56
|
+
const invalidate = () => {
|
|
57
|
+
cachedAt = 0
|
|
58
|
+
}
|
|
59
|
+
scope.on('api-session/added', invalidate)
|
|
60
|
+
scope.on('api-session/removed', invalidate)
|
|
61
|
+
|
|
62
|
+
// 只在调用方这一次请求上构建并缓存:不额外探测一次原方法,形状不符仅本次透传
|
|
63
|
+
controller.list = async (request, signal) => {
|
|
64
|
+
// 开关每次读取:设置页改动即时生效,无需重载插件
|
|
65
|
+
if (isEnabled !== undefined && !isEnabled()) return original.call(controller, request, signal)
|
|
66
|
+
if (fresh()) return cache
|
|
67
|
+
if (inflight !== undefined) return inflight
|
|
68
|
+
inflight = Promise.resolve(original.call(controller, request, signal))
|
|
69
|
+
.then((value) => {
|
|
70
|
+
if (!Array.isArray(value?.items)) return value
|
|
71
|
+
cache = { items: value.items.map((item) => slimItem(item, keep)) }
|
|
72
|
+
cachedAt = Date.now()
|
|
73
|
+
return cache
|
|
74
|
+
})
|
|
75
|
+
.finally(() => {
|
|
76
|
+
inflight = undefined
|
|
77
|
+
})
|
|
78
|
+
return inflight
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let timer
|
|
82
|
+
if (warmupDelayMs >= 0) {
|
|
83
|
+
timer = setTimeout(() => {
|
|
84
|
+
if (isEnabled !== undefined && !isEnabled()) return
|
|
85
|
+
if (fresh() || inflight !== undefined) return
|
|
86
|
+
void controller.list({}, undefined).catch(() => {})
|
|
87
|
+
}, warmupDelayMs)
|
|
88
|
+
timer.unref?.()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 卸载恢复原方法:本能力退出不改变宿主的列表语义
|
|
92
|
+
scope.effect(() => () => {
|
|
93
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
94
|
+
controller.list = original
|
|
95
|
+
})
|
|
96
|
+
ctx.logger?.info?.(`session-manager: 列表瘦身已启用 (keep=${keep.join(',')} ttl=${ttlMs}ms)`)
|
|
97
|
+
} catch (error) {
|
|
98
|
+
warn('装载失败,保持原行为: ' + String(error?.message ?? error))
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
return true
|
|
102
|
+
} catch (error) {
|
|
103
|
+
warn('未启用: ' + String(error?.message ?? error))
|
|
104
|
+
return false
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -44,3 +44,23 @@ test('源码契约:已删除区保持悬停显现且减弱动效覆盖新选择
|
|
|
44
44
|
assert.ok(/prefers-reduced-motion: reduce.*\{ \.sm-btn--danger, \.sm-deleted \.sm-row__actions \{ transition:none/.test(CLIENT_SRC),
|
|
45
45
|
'减弱动效未覆盖删除按钮与已删除区过渡')
|
|
46
46
|
})
|
|
47
|
+
|
|
48
|
+
// Given 归档行与已删除行的操作按钮, When 渲染, Then 每个按钮都带 title 悬停说明
|
|
49
|
+
test('源码契约:行操作按钮都带 title 悬停说明', () => {
|
|
50
|
+
const buttons = [
|
|
51
|
+
['restore', 'RESTORE_BUTTON_TITLE'],
|
|
52
|
+
['remount', 'REMOUNT_BUTTON_TITLE'],
|
|
53
|
+
['forget', 'FORGET_BUTTON_TITLE'],
|
|
54
|
+
['delete', 'DELETE_BUTTON_TITLE'],
|
|
55
|
+
['more', 'MORE_BUTTON_TITLE'],
|
|
56
|
+
]
|
|
57
|
+
for (const [key, constant] of buttons) {
|
|
58
|
+
const pattern = new RegExp("key: '" + key + "'[^\\n]*title: " + constant)
|
|
59
|
+
assert.ok(pattern.test(CLIENT_SRC), key + ' 按钮缺少 title(' + constant + ')')
|
|
60
|
+
}
|
|
61
|
+
for (const constant of ['RESTORE_BUTTON_TITLE', 'REMOUNT_BUTTON_TITLE', 'FORGET_BUTTON_TITLE', 'MORE_BUTTON_TITLE']) {
|
|
62
|
+
const matched = CLIENT_SRC.match(new RegExp('const ' + constant + " = '([^']+)'"))
|
|
63
|
+
assert.ok(matched, constant + ' 未定义')
|
|
64
|
+
assert.ok(matched[1].length >= 20, constant + ' 文案须讲清用途')
|
|
65
|
+
}
|
|
66
|
+
})
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// 开关形态守卫:布尔偏好一律 switch(track+thumb),原生 checkbox 视觉隐藏保语义与
|
|
2
|
+
// 可访问性,状态选择器以 input[type="checkbox"] 锚定,防止组合进含其他 input 的 label 时误伤。
|
|
3
|
+
import { test } from 'node:test'
|
|
4
|
+
import assert from 'node:assert/strict'
|
|
5
|
+
import { readFileSync } from 'node:fs'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import { dirname, join } from 'node:path'
|
|
8
|
+
|
|
9
|
+
const CLIENT_SOURCE = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'client.js'), 'utf8')
|
|
10
|
+
|
|
11
|
+
test('开关结构:label 包原生 checkbox + track + thumb', () => {
|
|
12
|
+
assert.match(CLIENT_SOURCE, /className: 'sm-switch'/, '开关 label 存在')
|
|
13
|
+
assert.match(CLIENT_SOURCE, /type: 'checkbox'/, '原生 checkbox 保语义')
|
|
14
|
+
assert.match(CLIENT_SOURCE, /className: 'sm-switch__track'/, 'track 存在')
|
|
15
|
+
assert.match(CLIENT_SOURCE, /className: 'sm-switch__thumb'/, 'thumb 存在')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('原生 input 被视觉隐藏,不以裸 checkbox 呈现', () => {
|
|
19
|
+
assert.match(CLIENT_SOURCE, /\.sm-switch input\[type="checkbox"\] \{ position:absolute; opacity:0;/, 'input 视觉隐藏')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('开关状态选择器以 input[type="checkbox"] 锚定', () => {
|
|
23
|
+
for (const state of [':checked', ':focus-visible', ':disabled']) {
|
|
24
|
+
const pattern = new RegExp('\\.sm-switch[^{]*\\' + state + '[^{]*\\{', 'g')
|
|
25
|
+
const matches = [...CLIENT_SOURCE.matchAll(pattern)].map((match) => match[0])
|
|
26
|
+
assert.ok(matches.length > 0, state + ' 选择器存在')
|
|
27
|
+
for (const selector of matches) {
|
|
28
|
+
assert.match(selector, /input\[type="checkbox"\]/, state + ' 必须以 input[type="checkbox"] 锚定: ' + selector)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('开关样式的 :hover 锚定 label 而非隐藏的 input', () => {
|
|
34
|
+
assert.match(CLIENT_SOURCE, /\.sm-switch:hover \.sm-switch__track/, 'hover 锚定 label')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('开关带悬停说明与无障碍标签', () => {
|
|
38
|
+
assert.match(CLIENT_SOURCE, /className: 'sm-switch', title: LIST_SLIM_TITLE/, 'label 带 title 悬停说明')
|
|
39
|
+
assert.match(CLIENT_SOURCE, /'aria-label': '会话列表瘦身'/, 'input 带 aria-label')
|
|
40
|
+
const matched = CLIENT_SOURCE.match(/const LIST_SLIM_TITLE = '([^']+)'/)
|
|
41
|
+
assert.ok(matched, '说明文案常量存在')
|
|
42
|
+
assert.ok(matched[1].length >= 40, '说明文案须讲清用途,不少于 40 字')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('样式类名带本包前缀 sm-', () => {
|
|
46
|
+
for (const selector of ['.sm-switch {', '.sm-switch__track', '.sm-switch__thumb']) {
|
|
47
|
+
assert.ok(CLIENT_SOURCE.includes(selector), selector + ' 存在')
|
|
48
|
+
}
|
|
49
|
+
})
|
package/test/index.test.mjs
CHANGED
|
@@ -1888,3 +1888,82 @@ test('0.1.7 形态:settings.configure 在场即关闭原生自动页', skipMissi
|
|
|
1888
1888
|
assert.equal(configureCalls.length, 1)
|
|
1889
1889
|
assert.deepEqual(configureCalls[0].presentation, { auto: false })
|
|
1890
1890
|
})
|
|
1891
|
+
|
|
1892
|
+
// ── 写路由同源 + JSON 守卫回归 ──
|
|
1893
|
+
|
|
1894
|
+
// POST 夹具:带 headers 的请求体走 readBody 的 data/end 事件流
|
|
1895
|
+
function guardedRequest(routePath, headers, body = { sessionId: 's1' }) {
|
|
1896
|
+
const req = new EventEmitter()
|
|
1897
|
+
req.method = 'POST'
|
|
1898
|
+
req.url = routePath
|
|
1899
|
+
req.headers = headers
|
|
1900
|
+
process.nextTick(() => {
|
|
1901
|
+
req.emit('data', Buffer.from(JSON.stringify(body)))
|
|
1902
|
+
req.emit('end')
|
|
1903
|
+
})
|
|
1904
|
+
return req
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
test('写路由守卫:跨源 POST 403 / 非 JSON 内容类型 415 / 同源 JSON 正常受理', skipMissingDeps, async () => {
|
|
1908
|
+
// Given 全部写路由(浏览器表单可发 text/plain 且载荷为合法 JSON,仅方法守卫不足)
|
|
1909
|
+
const POST_ROUTES = [
|
|
1910
|
+
'/api/session-manager/unarchive',
|
|
1911
|
+
'/api/session-manager/info',
|
|
1912
|
+
'/api/session-manager/delete',
|
|
1913
|
+
'/api/session-manager/auto-archive',
|
|
1914
|
+
'/api/session-manager/remount',
|
|
1915
|
+
'/api/session-manager/forget',
|
|
1916
|
+
]
|
|
1917
|
+
const ORIGIN = 'http://127.0.0.1:9291'
|
|
1918
|
+
const sameOrigin = {
|
|
1919
|
+
origin: ORIGIN,
|
|
1920
|
+
host: '127.0.0.1:9291',
|
|
1921
|
+
'content-type': 'application/json; charset=utf-8',
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
for (const routePath of POST_ROUTES) {
|
|
1925
|
+
const { handlers } = makeCtx({ archivedIds: ['s1'], headers: [HEADER], agents: new Map() })
|
|
1926
|
+
|
|
1927
|
+
// When Origin 与 Host 不同 Then 403,且响应为统一拒绝文案
|
|
1928
|
+
const crossOrigin = response()
|
|
1929
|
+
await handlers.get(routePath)(guardedRequest(routePath, { ...sameOrigin, origin: 'http://evil.example' }), crossOrigin)
|
|
1930
|
+
assert.equal(crossOrigin.status, 403, `${routePath} 跨源 POST 必须 403`)
|
|
1931
|
+
|
|
1932
|
+
// When 同源但 content-type: text/plain Then 415(drive-by 表单形态)
|
|
1933
|
+
const textPlain = response()
|
|
1934
|
+
await handlers.get(routePath)(guardedRequest(routePath, { ...sameOrigin, 'content-type': 'text/plain' }), textPlain)
|
|
1935
|
+
assert.equal(textPlain.status, 415, `${routePath} text/plain POST 必须 415`)
|
|
1936
|
+
|
|
1937
|
+
// When 无 Origin(本地 CLI)但带 text/plain Then 仍 415(内容类型守卫独立生效)
|
|
1938
|
+
const cliTextPlain = response()
|
|
1939
|
+
await handlers.get(routePath)(guardedRequest(routePath, { host: '127.0.0.1:9291', 'content-type': 'text/plain' }), cliTextPlain)
|
|
1940
|
+
assert.equal(cliTextPlain.status, 415, `${routePath} 无 Origin 的 text/plain 仍必须 415`)
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
// When 同源 application/json Then 正常受理(unarchive 可幂等成功)
|
|
1944
|
+
const { handlers } = makeCtx({ archivedIds: ['s1'], headers: [HEADER], agents: new Map() })
|
|
1945
|
+
const accepted = response()
|
|
1946
|
+
await handlers.get('/api/session-manager/unarchive')(guardedRequest('/api/session-manager/unarchive', sameOrigin), accepted)
|
|
1947
|
+
assert.equal(accepted.status, 200)
|
|
1948
|
+
assert.deepEqual(accepted.body, { ok: true })
|
|
1949
|
+
})
|
|
1950
|
+
|
|
1951
|
+
test('列表瘦身开关:GET 回显默认开启,POST 持久,GET 反映新值', skipMissingDeps, async () => {
|
|
1952
|
+
const { handlers } = makeCtx({ archivedIds: [], headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }], agents: new Map() })
|
|
1953
|
+
const initial = response()
|
|
1954
|
+
await handlers.get('/api/session-manager/list-slim')(getRequest2('/api/session-manager/list-slim', 'GET'), initial)
|
|
1955
|
+
assert.deepEqual(initial.body, { enabled: true }, '未配置时默认开启')
|
|
1956
|
+
const off = await postJson(handlers, '/api/session-manager/list-slim', { enabled: false })
|
|
1957
|
+
assert.deepEqual(off.body, { enabled: false })
|
|
1958
|
+
const reread = response()
|
|
1959
|
+
await handlers.get('/api/session-manager/list-slim')(getRequest2('/api/session-manager/list-slim', 'GET'), reread)
|
|
1960
|
+
assert.deepEqual(reread.body, { enabled: false }, 'settings 持久化开关')
|
|
1961
|
+
})
|
|
1962
|
+
|
|
1963
|
+
test('列表瘦身开关:非布尔载荷一律 400', skipMissingDeps, async () => {
|
|
1964
|
+
const { handlers } = makeCtx({ archivedIds: [], headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }], agents: new Map() })
|
|
1965
|
+
for (const payload of [{ enabled: 'yes' }, { enabled: 1 }, {}, { enabled: null }]) {
|
|
1966
|
+
const rejected = await postJson(handlers, '/api/session-manager/list-slim', payload)
|
|
1967
|
+
assert.equal(rejected.status, 400, JSON.stringify(payload))
|
|
1968
|
+
}
|
|
1969
|
+
})
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// 列表瘦身的契约:只裁白名单、命中缓存不重建、增删事件失效、任何异常回落到原行为。
|
|
2
|
+
import { test } from 'node:test'
|
|
3
|
+
import assert from 'node:assert/strict'
|
|
4
|
+
import { installListSlim, slimItem } from '../src/list-slim.mjs'
|
|
5
|
+
|
|
6
|
+
/** 一条带完整投影的列表项。 */
|
|
7
|
+
const item = (id, values) => ({ sessionId: id, updatedAt: 1, projections: { kind: 'cached', asOfSeq: 1, values } })
|
|
8
|
+
|
|
9
|
+
/** 造一个只实现本模块用到表面的上下文(inject 立即回调,等价于服务已就绪)。 */
|
|
10
|
+
function makeContext(list, { withController = true } = {}) {
|
|
11
|
+
const handlers = new Map()
|
|
12
|
+
const disposers = []
|
|
13
|
+
const controller = withController ? { list } : undefined
|
|
14
|
+
const ctx = {
|
|
15
|
+
get: (name) => (name === 'sessionController' ? controller : undefined),
|
|
16
|
+
on: (event, handler) => handlers.set(event, handler),
|
|
17
|
+
logger: { info() {}, warn() {} },
|
|
18
|
+
inject: (deps, callback) => {
|
|
19
|
+
callback({
|
|
20
|
+
sessionController: controller,
|
|
21
|
+
on: (event, handler) => handlers.set(event, handler),
|
|
22
|
+
effect: (factory) => {
|
|
23
|
+
disposers.push(factory())
|
|
24
|
+
},
|
|
25
|
+
})
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
return { ctx, controller, handlers, disposers }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const sample = () => [
|
|
32
|
+
item('a', { title: 'A', sessionListMetadata: { blank: false }, contextHeaders: [{ pad: 'x'.repeat(64) }] }),
|
|
33
|
+
item('b', { title: 'B', inbox: { 'next-turn': [], 'next-step': [] } }),
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
test('裁剪只保留白名单里的投影', () => {
|
|
37
|
+
const slim = slimItem(sample()[0], ['title'])
|
|
38
|
+
assert.deepEqual(Object.keys(slim.projections.values), ['title'])
|
|
39
|
+
assert.equal(slim.projections.kind, 'cached')
|
|
40
|
+
assert.equal(slim.sessionId, 'a')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('形状不符的条目原样返回', () => {
|
|
44
|
+
const bare = { sessionId: 'c' }
|
|
45
|
+
assert.equal(slimItem(bare, ['title']), bare)
|
|
46
|
+
assert.equal(slimItem({ projections: { values: null } }, ['title']).projections.values, null)
|
|
47
|
+
const array = { projections: { values: ['x'] } }
|
|
48
|
+
assert.equal(slimItem(array, ['title']), array)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('开关关闭时不接管 controller.list', async () => {
|
|
52
|
+
const original = async () => ({ items: sample() })
|
|
53
|
+
const { ctx, controller } = makeContext(original)
|
|
54
|
+
assert.equal(installListSlim(ctx, { enabled: false }), false)
|
|
55
|
+
assert.equal(controller.list, original)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('宿主没有 sessionController 时不接管任何方法', () => {
|
|
59
|
+
const { ctx } = makeContext(undefined, { withController: false })
|
|
60
|
+
installListSlim(ctx)
|
|
61
|
+
assert.equal(ctx.get('sessionController'), undefined)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('controller 缺 list 方法时静默放弃', () => {
|
|
65
|
+
const { ctx, controller } = makeContext(undefined)
|
|
66
|
+
installListSlim(ctx)
|
|
67
|
+
assert.equal(controller.list, undefined)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('第二次调用命中缓存,不重建', async () => {
|
|
71
|
+
let built = 0
|
|
72
|
+
const { ctx, controller } = makeContext(async () => {
|
|
73
|
+
built += 1
|
|
74
|
+
return { items: sample() }
|
|
75
|
+
})
|
|
76
|
+
installListSlim(ctx, { warmupDelayMs: -1 })
|
|
77
|
+
await controller.list({}, undefined)
|
|
78
|
+
const value = await controller.list({}, undefined)
|
|
79
|
+
assert.equal(built, 1)
|
|
80
|
+
assert.equal(value.items[0].projections.values.contextHeaders, undefined)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('过期后重建', async () => {
|
|
84
|
+
let built = 0
|
|
85
|
+
const { ctx, controller } = makeContext(async () => {
|
|
86
|
+
built += 1
|
|
87
|
+
return { items: sample() }
|
|
88
|
+
})
|
|
89
|
+
installListSlim(ctx, { ttlMs: 0, warmupDelayMs: -1 })
|
|
90
|
+
await controller.list({}, undefined)
|
|
91
|
+
await controller.list({}, undefined)
|
|
92
|
+
assert.equal(built, 2)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('会话增删让缓存失效', async () => {
|
|
96
|
+
let built = 0
|
|
97
|
+
const { ctx, controller, handlers } = makeContext(async () => {
|
|
98
|
+
built += 1
|
|
99
|
+
return { items: sample() }
|
|
100
|
+
})
|
|
101
|
+
installListSlim(ctx, { warmupDelayMs: -1 })
|
|
102
|
+
await controller.list({}, undefined)
|
|
103
|
+
handlers.get('api-session/added')()
|
|
104
|
+
await controller.list({}, undefined)
|
|
105
|
+
assert.equal(built, 2)
|
|
106
|
+
handlers.get('api-session/removed')()
|
|
107
|
+
await controller.list({}, undefined)
|
|
108
|
+
assert.equal(built, 3)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('并发请求只触发一次重建', async () => {
|
|
112
|
+
let built = 0
|
|
113
|
+
const { ctx, controller } = makeContext(async () => {
|
|
114
|
+
built += 1
|
|
115
|
+
return { items: sample() }
|
|
116
|
+
})
|
|
117
|
+
installListSlim(ctx, { warmupDelayMs: -1 })
|
|
118
|
+
await Promise.all([controller.list({}, undefined), controller.list({}, undefined), controller.list({}, undefined)])
|
|
119
|
+
assert.equal(built, 1)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('原方法抛错时错误照旧透传给调用方', async () => {
|
|
123
|
+
const { ctx, controller } = makeContext(async () => {
|
|
124
|
+
throw new Error('宿主故障')
|
|
125
|
+
})
|
|
126
|
+
installListSlim(ctx, { warmupDelayMs: -1 })
|
|
127
|
+
await assert.rejects(() => controller.list({}, undefined), /宿主故障/)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('响应形状不符时原样透传,不缓存', async () => {
|
|
131
|
+
let built = 0
|
|
132
|
+
const { ctx, controller } = makeContext(async () => {
|
|
133
|
+
built += 1
|
|
134
|
+
return { sessions: sample() }
|
|
135
|
+
})
|
|
136
|
+
installListSlim(ctx, { warmupDelayMs: -1 })
|
|
137
|
+
const first = await controller.list({}, undefined)
|
|
138
|
+
const second = await controller.list({}, undefined)
|
|
139
|
+
assert.deepEqual(Object.keys(first), ['sessions'])
|
|
140
|
+
assert.equal(built, 2)
|
|
141
|
+
assert.notEqual(second, first)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('把调用方 request 原样交给原方法', async () => {
|
|
145
|
+
const seen = []
|
|
146
|
+
const { ctx, controller } = makeContext(async (request) => {
|
|
147
|
+
seen.push(request)
|
|
148
|
+
return { items: sample() }
|
|
149
|
+
})
|
|
150
|
+
installListSlim(ctx, { warmupDelayMs: -1 })
|
|
151
|
+
const request = { cursor: 'abc' }
|
|
152
|
+
await controller.list(request, undefined)
|
|
153
|
+
assert.equal(seen[0], request)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test('预热在延迟后构建一次', async () => {
|
|
157
|
+
let built = 0
|
|
158
|
+
const { ctx } = makeContext(async () => {
|
|
159
|
+
built += 1
|
|
160
|
+
return { items: sample() }
|
|
161
|
+
})
|
|
162
|
+
installListSlim(ctx, { warmupDelayMs: 0 })
|
|
163
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
164
|
+
assert.equal(built, 1)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
test('卸载时恢复原方法', async () => {
|
|
168
|
+
const original = async () => ({ items: sample() })
|
|
169
|
+
const { ctx, controller, disposers } = makeContext(original)
|
|
170
|
+
installListSlim(ctx, { warmupDelayMs: -1 })
|
|
171
|
+
assert.notEqual(controller.list, original)
|
|
172
|
+
for (const dispose of disposers) dispose()
|
|
173
|
+
assert.equal(controller.list, original)
|
|
174
|
+
})
|