dsh-session-conductor 1.3.6 → 1.3.8
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/lib/apply.js +62 -0
- package/lib/client-parts/apply.js +10 -8
- package/lib/client-parts/components/panel.js +174 -123
- package/lib/client-parts/foundation/bootstrap.js +8 -4
- package/lib/client.js +192 -135
- package/lib/core/config.js +4 -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 +7 -0
- package/lib/features/session-ops-routes.js +13 -0
- package/lib/features/session-ops.js +32 -9
- package/lib/features/value-index.js +135 -0
- package/lib/i18n/en.json +6 -5
- package/lib/i18n/zh.json +6 -5
- package/lib/sessions/list-cache.js +30 -0
- package/lib/value.js +31 -0
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -90,12 +90,16 @@ window.__ModuleLoader__.load({
|
|
|
90
90
|
|
|
91
91
|
// ---------- 工具 ----------
|
|
92
92
|
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
|
|
93
|
+
// 统一 fetch 入口(url, opts, timeoutMs)。**默认不设超时**。
|
|
94
|
+
// 曾经默认 30 秒「兜底」,结果两次把慢但正常的请求掐成失败:
|
|
95
|
+
// · 会话列表冷缓存要逐份解压上百份日志(实测 41~226 秒)→ 刚重启报「读取会话列表失败」;
|
|
96
|
+
// · 按条件筛选勾了「低价值」时要逐个会话读事件 → 报「操作失败:signal is aborted…」。
|
|
97
|
+
// 结论:**判定只能按事件驱动**(请求完成 / 失败 / 数据到达),不能靠拍一个秒数上限;
|
|
98
|
+
// 时间阈值一写死,慢就被误读成坏。真要限时,必须由调用方显式传 timeoutMs 并说清依据。
|
|
96
99
|
window.__scFetch = function (url, opts, timeoutMs) {
|
|
100
|
+
if (!(timeoutMs > 0)) return fetch(url, opts || {});
|
|
97
101
|
const controller = new AbortController();
|
|
98
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs
|
|
102
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
99
103
|
const next = opts ? Object.assign({}, opts, { signal: controller.signal }) : { signal: controller.signal };
|
|
100
104
|
return fetch(url, next).finally(() => clearTimeout(timer));
|
|
101
105
|
};
|
|
@@ -414,18 +418,18 @@ window.__ModuleLoader__.load({
|
|
|
414
418
|
const [groups, setGroups] = react.useState(null); // workspaces[](group/list);null = host 无分组 API
|
|
415
419
|
const [collapsed, setCollapsed] = react.useState(() => new Set()); // 折叠的分组(含 "__ungrouped__")
|
|
416
420
|
const [error, setError] = react.useState(null);
|
|
421
|
+
const [retryIn, setRetryIn] = react.useState(null); // 自动重试倒计时(秒);null = 没有待重试的定时器
|
|
417
422
|
const [busy, setBusy] = react.useState(null);
|
|
418
423
|
// —— 搜索 + 批量/按条件删除 ——
|
|
419
424
|
const [query, setQuery] = react.useState(""); // 列表过滤关键词
|
|
420
425
|
const [selected, setSelected] = react.useState(() => new Set()); // 批量删除勾选
|
|
421
426
|
const [fullResults, setFullResults] = react.useState(null); // 全文搜索结果(null=未搜索)
|
|
422
427
|
const [fullSearching, setFullSearching] = react.useState(false);
|
|
423
|
-
const [ruleOpen, setRuleOpen] = react.useState(false); //
|
|
428
|
+
const [ruleOpen, setRuleOpen] = react.useState(false); // 筛选条件表单展开
|
|
424
429
|
const [ruleArchivedOnly, setRuleArchivedOnly] = react.useState(true);
|
|
425
430
|
const [ruleDays, setRuleDays] = react.useState(7);
|
|
426
431
|
const [rulePrefix, setRulePrefix] = react.useState("");
|
|
427
432
|
const [ruleLowValue, setRuleLowValue] = react.useState(false); // 低价值筛选(复用价值分析)
|
|
428
|
-
const [rulePreview, setRulePreview] = react.useState(null); // dryRun 预览(null=未预览)
|
|
429
433
|
|
|
430
434
|
// list 流式——NDJSON 每行一个会话,读到立即追加显示(每获取一个追加一个,
|
|
431
435
|
// 不等全部);按 updatedAt 倒序插入保持列表顺序;旧一次性 JSON(预览页离线垫片/
|
|
@@ -440,20 +444,51 @@ window.__ModuleLoader__.load({
|
|
|
440
444
|
next.splice(i, 0, item);
|
|
441
445
|
return next;
|
|
442
446
|
};
|
|
447
|
+
// 列表拉取失败自动重试:DSH 刚重启时插件路由/宿主服务未必已就绪,首次请求会直接
|
|
448
|
+
// 网络失败(Failed to fetch),冷缓存首次构建也很慢 —— 靠用户手动点「重试」既费事又
|
|
449
|
+
// 容易误以为插件坏了。这里按退避自动重试,界面照常给出失败原因 + 下次重试倒计时,
|
|
450
|
+
// 成功一次即清零。数组长度 = 最多自动重试几次。
|
|
451
|
+
const LIST_RETRY_DELAYS = [3000, 8000, 20000, 45000];
|
|
452
|
+
const listRetryRef = react.useRef(0);
|
|
453
|
+
const listTimerRef = react.useRef(null);
|
|
454
|
+
const refreshRef = react.useRef(null);
|
|
455
|
+
// 卸载时清掉待重试的定时器,避免组件已销毁还在拉数据(也会让「已关闭面板」继续打后端)
|
|
456
|
+
react.useEffect(() => () => {
|
|
457
|
+
if (listTimerRef.current) clearTimeout(listTimerRef.current);
|
|
458
|
+
listTimerRef.current = null;
|
|
459
|
+
}, []);
|
|
443
460
|
const refresh = react.useCallback(async () => {
|
|
461
|
+
if (listTimerRef.current) { clearTimeout(listTimerRef.current); listTimerRef.current = null; }
|
|
462
|
+
setRetryIn(null);
|
|
444
463
|
try {
|
|
445
|
-
|
|
464
|
+
// 流式列表**不设超时**(第三个参数 0):冷缓存要逐份解压上百份日志,本机实测
|
|
465
|
+
// 41~226 秒;套 30 秒上限会把「慢但正常」的请求掐成「读取会话列表失败」。
|
|
466
|
+
const listPromise = window.__scFetch("/api/session-conductor/list", { headers: { Accept: "application/x-ndjson" } }, 0);
|
|
446
467
|
const groupPromise = window.__scFetch("/api/session-conductor/group/list")
|
|
447
468
|
.then((r) => (r.ok ? r.json() : { ok: false })).catch(() => ({ ok: false }));
|
|
448
469
|
const listRes = await listPromise;
|
|
449
470
|
if (!listRes.ok) throw new Error("list failed: HTTP " + listRes.status);
|
|
450
471
|
let lastBatch = null;
|
|
451
472
|
if (typeof listRes.body?.getReader === "function") {
|
|
452
|
-
// 流式 NDJSON
|
|
473
|
+
// 流式 NDJSON:逐行解析追加。
|
|
474
|
+
// 但**不逐行 setState**:上百条会话逐条 setSessions 会触发上百次全列表重渲染
|
|
475
|
+
// (O(n²)),真实数据下界面会卡到无响应。改为攒 150ms 一批、一次性合并。
|
|
453
476
|
const reader = listRes.body.getReader();
|
|
454
477
|
const decoder = new TextDecoder();
|
|
455
478
|
let buf = "";
|
|
456
479
|
const batch = [];
|
|
480
|
+
const pending = [];
|
|
481
|
+
let flushTimer = null;
|
|
482
|
+
const flush = () => {
|
|
483
|
+
flushTimer = null;
|
|
484
|
+
if (pending.length === 0) return;
|
|
485
|
+
const items = pending.splice(0, pending.length);
|
|
486
|
+
setSessions((cur) => {
|
|
487
|
+
let next = Array.isArray(cur) ? cur : [];
|
|
488
|
+
for (const item of items) next = insertSorted(next, item);
|
|
489
|
+
return next;
|
|
490
|
+
});
|
|
491
|
+
};
|
|
457
492
|
for (;;) {
|
|
458
493
|
const { done, value } = await reader.read();
|
|
459
494
|
if (done) break;
|
|
@@ -467,10 +502,12 @@ window.__ModuleLoader__.load({
|
|
|
467
502
|
try { parsedLine = JSON.parse(line); } catch { continue; } // 半行/头行跳过
|
|
468
503
|
if (parsedLine && parsedLine.id) {
|
|
469
504
|
batch.push(parsedLine);
|
|
470
|
-
|
|
505
|
+
pending.push(parsedLine);
|
|
506
|
+
if (flushTimer === null) flushTimer = setTimeout(flush, 150);
|
|
471
507
|
}
|
|
472
508
|
}
|
|
473
509
|
}
|
|
510
|
+
flush();
|
|
474
511
|
lastBatch = batch;
|
|
475
512
|
} else {
|
|
476
513
|
const body = await listRes.json();
|
|
@@ -482,12 +519,37 @@ window.__ModuleLoader__.load({
|
|
|
482
519
|
const groupRes = await groupPromise;
|
|
483
520
|
setGroups(groupRes?.ok === true ? groupRes.workspaces ?? [] : null);
|
|
484
521
|
setError(null);
|
|
522
|
+
listRetryRef.current = 0; // 成功一次即清零,下次失败从最短退避重来
|
|
485
523
|
} catch (cause) {
|
|
486
524
|
setError(cause instanceof Error ? cause.message : String(cause));
|
|
525
|
+
const delay = LIST_RETRY_DELAYS[listRetryRef.current];
|
|
526
|
+
if (delay !== void 0) {
|
|
527
|
+
listRetryRef.current += 1;
|
|
528
|
+
setRetryIn(Math.round(delay / 1000));
|
|
529
|
+
listTimerRef.current = setTimeout(() => { refreshRef.current?.(); }, delay);
|
|
530
|
+
}
|
|
487
531
|
}
|
|
488
532
|
}, []);
|
|
533
|
+
refreshRef.current = refresh;
|
|
489
534
|
|
|
490
535
|
// 启动即自动加载(插件挂载就预取一次,不依赖面板打开);打开面板时再刷新一次保证最新
|
|
536
|
+
// ① 先读**落盘索引**(毫秒级)把列表填出来 —— 索引只写不用的话,冷缓存时面板要等
|
|
537
|
+
// 全量构建(实测 41~226 秒)才有内容,看起来就是「空的」。
|
|
538
|
+
// ② 随后 refresh() 的流式全量刷新在后台覆盖(每条到了就追加),两者互不阻塞。
|
|
539
|
+
react.useEffect(() => {
|
|
540
|
+
let alive = true;
|
|
541
|
+
(async () => {
|
|
542
|
+
try {
|
|
543
|
+
const res = await window.__scFetch("/api/session-conductor/list?fromIndex=true", { headers: { Accept: "application/json" } }, 0);
|
|
544
|
+
const body = await res.json();
|
|
545
|
+
if (!alive || !body?.ok) return;
|
|
546
|
+
const items = Array.isArray(body.sessions) ? body.sessions : [];
|
|
547
|
+
// 只在当前还是空列表时填充:不要用陈旧索引覆盖已经流式到达的新数据
|
|
548
|
+
if (items.length > 0) setSessions((cur) => (Array.isArray(cur) && cur.length > 0 ? cur : items));
|
|
549
|
+
} catch { /* 索引不可用就走全量刷新 */ }
|
|
550
|
+
})();
|
|
551
|
+
return () => { alive = false; };
|
|
552
|
+
}, []);
|
|
491
553
|
react.useEffect(() => {
|
|
492
554
|
refresh();
|
|
493
555
|
}, [refresh]);
|
|
@@ -848,15 +910,20 @@ window.__ModuleLoader__.load({
|
|
|
848
910
|
usedFallback = true;
|
|
849
911
|
}
|
|
850
912
|
if (!body?.ok) throw new Error(body?.error?.message ?? "search failed");
|
|
851
|
-
|
|
852
|
-
|
|
913
|
+
// 两条搜索来源、两种返回形状:官方 FTS 给 items[],本插件内置扫描给 hits[]。
|
|
914
|
+
// 原先只认 items ⇒ 官方接口不存在(本机实测 /api/session-search/query 直接 404,
|
|
915
|
+
// 必然走内置扫描)时命中恒为空 —— 搜到了也显示「没有找到」,命中区自然点不动。
|
|
916
|
+
const rawHits = Array.isArray(body.items) ? body.items : (Array.isArray(body.hits) ? body.hits : []);
|
|
917
|
+
const hits = rawHits.map((h) => ({
|
|
918
|
+
// sessionId 是点开会话的唯一凭据:缺了就是「点了没反应」,这里兼容常见别名并滤掉空值
|
|
919
|
+
sessionId: h.sessionId ?? h.session?.id ?? h.id ?? null,
|
|
853
920
|
title: h.title ?? null,
|
|
854
921
|
cwd: h.cwd ?? null,
|
|
855
922
|
archived: h.archived === true,
|
|
856
923
|
running: h.running === true,
|
|
857
924
|
updatedAt: h.updatedAt ?? null,
|
|
858
925
|
matches: Array.isArray(h.matches) ? h.matches : (h.match ? [h.match] : []),
|
|
859
|
-
}));
|
|
926
|
+
})).filter((h) => typeof h.sessionId === "string" && h.sessionId !== "");
|
|
860
927
|
setFullResults({ query: keywordText, hits, usedFallback });
|
|
861
928
|
} catch (cause) {
|
|
862
929
|
const message = cause instanceof Error ? cause.message : String(cause);
|
|
@@ -893,116 +960,108 @@ window.__ModuleLoader__.load({
|
|
|
893
960
|
}
|
|
894
961
|
};
|
|
895
962
|
|
|
896
|
-
// ——
|
|
897
|
-
//
|
|
898
|
-
//
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
sessionId
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
963
|
+
// —— 勾选的公共入口 ——
|
|
964
|
+
// 行内勾选框、筛选、清空都必须走这两个函数:状态只有 `selected` 一处。
|
|
965
|
+
// 之前行内勾选自己写一段 setSelected、筛选另写一段,两处口径一旦不同就会出现
|
|
966
|
+
// 「删除按钮的计数变了,但行内勾选框没勾上」这种鬼现象。
|
|
967
|
+
const setRowSelected = (sessionId, checked) => {
|
|
968
|
+
if (typeof sessionId !== "string" || sessionId === "") return;
|
|
969
|
+
setSelected((prev) => {
|
|
970
|
+
const next = new Set(prev);
|
|
971
|
+
if (checked) next.add(sessionId); else next.delete(sessionId);
|
|
972
|
+
return next;
|
|
973
|
+
});
|
|
974
|
+
};
|
|
975
|
+
const replaceSelected = (ids) => {
|
|
976
|
+
setSelected(new Set((Array.isArray(ids) ? ids : []).filter((id) => typeof id === "string" && id !== "")));
|
|
977
|
+
};
|
|
978
|
+
|
|
979
|
+
// —— 筛选:按条件把会话**自动勾选**到列表里 ——
|
|
980
|
+
// 批量删除只有一条路径:「勾选 → 删除选中」。条件(归档 / 价值 / 时间 / 目录)只负责把该勾的勾上。
|
|
981
|
+
// ① 归档 / 时间 / 目录:面板手里每一行已经带着 archived / cwd / updatedAt,
|
|
982
|
+
// 纯内存过滤 —— 点完即勾,**不发任何请求**。
|
|
983
|
+
// ② 低价值:唯一需要读日志的条件,走**流式**分析 —— 服务端每算完一条推一行,
|
|
984
|
+
// 前端**分析出一个勾一个**。服务端另有价值索引(按 updatedAt + TTL,默认 24 小时),
|
|
985
|
+
// 命中索引的会话零 I/O 秒回,只有新会话/变过的会话才真读日志。
|
|
986
|
+
// 全程不设时间上限:完成与否由**请求事件**判定(每行一个结果,读完即结束)。
|
|
987
|
+
const [filterProgress, setFilterProgress] = react.useState(null); // {done,total};null=没在分析
|
|
988
|
+
const streamLowValue = async (baseIds) => {
|
|
989
|
+
const base = new Set(baseIds);
|
|
990
|
+
replaceSelected([]); // 低价值:从空开始,分析出一个勾一个
|
|
991
|
+
setFilterProgress({ done: 0, total: base.size });
|
|
992
|
+
const res = await window.__scFetch("/api/session-conductor/value-analysis?stream=1", {
|
|
993
|
+
method: "POST",
|
|
994
|
+
headers: { "Content-Type": CONTENT_TYPE_JSON, Accept: "application/x-ndjson" },
|
|
995
|
+
body: JSON.stringify({ ids: baseIds }),
|
|
996
|
+
}, 0);
|
|
997
|
+
if (!res.ok) throw new Error("value analysis failed: HTTP " + res.status);
|
|
998
|
+
if (typeof res.body?.getReader !== "function") throw new Error("value analysis: 宿主未提供流式响应");
|
|
999
|
+
const reader = res.body.getReader();
|
|
1000
|
+
const decoder = new TextDecoder();
|
|
1001
|
+
let buf = "";
|
|
1002
|
+
let picked = 0;
|
|
1003
|
+
let seen = 0;
|
|
1004
|
+
for (;;) {
|
|
1005
|
+
const { done, value } = await reader.read();
|
|
1006
|
+
if (done) break;
|
|
1007
|
+
buf += decoder.decode(value, { stream: true });
|
|
1008
|
+
let nl;
|
|
1009
|
+
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
1010
|
+
const line = buf.slice(0, nl).trim();
|
|
1011
|
+
buf = buf.slice(nl + 1);
|
|
1012
|
+
if (!line) continue;
|
|
1013
|
+
let row;
|
|
1014
|
+
try { row = JSON.parse(line); } catch { continue; }
|
|
1015
|
+
if (!row || typeof row.id !== "string") continue; // 头行({ok,stream,total})跳过
|
|
1016
|
+
seen += 1;
|
|
1017
|
+
setFilterProgress({ done: typeof row.done === "number" ? row.done : seen, total: typeof row.total === "number" ? row.total : base.size });
|
|
1018
|
+
if (row.value === "low" && base.has(row.id)) {
|
|
1019
|
+
picked += 1;
|
|
1020
|
+
setRowSelected(row.id, true); // 走公共勾选入口,行内勾选框与计数永远一致
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
return picked;
|
|
1025
|
+
};
|
|
1026
|
+
|
|
1027
|
+
const applyFilter = async () => {
|
|
1028
|
+
const list = Array.isArray(sessions) ? sessions : [];
|
|
910
1029
|
const now = Date.now();
|
|
911
|
-
const lowIds = new Set((vaBody?.low ?? []).map((i) => i.id));
|
|
912
1030
|
const prefix = rulePrefix.trim().replace(/\/+$/, "");
|
|
913
1031
|
const days = Number(ruleDays) || 0;
|
|
914
|
-
const
|
|
915
|
-
if (ruleArchivedOnly &&
|
|
1032
|
+
const baseIds = list.filter((s) => {
|
|
1033
|
+
if (ruleArchivedOnly && s.archived !== true) return false;
|
|
916
1034
|
if (days > 0) {
|
|
917
|
-
const updated =
|
|
1035
|
+
const updated = typeof s.updatedAt === "number" ? s.updatedAt : NaN;
|
|
918
1036
|
if (!Number.isFinite(updated) || now - updated < days * 86400000) return false;
|
|
919
1037
|
}
|
|
920
1038
|
if (prefix !== "") {
|
|
921
|
-
const cwd = String(
|
|
1039
|
+
const cwd = String(s.cwd ?? "").replace(/\/+$/, "");
|
|
922
1040
|
if (cwd !== prefix && !cwd.startsWith(prefix + "/")) return false;
|
|
923
1041
|
}
|
|
924
|
-
if (ruleLowValue && !lowIds.has(m.sessionId)) return false;
|
|
925
1042
|
return true;
|
|
926
|
-
});
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
setRulePreview(null);
|
|
933
|
-
if (typeof window.__BACKEND__ !== "undefined" && window.__BACKEND__ === "") {
|
|
934
|
-
// 预览页离线:本地计算
|
|
935
|
-
try {
|
|
936
|
-
const matched = previewLocally();
|
|
937
|
-
setRulePreview(matched);
|
|
938
|
-
if (matched.length === 0) window.alert(t("deleteByRule.noMatch"));
|
|
939
|
-
} catch (cause) {
|
|
940
|
-
window.alert(t("error.action", { message: cause instanceof Error ? cause.message : String(cause) }));
|
|
941
|
-
} finally {
|
|
942
|
-
setBusy(null);
|
|
943
|
-
}
|
|
1043
|
+
}).map((s) => s.id);
|
|
1044
|
+
if (baseIds.length === 0) {
|
|
1045
|
+
// 勾选是「替换」语义:这次条件什么都没匹配到,必须把上一次的勾选清掉 ——
|
|
1046
|
+
// 否则界面还留着上次的勾选与「删除选中 (N)」,很容易照着上次的结果误删。
|
|
1047
|
+
replaceSelected([]);
|
|
1048
|
+
window.alert(t("deleteByRule.noMatch"));
|
|
944
1049
|
return;
|
|
945
1050
|
}
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
body: JSON.stringify({
|
|
951
|
-
archivedOnly: ruleArchivedOnly,
|
|
952
|
-
inactiveDays: Number(ruleDays) || 0,
|
|
953
|
-
cwdPrefix: rulePrefix.trim(),
|
|
954
|
-
lowValue: ruleLowValue,
|
|
955
|
-
dryRun: true,
|
|
956
|
-
}),
|
|
957
|
-
});
|
|
958
|
-
const body = await res.json();
|
|
959
|
-
if (!body.ok) throw new Error(body.error?.message ?? "preview failed");
|
|
960
|
-
setRulePreview(body.matched ?? []);
|
|
961
|
-
if (!body.matched || body.matched.length === 0) window.alert(t("deleteByRule.noMatch"));
|
|
962
|
-
} catch (cause) {
|
|
963
|
-
const message = cause instanceof Error ? cause.message : String(cause);
|
|
964
|
-
window.alert(t("error.action", { message }));
|
|
965
|
-
} finally {
|
|
966
|
-
setBusy(null);
|
|
967
|
-
}
|
|
968
|
-
};
|
|
969
|
-
|
|
970
|
-
const runRuleDelete = async () => {
|
|
971
|
-
if (!rulePreview || rulePreview.length === 0) return;
|
|
972
|
-
// 预览页离线模式:不执行真实删除(破坏性操作需真实后端),提示到 DSH GUI
|
|
973
|
-
if (typeof window.__BACKEND__ !== "undefined" && window.__BACKEND__ === "") {
|
|
974
|
-
window.alert(t("deleteByRule.offlineHint"));
|
|
975
|
-
return;
|
|
976
|
-
}
|
|
977
|
-
const yes = window.confirm(t("deleteByRule.preview", { n: rulePreview.length }));
|
|
978
|
-
if (!yes) return;
|
|
1051
|
+
// 切到「全部」让勾选结果当场可见 + 收起条件表单回到列表
|
|
1052
|
+
setTab("all");
|
|
1053
|
+
setRuleOpen(false);
|
|
1054
|
+
if (!ruleLowValue) { replaceSelected(baseIds); return; } // 纯本地,点完即勾
|
|
979
1055
|
setBusy("__rule__");
|
|
980
1056
|
try {
|
|
981
|
-
const
|
|
982
|
-
|
|
983
|
-
headers: { "Content-Type": CONTENT_TYPE_JSON },
|
|
984
|
-
body: JSON.stringify({
|
|
985
|
-
archivedOnly: ruleArchivedOnly,
|
|
986
|
-
inactiveDays: Number(ruleDays) || 0,
|
|
987
|
-
cwdPrefix: rulePrefix.trim(),
|
|
988
|
-
lowValue: ruleLowValue,
|
|
989
|
-
dryRun: false,
|
|
990
|
-
}),
|
|
991
|
-
});
|
|
992
|
-
const body = await res.json();
|
|
993
|
-
if (!body.ok) throw new Error(body.error?.message ?? "delete failed");
|
|
994
|
-
window.alert(t("deleteBatch.done", {
|
|
995
|
-
deleted: body.deleted?.length ?? 0,
|
|
996
|
-
skippedText: body.skipped?.length > 0 ? t("deleteBatch.skipped", { n: body.skipped.length }) : "",
|
|
997
|
-
}));
|
|
998
|
-
setRuleOpen(false);
|
|
999
|
-
setRulePreview(null);
|
|
1000
|
-
await refresh();
|
|
1057
|
+
const picked = await streamLowValue(baseIds);
|
|
1058
|
+
if (picked === 0) window.alert(t("deleteByRule.noMatch"));
|
|
1001
1059
|
} catch (cause) {
|
|
1002
1060
|
const message = cause instanceof Error ? cause.message : String(cause);
|
|
1003
1061
|
window.alert(t("error.action", { message }));
|
|
1004
1062
|
} finally {
|
|
1005
1063
|
setBusy(null);
|
|
1064
|
+
setFilterProgress(null);
|
|
1006
1065
|
}
|
|
1007
1066
|
};
|
|
1008
1067
|
|
|
@@ -1130,14 +1189,7 @@ window.__ModuleLoader__.load({
|
|
|
1130
1189
|
children: (0, react_jsx_runtime.jsx)("input", {
|
|
1131
1190
|
type: "checkbox",
|
|
1132
1191
|
checked: selected.has(session.id),
|
|
1133
|
-
onChange: (event) =>
|
|
1134
|
-
setSelected((prev) => {
|
|
1135
|
-
const next = new Set(prev);
|
|
1136
|
-
if (event.target.checked) next.add(session.id);
|
|
1137
|
-
else next.delete(session.id);
|
|
1138
|
-
return next;
|
|
1139
|
-
});
|
|
1140
|
-
},
|
|
1192
|
+
onChange: (event) => setRowSelected(session.id, event.target.checked),
|
|
1141
1193
|
}),
|
|
1142
1194
|
}),
|
|
1143
1195
|
(0, react_jsx_runtime.jsxs)("div", {
|
|
@@ -1500,10 +1552,16 @@ window.__ModuleLoader__.load({
|
|
|
1500
1552
|
onClick: () => deleteSelected(),
|
|
1501
1553
|
children: t("deleteBatch.action", { n: selected.size }),
|
|
1502
1554
|
}),
|
|
1555
|
+
// 低价值流式分析进度:每收到一条结果就 +1(事件驱动,不是秒表)
|
|
1556
|
+
filterProgress !== null &&
|
|
1557
|
+
(0, react_jsx_runtime.jsx)("span", {
|
|
1558
|
+
style: { fontSize: 12, color: CSS_LABEL_TERTIARY, whiteSpace: "nowrap" },
|
|
1559
|
+
children: t("deleteByRule.analyzing", { done: filterProgress.done, total: filterProgress.total }),
|
|
1560
|
+
}),
|
|
1503
1561
|
(0, react_jsx_runtime.jsx)("button", {
|
|
1504
1562
|
style: S.actionBtn(false),
|
|
1505
1563
|
disabled: busy !== null,
|
|
1506
|
-
onClick: () => { setRuleOpen((v) => !v);
|
|
1564
|
+
onClick: () => { setRuleOpen((v) => !v); },
|
|
1507
1565
|
children: t("deleteByRule.action"),
|
|
1508
1566
|
}),
|
|
1509
1567
|
(0, react_jsx_runtime.jsx)("button", {
|
|
@@ -1549,26 +1607,22 @@ window.__ModuleLoader__.load({
|
|
|
1549
1607
|
(0, react_jsx_runtime.jsx)("input", {
|
|
1550
1608
|
type: "text", value: rulePrefix, placeholder: t("deleteByRule.label.cwdPrefix"),
|
|
1551
1609
|
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" },
|
|
1552
|
-
onChange: (e) => { setRulePrefix(e.target.value);
|
|
1610
|
+
onChange: (e) => { setRulePrefix(e.target.value); },
|
|
1553
1611
|
}),
|
|
1554
1612
|
(0, react_jsx_runtime.jsx)("button", {
|
|
1555
1613
|
style: S.actionBtn(false),
|
|
1556
1614
|
disabled: busy !== null,
|
|
1557
|
-
onClick:
|
|
1558
|
-
|
|
1615
|
+
onClick: applyFilter,
|
|
1616
|
+
// 状态由**请求事件**驱动:发起 → 显示「筛选中…」,响应回来 → 立刻给结果。
|
|
1617
|
+
// 不用秒表、不设超时上限:慢但正常的请求不该被任何时间阈值判成失败。
|
|
1618
|
+
children: busy === "__rule__" ? t("deleteByRule.filtering") : t("deleteByRule.previewBtn"),
|
|
1619
|
+
}),
|
|
1620
|
+
// 条件关系提示:四个条件(归档 / 价值 / 时间 / 目录)是**与**关系,
|
|
1621
|
+
// 必须同时满足才会被勾选;界面上写清楚,免得误以为是不限/或关系。
|
|
1622
|
+
(0, react_jsx_runtime.jsx)("span", {
|
|
1623
|
+
style: { fontSize: 11.5, color: CSS_LABEL_TERTIARY, flexBasis: "100%" },
|
|
1624
|
+
children: t("deleteByRule.andHint"),
|
|
1559
1625
|
}),
|
|
1560
|
-
rulePreview !== null && rulePreview.length > 0 &&
|
|
1561
|
-
(0, react_jsx_runtime.jsx)("button", {
|
|
1562
|
-
style: { ...S.actionBtn(true), borderColor: CSS_DANGER, color: CSS_DANGER },
|
|
1563
|
-
disabled: busy !== null,
|
|
1564
|
-
onClick: runRuleDelete,
|
|
1565
|
-
children: t("deleteByRule.run") + ` (${rulePreview.length})`,
|
|
1566
|
-
}),
|
|
1567
|
-
rulePreview !== null && rulePreview.length > 0 &&
|
|
1568
|
-
(0, react_jsx_runtime.jsx)("span", {
|
|
1569
|
-
style: { color: CSS_LABEL_TERTIARY, fontSize: 12 },
|
|
1570
|
-
children: t("search.hits", { n: rulePreview.length }),
|
|
1571
|
-
}),
|
|
1572
1626
|
],
|
|
1573
1627
|
}),
|
|
1574
1628
|
(0, react_jsx_runtime.jsx)("div", {
|
|
@@ -1624,6 +1678,7 @@ window.__ModuleLoader__.load({
|
|
|
1624
1678
|
style: S.error,
|
|
1625
1679
|
children: [
|
|
1626
1680
|
t("list.error", { message: error }),
|
|
1681
|
+
retryIn !== null && t("list.autoRetry", { n: retryIn }),
|
|
1627
1682
|
" ",
|
|
1628
1683
|
(0, react_jsx_runtime.jsx)("button", {
|
|
1629
1684
|
style: { ...S.actionBtn(false), marginLeft: 6 },
|
|
@@ -2132,20 +2187,22 @@ window.__ModuleLoader__.load({
|
|
|
2132
2187
|
locale: NS,
|
|
2133
2188
|
inject: () => ({
|
|
2134
2189
|
onOpenSession: (sessionId) => {
|
|
2135
|
-
//
|
|
2136
|
-
// ·
|
|
2137
|
-
//
|
|
2138
|
-
//
|
|
2139
|
-
//
|
|
2140
|
-
//
|
|
2190
|
+
// 打开会话的入口,按「宿主真实存在的」优先级逐个试:
|
|
2191
|
+
// · ctx.get("uiWorkspace").openSession(id) —— 宿主自己的会话导航服务
|
|
2192
|
+
// (ui-conversation 内部就是 `const nav = ctx.get('uiWorkspace')` 再 nav.openSession(id);
|
|
2193
|
+
// 实测宿主包里根本没有 workspaceNavigation / openSession 这类名字,早期写法是猜的)
|
|
2194
|
+
// · ctx.uiWorkspace.openSession(id) —— 同一服务,仅在本插件声明过依赖时才作为属性可见
|
|
2195
|
+
// · ctx.get("sessions").open(id) —— 老版本兜底
|
|
2196
|
+
// 必须用 ctx.get(...):cordis 里未在 inject 声明的服务**没有** ctx.<name> 属性,
|
|
2197
|
+
// 但 ctx.get(name) 仍能取到 —— 只写 ctx.uiWorkspace 会永远 undefined,点了没反应。
|
|
2141
2198
|
try {
|
|
2142
|
-
const nav = ctx.get?.("
|
|
2199
|
+
const nav = ctx.get?.("uiWorkspace");
|
|
2143
2200
|
if (typeof nav?.openSession === "function") return nav.openSession(sessionId);
|
|
2144
2201
|
if (typeof ctx.uiWorkspace?.openSession === "function") return ctx.uiWorkspace.openSession(sessionId);
|
|
2145
2202
|
const legacy = ctx.get?.("sessions");
|
|
2146
2203
|
if (typeof legacy?.open === "function") return legacy.open(sessionId);
|
|
2147
2204
|
// eslint-disable-next-line no-console
|
|
2148
|
-
console.warn("[session-conductor]
|
|
2205
|
+
console.warn("[session-conductor] 打不开会话:宿主未暴露 uiWorkspace.openSession / sessions.open", sessionId);
|
|
2149
2206
|
} catch (error) {
|
|
2150
2207
|
// 打开失败不阻塞面板,但不再静默
|
|
2151
2208
|
// eslint-disable-next-line no-console
|
package/lib/core/config.js
CHANGED
|
@@ -31,6 +31,10 @@ 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,
|
|
34
38
|
};
|
|
35
39
|
|
|
36
40
|
/** 运行期配置(面板/测试可改)。外部只读引用;改整体请用 setConfig。 */
|
|
@@ -173,6 +173,12 @@ export async function analyzeSession(ctx, sessionId, opts = {}) {
|
|
|
173
173
|
} catch (error) {
|
|
174
174
|
return { ok: false, before: currentTitle, after: currentTitle, error: { code: "rename-failed", message: String(error?.message ?? error) } };
|
|
175
175
|
}
|
|
176
|
+
// 改完标题立刻让列表缓存作废:冷会话的标题来自落盘索引(revision 没变就不重算),
|
|
177
|
+
// 不作废就会出现「后端已改名、面板还显示旧标题」。
|
|
178
|
+
try {
|
|
179
|
+
const { markSessionListStale } = await import("../../sessions/list-cache.js");
|
|
180
|
+
markSessionListStale(ctx, session.id);
|
|
181
|
+
} catch { /* 缓存作废失败不影响重命名本身 */ }
|
|
176
182
|
log(ctx, `自动重命名 ${sessionId}: "${currentTitle ?? "(无标题)"}" → "${finalTitle}"`);
|
|
177
183
|
return { ok: true, renamed: true, before: currentTitle, after: finalTitle, title: finalTitle, reason: "对话主题明显偏移,已自动重命名" };
|
|
178
184
|
}
|
|
@@ -150,6 +150,11 @@ export async function refreshTitleState(ctx, session) {
|
|
|
150
150
|
if (next === current) return null;
|
|
151
151
|
try {
|
|
152
152
|
titleService.rename(session, next);
|
|
153
|
+
// 状态后缀变了也是「可见属性变了」,同样要让列表缓存与落盘索引作废
|
|
154
|
+
try {
|
|
155
|
+
const { markSessionListStale } = await import("../../sessions/list-cache.js");
|
|
156
|
+
markSessionListStale(ctx, session.id);
|
|
157
|
+
} catch { /* 作废失败不影响标题刷新 */ }
|
|
153
158
|
return next;
|
|
154
159
|
} catch (error) {
|
|
155
160
|
log(ctx, `标题状态刷新失败 ${session.id}: ${String(error?.message ?? error)}`);
|
package/lib/features/search.js
CHANGED
|
@@ -88,7 +88,9 @@ export function searchEventsText(events, query, { perSessionMax = SEARCH_PER_SES
|
|
|
88
88
|
export async function searchSessions(ctx, query, { scope = "all", maxSessions = SEARCH_MAX_SESSIONS, perSessionMax = SEARCH_PER_SESSION_MAX } = {}) {
|
|
89
89
|
const queryText = String(query ?? "").trim();
|
|
90
90
|
if (queryText.length < SEARCH_MIN_QUERY) return { query: queryText, scanned: 0, hits: [] };
|
|
91
|
-
|
|
91
|
+
// 列表用缓存版本(不 force):一次全量重建本机实测 41~226 秒,而搜索只需要「会话 id 清单」,
|
|
92
|
+
// 新建会话最多晚一个 TTL(默认 5 秒)出现,代价远小于每次都把上百份日志重解一遍。
|
|
93
|
+
const sessions = await buildSessionListCached(ctx);
|
|
92
94
|
const hits = [];
|
|
93
95
|
let scanned = 0;
|
|
94
96
|
for (const s of sessions) {
|
|
@@ -99,7 +101,7 @@ export async function searchSessions(ctx, query, { scope = "all", maxSessions =
|
|
|
99
101
|
try {
|
|
100
102
|
const found = await sessionEventsOf(ctx, s.id);
|
|
101
103
|
if (!found) continue;
|
|
102
|
-
const matches = searchEventsText(found.events,
|
|
104
|
+
const matches = searchEventsText(found.events, queryText, { perSessionMax });
|
|
103
105
|
if (matches.length > 0) {
|
|
104
106
|
hits.push({
|
|
105
107
|
sessionId: s.id,
|
|
@@ -115,5 +117,5 @@ export async function searchSessions(ctx, query, { scope = "all", maxSessions =
|
|
|
115
117
|
/* 单个会话失败跳过(损坏日志等),不中断整体搜索 */
|
|
116
118
|
}
|
|
117
119
|
}
|
|
118
|
-
return { query:
|
|
120
|
+
return { query: queryText, scanned, hits };
|
|
119
121
|
}
|
|
@@ -163,15 +163,29 @@ export async function listSessions(ctx) {
|
|
|
163
163
|
}
|
|
164
164
|
|
|
165
165
|
/**
|
|
166
|
-
* best-effort 描述目标会话(dryRun
|
|
167
|
-
*
|
|
166
|
+
* best-effort 描述目标会话(dryRun 用):报告状态与工作区。
|
|
167
|
+
*
|
|
168
|
+
* 【必须只查便宜来源】内存里的 live 会话 → 落盘索引(list-cache.json)。
|
|
169
|
+
* 曾经这里调 listSessions()(= 全量列表构建),冷缓存时一次 dryRun 预检要 41~226 秒 ——
|
|
170
|
+
* 「校验 + 预览」本该是毫秒级的事,却变成卡死。预检的正确成本上限是「读一次内存/索引」,
|
|
171
|
+
* 不是「扫一遍所有会话日志」;查不到就如实说查不到,不为了补一个状态字段去付全量代价。
|
|
172
|
+
* 列表不可用时如实说明原因,**不阻断**(dryRun 的价值在于「早知道」)。
|
|
168
173
|
*/
|
|
169
174
|
export async function describeTarget(ctx, sessionId) {
|
|
170
175
|
try {
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
176
|
+
const live = ctx.get?.("sessions")?.get?.(sessionId);
|
|
177
|
+
if (live) {
|
|
178
|
+
const cwd = String(live.header?.cwd ?? "(无工作区)");
|
|
179
|
+
return `(进行中 | ${cwd})`;
|
|
180
|
+
}
|
|
181
|
+
const { loadListDiskCache } = await import("../../sessions/list-cache.js");
|
|
182
|
+
const entry = loadListDiskCache(ctx)?.entries?.[sessionId];
|
|
183
|
+
if (entry) {
|
|
184
|
+
const cwd = String(entry.cwd ?? "(无工作区)");
|
|
185
|
+
const updated = typeof entry.updatedAt === "number" ? new Date(entry.updatedAt).toISOString() : "未知";
|
|
186
|
+
return `(已落盘 | ${cwd} | 最后活动 ${updated})`;
|
|
187
|
+
}
|
|
188
|
+
return "(⚠️ 内存与落盘索引里都没有这个 id —— 可能写错了,或它是刚建、尚未落盘的会话)";
|
|
175
189
|
} catch (error) {
|
|
176
190
|
return `(目标存在性无法核对:${shortError(error)})`;
|
|
177
191
|
}
|
|
@@ -62,12 +62,19 @@ export async function readListIndex(ctx) {
|
|
|
62
62
|
const { loadListDiskCache } = await import("../../sessions/list-cache.js");
|
|
63
63
|
const entries = loadListDiskCache(ctx)?.entries;
|
|
64
64
|
if (!entries || typeof entries !== "object") return null;
|
|
65
|
+
// 归档态:索引文件里没存(它是宿主持久化的名单),但取它很便宜(一次内存读),
|
|
66
|
+
// 顺手带上 —— 否则面板「先读索引出列表」时按归档筛选会全部落空。
|
|
67
|
+
const registry = ctx.get?.("workspaceRegistry");
|
|
68
|
+
const archived = new Set(
|
|
69
|
+
(registry?.archivedSessionIds ?? []).filter((id) => typeof id === "string" && id !== ""),
|
|
70
|
+
);
|
|
65
71
|
return Object.entries(entries).map(([id, e]) => ({
|
|
66
72
|
id,
|
|
67
73
|
title: e?.title ?? null,
|
|
68
74
|
cwd: e?.cwd ?? null,
|
|
69
75
|
updatedAt: e?.updatedAt ?? e?.createdAt ?? 0,
|
|
70
76
|
parentSession: e?.parentSession ?? null,
|
|
77
|
+
archived: archived.has(id),
|
|
71
78
|
fromIndex: true,
|
|
72
79
|
}));
|
|
73
80
|
}
|
|
@@ -75,6 +75,11 @@ export function registerSessionOpsRoutes({ webServer, ctx, send, readJson, inval
|
|
|
75
75
|
const { title: newTitle } = archiveTitleWithWs(currentTitle, ws);
|
|
76
76
|
if (newTitle !== currentTitle) {
|
|
77
77
|
titleService.rename(session, newTitle);
|
|
78
|
+
// 归档改的是标题(加工作区前缀)与归档态 —— 两者都在列表里可见,必须作废缓存
|
|
79
|
+
try {
|
|
80
|
+
const { markSessionListStale } = await import("../sessions/list-cache.js");
|
|
81
|
+
markSessionListStale(ctx, body.sessionId);
|
|
82
|
+
} catch { /* 作废失败不影响归档 */ }
|
|
78
83
|
log(ctx, `归档加工作区前缀 ${body.sessionId}: "${currentTitle}" → "${newTitle}"`);
|
|
79
84
|
}
|
|
80
85
|
}
|
|
@@ -111,6 +116,10 @@ export function registerSessionOpsRoutes({ webServer, ctx, send, readJson, inval
|
|
|
111
116
|
const { title: stripped } = stripArchiveWsPrefix(currentTitle);
|
|
112
117
|
if (stripped !== currentTitle) {
|
|
113
118
|
titleService.rename(session, stripped);
|
|
119
|
+
try {
|
|
120
|
+
const { markSessionListStale } = await import("../sessions/list-cache.js");
|
|
121
|
+
markSessionListStale(ctx, body.sessionId);
|
|
122
|
+
} catch { /* 作废失败不影响取消归档 */ }
|
|
114
123
|
log(ctx, `取消归档还原标题 ${body.sessionId}: "${currentTitle}" → "${stripped}"`);
|
|
115
124
|
}
|
|
116
125
|
}
|
|
@@ -207,6 +216,10 @@ export function registerSessionOpsRoutes({ webServer, ctx, send, readJson, inval
|
|
|
207
216
|
? Math.min(MAX_INACTIVE_DAYS, Math.max(0, Math.round(body.inactiveDays)))
|
|
208
217
|
: 0,
|
|
209
218
|
cwdPrefix: typeof body?.cwdPrefix === "string" ? body.cwdPrefix : "",
|
|
219
|
+
// 低价值条件必须**透传**:面板一直有「仅低价值会话」勾选框并把它放进请求体,
|
|
220
|
+
// 但路由原先没往下传 ⇒ deleteByRule 里 lowValue 恒为 false,这个条件等于没写,
|
|
221
|
+
// 勾了也和没勾一样(四个条件里唯一失效的一个)。
|
|
222
|
+
lowValue: body?.lowValue === true,
|
|
210
223
|
dryRun: body?.dryRun === true,
|
|
211
224
|
});
|
|
212
225
|
send(res, 200, ruleDeleteResult);
|