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 CHANGED
@@ -50,6 +50,8 @@ import {
50
50
  collectSessionTitleMessages, resolveSessionTitle, stateSuffixOf, stripTitleStateSuffix, refreshTitleState,
51
51
  } from "./features/rename/title.js";
52
52
  import { SEARCH_MIN_QUERY, searchSessions } from './features/search.js';
53
+ // 价值判定索引:同一份会话内容在 TTL(默认 24 小时)内只算一次,避免每次筛选都重读日志
54
+ import { getValueVerdict, putValueVerdict, saveValueIndexNow } from './features/value-index.js';
53
55
  // 成员模型:本文件用到 findTargetAgent / switchAgentModel / getMemberModelOverride /
54
56
  // applyModelOverride / validateModelPair(拆出 apply.js 时漏了这一行 import,
55
57
  // 运行到相关代码路径会 ReferenceError: getMemberModelOverride is not defined)
@@ -88,6 +90,8 @@ export async function apply(ctx, config = {}) {
88
90
  maxContinuesPerSession: num(config?.maxContinuesPerSession, 1, MAX_CONTINUES_PER_SESSION_LIMIT, DEFAULTS.maxContinuesPerSession),
89
91
  turnTimeoutMs: num(config?.turnTimeoutMs, 60 * 1000, 6 * 3600 * 1000, DEFAULTS.turnTimeoutMs),
90
92
  scanIntervalMs: num(config?.scanIntervalMs, 30 * 1000, 24 * 3600 * 1000, DEFAULTS.scanIntervalMs),
93
+ // 价值判定索引有效期(小时):同一份会话内容在该时长内只算一次(默认 24 小时,可配置覆盖)
94
+ valueIndexTtlHours: num(config?.valueIndexTtlHours, 1, 24 * 30, DEFAULTS.valueIndexTtlHours),
91
95
  // 自动重命名模型路由(成对配置才生效;缺省 = 继承会话 request/header 的对话模型)
92
96
  autoRenameProvider: typeof config?.autoRenameProvider === "string" && config.autoRenameProvider !== "" ? config.autoRenameProvider : undefined,
93
97
  autoRenameModel: typeof config?.autoRenameModel === "string" && config.autoRenameModel !== "" ? config.autoRenameModel : undefined,
@@ -318,6 +322,13 @@ export async function apply(ctx, config = {}) {
318
322
  res.setHeader("Cache-Control", "no-store");
319
323
  if (res.socket?.setNoDelay) res.socket.setNoDelay(true);
320
324
  res.write(JSON.stringify({ ok: true, stream: true }) + "\n"); // 头行(前端跳过非会话行)
325
+ // 立即把头行推出去:冷缓存时后面要逐份解压上百份日志(实测 41~226 秒),
326
+ // 若头行压在缓冲里,浏览器这侧会一直「没有响应」,看起来就像接口挂了。
327
+ // flushHeaders/flush 老版本不一定有,取不到就跳过(写操作本身已尽量及时)。
328
+ try {
329
+ if (typeof res.flushHeaders === "function") res.flushHeaders();
330
+ if (typeof res.flush === "function") res.flush();
331
+ } catch { /* 不支持 flush 的宿主不影响主流程 */ }
321
332
  const onItem = (item) => { try { res.write(JSON.stringify(item) + "\n"); } catch { /* 连接已断 */ } };
322
333
  await buildSessionListCached(ctx, { onItem, serial: true });
323
334
  try { res.end(); } catch { /* 连接已断 */ }
@@ -1064,6 +1075,57 @@ export async function apply(ctx, config = {}) {
1064
1075
  : 3;
1065
1076
  const useLlm = body?.llm === true;
1066
1077
  const sessions = await buildSessionListCached(ctx); // 价值分析走缓存(容忍 5s 陈旧,避免与面板刷新叠加扫描)
1078
+ // 流式(`?stream=1` 或 Accept: application/x-ndjson):**每分析完一个会话就推一行**。
1079
+ // 前端拿到 value==="low" 立刻勾上(分析出一个勾一个),不必等全量跑完 ——
1080
+ // 全量要逐个读上百份日志,慢的是读取本身,而读取是天然可增量的。
1081
+ // 判定规则与批量路径同源(value.js 的 classifySessionLevel),不在这里另写一套。
1082
+ const wantValueStream = /application\/x-ndjson/i.test(req.headers?.accept ?? "")
1083
+ || String(req.url ?? "").includes("stream=1");
1084
+ if (wantValueStream) {
1085
+ // body.ids:只分析这些会话(面板已先用归档/时间/目录在本地筛过一轮,
1086
+ // 没必要把全量会话都读一遍事件)。不传 = 全量。
1087
+ const wantedIds = Array.isArray(body?.ids)
1088
+ ? new Set(body.ids.filter((id) => typeof id === "string" && id !== ""))
1089
+ : null;
1090
+ const streamTargets = wantedIds ? sessions.filter((s) => wantedIds.has(s.id)) : sessions;
1091
+ res.setHeader("Content-Type", "application/x-ndjson; charset=utf-8");
1092
+ res.setHeader("Cache-Control", "no-store");
1093
+ if (res.socket?.setNoDelay) res.socket.setNoDelay(true);
1094
+ res.write(JSON.stringify({ ok: true, stream: true, total: streamTargets.length }) + "\n");
1095
+ try {
1096
+ if (typeof res.flushHeaders === "function") res.flushHeaders();
1097
+ } catch { /* 老宿主不支持 flush,不影响主流程 */ }
1098
+ const { classifySessionLevel } = await lazyValue();
1099
+ let done = 0;
1100
+ for (const s of streamTargets) {
1101
+ done += 1;
1102
+ // ① 索引命中(内容没变 + 未超 TTL)→ 零 I/O 直接给结论
1103
+ const cachedVerdict = getValueVerdict(ctx, s.id, s.updatedAt);
1104
+ if (cachedVerdict) {
1105
+ try {
1106
+ res.write(JSON.stringify({
1107
+ id: s.id, value: cachedVerdict.value, score: cachedVerdict.score,
1108
+ reason: cachedVerdict.reason, cached: true, done, total: streamTargets.length,
1109
+ }) + "\n");
1110
+ } catch { break; }
1111
+ continue;
1112
+ }
1113
+ // ② 没索引 / 会话变过 / 超 TTL → 真读一次日志
1114
+ let events = [];
1115
+ try {
1116
+ const found = await sessionEventsOf(ctx, s.id);
1117
+ events = found?.events ?? [];
1118
+ } catch { /* 读不到就当空事件:会走 artifact-safe 保守判 high,不误删 */ }
1119
+ const verdict = classifySessionLevel(s, events, new Date(), staleDays);
1120
+ putValueVerdict(ctx, s.id, s.updatedAt, verdict);
1121
+ try {
1122
+ res.write(JSON.stringify({ ...verdict, cached: false, done, total: streamTargets.length }) + "\n");
1123
+ } catch { break; }
1124
+ }
1125
+ saveValueIndexNow(ctx); // 本次算出来的结论立刻落盘(下次筛选直接命中索引)
1126
+ try { res.end(); } catch { /* 连接已断 */ }
1127
+ return;
1128
+ }
1067
1129
  // 一次性读取各会话事件(复用,避免大会话重复解压导致超时/失败)
1068
1130
  const eventsById = {};
1069
1131
  // 逐个会话读取最后 assistant 文本 + 最后用户消息(损坏/不可读会话跳过,用空串)
@@ -17,20 +17,22 @@
17
17
  locale: NS,
18
18
  inject: () => ({
19
19
  onOpenSession: (sessionId) => {
20
- // 打开会话的真实入口随宿主版本不同,按优先级尝试(**不要只认一个**):
21
- // · workspaceNavigation.openSession(id) —— dsh-client-ui-conversation 的用法
22
- // · ctx.uiWorkspace.openSession(id) —— dsh-client-ui-chat 的用法
23
- // · sessions.open(id) —— 早期路径,保留兜底
24
- // 以前只写了第三个,而宿主客户端并没有 `sessions` 这个服务 ⇒ 可选链把失败吞成
25
- // 「点了没反应」,且不报错、难查。所以这里:多个入口都试;全失败要留下痕迹。
20
+ // 打开会话的入口,按「宿主真实存在的」优先级逐个试:
21
+ // · ctx.get("uiWorkspace").openSession(id) —— 宿主自己的会话导航服务
22
+ // (ui-conversation 内部就是 `const nav = ctx.get('uiWorkspace')` 再 nav.openSession(id);
23
+ // 实测宿主包里根本没有 workspaceNavigation / openSession 这类名字,早期写法是猜的)
24
+ // · ctx.uiWorkspace.openSession(id) —— 同一服务,仅在本插件声明过依赖时才作为属性可见
25
+ // · ctx.get("sessions").open(id) —— 老版本兜底
26
+ // 必须用 ctx.get(...):cordis 里未在 inject 声明的服务**没有** ctx.<name> 属性,
27
+ // 但 ctx.get(name) 仍能取到 —— 只写 ctx.uiWorkspace 会永远 undefined,点了没反应。
26
28
  try {
27
- const nav = ctx.get?.("workspaceNavigation");
29
+ const nav = ctx.get?.("uiWorkspace");
28
30
  if (typeof nav?.openSession === "function") return nav.openSession(sessionId);
29
31
  if (typeof ctx.uiWorkspace?.openSession === "function") return ctx.uiWorkspace.openSession(sessionId);
30
32
  const legacy = ctx.get?.("sessions");
31
33
  if (typeof legacy?.open === "function") return legacy.open(sessionId);
32
34
  // eslint-disable-next-line no-console
33
- console.warn("[session-conductor] 打不开会话:宿主客户端未暴露 workspaceNavigation/uiWorkspace/sessions 任一入口", sessionId);
35
+ console.warn("[session-conductor] 打不开会话:宿主未暴露 uiWorkspace.openSession / sessions.open", sessionId);
34
36
  } catch (error) {
35
37
  // 打开失败不阻塞面板,但不再静默
36
38
  // eslint-disable-next-line no-console
@@ -5,18 +5,18 @@
5
5
  const [groups, setGroups] = react.useState(null); // workspaces[](group/list);null = host 无分组 API
6
6
  const [collapsed, setCollapsed] = react.useState(() => new Set()); // 折叠的分组(含 "__ungrouped__")
7
7
  const [error, setError] = react.useState(null);
8
+ const [retryIn, setRetryIn] = react.useState(null); // 自动重试倒计时(秒);null = 没有待重试的定时器
8
9
  const [busy, setBusy] = react.useState(null);
9
10
  // —— 搜索 + 批量/按条件删除 ——
10
11
  const [query, setQuery] = react.useState(""); // 列表过滤关键词
11
12
  const [selected, setSelected] = react.useState(() => new Set()); // 批量删除勾选
12
13
  const [fullResults, setFullResults] = react.useState(null); // 全文搜索结果(null=未搜索)
13
14
  const [fullSearching, setFullSearching] = react.useState(false);
14
- const [ruleOpen, setRuleOpen] = react.useState(false); // 按条件删除表单展开
15
+ const [ruleOpen, setRuleOpen] = react.useState(false); // 筛选条件表单展开
15
16
  const [ruleArchivedOnly, setRuleArchivedOnly] = react.useState(true);
16
17
  const [ruleDays, setRuleDays] = react.useState(7);
17
18
  const [rulePrefix, setRulePrefix] = react.useState("");
18
19
  const [ruleLowValue, setRuleLowValue] = react.useState(false); // 低价值筛选(复用价值分析)
19
- const [rulePreview, setRulePreview] = react.useState(null); // dryRun 预览(null=未预览)
20
20
 
21
21
  // list 流式——NDJSON 每行一个会话,读到立即追加显示(每获取一个追加一个,
22
22
  // 不等全部);按 updatedAt 倒序插入保持列表顺序;旧一次性 JSON(预览页离线垫片/
@@ -31,20 +31,51 @@
31
31
  next.splice(i, 0, item);
32
32
  return next;
33
33
  };
34
+ // 列表拉取失败自动重试:DSH 刚重启时插件路由/宿主服务未必已就绪,首次请求会直接
35
+ // 网络失败(Failed to fetch),冷缓存首次构建也很慢 —— 靠用户手动点「重试」既费事又
36
+ // 容易误以为插件坏了。这里按退避自动重试,界面照常给出失败原因 + 下次重试倒计时,
37
+ // 成功一次即清零。数组长度 = 最多自动重试几次。
38
+ const LIST_RETRY_DELAYS = [3000, 8000, 20000, 45000];
39
+ const listRetryRef = react.useRef(0);
40
+ const listTimerRef = react.useRef(null);
41
+ const refreshRef = react.useRef(null);
42
+ // 卸载时清掉待重试的定时器,避免组件已销毁还在拉数据(也会让「已关闭面板」继续打后端)
43
+ react.useEffect(() => () => {
44
+ if (listTimerRef.current) clearTimeout(listTimerRef.current);
45
+ listTimerRef.current = null;
46
+ }, []);
34
47
  const refresh = react.useCallback(async () => {
48
+ if (listTimerRef.current) { clearTimeout(listTimerRef.current); listTimerRef.current = null; }
49
+ setRetryIn(null);
35
50
  try {
36
- const listPromise = window.__scFetch("/api/session-conductor/list", { headers: { Accept: "application/x-ndjson" } });
51
+ // 流式列表**不设超时**(第三个参数 0):冷缓存要逐份解压上百份日志,本机实测
52
+ // 41~226 秒;套 30 秒上限会把「慢但正常」的请求掐成「读取会话列表失败」。
53
+ const listPromise = window.__scFetch("/api/session-conductor/list", { headers: { Accept: "application/x-ndjson" } }, 0);
37
54
  const groupPromise = window.__scFetch("/api/session-conductor/group/list")
38
55
  .then((r) => (r.ok ? r.json() : { ok: false })).catch(() => ({ ok: false }));
39
56
  const listRes = await listPromise;
40
57
  if (!listRes.ok) throw new Error("list failed: HTTP " + listRes.status);
41
58
  let lastBatch = null;
42
59
  if (typeof listRes.body?.getReader === "function") {
43
- // 流式 NDJSON:逐行解析追加
60
+ // 流式 NDJSON:逐行解析追加。
61
+ // 但**不逐行 setState**:上百条会话逐条 setSessions 会触发上百次全列表重渲染
62
+ // (O(n²)),真实数据下界面会卡到无响应。改为攒 150ms 一批、一次性合并。
44
63
  const reader = listRes.body.getReader();
45
64
  const decoder = new TextDecoder();
46
65
  let buf = "";
47
66
  const batch = [];
67
+ const pending = [];
68
+ let flushTimer = null;
69
+ const flush = () => {
70
+ flushTimer = null;
71
+ if (pending.length === 0) return;
72
+ const items = pending.splice(0, pending.length);
73
+ setSessions((cur) => {
74
+ let next = Array.isArray(cur) ? cur : [];
75
+ for (const item of items) next = insertSorted(next, item);
76
+ return next;
77
+ });
78
+ };
48
79
  for (;;) {
49
80
  const { done, value } = await reader.read();
50
81
  if (done) break;
@@ -58,10 +89,12 @@
58
89
  try { parsedLine = JSON.parse(line); } catch { continue; } // 半行/头行跳过
59
90
  if (parsedLine && parsedLine.id) {
60
91
  batch.push(parsedLine);
61
- setSessions((cur) => insertSorted(cur, parsedLine)); // 每获取一个追加一个
92
+ pending.push(parsedLine);
93
+ if (flushTimer === null) flushTimer = setTimeout(flush, 150);
62
94
  }
63
95
  }
64
96
  }
97
+ flush();
65
98
  lastBatch = batch;
66
99
  } else {
67
100
  const body = await listRes.json();
@@ -73,12 +106,37 @@
73
106
  const groupRes = await groupPromise;
74
107
  setGroups(groupRes?.ok === true ? groupRes.workspaces ?? [] : null);
75
108
  setError(null);
109
+ listRetryRef.current = 0; // 成功一次即清零,下次失败从最短退避重来
76
110
  } catch (cause) {
77
111
  setError(cause instanceof Error ? cause.message : String(cause));
112
+ const delay = LIST_RETRY_DELAYS[listRetryRef.current];
113
+ if (delay !== void 0) {
114
+ listRetryRef.current += 1;
115
+ setRetryIn(Math.round(delay / 1000));
116
+ listTimerRef.current = setTimeout(() => { refreshRef.current?.(); }, delay);
117
+ }
78
118
  }
79
119
  }, []);
120
+ refreshRef.current = refresh;
80
121
 
81
122
  // 启动即自动加载(插件挂载就预取一次,不依赖面板打开);打开面板时再刷新一次保证最新
123
+ // ① 先读**落盘索引**(毫秒级)把列表填出来 —— 索引只写不用的话,冷缓存时面板要等
124
+ // 全量构建(实测 41~226 秒)才有内容,看起来就是「空的」。
125
+ // ② 随后 refresh() 的流式全量刷新在后台覆盖(每条到了就追加),两者互不阻塞。
126
+ react.useEffect(() => {
127
+ let alive = true;
128
+ (async () => {
129
+ try {
130
+ const res = await window.__scFetch("/api/session-conductor/list?fromIndex=true", { headers: { Accept: "application/json" } }, 0);
131
+ const body = await res.json();
132
+ if (!alive || !body?.ok) return;
133
+ const items = Array.isArray(body.sessions) ? body.sessions : [];
134
+ // 只在当前还是空列表时填充:不要用陈旧索引覆盖已经流式到达的新数据
135
+ if (items.length > 0) setSessions((cur) => (Array.isArray(cur) && cur.length > 0 ? cur : items));
136
+ } catch { /* 索引不可用就走全量刷新 */ }
137
+ })();
138
+ return () => { alive = false; };
139
+ }, []);
82
140
  react.useEffect(() => {
83
141
  refresh();
84
142
  }, [refresh]);
@@ -439,15 +497,20 @@
439
497
  usedFallback = true;
440
498
  }
441
499
  if (!body?.ok) throw new Error(body?.error?.message ?? "search failed");
442
- const hits = (body.items ?? []).map((h) => ({
443
- sessionId: h.sessionId,
500
+ // 两条搜索来源、两种返回形状:官方 FTS 给 items[],本插件内置扫描给 hits[]。
501
+ // 原先只认 items ⇒ 官方接口不存在(本机实测 /api/session-search/query 直接 404,
502
+ // 必然走内置扫描)时命中恒为空 —— 搜到了也显示「没有找到」,命中区自然点不动。
503
+ const rawHits = Array.isArray(body.items) ? body.items : (Array.isArray(body.hits) ? body.hits : []);
504
+ const hits = rawHits.map((h) => ({
505
+ // sessionId 是点开会话的唯一凭据:缺了就是「点了没反应」,这里兼容常见别名并滤掉空值
506
+ sessionId: h.sessionId ?? h.session?.id ?? h.id ?? null,
444
507
  title: h.title ?? null,
445
508
  cwd: h.cwd ?? null,
446
509
  archived: h.archived === true,
447
510
  running: h.running === true,
448
511
  updatedAt: h.updatedAt ?? null,
449
512
  matches: Array.isArray(h.matches) ? h.matches : (h.match ? [h.match] : []),
450
- }));
513
+ })).filter((h) => typeof h.sessionId === "string" && h.sessionId !== "");
451
514
  setFullResults({ query: keywordText, hits, usedFallback });
452
515
  } catch (cause) {
453
516
  const message = cause instanceof Error ? cause.message : String(cause);
@@ -484,116 +547,108 @@
484
547
  }
485
548
  };
486
549
 
487
- // —— 按条件删除(预览 + 执行)——
488
- // 预览页离线模式(window.__BACKEND__ === ""):按条件删除在快照数据上本地计算预览
489
- // (真实 GUI 走后端 API;预览页点预览可立即看到命中,无需后端)
490
- const previewLocally = () => {
491
- const ep = window.__ENDPOINTS__ || {};
492
- const listBody = ep["/api/session-conductor/list"];
493
- const vaBody = ep["/api/session-conductor/value-analysis"];
494
- const rows = (listBody?.sessions ?? []).map((s) => ({
495
- sessionId: s.id,
496
- title: s.title,
497
- cwd: s.cwd,
498
- archived: s.archived === true,
499
- updatedAt: typeof s.updatedAt === "number" ? s.updatedAt : null,
500
- }));
550
+ // —— 勾选的公共入口 ——
551
+ // 行内勾选框、筛选、清空都必须走这两个函数:状态只有 `selected` 一处。
552
+ // 之前行内勾选自己写一段 setSelected、筛选另写一段,两处口径一旦不同就会出现
553
+ // 「删除按钮的计数变了,但行内勾选框没勾上」这种鬼现象。
554
+ const setRowSelected = (sessionId, checked) => {
555
+ if (typeof sessionId !== "string" || sessionId === "") return;
556
+ setSelected((prev) => {
557
+ const next = new Set(prev);
558
+ if (checked) next.add(sessionId); else next.delete(sessionId);
559
+ return next;
560
+ });
561
+ };
562
+ const replaceSelected = (ids) => {
563
+ setSelected(new Set((Array.isArray(ids) ? ids : []).filter((id) => typeof id === "string" && id !== "")));
564
+ };
565
+
566
+ // —— 筛选:按条件把会话**自动勾选**到列表里 ——
567
+ // 批量删除只有一条路径:「勾选 → 删除选中」。条件(归档 / 价值 / 时间 / 目录)只负责把该勾的勾上。
568
+ // ① 归档 / 时间 / 目录:面板手里每一行已经带着 archived / cwd / updatedAt,
569
+ // 纯内存过滤 —— 点完即勾,**不发任何请求**。
570
+ // ② 低价值:唯一需要读日志的条件,走**流式**分析 —— 服务端每算完一条推一行,
571
+ // 前端**分析出一个勾一个**。服务端另有价值索引(按 updatedAt + TTL,默认 24 小时),
572
+ // 命中索引的会话零 I/O 秒回,只有新会话/变过的会话才真读日志。
573
+ // 全程不设时间上限:完成与否由**请求事件**判定(每行一个结果,读完即结束)。
574
+ const [filterProgress, setFilterProgress] = react.useState(null); // {done,total};null=没在分析
575
+ const streamLowValue = async (baseIds) => {
576
+ const base = new Set(baseIds);
577
+ replaceSelected([]); // 低价值:从空开始,分析出一个勾一个
578
+ setFilterProgress({ done: 0, total: base.size });
579
+ const res = await window.__scFetch("/api/session-conductor/value-analysis?stream=1", {
580
+ method: "POST",
581
+ headers: { "Content-Type": CONTENT_TYPE_JSON, Accept: "application/x-ndjson" },
582
+ body: JSON.stringify({ ids: baseIds }),
583
+ }, 0);
584
+ if (!res.ok) throw new Error("value analysis failed: HTTP " + res.status);
585
+ if (typeof res.body?.getReader !== "function") throw new Error("value analysis: 宿主未提供流式响应");
586
+ const reader = res.body.getReader();
587
+ const decoder = new TextDecoder();
588
+ let buf = "";
589
+ let picked = 0;
590
+ let seen = 0;
591
+ for (;;) {
592
+ const { done, value } = await reader.read();
593
+ if (done) break;
594
+ buf += decoder.decode(value, { stream: true });
595
+ let nl;
596
+ while ((nl = buf.indexOf("\n")) >= 0) {
597
+ const line = buf.slice(0, nl).trim();
598
+ buf = buf.slice(nl + 1);
599
+ if (!line) continue;
600
+ let row;
601
+ try { row = JSON.parse(line); } catch { continue; }
602
+ if (!row || typeof row.id !== "string") continue; // 头行({ok,stream,total})跳过
603
+ seen += 1;
604
+ setFilterProgress({ done: typeof row.done === "number" ? row.done : seen, total: typeof row.total === "number" ? row.total : base.size });
605
+ if (row.value === "low" && base.has(row.id)) {
606
+ picked += 1;
607
+ setRowSelected(row.id, true); // 走公共勾选入口,行内勾选框与计数永远一致
608
+ }
609
+ }
610
+ }
611
+ return picked;
612
+ };
613
+
614
+ const applyFilter = async () => {
615
+ const list = Array.isArray(sessions) ? sessions : [];
501
616
  const now = Date.now();
502
- const lowIds = new Set((vaBody?.low ?? []).map((i) => i.id));
503
617
  const prefix = rulePrefix.trim().replace(/\/+$/, "");
504
618
  const days = Number(ruleDays) || 0;
505
- const matched = rows.filter((m) => {
506
- if (ruleArchivedOnly && !m.archived) return false;
619
+ const baseIds = list.filter((s) => {
620
+ if (ruleArchivedOnly && s.archived !== true) return false;
507
621
  if (days > 0) {
508
- const updated = m.updatedAt;
622
+ const updated = typeof s.updatedAt === "number" ? s.updatedAt : NaN;
509
623
  if (!Number.isFinite(updated) || now - updated < days * 86400000) return false;
510
624
  }
511
625
  if (prefix !== "") {
512
- const cwd = String(m.cwd ?? "").replace(/\/+$/, "");
626
+ const cwd = String(s.cwd ?? "").replace(/\/+$/, "");
513
627
  if (cwd !== prefix && !cwd.startsWith(prefix + "/")) return false;
514
628
  }
515
- if (ruleLowValue && !lowIds.has(m.sessionId)) return false;
516
629
  return true;
517
- });
518
- return matched;
519
- };
520
-
521
- const previewRuleDelete = async () => {
522
- setBusy("__rule__");
523
- setRulePreview(null);
524
- if (typeof window.__BACKEND__ !== "undefined" && window.__BACKEND__ === "") {
525
- // 预览页离线:本地计算
526
- try {
527
- const matched = previewLocally();
528
- setRulePreview(matched);
529
- if (matched.length === 0) window.alert(t("deleteByRule.noMatch"));
530
- } catch (cause) {
531
- window.alert(t("error.action", { message: cause instanceof Error ? cause.message : String(cause) }));
532
- } finally {
533
- setBusy(null);
534
- }
535
- return;
536
- }
537
- try {
538
- const res = await window.__scFetch("/api/session-conductor/delete-by-rule", {
539
- method: "POST",
540
- headers: { "Content-Type": CONTENT_TYPE_JSON },
541
- body: JSON.stringify({
542
- archivedOnly: ruleArchivedOnly,
543
- inactiveDays: Number(ruleDays) || 0,
544
- cwdPrefix: rulePrefix.trim(),
545
- lowValue: ruleLowValue,
546
- dryRun: true,
547
- }),
548
- });
549
- const body = await res.json();
550
- if (!body.ok) throw new Error(body.error?.message ?? "preview failed");
551
- setRulePreview(body.matched ?? []);
552
- if (!body.matched || body.matched.length === 0) window.alert(t("deleteByRule.noMatch"));
553
- } catch (cause) {
554
- const message = cause instanceof Error ? cause.message : String(cause);
555
- window.alert(t("error.action", { message }));
556
- } finally {
557
- setBusy(null);
558
- }
559
- };
560
-
561
- const runRuleDelete = async () => {
562
- if (!rulePreview || rulePreview.length === 0) return;
563
- // 预览页离线模式:不执行真实删除(破坏性操作需真实后端),提示到 DSH GUI
564
- if (typeof window.__BACKEND__ !== "undefined" && window.__BACKEND__ === "") {
565
- window.alert(t("deleteByRule.offlineHint"));
630
+ }).map((s) => s.id);
631
+ if (baseIds.length === 0) {
632
+ // 勾选是「替换」语义:这次条件什么都没匹配到,必须把上一次的勾选清掉 ——
633
+ // 否则界面还留着上次的勾选与「删除选中 (N)」,很容易照着上次的结果误删。
634
+ replaceSelected([]);
635
+ window.alert(t("deleteByRule.noMatch"));
566
636
  return;
567
637
  }
568
- const yes = window.confirm(t("deleteByRule.preview", { n: rulePreview.length }));
569
- if (!yes) return;
638
+ // 切到「全部」让勾选结果当场可见 + 收起条件表单回到列表
639
+ setTab("all");
640
+ setRuleOpen(false);
641
+ if (!ruleLowValue) { replaceSelected(baseIds); return; } // 纯本地,点完即勾
570
642
  setBusy("__rule__");
571
643
  try {
572
- const res = await window.__scFetch("/api/session-conductor/delete-by-rule", {
573
- method: "POST",
574
- headers: { "Content-Type": CONTENT_TYPE_JSON },
575
- body: JSON.stringify({
576
- archivedOnly: ruleArchivedOnly,
577
- inactiveDays: Number(ruleDays) || 0,
578
- cwdPrefix: rulePrefix.trim(),
579
- lowValue: ruleLowValue,
580
- dryRun: false,
581
- }),
582
- });
583
- const body = await res.json();
584
- if (!body.ok) throw new Error(body.error?.message ?? "delete failed");
585
- window.alert(t("deleteBatch.done", {
586
- deleted: body.deleted?.length ?? 0,
587
- skippedText: body.skipped?.length > 0 ? t("deleteBatch.skipped", { n: body.skipped.length }) : "",
588
- }));
589
- setRuleOpen(false);
590
- setRulePreview(null);
591
- await refresh();
644
+ const picked = await streamLowValue(baseIds);
645
+ if (picked === 0) window.alert(t("deleteByRule.noMatch"));
592
646
  } catch (cause) {
593
647
  const message = cause instanceof Error ? cause.message : String(cause);
594
648
  window.alert(t("error.action", { message }));
595
649
  } finally {
596
650
  setBusy(null);
651
+ setFilterProgress(null);
597
652
  }
598
653
  };
599
654
 
@@ -721,14 +776,7 @@
721
776
  children: (0, react_jsx_runtime.jsx)("input", {
722
777
  type: "checkbox",
723
778
  checked: selected.has(session.id),
724
- onChange: (event) => {
725
- setSelected((prev) => {
726
- const next = new Set(prev);
727
- if (event.target.checked) next.add(session.id);
728
- else next.delete(session.id);
729
- return next;
730
- });
731
- },
779
+ onChange: (event) => setRowSelected(session.id, event.target.checked),
732
780
  }),
733
781
  }),
734
782
  (0, react_jsx_runtime.jsxs)("div", {
@@ -1091,10 +1139,16 @@
1091
1139
  onClick: () => deleteSelected(),
1092
1140
  children: t("deleteBatch.action", { n: selected.size }),
1093
1141
  }),
1142
+ // 低价值流式分析进度:每收到一条结果就 +1(事件驱动,不是秒表)
1143
+ filterProgress !== null &&
1144
+ (0, react_jsx_runtime.jsx)("span", {
1145
+ style: { fontSize: 12, color: CSS_LABEL_TERTIARY, whiteSpace: "nowrap" },
1146
+ children: t("deleteByRule.analyzing", { done: filterProgress.done, total: filterProgress.total }),
1147
+ }),
1094
1148
  (0, react_jsx_runtime.jsx)("button", {
1095
1149
  style: S.actionBtn(false),
1096
1150
  disabled: busy !== null,
1097
- onClick: () => { setRuleOpen((v) => !v); setRulePreview(null); },
1151
+ onClick: () => { setRuleOpen((v) => !v); },
1098
1152
  children: t("deleteByRule.action"),
1099
1153
  }),
1100
1154
  (0, react_jsx_runtime.jsx)("button", {
@@ -1140,26 +1194,22 @@
1140
1194
  (0, react_jsx_runtime.jsx)("input", {
1141
1195
  type: "text", value: rulePrefix, placeholder: t("deleteByRule.label.cwdPrefix"),
1142
1196
  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" },
1143
- onChange: (e) => { setRulePrefix(e.target.value); setRulePreview(null); },
1197
+ onChange: (e) => { setRulePrefix(e.target.value); },
1144
1198
  }),
1145
1199
  (0, react_jsx_runtime.jsx)("button", {
1146
1200
  style: S.actionBtn(false),
1147
1201
  disabled: busy !== null,
1148
- onClick: previewRuleDelete,
1149
- children: t("deleteByRule.previewBtn"),
1202
+ onClick: applyFilter,
1203
+ // 状态由**请求事件**驱动:发起 → 显示「筛选中…」,响应回来 → 立刻给结果。
1204
+ // 不用秒表、不设超时上限:慢但正常的请求不该被任何时间阈值判成失败。
1205
+ children: busy === "__rule__" ? t("deleteByRule.filtering") : t("deleteByRule.previewBtn"),
1206
+ }),
1207
+ // 条件关系提示:四个条件(归档 / 价值 / 时间 / 目录)是**与**关系,
1208
+ // 必须同时满足才会被勾选;界面上写清楚,免得误以为是不限/或关系。
1209
+ (0, react_jsx_runtime.jsx)("span", {
1210
+ style: { fontSize: 11.5, color: CSS_LABEL_TERTIARY, flexBasis: "100%" },
1211
+ children: t("deleteByRule.andHint"),
1150
1212
  }),
1151
- rulePreview !== null && rulePreview.length > 0 &&
1152
- (0, react_jsx_runtime.jsx)("button", {
1153
- style: { ...S.actionBtn(true), borderColor: CSS_DANGER, color: CSS_DANGER },
1154
- disabled: busy !== null,
1155
- onClick: runRuleDelete,
1156
- children: t("deleteByRule.run") + ` (${rulePreview.length})`,
1157
- }),
1158
- rulePreview !== null && rulePreview.length > 0 &&
1159
- (0, react_jsx_runtime.jsx)("span", {
1160
- style: { color: CSS_LABEL_TERTIARY, fontSize: 12 },
1161
- children: t("search.hits", { n: rulePreview.length }),
1162
- }),
1163
1213
  ],
1164
1214
  }),
1165
1215
  (0, react_jsx_runtime.jsx)("div", {
@@ -1215,6 +1265,7 @@
1215
1265
  style: S.error,
1216
1266
  children: [
1217
1267
  t("list.error", { message: error }),
1268
+ retryIn !== null && t("list.autoRetry", { n: retryIn }),
1218
1269
  " ",
1219
1270
  (0, react_jsx_runtime.jsx)("button", {
1220
1271
  style: { ...S.actionBtn(false), marginLeft: 6 },
@@ -13,12 +13,16 @@
13
13
 
14
14
  // ---------- 工具 ----------
15
15
 
16
- // 全局 fetch 超时兜底:DSH API 挂起(重负载/路由未注册)时界面不再无限等待。
17
- // 与 fetch 同签名(url, opts, timeoutMs),默认 30s;分片统一走 __scFetch。
18
- const FETCH_TIMEOUT_MS = 30000;
16
+ // 统一 fetch 入口(url, opts, timeoutMs)。**默认不设超时**。
17
+ // 曾经默认 30 秒「兜底」,结果两次把慢但正常的请求掐成失败:
18
+ // · 会话列表冷缓存要逐份解压上百份日志(实测 41~226 秒)→ 刚重启报「读取会话列表失败」;
19
+ // · 按条件筛选勾了「低价值」时要逐个会话读事件 → 报「操作失败:signal is aborted…」。
20
+ // 结论:**判定只能按事件驱动**(请求完成 / 失败 / 数据到达),不能靠拍一个秒数上限;
21
+ // 时间阈值一写死,慢就被误读成坏。真要限时,必须由调用方显式传 timeoutMs 并说清依据。
19
22
  window.__scFetch = function (url, opts, timeoutMs) {
23
+ if (!(timeoutMs > 0)) return fetch(url, opts || {});
20
24
  const controller = new AbortController();
21
- const timer = setTimeout(() => controller.abort(), timeoutMs || FETCH_TIMEOUT_MS);
25
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
22
26
  const next = opts ? Object.assign({}, opts, { signal: controller.signal }) : { signal: controller.signal };
23
27
  return fetch(url, next).finally(() => clearTimeout(timer));
24
28
  };