dsh-session-conductor 1.3.8 → 1.3.10
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 +7 -1
- package/lib/apply.js +20 -1
- package/lib/client-parts/components/panel.js +177 -0
- package/lib/client.js +177 -0
- package/lib/core/config.js +3 -0
- package/lib/core/index-store.js +148 -0
- package/lib/features/member-model/command.js +5 -35
- package/lib/features/member-model/locate.js +49 -3
- package/lib/features/session-messaging/tools.js +7 -1
- package/lib/features/session-ops-routes.js +132 -1
- package/lib/features/session-ops.js +90 -40
- package/lib/features/trash.js +206 -0
- package/lib/features/value-index.js +11 -26
- package/lib/i18n/en.json +24 -9
- package/lib/i18n/zh.json +22 -7
- package/lib/sessions/list-cache.js +11 -29
- package/lib/sessions/list.js +11 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
|
|
11
11
|
| 功能 | 说明 | 入口 |
|
|
12
12
|
|---|---|---|
|
|
13
|
-
| 会话管理面板 |
|
|
13
|
+
| 会话管理面板 | 全部/已归档/活跃/**回收站**/说明五视图;归档、恢复、删除(批量/筛选)、置为不活跃、释放全部空闲 | 侧边栏「会话管理」按钮 |
|
|
14
|
+
| 回收站 | **删除只打标记、不动会话文件**:删掉的会话进回收站,可单条还原、多选还原、按批次撤销(还原最近一次删除),到期(保留天数可设,默认 7 天)或「一键清空」才真正删盘。真正删盘由每小时一次的到期清理执行 | 面板「回收站」标签页 |
|
|
14
15
|
| 自动重命名 | 每个回合后 AI 分析会话内容,主题明显偏离时自动生成新标题(带运行状态后缀) | 面板逐会话开关 |
|
|
15
16
|
| 自动续跑 | 识别非人为中断(崩溃/限流/超时/错误),自动继续原任务;失败后 30s 重试 | 面板逐会话开关(临时设置,重启即默认关) |
|
|
16
17
|
| 会话分组 | 按工作区分组显示;「在分组下新建会话」 | 设置 → 插件 → 会话分组 |
|
|
@@ -122,8 +123,13 @@ allowBuilds:
|
|
|
122
123
|
- **全部**:列出每个会话(标题/目录/时间/状态),支持归档、删除、撤回、自动重命名/续跑开关、搜索、价值分析
|
|
123
124
|
- **已归档**:列出被归档的会话,可恢复(取消归档)、删除
|
|
124
125
|
- **活跃**:当前活跃(live/running)的会话
|
|
126
|
+
- **回收站**:列出被删除(只打了标记)的会话,显示删除时间与剩余天数;五个操作:**设置保留天数**、**撤销上次删除**(还原最近一个批次)、**还原选中**、**全选**、**一键清空**(二次确认后立即真删)。单条也能点行内「还原」
|
|
125
127
|
- **说明**:损坏修复(seq-gap / EIO / 双格式)+ 使用说明
|
|
126
128
|
|
|
129
|
+
> 删除语义:面板上的「删除」= 在索引里打标记 + 从列表隐藏,**会话日志文件原样保留**,
|
|
130
|
+
> 所以还原是秒级且零风险。真正删盘只发生在回收站到期(`trashRetentionDays`,默认 7 天)
|
|
131
|
+
> 或「一键清空」,由 `purgeExpiredTrash` 执行。
|
|
132
|
+
|
|
127
133
|
### 设置页(设置 → 会话管理)
|
|
128
134
|
|
|
129
135
|
左侧栏「会话管理」独立页:
|
package/lib/apply.js
CHANGED
|
@@ -60,6 +60,8 @@ import { findTargetAgent, switchAgentModel, getMemberModelOverride, applyModelOv
|
|
|
60
60
|
registerModelSwitchCommand } from './features/member-model/index.js';
|
|
61
61
|
// 会话操作的路由注册已抽到独立模块(handler 实现见该模块;此处只留注册入口)
|
|
62
62
|
import { registerSessionOpsRoutes } from './features/session-ops-routes.js';
|
|
63
|
+
import { registerTrashRoutes } from './features/session-ops-routes.js';
|
|
64
|
+
import { purgeExpiredTrash } from './features/session-ops.js';
|
|
63
65
|
import { installProcessGuards } from './guards.js';
|
|
64
66
|
import { readJson, send } from './http.js';
|
|
65
67
|
import { CONDUCTOR_SETTINGS_NS, PresenceSchema } from './shared/settings.js';
|
|
@@ -92,6 +94,8 @@ export async function apply(ctx, config = {}) {
|
|
|
92
94
|
scanIntervalMs: num(config?.scanIntervalMs, 30 * 1000, 24 * 3600 * 1000, DEFAULTS.scanIntervalMs),
|
|
93
95
|
// 价值判定索引有效期(小时):同一份会话内容在该时长内只算一次(默认 24 小时,可配置覆盖)
|
|
94
96
|
valueIndexTtlHours: num(config?.valueIndexTtlHours, 1, 24 * 30, DEFAULTS.valueIndexTtlHours),
|
|
97
|
+
// 回收站保留天数(面板上设置的值优先;这里是兜底默认)
|
|
98
|
+
trashRetentionDays: num(config?.trashRetentionDays, 1, 365, DEFAULTS.trashRetentionDays),
|
|
95
99
|
// 自动重命名模型路由(成对配置才生效;缺省 = 继承会话 request/header 的对话模型)
|
|
96
100
|
autoRenameProvider: typeof config?.autoRenameProvider === "string" && config.autoRenameProvider !== "" ? config.autoRenameProvider : undefined,
|
|
97
101
|
autoRenameModel: typeof config?.autoRenameModel === "string" && config.autoRenameModel !== "" ? config.autoRenameModel : undefined,
|
|
@@ -657,6 +661,20 @@ export async function apply(ctx, config = {}) {
|
|
|
657
661
|
// 具体 handler 实现见该模块(行为逐字不变,依赖以 deps 传入)。
|
|
658
662
|
registerSessionOpsRoutes({ webServer, ctx, send, readJson, invalidateSessionListCache });
|
|
659
663
|
|
|
664
|
+
// ---------- 回收站路由 ----------
|
|
665
|
+
// 删除只是打标记(文件不动),所以还原零成本;真删只发生在到期与一键清空。
|
|
666
|
+
registerTrashRoutes({ webServer, ctx, send, readJson, invalidateSessionListCache });
|
|
667
|
+
|
|
668
|
+
// 到期清理:每小时扫一次(回收站保留天数可配,默认 7 天)。
|
|
669
|
+
// 用 unref 的定时器 + 单次 try/catch:清理失败绝不能影响宿主进程。
|
|
670
|
+
try {
|
|
671
|
+
const purgeTimer = setInterval(() => {
|
|
672
|
+
purgeExpiredTrash(ctx).catch(() => { /* 单次清理失败,下一小时再试 */ });
|
|
673
|
+
}, 3600_000);
|
|
674
|
+
if (typeof purgeTimer?.unref === "function") purgeTimer.unref();
|
|
675
|
+
ctx.effect?.(() => () => { try { clearInterval(purgeTimer); } catch { /* 已清理 */ } }, "session-conductor: trash purge timer");
|
|
676
|
+
} catch { /* 定时器建不起来也不影响手动清理 */ }
|
|
677
|
+
|
|
660
678
|
// ---------- POST /api/session-conductor/search ----------
|
|
661
679
|
// 全文搜索:跨会话搜消息内容,返回命中会话 + 上下文片段
|
|
662
680
|
webServer.register({
|
|
@@ -1515,7 +1533,8 @@ export async function apply(ctx, config = {}) {
|
|
|
1515
1533
|
// 先校验组合有效性——无效组合会让后续回合的模型请求被拒(表现为「切换生效但回合失败」)
|
|
1516
1534
|
const pair = await validateModelPair(ctx, provider, model);
|
|
1517
1535
|
if (pair.error) return `切换失败:${pair.error}`;
|
|
1518
|
-
|
|
1536
|
+
// 显式传发起方:与聊天指令 /model-switch 走**同一条**解析路径(抽公共后两边只此一份)
|
|
1537
|
+
const found = findTargetAgent(ctx, args?.target, { caller: exec?.agent ?? null });
|
|
1519
1538
|
if (found.error) return `切换失败:${found.error}`;
|
|
1520
1539
|
const switchResult = await switchAgentModel(ctx, found.sessionId, provider, model, found.agent);
|
|
1521
1540
|
if (switchResult.error) return `切换失败:${switchResult.error}`;
|
|
@@ -17,6 +17,11 @@
|
|
|
17
17
|
const [ruleDays, setRuleDays] = react.useState(7);
|
|
18
18
|
const [rulePrefix, setRulePrefix] = react.useState("");
|
|
19
19
|
const [ruleLowValue, setRuleLowValue] = react.useState(false); // 低价值筛选(复用价值分析)
|
|
20
|
+
// —— 回收站(删除只是打标记,这里管理被标记的会话)——
|
|
21
|
+
const [trashItems, setTrashItems] = react.useState(null); // null = 尚未加载
|
|
22
|
+
const [trashSel, setTrashSel] = react.useState(() => new Set());
|
|
23
|
+
const [trashRetention, setTrashRetention] = react.useState(7); // 保留天数
|
|
24
|
+
const [trashBatch, setTrashBatch] = react.useState(null); // 最近一个删除批次(撤销用)
|
|
20
25
|
|
|
21
26
|
// list 流式——NDJSON 每行一个会话,读到立即追加显示(每获取一个追加一个,
|
|
22
27
|
// 不等全部);按 updatedAt 倒序插入保持列表顺序;旧一次性 JSON(预览页离线垫片/
|
|
@@ -563,6 +568,93 @@
|
|
|
563
568
|
setSelected(new Set((Array.isArray(ids) ? ids : []).filter((id) => typeof id === "string" && id !== "")));
|
|
564
569
|
};
|
|
565
570
|
|
|
571
|
+
// —— 回收站操作 ——
|
|
572
|
+
// 删除只是打标记,所以还原就是「删标记」,几乎零成本;真删只发生在到期与一键清空。
|
|
573
|
+
const loadTrashItems = async () => {
|
|
574
|
+
try {
|
|
575
|
+
const res = await window.__scFetch("/api/session-conductor/trash", { headers: { accept: "application/json" } }, 0);
|
|
576
|
+
const body = await res.json();
|
|
577
|
+
if (!body?.ok) throw new Error(body?.error?.message ?? "trash load failed");
|
|
578
|
+
const items = Array.isArray(body.items) ? body.items : [];
|
|
579
|
+
setTrashItems(items);
|
|
580
|
+
setTrashRetention(Number(body.retentionDays) || 7);
|
|
581
|
+
setTrashBatch(body.latestBatch ?? null);
|
|
582
|
+
setTrashSel((prev) => new Set([...prev].filter((id) => items.some((it) => it.sessionId === id))));
|
|
583
|
+
} catch (cause) {
|
|
584
|
+
window.alert(t("error.action", { message: cause instanceof Error ? cause.message : String(cause) }));
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
const restoreTrash = async (ids) => {
|
|
588
|
+
if (!Array.isArray(ids) || ids.length === 0) return;
|
|
589
|
+
try {
|
|
590
|
+
const res = await window.__scFetch("/api/session-conductor/trash/restore", {
|
|
591
|
+
method: "POST",
|
|
592
|
+
headers: { "Content-Type": CONTENT_TYPE_JSON },
|
|
593
|
+
body: JSON.stringify({ sessionIds: ids }),
|
|
594
|
+
}, 0);
|
|
595
|
+
const body = await res.json();
|
|
596
|
+
if (!body?.ok) throw new Error(body?.error?.message ?? "restore failed");
|
|
597
|
+
setTrashSel(new Set());
|
|
598
|
+
await loadTrashItems();
|
|
599
|
+
await refresh();
|
|
600
|
+
} catch (cause) {
|
|
601
|
+
window.alert(t("error.action", { message: cause instanceof Error ? cause.message : String(cause) }));
|
|
602
|
+
}
|
|
603
|
+
};
|
|
604
|
+
const restoreLastBatch = async () => {
|
|
605
|
+
try {
|
|
606
|
+
const res = await window.__scFetch("/api/session-conductor/trash/restore-last-batch", { method: "POST", headers: { "Content-Type": CONTENT_TYPE_JSON }, body: "{}" }, 0);
|
|
607
|
+
const body = await res.json();
|
|
608
|
+
if (!body?.ok) throw new Error(body?.error?.message ?? "restore batch failed");
|
|
609
|
+
if ((body.restored ?? []).length === 0) window.alert(t("trash.nothingToUndo"));
|
|
610
|
+
setTrashSel(new Set());
|
|
611
|
+
await loadTrashItems();
|
|
612
|
+
await refresh();
|
|
613
|
+
} catch (cause) {
|
|
614
|
+
window.alert(t("error.action", { message: cause instanceof Error ? cause.message : String(cause) }));
|
|
615
|
+
}
|
|
616
|
+
};
|
|
617
|
+
const emptyTrashAll = async () => {
|
|
618
|
+
const n = (trashItems ?? []).length;
|
|
619
|
+
if (n === 0) return;
|
|
620
|
+
if (!window.confirm(t("trash.emptyConfirm", { n }))) return;
|
|
621
|
+
try {
|
|
622
|
+
const res = await window.__scFetch("/api/session-conductor/trash/empty", {
|
|
623
|
+
method: "POST",
|
|
624
|
+
headers: { "Content-Type": CONTENT_TYPE_JSON },
|
|
625
|
+
body: JSON.stringify({ confirm: true }),
|
|
626
|
+
}, 0);
|
|
627
|
+
const body = await res.json();
|
|
628
|
+
if (!body?.ok) throw new Error(body?.error?.message ?? "empty failed");
|
|
629
|
+
setTrashSel(new Set());
|
|
630
|
+
await loadTrashItems();
|
|
631
|
+
} catch (cause) {
|
|
632
|
+
window.alert(t("error.action", { message: cause instanceof Error ? cause.message : String(cause) }));
|
|
633
|
+
}
|
|
634
|
+
};
|
|
635
|
+
const saveTrashRetention = async () => {
|
|
636
|
+
try {
|
|
637
|
+
const res = await window.__scFetch("/api/session-conductor/trash/retention", {
|
|
638
|
+
method: "POST",
|
|
639
|
+
headers: { "Content-Type": CONTENT_TYPE_JSON },
|
|
640
|
+
body: JSON.stringify({ days: Number(trashRetention) || 7 }),
|
|
641
|
+
}, 0);
|
|
642
|
+
const body = await res.json();
|
|
643
|
+
if (!body?.ok) throw new Error(body?.error?.message ?? "retention failed");
|
|
644
|
+
setTrashRetention(Number(body.retentionDays) || 7);
|
|
645
|
+
await loadTrashItems();
|
|
646
|
+
} catch (cause) {
|
|
647
|
+
window.alert(t("error.action", { message: cause instanceof Error ? cause.message : String(cause) }));
|
|
648
|
+
}
|
|
649
|
+
};
|
|
650
|
+
const toggleTrashSel = (sessionId, checked) => {
|
|
651
|
+
setTrashSel((prev) => {
|
|
652
|
+
const next = new Set(prev);
|
|
653
|
+
if (checked) next.add(sessionId); else next.delete(sessionId);
|
|
654
|
+
return next;
|
|
655
|
+
});
|
|
656
|
+
};
|
|
657
|
+
|
|
566
658
|
// —— 筛选:按条件把会话**自动勾选**到列表里 ——
|
|
567
659
|
// 批量删除只有一条路径:「勾选 → 删除选中」。条件(归档 / 价值 / 时间 / 目录)只负责把该勾的勾上。
|
|
568
660
|
// ① 归档 / 时间 / 目录:面板手里每一行已经带着 archived / cwd / updatedAt,
|
|
@@ -652,6 +744,84 @@
|
|
|
652
744
|
}
|
|
653
745
|
};
|
|
654
746
|
|
|
747
|
+
/** 回收站面板:工具栏(保留期 / 撤销 / 还原选中 / 全选 / 一键清空)+ 条目列表 */
|
|
748
|
+
const renderTrash = () => (0, react_jsx_runtime.jsxs)("div", {
|
|
749
|
+
style: { padding: PADDING_BAR },
|
|
750
|
+
children: [
|
|
751
|
+
(0, react_jsx_runtime.jsxs)("div", {
|
|
752
|
+
style: { display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap", marginBottom: 8, fontSize: FONT_SIZE_SMALL },
|
|
753
|
+
children: [
|
|
754
|
+
(0, react_jsx_runtime.jsx)("span", { style: { color: CSS_LABEL_SECONDARY }, children: t("trash.retention.label") }),
|
|
755
|
+
(0, react_jsx_runtime.jsx)("input", {
|
|
756
|
+
type: "number", min: 1, max: 365, value: trashRetention,
|
|
757
|
+
style: { width: 64, height: SMALL_CONTROL_HEIGHT, borderRadius: CONTROL_RADIUS, border: CSS_BORDER_L2, background: CSS_BG_BASE, color: CSS_LABEL_PRIMARY, padding: "0 6px", fontSize: FONT_SIZE_SMALL },
|
|
758
|
+
onChange: (e) => setTrashRetention(e.target.value),
|
|
759
|
+
}),
|
|
760
|
+
(0, react_jsx_runtime.jsx)("span", { style: { color: CSS_LABEL_TERTIARY }, children: t("trash.retention.unit") }),
|
|
761
|
+
(0, react_jsx_runtime.jsx)("button", { style: S.actionBtn(false), onClick: saveTrashRetention, children: t("trash.retention.save") }),
|
|
762
|
+
(0, react_jsx_runtime.jsx)("button", {
|
|
763
|
+
style: S.actionBtn(false),
|
|
764
|
+
disabled: !trashBatch || (trashBatch.ids ?? []).length === 0,
|
|
765
|
+
title: trashBatch ? t("trash.undo.title", { n: (trashBatch.ids ?? []).length }) : t("trash.nothingToUndo"),
|
|
766
|
+
onClick: restoreLastBatch,
|
|
767
|
+
children: t("trash.undo"),
|
|
768
|
+
}),
|
|
769
|
+
(0, react_jsx_runtime.jsx)("button", {
|
|
770
|
+
style: S.actionBtn(false),
|
|
771
|
+
disabled: trashSel.size === 0,
|
|
772
|
+
onClick: () => restoreTrash([...trashSel]),
|
|
773
|
+
children: t("trash.restoreSelected", { n: trashSel.size }),
|
|
774
|
+
}),
|
|
775
|
+
(0, react_jsx_runtime.jsx)("button", {
|
|
776
|
+
style: S.actionBtn(false),
|
|
777
|
+
disabled: (trashItems ?? []).length === 0,
|
|
778
|
+
onClick: () => setTrashSel(new Set((trashItems ?? []).map((it) => it.sessionId))),
|
|
779
|
+
children: t("trash.selectAll"),
|
|
780
|
+
}),
|
|
781
|
+
(0, react_jsx_runtime.jsx)("button", {
|
|
782
|
+
style: { ...S.actionBtn(true), borderColor: CSS_DANGER, color: CSS_DANGER, marginLeft: "auto" },
|
|
783
|
+
disabled: (trashItems ?? []).length === 0,
|
|
784
|
+
onClick: emptyTrashAll,
|
|
785
|
+
children: t("trash.empty"),
|
|
786
|
+
}),
|
|
787
|
+
],
|
|
788
|
+
}),
|
|
789
|
+
(0, react_jsx_runtime.jsx)("div", { style: { fontSize: 11.5, color: CSS_LABEL_TERTIARY, marginBottom: 6 }, children: t("trash.hint") }),
|
|
790
|
+
trashItems === null
|
|
791
|
+
? (0, react_jsx_runtime.jsx)("div", { style: S.empty, children: t("list.loading") })
|
|
792
|
+
: trashItems.length === 0
|
|
793
|
+
? (0, react_jsx_runtime.jsx)("div", { style: S.empty, children: t("trash.emptyList") })
|
|
794
|
+
: trashItems.map((item) => (0, react_jsx_runtime.jsxs)("div", {
|
|
795
|
+
style: { display: "flex", alignItems: "center", gap: 8, padding: "6px 4px", borderTop: "1px solid var(--dsw-alias-border-l1, rgba(128,128,128,.15))" },
|
|
796
|
+
children: [
|
|
797
|
+
(0, react_jsx_runtime.jsx)("input", {
|
|
798
|
+
type: "checkbox",
|
|
799
|
+
checked: trashSel.has(item.sessionId),
|
|
800
|
+
onChange: (e) => toggleTrashSel(item.sessionId, e.target.checked),
|
|
801
|
+
}),
|
|
802
|
+
(0, react_jsx_runtime.jsxs)("div", {
|
|
803
|
+
style: { flex: 1, minWidth: 0 },
|
|
804
|
+
children: [
|
|
805
|
+
(0, react_jsx_runtime.jsx)("div", {
|
|
806
|
+
style: { fontSize: FONT_SIZE_SMALL, color: CSS_LABEL_PRIMARY, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
|
|
807
|
+
children: item.title || basenameOf(item.cwd) || item.sessionId,
|
|
808
|
+
}),
|
|
809
|
+
(0, react_jsx_runtime.jsx)("div", {
|
|
810
|
+
style: { fontSize: 11.5, color: CSS_LABEL_TERTIARY, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
|
|
811
|
+
children: `${item.cwd || item.sessionId} | ${t("trash.remaining", { n: item.remainingDays ?? 0 })}`,
|
|
812
|
+
}),
|
|
813
|
+
],
|
|
814
|
+
}),
|
|
815
|
+
(0, react_jsx_runtime.jsx)("button", {
|
|
816
|
+
style: S.actionBtn(false),
|
|
817
|
+
onClick: () => restoreTrash([item.sessionId]),
|
|
818
|
+
children: t("trash.restore"),
|
|
819
|
+
}),
|
|
820
|
+
],
|
|
821
|
+
}, item.sessionId)),
|
|
822
|
+
],
|
|
823
|
+
});
|
|
824
|
+
|
|
655
825
|
const rows = sessions ?? [];
|
|
656
826
|
// 列表过滤:标题 / cwd / id / 状态词(大小写不敏感)
|
|
657
827
|
const ql = query.trim().toLowerCase();
|
|
@@ -1107,6 +1277,11 @@
|
|
|
1107
1277
|
onClick: () => setTab("active"),
|
|
1108
1278
|
children: `${t("tab.active")} (${rows.filter((row) => !row.archived && (row.live === true || row.running === true)).length})`,
|
|
1109
1279
|
}),
|
|
1280
|
+
(0, react_jsx_runtime.jsx)("button", {
|
|
1281
|
+
style: S.tab(tab === "trash"),
|
|
1282
|
+
onClick: () => { setTab("trash"); loadTrashItems(); },
|
|
1283
|
+
children: `${t("tab.trash")}${trashItems === null ? "" : ` (${trashItems.length})`}`,
|
|
1284
|
+
}),
|
|
1110
1285
|
(0, react_jsx_runtime.jsx)("button", {
|
|
1111
1286
|
style: S.tab(tab === "help"),
|
|
1112
1287
|
onClick: () => setTab("help"),
|
|
@@ -1216,6 +1391,8 @@
|
|
|
1216
1391
|
style: S.body,
|
|
1217
1392
|
children: tab === "help"
|
|
1218
1393
|
? renderHelp()
|
|
1394
|
+
: tab === "trash"
|
|
1395
|
+
? renderTrash()
|
|
1219
1396
|
: (0, react_jsx_runtime.jsxs)(react.Fragment, {
|
|
1220
1397
|
children: [
|
|
1221
1398
|
// —— 全文搜索结果区(官方 FTS5 优先,自研兜底) ——
|
package/lib/client.js
CHANGED
|
@@ -430,6 +430,11 @@ window.__ModuleLoader__.load({
|
|
|
430
430
|
const [ruleDays, setRuleDays] = react.useState(7);
|
|
431
431
|
const [rulePrefix, setRulePrefix] = react.useState("");
|
|
432
432
|
const [ruleLowValue, setRuleLowValue] = react.useState(false); // 低价值筛选(复用价值分析)
|
|
433
|
+
// —— 回收站(删除只是打标记,这里管理被标记的会话)——
|
|
434
|
+
const [trashItems, setTrashItems] = react.useState(null); // null = 尚未加载
|
|
435
|
+
const [trashSel, setTrashSel] = react.useState(() => new Set());
|
|
436
|
+
const [trashRetention, setTrashRetention] = react.useState(7); // 保留天数
|
|
437
|
+
const [trashBatch, setTrashBatch] = react.useState(null); // 最近一个删除批次(撤销用)
|
|
433
438
|
|
|
434
439
|
// list 流式——NDJSON 每行一个会话,读到立即追加显示(每获取一个追加一个,
|
|
435
440
|
// 不等全部);按 updatedAt 倒序插入保持列表顺序;旧一次性 JSON(预览页离线垫片/
|
|
@@ -976,6 +981,93 @@ window.__ModuleLoader__.load({
|
|
|
976
981
|
setSelected(new Set((Array.isArray(ids) ? ids : []).filter((id) => typeof id === "string" && id !== "")));
|
|
977
982
|
};
|
|
978
983
|
|
|
984
|
+
// —— 回收站操作 ——
|
|
985
|
+
// 删除只是打标记,所以还原就是「删标记」,几乎零成本;真删只发生在到期与一键清空。
|
|
986
|
+
const loadTrashItems = async () => {
|
|
987
|
+
try {
|
|
988
|
+
const res = await window.__scFetch("/api/session-conductor/trash", { headers: { accept: "application/json" } }, 0);
|
|
989
|
+
const body = await res.json();
|
|
990
|
+
if (!body?.ok) throw new Error(body?.error?.message ?? "trash load failed");
|
|
991
|
+
const items = Array.isArray(body.items) ? body.items : [];
|
|
992
|
+
setTrashItems(items);
|
|
993
|
+
setTrashRetention(Number(body.retentionDays) || 7);
|
|
994
|
+
setTrashBatch(body.latestBatch ?? null);
|
|
995
|
+
setTrashSel((prev) => new Set([...prev].filter((id) => items.some((it) => it.sessionId === id))));
|
|
996
|
+
} catch (cause) {
|
|
997
|
+
window.alert(t("error.action", { message: cause instanceof Error ? cause.message : String(cause) }));
|
|
998
|
+
}
|
|
999
|
+
};
|
|
1000
|
+
const restoreTrash = async (ids) => {
|
|
1001
|
+
if (!Array.isArray(ids) || ids.length === 0) return;
|
|
1002
|
+
try {
|
|
1003
|
+
const res = await window.__scFetch("/api/session-conductor/trash/restore", {
|
|
1004
|
+
method: "POST",
|
|
1005
|
+
headers: { "Content-Type": CONTENT_TYPE_JSON },
|
|
1006
|
+
body: JSON.stringify({ sessionIds: ids }),
|
|
1007
|
+
}, 0);
|
|
1008
|
+
const body = await res.json();
|
|
1009
|
+
if (!body?.ok) throw new Error(body?.error?.message ?? "restore failed");
|
|
1010
|
+
setTrashSel(new Set());
|
|
1011
|
+
await loadTrashItems();
|
|
1012
|
+
await refresh();
|
|
1013
|
+
} catch (cause) {
|
|
1014
|
+
window.alert(t("error.action", { message: cause instanceof Error ? cause.message : String(cause) }));
|
|
1015
|
+
}
|
|
1016
|
+
};
|
|
1017
|
+
const restoreLastBatch = async () => {
|
|
1018
|
+
try {
|
|
1019
|
+
const res = await window.__scFetch("/api/session-conductor/trash/restore-last-batch", { method: "POST", headers: { "Content-Type": CONTENT_TYPE_JSON }, body: "{}" }, 0);
|
|
1020
|
+
const body = await res.json();
|
|
1021
|
+
if (!body?.ok) throw new Error(body?.error?.message ?? "restore batch failed");
|
|
1022
|
+
if ((body.restored ?? []).length === 0) window.alert(t("trash.nothingToUndo"));
|
|
1023
|
+
setTrashSel(new Set());
|
|
1024
|
+
await loadTrashItems();
|
|
1025
|
+
await refresh();
|
|
1026
|
+
} catch (cause) {
|
|
1027
|
+
window.alert(t("error.action", { message: cause instanceof Error ? cause.message : String(cause) }));
|
|
1028
|
+
}
|
|
1029
|
+
};
|
|
1030
|
+
const emptyTrashAll = async () => {
|
|
1031
|
+
const n = (trashItems ?? []).length;
|
|
1032
|
+
if (n === 0) return;
|
|
1033
|
+
if (!window.confirm(t("trash.emptyConfirm", { n }))) return;
|
|
1034
|
+
try {
|
|
1035
|
+
const res = await window.__scFetch("/api/session-conductor/trash/empty", {
|
|
1036
|
+
method: "POST",
|
|
1037
|
+
headers: { "Content-Type": CONTENT_TYPE_JSON },
|
|
1038
|
+
body: JSON.stringify({ confirm: true }),
|
|
1039
|
+
}, 0);
|
|
1040
|
+
const body = await res.json();
|
|
1041
|
+
if (!body?.ok) throw new Error(body?.error?.message ?? "empty failed");
|
|
1042
|
+
setTrashSel(new Set());
|
|
1043
|
+
await loadTrashItems();
|
|
1044
|
+
} catch (cause) {
|
|
1045
|
+
window.alert(t("error.action", { message: cause instanceof Error ? cause.message : String(cause) }));
|
|
1046
|
+
}
|
|
1047
|
+
};
|
|
1048
|
+
const saveTrashRetention = async () => {
|
|
1049
|
+
try {
|
|
1050
|
+
const res = await window.__scFetch("/api/session-conductor/trash/retention", {
|
|
1051
|
+
method: "POST",
|
|
1052
|
+
headers: { "Content-Type": CONTENT_TYPE_JSON },
|
|
1053
|
+
body: JSON.stringify({ days: Number(trashRetention) || 7 }),
|
|
1054
|
+
}, 0);
|
|
1055
|
+
const body = await res.json();
|
|
1056
|
+
if (!body?.ok) throw new Error(body?.error?.message ?? "retention failed");
|
|
1057
|
+
setTrashRetention(Number(body.retentionDays) || 7);
|
|
1058
|
+
await loadTrashItems();
|
|
1059
|
+
} catch (cause) {
|
|
1060
|
+
window.alert(t("error.action", { message: cause instanceof Error ? cause.message : String(cause) }));
|
|
1061
|
+
}
|
|
1062
|
+
};
|
|
1063
|
+
const toggleTrashSel = (sessionId, checked) => {
|
|
1064
|
+
setTrashSel((prev) => {
|
|
1065
|
+
const next = new Set(prev);
|
|
1066
|
+
if (checked) next.add(sessionId); else next.delete(sessionId);
|
|
1067
|
+
return next;
|
|
1068
|
+
});
|
|
1069
|
+
};
|
|
1070
|
+
|
|
979
1071
|
// —— 筛选:按条件把会话**自动勾选**到列表里 ——
|
|
980
1072
|
// 批量删除只有一条路径:「勾选 → 删除选中」。条件(归档 / 价值 / 时间 / 目录)只负责把该勾的勾上。
|
|
981
1073
|
// ① 归档 / 时间 / 目录:面板手里每一行已经带着 archived / cwd / updatedAt,
|
|
@@ -1065,6 +1157,84 @@ window.__ModuleLoader__.load({
|
|
|
1065
1157
|
}
|
|
1066
1158
|
};
|
|
1067
1159
|
|
|
1160
|
+
/** 回收站面板:工具栏(保留期 / 撤销 / 还原选中 / 全选 / 一键清空)+ 条目列表 */
|
|
1161
|
+
const renderTrash = () => (0, react_jsx_runtime.jsxs)("div", {
|
|
1162
|
+
style: { padding: PADDING_BAR },
|
|
1163
|
+
children: [
|
|
1164
|
+
(0, react_jsx_runtime.jsxs)("div", {
|
|
1165
|
+
style: { display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap", marginBottom: 8, fontSize: FONT_SIZE_SMALL },
|
|
1166
|
+
children: [
|
|
1167
|
+
(0, react_jsx_runtime.jsx)("span", { style: { color: CSS_LABEL_SECONDARY }, children: t("trash.retention.label") }),
|
|
1168
|
+
(0, react_jsx_runtime.jsx)("input", {
|
|
1169
|
+
type: "number", min: 1, max: 365, value: trashRetention,
|
|
1170
|
+
style: { width: 64, height: SMALL_CONTROL_HEIGHT, borderRadius: CONTROL_RADIUS, border: CSS_BORDER_L2, background: CSS_BG_BASE, color: CSS_LABEL_PRIMARY, padding: "0 6px", fontSize: FONT_SIZE_SMALL },
|
|
1171
|
+
onChange: (e) => setTrashRetention(e.target.value),
|
|
1172
|
+
}),
|
|
1173
|
+
(0, react_jsx_runtime.jsx)("span", { style: { color: CSS_LABEL_TERTIARY }, children: t("trash.retention.unit") }),
|
|
1174
|
+
(0, react_jsx_runtime.jsx)("button", { style: S.actionBtn(false), onClick: saveTrashRetention, children: t("trash.retention.save") }),
|
|
1175
|
+
(0, react_jsx_runtime.jsx)("button", {
|
|
1176
|
+
style: S.actionBtn(false),
|
|
1177
|
+
disabled: !trashBatch || (trashBatch.ids ?? []).length === 0,
|
|
1178
|
+
title: trashBatch ? t("trash.undo.title", { n: (trashBatch.ids ?? []).length }) : t("trash.nothingToUndo"),
|
|
1179
|
+
onClick: restoreLastBatch,
|
|
1180
|
+
children: t("trash.undo"),
|
|
1181
|
+
}),
|
|
1182
|
+
(0, react_jsx_runtime.jsx)("button", {
|
|
1183
|
+
style: S.actionBtn(false),
|
|
1184
|
+
disabled: trashSel.size === 0,
|
|
1185
|
+
onClick: () => restoreTrash([...trashSel]),
|
|
1186
|
+
children: t("trash.restoreSelected", { n: trashSel.size }),
|
|
1187
|
+
}),
|
|
1188
|
+
(0, react_jsx_runtime.jsx)("button", {
|
|
1189
|
+
style: S.actionBtn(false),
|
|
1190
|
+
disabled: (trashItems ?? []).length === 0,
|
|
1191
|
+
onClick: () => setTrashSel(new Set((trashItems ?? []).map((it) => it.sessionId))),
|
|
1192
|
+
children: t("trash.selectAll"),
|
|
1193
|
+
}),
|
|
1194
|
+
(0, react_jsx_runtime.jsx)("button", {
|
|
1195
|
+
style: { ...S.actionBtn(true), borderColor: CSS_DANGER, color: CSS_DANGER, marginLeft: "auto" },
|
|
1196
|
+
disabled: (trashItems ?? []).length === 0,
|
|
1197
|
+
onClick: emptyTrashAll,
|
|
1198
|
+
children: t("trash.empty"),
|
|
1199
|
+
}),
|
|
1200
|
+
],
|
|
1201
|
+
}),
|
|
1202
|
+
(0, react_jsx_runtime.jsx)("div", { style: { fontSize: 11.5, color: CSS_LABEL_TERTIARY, marginBottom: 6 }, children: t("trash.hint") }),
|
|
1203
|
+
trashItems === null
|
|
1204
|
+
? (0, react_jsx_runtime.jsx)("div", { style: S.empty, children: t("list.loading") })
|
|
1205
|
+
: trashItems.length === 0
|
|
1206
|
+
? (0, react_jsx_runtime.jsx)("div", { style: S.empty, children: t("trash.emptyList") })
|
|
1207
|
+
: trashItems.map((item) => (0, react_jsx_runtime.jsxs)("div", {
|
|
1208
|
+
style: { display: "flex", alignItems: "center", gap: 8, padding: "6px 4px", borderTop: "1px solid var(--dsw-alias-border-l1, rgba(128,128,128,.15))" },
|
|
1209
|
+
children: [
|
|
1210
|
+
(0, react_jsx_runtime.jsx)("input", {
|
|
1211
|
+
type: "checkbox",
|
|
1212
|
+
checked: trashSel.has(item.sessionId),
|
|
1213
|
+
onChange: (e) => toggleTrashSel(item.sessionId, e.target.checked),
|
|
1214
|
+
}),
|
|
1215
|
+
(0, react_jsx_runtime.jsxs)("div", {
|
|
1216
|
+
style: { flex: 1, minWidth: 0 },
|
|
1217
|
+
children: [
|
|
1218
|
+
(0, react_jsx_runtime.jsx)("div", {
|
|
1219
|
+
style: { fontSize: FONT_SIZE_SMALL, color: CSS_LABEL_PRIMARY, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
|
|
1220
|
+
children: item.title || basenameOf(item.cwd) || item.sessionId,
|
|
1221
|
+
}),
|
|
1222
|
+
(0, react_jsx_runtime.jsx)("div", {
|
|
1223
|
+
style: { fontSize: 11.5, color: CSS_LABEL_TERTIARY, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
|
|
1224
|
+
children: `${item.cwd || item.sessionId} | ${t("trash.remaining", { n: item.remainingDays ?? 0 })}`,
|
|
1225
|
+
}),
|
|
1226
|
+
],
|
|
1227
|
+
}),
|
|
1228
|
+
(0, react_jsx_runtime.jsx)("button", {
|
|
1229
|
+
style: S.actionBtn(false),
|
|
1230
|
+
onClick: () => restoreTrash([item.sessionId]),
|
|
1231
|
+
children: t("trash.restore"),
|
|
1232
|
+
}),
|
|
1233
|
+
],
|
|
1234
|
+
}, item.sessionId)),
|
|
1235
|
+
],
|
|
1236
|
+
});
|
|
1237
|
+
|
|
1068
1238
|
const rows = sessions ?? [];
|
|
1069
1239
|
// 列表过滤:标题 / cwd / id / 状态词(大小写不敏感)
|
|
1070
1240
|
const ql = query.trim().toLowerCase();
|
|
@@ -1520,6 +1690,11 @@ window.__ModuleLoader__.load({
|
|
|
1520
1690
|
onClick: () => setTab("active"),
|
|
1521
1691
|
children: `${t("tab.active")} (${rows.filter((row) => !row.archived && (row.live === true || row.running === true)).length})`,
|
|
1522
1692
|
}),
|
|
1693
|
+
(0, react_jsx_runtime.jsx)("button", {
|
|
1694
|
+
style: S.tab(tab === "trash"),
|
|
1695
|
+
onClick: () => { setTab("trash"); loadTrashItems(); },
|
|
1696
|
+
children: `${t("tab.trash")}${trashItems === null ? "" : ` (${trashItems.length})`}`,
|
|
1697
|
+
}),
|
|
1523
1698
|
(0, react_jsx_runtime.jsx)("button", {
|
|
1524
1699
|
style: S.tab(tab === "help"),
|
|
1525
1700
|
onClick: () => setTab("help"),
|
|
@@ -1629,6 +1804,8 @@ window.__ModuleLoader__.load({
|
|
|
1629
1804
|
style: S.body,
|
|
1630
1805
|
children: tab === "help"
|
|
1631
1806
|
? renderHelp()
|
|
1807
|
+
: tab === "trash"
|
|
1808
|
+
? renderTrash()
|
|
1632
1809
|
: (0, react_jsx_runtime.jsxs)(react.Fragment, {
|
|
1633
1810
|
children: [
|
|
1634
1811
|
// —— 全文搜索结果区(官方 FTS5 优先,自研兜底) ——
|
package/lib/core/config.js
CHANGED
|
@@ -35,6 +35,9 @@ export const DEFAULTS = {
|
|
|
35
35
|
// 同一条会话的价值结论在「内容没变(updatedAt 未变)+ 未超过该时长」时直接复用,
|
|
36
36
|
// 不再重读日志(实测单条 0.2 MB≈85 ms / 7 MB≈1.1 s / 86 MB≈16 s)。默认 24 小时。
|
|
37
37
|
valueIndexTtlHours: 24,
|
|
38
|
+
// ── 回收站 ──
|
|
39
|
+
// 删除只打标记;超过该天数才真正删盘(面板上改的值存在索引里,优先于这里)
|
|
40
|
+
trashRetentionDays: 7,
|
|
38
41
|
};
|
|
39
42
|
|
|
40
43
|
/** 运行期配置(面板/测试可改)。外部只读引用;改整体请用 setConfig。 */
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// 插件索引的唯一落盘位置:<DSH_HOME>/storages/dsh-session-conductor/index.json
|
|
2
|
+
//
|
|
3
|
+
// 【为什么要收敛】此前每类索引各写一个文件(list-cache.json / value-index.json / trash.json),
|
|
4
|
+
// 于是同一个目录里躺着三套「版本号 + 原子写 + 路径解析」的复制品:改一处忘一处、
|
|
5
|
+
// 备份/迁移要记三份名字、出问题时不知道先看哪个。现在统一成**一个文件、一个版本号、一个原子写**,
|
|
6
|
+
// 各模块只关心自己的 section。
|
|
7
|
+
//
|
|
8
|
+
// 【结构】
|
|
9
|
+
// {
|
|
10
|
+
// version: 1,
|
|
11
|
+
// list: { entries: { [sessionId]: {...} } }, // 会话列表解析缓存
|
|
12
|
+
// value: { entries: { [sessionId]: {...} } }, // 价值判定索引
|
|
13
|
+
// trash: { entries: { [sessionId]: {...} }, retentionDays } // 回收站
|
|
14
|
+
// }
|
|
15
|
+
//
|
|
16
|
+
// 【迁移】首次加载若发现旧文件,按 section 合并进来,并把旧文件改名为
|
|
17
|
+
// `<原名>.migrated`(不删、可回滚)。
|
|
18
|
+
//
|
|
19
|
+
// 【写入】原子写(tmp + rename)。整文件重写:当前规模 30~80 KB、序列化 ~1 ms,
|
|
20
|
+
// 各模块的写盘本来就有节流/低频,代价可忽略;等真到上千条再评估换存储。
|
|
21
|
+
|
|
22
|
+
import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync } from "node:fs";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
|
|
25
|
+
import { cfg, resolveDshHome } from "./config.js";
|
|
26
|
+
|
|
27
|
+
const STORE_VERSION = 1;
|
|
28
|
+
const STORE_SUBDIR = ["storages", "dsh-session-conductor"];
|
|
29
|
+
const STORE_FILE = "index.json";
|
|
30
|
+
/** 旧文件 → section 名(迁移用) */
|
|
31
|
+
const LEGACY_FILES = [
|
|
32
|
+
["list-cache.json", "list"],
|
|
33
|
+
["value-index.json", "value"],
|
|
34
|
+
["trash.json", "trash"],
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
let storeCache = null;
|
|
38
|
+
let storePath = null;
|
|
39
|
+
|
|
40
|
+
/** 索引文件绝对路径(首次解析后缓存)。 */
|
|
41
|
+
export function storeFilePath(ctx) {
|
|
42
|
+
if (storePath !== null) return storePath;
|
|
43
|
+
const home = resolveDshHome(ctx, cfg);
|
|
44
|
+
storePath = home ? path.join(home, ...STORE_SUBDIR, STORE_FILE) : "";
|
|
45
|
+
return storePath;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 空结构(各 section 都带默认值,调用方不必判空)。 */
|
|
49
|
+
function emptyStore() {
|
|
50
|
+
return { version: STORE_VERSION, list: { entries: {} }, value: { entries: {} }, trash: { entries: {} } };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 读索引(进程内只读一次)。文件缺失/损坏 → 空结构;随后尝试从旧文件迁移一次。
|
|
55
|
+
*/
|
|
56
|
+
export function loadStore(ctx) {
|
|
57
|
+
if (storeCache !== null) return storeCache;
|
|
58
|
+
storeCache = emptyStore();
|
|
59
|
+
const file = storeFilePath(ctx);
|
|
60
|
+
if (!file) return storeCache;
|
|
61
|
+
let loaded = false;
|
|
62
|
+
try {
|
|
63
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
64
|
+
if (parsed && typeof parsed === "object") {
|
|
65
|
+
storeCache = {
|
|
66
|
+
version: STORE_VERSION,
|
|
67
|
+
list: { entries: parsed.list?.entries && typeof parsed.list.entries === "object" ? parsed.list.entries : {} },
|
|
68
|
+
value: { entries: parsed.value?.entries && typeof parsed.value.entries === "object" ? parsed.value.entries : {} },
|
|
69
|
+
trash: {
|
|
70
|
+
entries: parsed.trash?.entries && typeof parsed.trash.entries === "object" ? parsed.trash.entries : {},
|
|
71
|
+
...(Number.isFinite(parsed.trash?.retentionDays) ? { retentionDays: parsed.trash.retentionDays } : {}),
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
loaded = true;
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
// 首次运行 / 文件损坏:保持空结构
|
|
78
|
+
}
|
|
79
|
+
if (!loaded) migrateLegacyFiles(ctx);
|
|
80
|
+
return storeCache;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 一次性迁移旧文件:按 section 合并,成功则把旧文件改名 `.migrated`(保留可回滚)。
|
|
85
|
+
* 任一文件解析失败就跳过它(不阻断启动)。
|
|
86
|
+
*/
|
|
87
|
+
function migrateLegacyFiles(ctx) {
|
|
88
|
+
const file = storeFilePath(ctx);
|
|
89
|
+
if (!file) return;
|
|
90
|
+
const dir = path.dirname(file);
|
|
91
|
+
let migratedAny = false;
|
|
92
|
+
for (const [legacyName, section] of LEGACY_FILES) {
|
|
93
|
+
const legacyPath = path.join(dir, legacyName);
|
|
94
|
+
if (!existsSync(legacyPath)) continue;
|
|
95
|
+
try {
|
|
96
|
+
const parsed = JSON.parse(readFileSync(legacyPath, "utf8"));
|
|
97
|
+
const entries = parsed?.entries && typeof parsed.entries === "object" ? parsed.entries : null;
|
|
98
|
+
if (entries) {
|
|
99
|
+
storeCache[section] = {
|
|
100
|
+
entries,
|
|
101
|
+
...(section === "trash" && Number.isFinite(parsed?.retentionDays) ? { retentionDays: parsed.retentionDays } : {}),
|
|
102
|
+
};
|
|
103
|
+
migratedAny = true;
|
|
104
|
+
}
|
|
105
|
+
// 迁移后改名:不删除,出问题可人工回滚
|
|
106
|
+
renameSync(legacyPath, `${legacyPath}.migrated`);
|
|
107
|
+
} catch {
|
|
108
|
+
// 旧文件坏了:跳过,保持空 section
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (migratedAny) saveStoreNow(ctx);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 立即落盘(原子写:tmp + rename)。失败只丢本次写入,不抛。 */
|
|
115
|
+
export function saveStoreNow(ctx) {
|
|
116
|
+
const file = storeFilePath(ctx);
|
|
117
|
+
if (!file || storeCache === null) return false;
|
|
118
|
+
try {
|
|
119
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
120
|
+
const tmpPath = `${file}.tmp-${process.pid}`;
|
|
121
|
+
writeFileSync(tmpPath, JSON.stringify(storeCache), "utf8");
|
|
122
|
+
renameSync(tmpPath, file);
|
|
123
|
+
return true;
|
|
124
|
+
} catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 取某个 section(不存在时给默认值)。 */
|
|
130
|
+
export function getSection(ctx, name) {
|
|
131
|
+
const store = loadStore(ctx);
|
|
132
|
+
if (!store[name]) store[name] = { entries: {} };
|
|
133
|
+
if (!store[name].entries || typeof store[name].entries !== "object") store[name].entries = {};
|
|
134
|
+
return store[name];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 写某个 section(只改内存,落盘由调用方决定时机)。 */
|
|
138
|
+
export function setSection(ctx, name, value) {
|
|
139
|
+
const store = loadStore(ctx);
|
|
140
|
+
store[name] = value;
|
|
141
|
+
return store[name];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 测试钩子:清空内存态。 */
|
|
145
|
+
export function resetStoreForTest() {
|
|
146
|
+
storeCache = null;
|
|
147
|
+
storePath = null;
|
|
148
|
+
}
|