dsh-session-conductor 1.3.7 → 1.3.9
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 +73 -0
- package/lib/client-parts/apply.js +10 -8
- package/lib/client-parts/components/panel.js +321 -120
- package/lib/client-parts/foundation/bootstrap.js +8 -10
- package/lib/client.js +339 -138
- package/lib/core/config.js +7 -0
- package/lib/core/index-store.js +148 -0
- package/lib/features/rename/analysis.js +6 -0
- package/lib/features/rename/title.js +5 -0
- package/lib/features/search.js +5 -3
- package/lib/features/session-messaging/logic.js +20 -6
- package/lib/features/session-messaging/tools.js +14 -1
- package/lib/features/session-ops-routes.js +145 -1
- package/lib/features/session-ops.js +114 -41
- package/lib/features/trash.js +206 -0
- package/lib/features/value-index.js +120 -0
- package/lib/i18n/en.json +29 -14
- package/lib/i18n/zh.json +27 -12
- package/lib/sessions/list-cache.js +41 -29
- package/lib/sessions/list.js +11 -0
- package/lib/value.js +31 -0
- package/package.json +2 -2
package/lib/client.js
CHANGED
|
@@ -90,18 +90,16 @@ window.__ModuleLoader__.load({
|
|
|
90
90
|
|
|
91
91
|
// ---------- 工具 ----------
|
|
92
92
|
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
const FETCH_TIMEOUT_MS = 30000;
|
|
93
|
+
// 统一 fetch 入口(url, opts, timeoutMs)。**默认不设超时**。
|
|
94
|
+
// 曾经默认 30 秒「兜底」,结果两次把慢但正常的请求掐成失败:
|
|
95
|
+
// · 会话列表冷缓存要逐份解压上百份日志(实测 41~226 秒)→ 刚重启报「读取会话列表失败」;
|
|
96
|
+
// · 按条件筛选勾了「低价值」时要逐个会话读事件 → 报「操作失败:signal is aborted…」。
|
|
97
|
+
// 结论:**判定只能按事件驱动**(请求完成 / 失败 / 数据到达),不能靠拍一个秒数上限;
|
|
98
|
+
// 时间阈值一写死,慢就被误读成坏。真要限时,必须由调用方显式传 timeoutMs 并说清依据。
|
|
100
99
|
window.__scFetch = function (url, opts, timeoutMs) {
|
|
101
|
-
|
|
102
|
-
if (!(budget > 0)) return fetch(url, opts || {});
|
|
100
|
+
if (!(timeoutMs > 0)) return fetch(url, opts || {});
|
|
103
101
|
const controller = new AbortController();
|
|
104
|
-
const timer = setTimeout(() => controller.abort(),
|
|
102
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
105
103
|
const next = opts ? Object.assign({}, opts, { signal: controller.signal }) : { signal: controller.signal };
|
|
106
104
|
return fetch(url, next).finally(() => clearTimeout(timer));
|
|
107
105
|
};
|
|
@@ -427,12 +425,16 @@ window.__ModuleLoader__.load({
|
|
|
427
425
|
const [selected, setSelected] = react.useState(() => new Set()); // 批量删除勾选
|
|
428
426
|
const [fullResults, setFullResults] = react.useState(null); // 全文搜索结果(null=未搜索)
|
|
429
427
|
const [fullSearching, setFullSearching] = react.useState(false);
|
|
430
|
-
const [ruleOpen, setRuleOpen] = react.useState(false); //
|
|
428
|
+
const [ruleOpen, setRuleOpen] = react.useState(false); // 筛选条件表单展开
|
|
431
429
|
const [ruleArchivedOnly, setRuleArchivedOnly] = react.useState(true);
|
|
432
430
|
const [ruleDays, setRuleDays] = react.useState(7);
|
|
433
431
|
const [rulePrefix, setRulePrefix] = react.useState("");
|
|
434
432
|
const [ruleLowValue, setRuleLowValue] = react.useState(false); // 低价值筛选(复用价值分析)
|
|
435
|
-
|
|
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); // 最近一个删除批次(撤销用)
|
|
436
438
|
|
|
437
439
|
// list 流式——NDJSON 每行一个会话,读到立即追加显示(每获取一个追加一个,
|
|
438
440
|
// 不等全部);按 updatedAt 倒序插入保持列表顺序;旧一次性 JSON(预览页离线垫片/
|
|
@@ -473,11 +475,25 @@ window.__ModuleLoader__.load({
|
|
|
473
475
|
if (!listRes.ok) throw new Error("list failed: HTTP " + listRes.status);
|
|
474
476
|
let lastBatch = null;
|
|
475
477
|
if (typeof listRes.body?.getReader === "function") {
|
|
476
|
-
// 流式 NDJSON
|
|
478
|
+
// 流式 NDJSON:逐行解析追加。
|
|
479
|
+
// 但**不逐行 setState**:上百条会话逐条 setSessions 会触发上百次全列表重渲染
|
|
480
|
+
// (O(n²)),真实数据下界面会卡到无响应。改为攒 150ms 一批、一次性合并。
|
|
477
481
|
const reader = listRes.body.getReader();
|
|
478
482
|
const decoder = new TextDecoder();
|
|
479
483
|
let buf = "";
|
|
480
484
|
const batch = [];
|
|
485
|
+
const pending = [];
|
|
486
|
+
let flushTimer = null;
|
|
487
|
+
const flush = () => {
|
|
488
|
+
flushTimer = null;
|
|
489
|
+
if (pending.length === 0) return;
|
|
490
|
+
const items = pending.splice(0, pending.length);
|
|
491
|
+
setSessions((cur) => {
|
|
492
|
+
let next = Array.isArray(cur) ? cur : [];
|
|
493
|
+
for (const item of items) next = insertSorted(next, item);
|
|
494
|
+
return next;
|
|
495
|
+
});
|
|
496
|
+
};
|
|
481
497
|
for (;;) {
|
|
482
498
|
const { done, value } = await reader.read();
|
|
483
499
|
if (done) break;
|
|
@@ -491,10 +507,12 @@ window.__ModuleLoader__.load({
|
|
|
491
507
|
try { parsedLine = JSON.parse(line); } catch { continue; } // 半行/头行跳过
|
|
492
508
|
if (parsedLine && parsedLine.id) {
|
|
493
509
|
batch.push(parsedLine);
|
|
494
|
-
|
|
510
|
+
pending.push(parsedLine);
|
|
511
|
+
if (flushTimer === null) flushTimer = setTimeout(flush, 150);
|
|
495
512
|
}
|
|
496
513
|
}
|
|
497
514
|
}
|
|
515
|
+
flush();
|
|
498
516
|
lastBatch = batch;
|
|
499
517
|
} else {
|
|
500
518
|
const body = await listRes.json();
|
|
@@ -520,6 +538,23 @@ window.__ModuleLoader__.load({
|
|
|
520
538
|
refreshRef.current = refresh;
|
|
521
539
|
|
|
522
540
|
// 启动即自动加载(插件挂载就预取一次,不依赖面板打开);打开面板时再刷新一次保证最新
|
|
541
|
+
// ① 先读**落盘索引**(毫秒级)把列表填出来 —— 索引只写不用的话,冷缓存时面板要等
|
|
542
|
+
// 全量构建(实测 41~226 秒)才有内容,看起来就是「空的」。
|
|
543
|
+
// ② 随后 refresh() 的流式全量刷新在后台覆盖(每条到了就追加),两者互不阻塞。
|
|
544
|
+
react.useEffect(() => {
|
|
545
|
+
let alive = true;
|
|
546
|
+
(async () => {
|
|
547
|
+
try {
|
|
548
|
+
const res = await window.__scFetch("/api/session-conductor/list?fromIndex=true", { headers: { Accept: "application/json" } }, 0);
|
|
549
|
+
const body = await res.json();
|
|
550
|
+
if (!alive || !body?.ok) return;
|
|
551
|
+
const items = Array.isArray(body.sessions) ? body.sessions : [];
|
|
552
|
+
// 只在当前还是空列表时填充:不要用陈旧索引覆盖已经流式到达的新数据
|
|
553
|
+
if (items.length > 0) setSessions((cur) => (Array.isArray(cur) && cur.length > 0 ? cur : items));
|
|
554
|
+
} catch { /* 索引不可用就走全量刷新 */ }
|
|
555
|
+
})();
|
|
556
|
+
return () => { alive = false; };
|
|
557
|
+
}, []);
|
|
523
558
|
react.useEffect(() => {
|
|
524
559
|
refresh();
|
|
525
560
|
}, [refresh]);
|
|
@@ -880,15 +915,20 @@ window.__ModuleLoader__.load({
|
|
|
880
915
|
usedFallback = true;
|
|
881
916
|
}
|
|
882
917
|
if (!body?.ok) throw new Error(body?.error?.message ?? "search failed");
|
|
883
|
-
|
|
884
|
-
|
|
918
|
+
// 两条搜索来源、两种返回形状:官方 FTS 给 items[],本插件内置扫描给 hits[]。
|
|
919
|
+
// 原先只认 items ⇒ 官方接口不存在(本机实测 /api/session-search/query 直接 404,
|
|
920
|
+
// 必然走内置扫描)时命中恒为空 —— 搜到了也显示「没有找到」,命中区自然点不动。
|
|
921
|
+
const rawHits = Array.isArray(body.items) ? body.items : (Array.isArray(body.hits) ? body.hits : []);
|
|
922
|
+
const hits = rawHits.map((h) => ({
|
|
923
|
+
// sessionId 是点开会话的唯一凭据:缺了就是「点了没反应」,这里兼容常见别名并滤掉空值
|
|
924
|
+
sessionId: h.sessionId ?? h.session?.id ?? h.id ?? null,
|
|
885
925
|
title: h.title ?? null,
|
|
886
926
|
cwd: h.cwd ?? null,
|
|
887
927
|
archived: h.archived === true,
|
|
888
928
|
running: h.running === true,
|
|
889
929
|
updatedAt: h.updatedAt ?? null,
|
|
890
930
|
matches: Array.isArray(h.matches) ? h.matches : (h.match ? [h.match] : []),
|
|
891
|
-
}));
|
|
931
|
+
})).filter((h) => typeof h.sessionId === "string" && h.sessionId !== "");
|
|
892
932
|
setFullResults({ query: keywordText, hits, usedFallback });
|
|
893
933
|
} catch (cause) {
|
|
894
934
|
const message = cause instanceof Error ? cause.message : String(cause);
|
|
@@ -925,119 +965,276 @@ window.__ModuleLoader__.load({
|
|
|
925
965
|
}
|
|
926
966
|
};
|
|
927
967
|
|
|
928
|
-
// ——
|
|
929
|
-
//
|
|
930
|
-
//
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
sessionId
|
|
937
|
-
|
|
938
|
-
cwd: s.cwd,
|
|
939
|
-
archived: s.archived === true,
|
|
940
|
-
updatedAt: typeof s.updatedAt === "number" ? s.updatedAt : null,
|
|
941
|
-
}));
|
|
942
|
-
const now = Date.now();
|
|
943
|
-
const lowIds = new Set((vaBody?.low ?? []).map((i) => i.id));
|
|
944
|
-
const prefix = rulePrefix.trim().replace(/\/+$/, "");
|
|
945
|
-
const days = Number(ruleDays) || 0;
|
|
946
|
-
const matched = rows.filter((m) => {
|
|
947
|
-
if (ruleArchivedOnly && !m.archived) return false;
|
|
948
|
-
if (days > 0) {
|
|
949
|
-
const updated = m.updatedAt;
|
|
950
|
-
if (!Number.isFinite(updated) || now - updated < days * 86400000) return false;
|
|
951
|
-
}
|
|
952
|
-
if (prefix !== "") {
|
|
953
|
-
const cwd = String(m.cwd ?? "").replace(/\/+$/, "");
|
|
954
|
-
if (cwd !== prefix && !cwd.startsWith(prefix + "/")) return false;
|
|
955
|
-
}
|
|
956
|
-
if (ruleLowValue && !lowIds.has(m.sessionId)) return false;
|
|
957
|
-
return true;
|
|
968
|
+
// —— 勾选的公共入口 ——
|
|
969
|
+
// 行内勾选框、筛选、清空都必须走这两个函数:状态只有 `selected` 一处。
|
|
970
|
+
// 之前行内勾选自己写一段 setSelected、筛选另写一段,两处口径一旦不同就会出现
|
|
971
|
+
// 「删除按钮的计数变了,但行内勾选框没勾上」这种鬼现象。
|
|
972
|
+
const setRowSelected = (sessionId, checked) => {
|
|
973
|
+
if (typeof sessionId !== "string" || sessionId === "") return;
|
|
974
|
+
setSelected((prev) => {
|
|
975
|
+
const next = new Set(prev);
|
|
976
|
+
if (checked) next.add(sessionId); else next.delete(sessionId);
|
|
977
|
+
return next;
|
|
958
978
|
});
|
|
959
|
-
|
|
979
|
+
};
|
|
980
|
+
const replaceSelected = (ids) => {
|
|
981
|
+
setSelected(new Set((Array.isArray(ids) ? ids : []).filter((id) => typeof id === "string" && id !== "")));
|
|
960
982
|
};
|
|
961
983
|
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
}
|
|
976
|
-
return;
|
|
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) }));
|
|
977
998
|
}
|
|
999
|
+
};
|
|
1000
|
+
const restoreTrash = async (ids) => {
|
|
1001
|
+
if (!Array.isArray(ids) || ids.length === 0) return;
|
|
978
1002
|
try {
|
|
979
|
-
const res = await window.__scFetch("/api/session-conductor/
|
|
1003
|
+
const res = await window.__scFetch("/api/session-conductor/trash/restore", {
|
|
980
1004
|
method: "POST",
|
|
981
1005
|
headers: { "Content-Type": CONTENT_TYPE_JSON },
|
|
982
|
-
body: JSON.stringify({
|
|
983
|
-
|
|
984
|
-
inactiveDays: Number(ruleDays) || 0,
|
|
985
|
-
cwdPrefix: rulePrefix.trim(),
|
|
986
|
-
lowValue: ruleLowValue,
|
|
987
|
-
dryRun: true,
|
|
988
|
-
}),
|
|
989
|
-
});
|
|
1006
|
+
body: JSON.stringify({ sessionIds: ids }),
|
|
1007
|
+
}, 0);
|
|
990
1008
|
const body = await res.json();
|
|
991
|
-
if (!body
|
|
992
|
-
|
|
993
|
-
|
|
1009
|
+
if (!body?.ok) throw new Error(body?.error?.message ?? "restore failed");
|
|
1010
|
+
setTrashSel(new Set());
|
|
1011
|
+
await loadTrashItems();
|
|
1012
|
+
await refresh();
|
|
994
1013
|
} catch (cause) {
|
|
995
|
-
|
|
996
|
-
window.alert(t("error.action", { message }));
|
|
997
|
-
} finally {
|
|
998
|
-
setBusy(null);
|
|
1014
|
+
window.alert(t("error.action", { message: cause instanceof Error ? cause.message : String(cause) }));
|
|
999
1015
|
}
|
|
1000
1016
|
};
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
window.alert(t("
|
|
1007
|
-
|
|
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) }));
|
|
1008
1028
|
}
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1029
|
+
};
|
|
1030
|
+
const emptyTrashAll = async () => {
|
|
1031
|
+
const n = (trashItems ?? []).length;
|
|
1032
|
+
if (n === 0) return;
|
|
1033
|
+
if (!window.confirm(t("trash.emptyConfirm", { n }))) return;
|
|
1012
1034
|
try {
|
|
1013
|
-
const res = await window.__scFetch("/api/session-conductor/
|
|
1035
|
+
const res = await window.__scFetch("/api/session-conductor/trash/empty", {
|
|
1014
1036
|
method: "POST",
|
|
1015
1037
|
headers: { "Content-Type": CONTENT_TYPE_JSON },
|
|
1016
|
-
body: JSON.stringify({
|
|
1017
|
-
|
|
1018
|
-
inactiveDays: Number(ruleDays) || 0,
|
|
1019
|
-
cwdPrefix: rulePrefix.trim(),
|
|
1020
|
-
lowValue: ruleLowValue,
|
|
1021
|
-
dryRun: false,
|
|
1022
|
-
}),
|
|
1023
|
-
});
|
|
1038
|
+
body: JSON.stringify({ confirm: true }),
|
|
1039
|
+
}, 0);
|
|
1024
1040
|
const body = await res.json();
|
|
1025
|
-
if (!body
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
}));
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
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
|
+
|
|
1071
|
+
// —— 筛选:按条件把会话**自动勾选**到列表里 ——
|
|
1072
|
+
// 批量删除只有一条路径:「勾选 → 删除选中」。条件(归档 / 价值 / 时间 / 目录)只负责把该勾的勾上。
|
|
1073
|
+
// ① 归档 / 时间 / 目录:面板手里每一行已经带着 archived / cwd / updatedAt,
|
|
1074
|
+
// 纯内存过滤 —— 点完即勾,**不发任何请求**。
|
|
1075
|
+
// ② 低价值:唯一需要读日志的条件,走**流式**分析 —— 服务端每算完一条推一行,
|
|
1076
|
+
// 前端**分析出一个勾一个**。服务端另有价值索引(按 updatedAt + TTL,默认 24 小时),
|
|
1077
|
+
// 命中索引的会话零 I/O 秒回,只有新会话/变过的会话才真读日志。
|
|
1078
|
+
// 全程不设时间上限:完成与否由**请求事件**判定(每行一个结果,读完即结束)。
|
|
1079
|
+
const [filterProgress, setFilterProgress] = react.useState(null); // {done,total};null=没在分析
|
|
1080
|
+
const streamLowValue = async (baseIds) => {
|
|
1081
|
+
const base = new Set(baseIds);
|
|
1082
|
+
replaceSelected([]); // 低价值:从空开始,分析出一个勾一个
|
|
1083
|
+
setFilterProgress({ done: 0, total: base.size });
|
|
1084
|
+
const res = await window.__scFetch("/api/session-conductor/value-analysis?stream=1", {
|
|
1085
|
+
method: "POST",
|
|
1086
|
+
headers: { "Content-Type": CONTENT_TYPE_JSON, Accept: "application/x-ndjson" },
|
|
1087
|
+
body: JSON.stringify({ ids: baseIds }),
|
|
1088
|
+
}, 0);
|
|
1089
|
+
if (!res.ok) throw new Error("value analysis failed: HTTP " + res.status);
|
|
1090
|
+
if (typeof res.body?.getReader !== "function") throw new Error("value analysis: 宿主未提供流式响应");
|
|
1091
|
+
const reader = res.body.getReader();
|
|
1092
|
+
const decoder = new TextDecoder();
|
|
1093
|
+
let buf = "";
|
|
1094
|
+
let picked = 0;
|
|
1095
|
+
let seen = 0;
|
|
1096
|
+
for (;;) {
|
|
1097
|
+
const { done, value } = await reader.read();
|
|
1098
|
+
if (done) break;
|
|
1099
|
+
buf += decoder.decode(value, { stream: true });
|
|
1100
|
+
let nl;
|
|
1101
|
+
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
1102
|
+
const line = buf.slice(0, nl).trim();
|
|
1103
|
+
buf = buf.slice(nl + 1);
|
|
1104
|
+
if (!line) continue;
|
|
1105
|
+
let row;
|
|
1106
|
+
try { row = JSON.parse(line); } catch { continue; }
|
|
1107
|
+
if (!row || typeof row.id !== "string") continue; // 头行({ok,stream,total})跳过
|
|
1108
|
+
seen += 1;
|
|
1109
|
+
setFilterProgress({ done: typeof row.done === "number" ? row.done : seen, total: typeof row.total === "number" ? row.total : base.size });
|
|
1110
|
+
if (row.value === "low" && base.has(row.id)) {
|
|
1111
|
+
picked += 1;
|
|
1112
|
+
setRowSelected(row.id, true); // 走公共勾选入口,行内勾选框与计数永远一致
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
return picked;
|
|
1117
|
+
};
|
|
1118
|
+
|
|
1119
|
+
const applyFilter = async () => {
|
|
1120
|
+
const list = Array.isArray(sessions) ? sessions : [];
|
|
1121
|
+
const now = Date.now();
|
|
1122
|
+
const prefix = rulePrefix.trim().replace(/\/+$/, "");
|
|
1123
|
+
const days = Number(ruleDays) || 0;
|
|
1124
|
+
const baseIds = list.filter((s) => {
|
|
1125
|
+
if (ruleArchivedOnly && s.archived !== true) return false;
|
|
1126
|
+
if (days > 0) {
|
|
1127
|
+
const updated = typeof s.updatedAt === "number" ? s.updatedAt : NaN;
|
|
1128
|
+
if (!Number.isFinite(updated) || now - updated < days * 86400000) return false;
|
|
1129
|
+
}
|
|
1130
|
+
if (prefix !== "") {
|
|
1131
|
+
const cwd = String(s.cwd ?? "").replace(/\/+$/, "");
|
|
1132
|
+
if (cwd !== prefix && !cwd.startsWith(prefix + "/")) return false;
|
|
1133
|
+
}
|
|
1134
|
+
return true;
|
|
1135
|
+
}).map((s) => s.id);
|
|
1136
|
+
if (baseIds.length === 0) {
|
|
1137
|
+
// 勾选是「替换」语义:这次条件什么都没匹配到,必须把上一次的勾选清掉 ——
|
|
1138
|
+
// 否则界面还留着上次的勾选与「删除选中 (N)」,很容易照着上次的结果误删。
|
|
1139
|
+
replaceSelected([]);
|
|
1140
|
+
window.alert(t("deleteByRule.noMatch"));
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
// 切到「全部」让勾选结果当场可见 + 收起条件表单回到列表
|
|
1144
|
+
setTab("all");
|
|
1145
|
+
setRuleOpen(false);
|
|
1146
|
+
if (!ruleLowValue) { replaceSelected(baseIds); return; } // 纯本地,点完即勾
|
|
1147
|
+
setBusy("__rule__");
|
|
1148
|
+
try {
|
|
1149
|
+
const picked = await streamLowValue(baseIds);
|
|
1150
|
+
if (picked === 0) window.alert(t("deleteByRule.noMatch"));
|
|
1033
1151
|
} catch (cause) {
|
|
1034
1152
|
const message = cause instanceof Error ? cause.message : String(cause);
|
|
1035
1153
|
window.alert(t("error.action", { message }));
|
|
1036
1154
|
} finally {
|
|
1037
1155
|
setBusy(null);
|
|
1156
|
+
setFilterProgress(null);
|
|
1038
1157
|
}
|
|
1039
1158
|
};
|
|
1040
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
|
+
|
|
1041
1238
|
const rows = sessions ?? [];
|
|
1042
1239
|
// 列表过滤:标题 / cwd / id / 状态词(大小写不敏感)
|
|
1043
1240
|
const ql = query.trim().toLowerCase();
|
|
@@ -1162,14 +1359,7 @@ window.__ModuleLoader__.load({
|
|
|
1162
1359
|
children: (0, react_jsx_runtime.jsx)("input", {
|
|
1163
1360
|
type: "checkbox",
|
|
1164
1361
|
checked: selected.has(session.id),
|
|
1165
|
-
onChange: (event) =>
|
|
1166
|
-
setSelected((prev) => {
|
|
1167
|
-
const next = new Set(prev);
|
|
1168
|
-
if (event.target.checked) next.add(session.id);
|
|
1169
|
-
else next.delete(session.id);
|
|
1170
|
-
return next;
|
|
1171
|
-
});
|
|
1172
|
-
},
|
|
1362
|
+
onChange: (event) => setRowSelected(session.id, event.target.checked),
|
|
1173
1363
|
}),
|
|
1174
1364
|
}),
|
|
1175
1365
|
(0, react_jsx_runtime.jsxs)("div", {
|
|
@@ -1500,6 +1690,11 @@ window.__ModuleLoader__.load({
|
|
|
1500
1690
|
onClick: () => setTab("active"),
|
|
1501
1691
|
children: `${t("tab.active")} (${rows.filter((row) => !row.archived && (row.live === true || row.running === true)).length})`,
|
|
1502
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
|
+
}),
|
|
1503
1698
|
(0, react_jsx_runtime.jsx)("button", {
|
|
1504
1699
|
style: S.tab(tab === "help"),
|
|
1505
1700
|
onClick: () => setTab("help"),
|
|
@@ -1532,10 +1727,16 @@ window.__ModuleLoader__.load({
|
|
|
1532
1727
|
onClick: () => deleteSelected(),
|
|
1533
1728
|
children: t("deleteBatch.action", { n: selected.size }),
|
|
1534
1729
|
}),
|
|
1730
|
+
// 低价值流式分析进度:每收到一条结果就 +1(事件驱动,不是秒表)
|
|
1731
|
+
filterProgress !== null &&
|
|
1732
|
+
(0, react_jsx_runtime.jsx)("span", {
|
|
1733
|
+
style: { fontSize: 12, color: CSS_LABEL_TERTIARY, whiteSpace: "nowrap" },
|
|
1734
|
+
children: t("deleteByRule.analyzing", { done: filterProgress.done, total: filterProgress.total }),
|
|
1735
|
+
}),
|
|
1535
1736
|
(0, react_jsx_runtime.jsx)("button", {
|
|
1536
1737
|
style: S.actionBtn(false),
|
|
1537
1738
|
disabled: busy !== null,
|
|
1538
|
-
onClick: () => { setRuleOpen((v) => !v);
|
|
1739
|
+
onClick: () => { setRuleOpen((v) => !v); },
|
|
1539
1740
|
children: t("deleteByRule.action"),
|
|
1540
1741
|
}),
|
|
1541
1742
|
(0, react_jsx_runtime.jsx)("button", {
|
|
@@ -1581,32 +1782,30 @@ window.__ModuleLoader__.load({
|
|
|
1581
1782
|
(0, react_jsx_runtime.jsx)("input", {
|
|
1582
1783
|
type: "text", value: rulePrefix, placeholder: t("deleteByRule.label.cwdPrefix"),
|
|
1583
1784
|
style: { flex: 1, minWidth: RULE_PREFIX_MIN_WIDTH, height: SMALL_CONTROL_HEIGHT, borderRadius: CONTROL_RADIUS, border: CSS_BORDER_L2, background: CSS_BG_BASE, color: CSS_LABEL_PRIMARY, padding: "0 8px", fontSize: FONT_SIZE_SMALL, outline: "none" },
|
|
1584
|
-
onChange: (e) => { setRulePrefix(e.target.value);
|
|
1785
|
+
onChange: (e) => { setRulePrefix(e.target.value); },
|
|
1585
1786
|
}),
|
|
1586
1787
|
(0, react_jsx_runtime.jsx)("button", {
|
|
1587
1788
|
style: S.actionBtn(false),
|
|
1588
1789
|
disabled: busy !== null,
|
|
1589
|
-
onClick:
|
|
1590
|
-
|
|
1790
|
+
onClick: applyFilter,
|
|
1791
|
+
// 状态由**请求事件**驱动:发起 → 显示「筛选中…」,响应回来 → 立刻给结果。
|
|
1792
|
+
// 不用秒表、不设超时上限:慢但正常的请求不该被任何时间阈值判成失败。
|
|
1793
|
+
children: busy === "__rule__" ? t("deleteByRule.filtering") : t("deleteByRule.previewBtn"),
|
|
1794
|
+
}),
|
|
1795
|
+
// 条件关系提示:四个条件(归档 / 价值 / 时间 / 目录)是**与**关系,
|
|
1796
|
+
// 必须同时满足才会被勾选;界面上写清楚,免得误以为是不限/或关系。
|
|
1797
|
+
(0, react_jsx_runtime.jsx)("span", {
|
|
1798
|
+
style: { fontSize: 11.5, color: CSS_LABEL_TERTIARY, flexBasis: "100%" },
|
|
1799
|
+
children: t("deleteByRule.andHint"),
|
|
1591
1800
|
}),
|
|
1592
|
-
rulePreview !== null && rulePreview.length > 0 &&
|
|
1593
|
-
(0, react_jsx_runtime.jsx)("button", {
|
|
1594
|
-
style: { ...S.actionBtn(true), borderColor: CSS_DANGER, color: CSS_DANGER },
|
|
1595
|
-
disabled: busy !== null,
|
|
1596
|
-
onClick: runRuleDelete,
|
|
1597
|
-
children: t("deleteByRule.run") + ` (${rulePreview.length})`,
|
|
1598
|
-
}),
|
|
1599
|
-
rulePreview !== null && rulePreview.length > 0 &&
|
|
1600
|
-
(0, react_jsx_runtime.jsx)("span", {
|
|
1601
|
-
style: { color: CSS_LABEL_TERTIARY, fontSize: 12 },
|
|
1602
|
-
children: t("search.hits", { n: rulePreview.length }),
|
|
1603
|
-
}),
|
|
1604
1801
|
],
|
|
1605
1802
|
}),
|
|
1606
1803
|
(0, react_jsx_runtime.jsx)("div", {
|
|
1607
1804
|
style: S.body,
|
|
1608
1805
|
children: tab === "help"
|
|
1609
1806
|
? renderHelp()
|
|
1807
|
+
: tab === "trash"
|
|
1808
|
+
? renderTrash()
|
|
1610
1809
|
: (0, react_jsx_runtime.jsxs)(react.Fragment, {
|
|
1611
1810
|
children: [
|
|
1612
1811
|
// —— 全文搜索结果区(官方 FTS5 优先,自研兜底) ——
|
|
@@ -2165,20 +2364,22 @@ window.__ModuleLoader__.load({
|
|
|
2165
2364
|
locale: NS,
|
|
2166
2365
|
inject: () => ({
|
|
2167
2366
|
onOpenSession: (sessionId) => {
|
|
2168
|
-
//
|
|
2169
|
-
// ·
|
|
2170
|
-
//
|
|
2171
|
-
//
|
|
2172
|
-
//
|
|
2173
|
-
//
|
|
2367
|
+
// 打开会话的入口,按「宿主真实存在的」优先级逐个试:
|
|
2368
|
+
// · ctx.get("uiWorkspace").openSession(id) —— 宿主自己的会话导航服务
|
|
2369
|
+
// (ui-conversation 内部就是 `const nav = ctx.get('uiWorkspace')` 再 nav.openSession(id);
|
|
2370
|
+
// 实测宿主包里根本没有 workspaceNavigation / openSession 这类名字,早期写法是猜的)
|
|
2371
|
+
// · ctx.uiWorkspace.openSession(id) —— 同一服务,仅在本插件声明过依赖时才作为属性可见
|
|
2372
|
+
// · ctx.get("sessions").open(id) —— 老版本兜底
|
|
2373
|
+
// 必须用 ctx.get(...):cordis 里未在 inject 声明的服务**没有** ctx.<name> 属性,
|
|
2374
|
+
// 但 ctx.get(name) 仍能取到 —— 只写 ctx.uiWorkspace 会永远 undefined,点了没反应。
|
|
2174
2375
|
try {
|
|
2175
|
-
const nav = ctx.get?.("
|
|
2376
|
+
const nav = ctx.get?.("uiWorkspace");
|
|
2176
2377
|
if (typeof nav?.openSession === "function") return nav.openSession(sessionId);
|
|
2177
2378
|
if (typeof ctx.uiWorkspace?.openSession === "function") return ctx.uiWorkspace.openSession(sessionId);
|
|
2178
2379
|
const legacy = ctx.get?.("sessions");
|
|
2179
2380
|
if (typeof legacy?.open === "function") return legacy.open(sessionId);
|
|
2180
2381
|
// eslint-disable-next-line no-console
|
|
2181
|
-
console.warn("[session-conductor]
|
|
2382
|
+
console.warn("[session-conductor] 打不开会话:宿主未暴露 uiWorkspace.openSession / sessions.open", sessionId);
|
|
2182
2383
|
} catch (error) {
|
|
2183
2384
|
// 打开失败不阻塞面板,但不再静默
|
|
2184
2385
|
// eslint-disable-next-line no-console
|
package/lib/core/config.js
CHANGED
|
@@ -31,6 +31,13 @@ export const DEFAULTS = {
|
|
|
31
31
|
retrieveMemoryMinDropTokens: 0, // 触发阈值:下降绝对量(0 = 不限;两者满足其一即触发)
|
|
32
32
|
retrieveMemoryMaxChars: 4000, // 注入总长上限(字符)
|
|
33
33
|
retrieveMemoryMaxCharsPerItem: 800, // 单条(用户消息文本 / AI 首条回应)上限(字符)
|
|
34
|
+
// ── 价值判定索引 ──
|
|
35
|
+
// 同一条会话的价值结论在「内容没变(updatedAt 未变)+ 未超过该时长」时直接复用,
|
|
36
|
+
// 不再重读日志(实测单条 0.2 MB≈85 ms / 7 MB≈1.1 s / 86 MB≈16 s)。默认 24 小时。
|
|
37
|
+
valueIndexTtlHours: 24,
|
|
38
|
+
// ── 回收站 ──
|
|
39
|
+
// 删除只打标记;超过该天数才真正删盘(面板上改的值存在索引里,优先于这里)
|
|
40
|
+
trashRetentionDays: 7,
|
|
34
41
|
};
|
|
35
42
|
|
|
36
43
|
/** 运行期配置(面板/测试可改)。外部只读引用;改整体请用 setConfig。 */
|