@leonhardthust/dsh-decision-engine 1.9.2 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -127,13 +127,17 @@ window.__ModuleLoader__.load({
127
127
  "开启后在每次工具调用前做风险判断": "When on, a risk judgement runs before every tool call",
128
128
  "真正拦截": "Actually block",
129
129
  "关闭时只记录判断结果(shadow);开启后按判断执行 deny / ask": "When off, verdicts are only recorded (shadow); when on, deny / ask are enforced",
130
- "思考模式": "Thinking mode",
131
- "总是思考:判断更充分,但每次工具调用约多等 5 秒": "Always think: fuller judgement, but roughly 5 extra seconds per tool call",
132
- "仅在模型自己判断需要时思考(折中)": "Think only when the model itself decides it is needed (middle ground)",
133
- "不思考:最快(约 1.9s),热路径首选": "Never think: fastest (about 1.9s), the hot-path default",
134
- "never(不思考)": "never (no thinking)",
135
- "auto(按需思考)": "auto (think when needed)",
136
- "always(总是思考)": "always (always think)",
130
+ // —— 决策模型思考(T2,原「思考模式」)——
131
+ // 这一项只改**本插件发给决策端点**的 think_mode,与宿主主模型的推理档位
132
+ // 无关。旧文案「思考模式」+「总是思考:判断更充分」把两者混成一件,用户
133
+ // 以为调它会改变主模型的推理强度。
134
+ "决策模型思考": "Decision-model thinking",
135
+ "只控制本插件发给决策端点的 think_mode,不影响宿主主模型;宿主回退模型的推理档位在「自动审查回退」那一行。": "Controls only the think_mode this plugin sends to the decision endpoint; the host model is unaffected. The host fallback model's reasoning effort is the \"Automatic review fallback\" row.",
136
+ "该端点未实测是否支持 think_mode:会照常发送,可能被忽略(不报错)。": "This endpoint has not been measured for think_mode support: it is still sent and may simply be ignored (no error).",
137
+ "该端点的公开 API 没有 think_mode 协议,此项对当前 provider 不生效。": "This endpoint's public API has no think_mode protocol, so this option has no effect for the current provider.",
138
+ "never|跳过思考(最快,默认)": "never | skip thinking (fastest, default)",
139
+ "auto|由服务端决定(实测约 7.2s,比 never 慢)": "auto | decided by the server (measured ~7.2s, slower than never)",
140
+ "always|总是思考(约 +5s,质量收益未实测)": "always | always think (about +5s; quality gain not measured)",
137
141
  // —— 匿名使用统计(DEC-020)——
138
142
  "匿名统计": "Anonymous stats",
139
143
  "每天上报一次:匿名安装 id、版本、平台与当日决策数": "Reports once a day: anonymous install id, version, platform, and today's decision count",
@@ -275,15 +279,17 @@ window.__ModuleLoader__.load({
275
279
  "正在读取可选范围…": "Reading available ranges…",
276
280
  "至": "to",
277
281
  // —— 裁决统计 ——
282
+ // 「ask」这一格统一口径为「转人工」(T4):裁决格里它写「转人工」,
283
+ // 统计页若还写「询问」,同一件事在同一页面上有两个名字。
278
284
  "裁决统计(allow / ask / reject)": "Verdicts (allow / ask / reject)",
279
- "决策模型的裁决结果:放行 = 动作照常进行;询问 = 升级人工确认;拦截 = 动作被拦下。": "Decision-model verdicts: allow = the action proceeds; ask = escalated to a human; reject = the action is stopped.",
285
+ "决策模型的裁决结果:放行 = 动作照常进行;转人工 = 升级人工确认;拦截 = 动作被拦下。": "Decision-model verdicts: allow = the action proceeds; escalate = escalated to a human; reject = the action is stopped.",
280
286
  "放行 allow": "allow",
281
- "询问 ask": "ask",
287
+ "转人工 ask": "ask",
282
288
  "拦截 reject": "reject",
283
289
  "未裁决": "Undecided",
284
290
  "决策点": "Decision point",
285
291
  "放行": "Allow",
286
- "询问": "Ask",
292
+ "转人工": "Escalate",
287
293
  "拦截": "Reject",
288
294
  "。这是新增决策点或裁决词后忘了登记导致的。": ". This happens when a new decision point or verdict word is added without registering it.",
289
295
  "横向对比(provider × model)": "Side by side (provider × model)",
@@ -382,25 +388,42 @@ window.__ModuleLoader__.load({
382
388
  "审批卡正文可读化": "Readable approval-card command block",
383
389
  "接管宿主的命令块:显示「几步、哪几步危险」的摘要,原文默认收起、可展开;保留换行,长命令限高滚动": "Take over the host's command block: show a summary of how many steps there are and which look risky, with the original collapsed by default and expandable; line breaks preserved and long commands scrolling inside a fixed height",
384
390
  "关闭时完全沿用宿主的渲染:命令原文原样铺满整个块,换行会被折叠": "When off, the host's rendering is used unchanged: the raw command fills the whole block and its line breaks are collapsed",
385
- // —— 人工审核比例(DEC-035):配置页与决策历史页共用的滑块 ——
386
- "人工审核比例": "Review tolerance",
387
- "自治": "Autonomous",
388
- "轻松": "Relaxed",
391
+ // —— 日志分文件 / 保留(T3):明细写到哪、留多久 ——
392
+ "日志分文件": "Log sharding",
393
+ "日志保留": "Log retention",
394
+ "日期分隔符": "Date separator",
395
+ "分隔符只影响文件名(logs/2026-11-15.log),不影响已经写下的记录": "The separator only affects file names (logs/2026-11-15.log); records already written are untouched",
396
+ "single|单一文件": "single | one file",
397
+ "date|按日期": "date | by date",
398
+ "session|按会话": "session | by session",
399
+ "workspace|按工作区": "workspace | by workspace",
400
+ "短横线(-)": "hyphen (-)",
401
+ "下划线(_)": "underscore (_)",
402
+ "点(.)": "dot (.)",
403
+ "全部追加到同一个 decisions.jsonl(升级前的行为)": "Everything is appended to a single decisions.jsonl (the behaviour before this upgrade)",
404
+ "按会话分文件:logs/<会话名>__<会话 id 前 8 位>.log,一个会话一个文件": "One file per session: logs/<session name>__<first 8 chars of the session id>.log",
405
+ "按工作区分文件:logs/<工作区名>.log,同一工作区的会话写在一起": "One file per workspace: logs/<workspace name>.log, so sessions in the same workspace share a file",
406
+ // —— 安全档位(DEC-035,T1 改口径):配置页与决策历史页共用的滑块 ——
407
+ //
408
+ // 旧文案(「人工审核比例」「自治(从不询问)」「轻松(只问最前 1%)」
409
+ // 「高风险调用一律直接拦下(四档一致)」)已删除:它把「按分位数升级
410
+ // 软性审查」说成「审核比例」,又把「四档对高风险行为一致」说成拦下——
411
+ // 而权限强制的人工批准/拒绝从来不是被这四档拦下的。
412
+ "安全档位": "Safety level",
413
+ "决定软性审查多频繁地升级;权限强制的人工批准与拒绝不受档位影响;审批策略为永不询问时任何询问都会变成拒绝。": "Decides how often soft review escalates; permission-mandated approval and denial are unaffected by this level, and with an approval policy of \"never ask\" any ask becomes a denial.",
414
+ // 四档的 label / summary 由服务端(lib/tolerance.js)下发,下面几条是
415
+ // **兜底**:服务端没带 labelEn / summaryEn 时英文界面也不能留中文。
416
+ "不问": "Don't ask",
417
+ "少问": "Ask less",
389
418
  "默认": "Default",
390
- "谨慎": "Cautious",
391
- "自治(从不询问)": "Autonomous (never asks)",
392
- "轻松(只问最前 1%)": "Relaxed (top 1% only)",
393
- "默认(当前行为)": "Default (current behaviour)",
394
- "谨慎(更保守)": "Cautious (more conservative)",
395
- "自治:从不询问用户,只有高风险调用会被直接拦下": "Autonomous: never asks the user; only high-risk calls are blocked outright",
396
- "轻松:只有严重度排在最前的 1% 才询问用户": "Relaxed: only the top 1% by severity asks the user",
397
- "默认:按决策模型自己的判断提问(当前的默认行为)": "Default: asks according to the decision model's own judgement (current behaviour)",
398
- "谨慎:在默认判断之外,再把严重度靠前的 25% 也拿来询问": "Cautious: beyond the default judgement, also asks for the top 25% by severity",
399
- "按比例校准这个模型的打扰频率。换决策模型不需要重调阈值:设定是模型无关的,插件会按该模型最近的分数量自己换算。": "Calibrates how often this model interrupts you. Switching decision models needs no re-tuning: the setting is model-agnostic, and the plugin converts it against that model's recent score distribution.",
400
- "自治档不按比例:从不询问用户": "Autonomous ignores proportionality: it never asks the user",
401
- "这一档沿用决策模型自己的判断,不需要校准": "This level follows the decision model's own judgement and needs no calibration",
402
- "高风险调用一律直接拦下(不打扰用户,四档一致)": "High-risk calls are always blocked outright (no interruption; the same in all four levels)",
403
- "分位数窗口按 provider × model 分开,换模型会重新校准": "The percentile window is kept per provider × model, so switching models re-calibrates",
419
+ "多问": "Ask more",
420
+ "软性审查不弹窗、不按比例;权限强制的人工批准与拒绝照旧": "Soft checks never prompt and no percentile band applies; permission-mandated approval and denial are unchanged",
421
+ "只按严重度尾部:校准后约最前 1%,校准前固定分 0.60;替换默认判断,因此可能多问也可能少问": "Severity tail only: about the top 1% once calibrated, a fixed score 0.60 before that; replaces the default judgement, so it can ask more or less often",
422
+ "沿用决策模型判断:中/高风险、疑似注入、不可逆且与任务无关会问": "Keeps the decision model's own judgement: asks on medium/high risk, suspected injection, or irreversible and off-task calls",
423
+ "默认判断之上再加严重度靠前一批:校准后约 25%,校准前固定分 0.30": "Adds the top severity band on top of the default judgement: about 25% once calibrated, a fixed score 0.30 before that",
424
+ "这一档不做比例判断": "This level makes no percentile judgement",
425
+ "沿用决策模型判断,不做比例校准": "Follows the decision model's judgement; no percentile calibration applies",
426
+ "分位数按 provider × model 分开校准;权限强制的人工批准与拒绝不受档位影响。": "The percentile is calibrated per provider × model; permission-mandated approval and denial are unaffected by this level.",
404
427
  };
405
428
 
406
429
  /** i18n:labels —— 服务端下发、且本文件别处没有同名词的中文文案。 */
@@ -416,26 +439,12 @@ window.__ModuleLoader__.load({
416
439
  // 不收进 EN_CONFIG/EN_STATS,因为它是**服务端数据**:将来新增带中文名
417
440
  // 的预设,只要在这里补一行;界面在渲染边界统一翻译,不用改调用点。
418
441
  "硅基流动": "SiliconFlow",
419
- // —— 裁决词的中文名(客户端 VERDICT_LABELS)——
420
- // 值就是**机器词本身**:英文界面翻回去的还是 `delegate` / `inline` 这些
421
- // 原词,与改前逐字一致。`degraded` 复用上面已有的「已降级」。
422
- "照常放行": "allow",
423
- "保留原模型": "keep",
424
- "交给主模型": "delegate",
425
- "换模型": "switch",
426
- "改用别的技能": "redirect",
427
- "建议分派": "advise",
428
- "裁剪结果": "prune",
429
- "摘要压缩": "summarize",
430
- "续一轮": "continue",
431
- "翻译": "translate",
432
- "收束本轮": "stop",
433
- "自己接着做": "inline",
434
- "跳过": "skip",
435
- "让位给用户": "defer",
436
- "转人工确认": "ask",
437
- "拒绝执行": "deny",
438
- "降级未裁决": "degraded",
442
+ // —— 裁决格的词表(T4)已移出全局词典 ——
443
+ // 旧实现把 16 个裁决词的中文名登记在这里(值 = 机器词本身),靠 `tr()`
444
+ // 在渲染边界翻回英文。现在裁决格改用**独立**的 `VERDICT_TEXT_ZH` /
445
+ // `VERDICT_TEXT_EN`(按 决策点×裁决词 取词):这些词面与统计页重名却
446
+ // 表达不同东西(统计页「放行」是结果分格,裁决格说的是这一步做了什么),
447
+ // 挤进同一张表必然互相覆盖。因此这 17 条已删除,请勿再加回来。
439
448
  // —— /decisions 的决策点元数据(lib/decision-points.js) ——
440
449
  // label:思维链点的服务端名比客户端 POINT_LABELS(EN_MISC)多「门槛」二字
441
450
  "思维链翻译门槛": "COT translation gate",
@@ -534,12 +543,18 @@ window.__ModuleLoader__.load({
534
543
  "服务端不可用:{error};已回落到本地手动配置": "Server unavailable: {error}; fell back to the local manual configuration",
535
544
  " · 获取于 {time}": " · fetched at {time}",
536
545
  ";当前生效的是服务端下发的密钥": "; the API key currently in effect comes from the server",
537
- // —— 人工审核比例(DEC-035) ——
538
- "校准中 {samples}/{min} 次授权决策(当前用冷启动阈值 {fallback})": "Calibrating {samples}/{min} authorization decisions (using the cold-start threshold {fallback})",
546
+ // —— 安全档位(DEC-035,T1 改口径) ——
547
+ // 冷启动脚注明说「不是分位数」:旧文案只写「用冷启动阈值」,用户以为
548
+ // 那个阈值就是分位数换算出来的,于是把「比想象中安静」当成设置没生效。
549
+ "校准中 {samples}/{min} 次授权决策(当前用冷启动阈值 {fallback},不是分位数)": "Calibrating {samples}/{min} authorization decisions (using the cold-start threshold {fallback}, not a percentile)",
539
550
  "已按这个模型校准:最前的 {percent}% ≈ 严重度 {threshold}({samples} 个样本)": "Calibrated for this model: top {percent}% ≈ severity {threshold} ({samples} samples)",
540
- "人工审核比例已设为「{level}」": "Review tolerance set to \"{level}\"",
551
+ "安全档位已设为「{level}」": "Safety level set to \"{level}\"",
541
552
  "保存失败:{error}": "Save failed: {error}",
542
553
  "宿主模型库不可用({reason});下面三格仍可手填": "Host model catalog unavailable ({reason}); the three fields below can still be typed",
554
+ // —— 日志分文件 / 保留(T3) ——
555
+ "按日期分文件:logs/2026{sep}11{sep}15.log,一天一个文件,便于按天归档或删除": "One file per day: logs/2026{sep}11{sep}15.log, which makes archiving or deleting by day easy",
556
+ "超过保留天数的日志每 6 小时清理一次(0 = 不按天清理);当前 {files} 个文件 / {bytes};文件名会包含会话名或工作区名": "Logs older than the retention days are cleaned up every 6 hours (0 = no age-based cleanup); currently {files} files / {bytes}; file names include the session or workspace name",
557
+ "超过保留天数的日志每 6 小时清理一次(0 = 不按天清理);文件名会包含会话名或工作区名": "Logs older than the retention days are cleaned up every 6 hours (0 = no age-based cleanup); file names include the session or workspace name",
543
558
  };
544
559
 
545
560
  /** i18n:templates:stats */
@@ -570,7 +585,7 @@ window.__ModuleLoader__.load({
570
585
  "{label}:{value}": "{label}: {value}",
571
586
  "{label}:实际 {real},基线 {base}": "{label}: actual {real}, baseline {base}",
572
587
  "{label}(实际 vs 基线)": "{label} (actual vs baseline)",
573
- "裁决构成:放行 {allow}、询问 {ask}、拦截 {reject}": "Verdict mix: allow {allow}, ask {ask}, reject {reject}",
588
+ "裁决构成:放行 {allow}、转人工 {ask}、拦截 {reject}": "Verdict mix: allow {allow}, escalate {ask}, reject {reject}",
574
589
  "按{bucket}分桶,共 {count} 个桶;空桶补 0,掉到零就是真的没有请求。": "Bucketed by {bucket}, {count} buckets; empty buckets are zero-filled, so a drop to zero really means no requests.",
575
590
  "前 {count} 个组合的请求次数对比": "Requests for the top {count} combinations",
576
591
  "按决策点分账:{parts}": "Breakdown by decision point: {parts}",
@@ -596,15 +611,18 @@ window.__ModuleLoader__.load({
596
611
  "· {tool}": "· {tool}",
597
612
  // —— 决策历史(DEC-022)——
598
613
  "记录文件:{path}": "Records file: {path}",
614
+ // T3:分文件之后一个会话可能跨多个文件(按日期分就是按天一个)。
615
+ // 逐个列路径会把附注撑成一整段,所以多文件时只报数量与目录。
616
+ "记录文件:{count} 个文件,目录 {dir}": "Record files: {count} files in {dir}",
599
617
  "读取决策历史失败:{error}": "Failed to load decision history: {error}",
600
618
  "只落本地,永不外传": "Stored locally only, never sent anywhere",
601
619
  "把本会话里经本插件做出的每次决策按时间记下来,并在会话里新增一个「决策历史」标签页": "Record every decision this plugin made in this session, and add a \"Decisions\" tab to the session",
602
620
  "关闭后不写任何明细文件(不采集内容、不占磁盘)": "When off, no detail file is written at all (no content is captured, no disk is used)",
603
- // —— 人工审核比例(DEC-035):当前档位的比例说明 ——
604
- // 落点标签只用档位名(宽度所限,见 toleranceShortLabel 的注释),
605
- // 比例放轨道下方那一行,所以这里是**整句**而不是肢解的片段。
606
- "当前档位只问最前 {percent}% 的调用": "This level asks only on the top {percent}% of calls",
607
- "分位数窗口按 provider × model 分开,换模型会重新校准;高风险调用一律直接拦下(四档一致)": "The percentile window is kept per provider × model, so switching models re-calibrates; high-risk calls are always blocked outright (the same in all four levels)",
621
+ // —— 安全档位(DEC-035,T1 改口径):轨道下方那两行说明 ——
622
+ // 删掉了「当前档位只问最前 {percent}% 的调用」:它把「分位数落点」换算成
623
+ // 「有多少比例的调用会被问」,而这两件事在冷启动、按点分义、权限强制
624
+ // 升级三种情况下都对不上——用户据此调档,得到的结果与预期相反。
625
+ // 现在这一行只说服务端下发的 summary(这一档到底做什么)。
608
626
  // —— 审批卡正文可读化(DEC-042):带数字的摘要行 ——
609
627
  "命令摘要:{summary}": "Command summary: {summary}",
610
628
  "{steps} 个步骤": "{steps} steps",
@@ -1419,7 +1437,7 @@ window.__ModuleLoader__.load({
1419
1437
  whiteSpace: "nowrap",
1420
1438
  }),
1421
1439
  /**
1422
- * 人工审核比例的分段滑块(DEC-035)。
1440
+ * 安全档位的分段滑块(DEC-035)。
1423
1441
  *
1424
1442
  * 为什么是「分段 + 滑块」而不是下拉:
1425
1443
  *
@@ -1434,7 +1452,7 @@ window.__ModuleLoader__.load({
1434
1452
  * 鼠标都能用;没有引入真正的 `<input type="range">`,因为它的值域是
1435
1453
  * 数字而四档是离散语义,映射回去反而要处理「拖到两档之间」的中间态。
1436
1454
  */
1437
- /** 人工审核比例的整块容器(配置页):竖排,占满一整行。 */
1455
+ /** 安全档位的整块容器(配置页):竖排,占满一整行。 */
1438
1456
  toleranceSection: {
1439
1457
  display: "flex",
1440
1458
  flexDirection: "column",
@@ -1809,7 +1827,7 @@ window.__ModuleLoader__.load({
1809
1827
  // 表头就改粘它,而它从不滚动,粘性等于没写。
1810
1828
  },
1811
1829
  /**
1812
- * 决策历史页上的人工审核比例面板(DEC-035)。
1830
+ * 决策历史页上的安全档位面板(DEC-035)。
1813
1831
  *
1814
1832
  * 与工具条一起放在滚动区之外:滑块不该跟着表格滚走——用户往下翻记录
1815
1833
  * 时正是最容易想改档位的时候。
@@ -2523,45 +2541,56 @@ window.__ModuleLoader__.load({
2523
2541
  /**
2524
2542
  * 滑块落点上的**短标签**(只有档位名)。
2525
2543
  *
2526
- * 为什么不在落点上带语义尾注(「轻松(只问最前 1%)」):
2544
+ * 为什么不在落点上带语义尾注(「少问(只问最前 1%)」):
2527
2545
  *
2528
2546
  * - 落点上带尾注时,四段等宽,每段只剩标签本身那点宽度:当年容器还限死
2529
- * 620px,每段只有 ~140px,「轻松(只问最前 1%)」放不下,只能换行或溢出,
2547
+ * 620px,每段只有 ~140px,「少问(只问最前 1%)」放不下,只能换行或溢出,
2530
2548
  * 而换行会让四个段叠成一团(真机上就是这么坏的)。容器如今不限宽
2531
2549
  * (DEC-040),但这条不翻案:窗口窄的时候它照样成立。
2532
- * - 尾注只对**当前档位**有意义:用户要确认的是「我选的这档到底多频繁」,
2533
- * 另外三档的具体比例在他点过去之后才知道就行了。
2550
+ * - 尾注只对**当前档位**有意义:用户要确认的是「我选的这档到底做什么」,
2551
+ * 另外三档的说明在他点过去之后再看就行。
2534
2552
  *
2535
2553
  * 因此尾注移到轨道**下方那行说明**里(见 toleranceActiveSummary),
2536
2554
  * 落点上只留档位名——这也是分段控件的常规形态。
2537
2555
  *
2556
+ * T1 之后档位名(不问 / 少问 / 默认 / 多问)由**服务端**下发:档位语义
2557
+ * 只有 `lib/tolerance.js` 知道,前端再写一份就是「界面显示 A、实际 B」。
2558
+ * 英文优先用服务端带来的 `labelEn`——那比在词表里抄一遍服务端文案可靠
2559
+ * (服务端改词时词表不会跟着漂);`labelEn` 缺席时才回落到 `tr()` 兜底。
2560
+ *
2538
2561
  * @param preset - 服务端下发的一档元数据。
2539
2562
  * @returns 档位名(已过语言)。
2540
2563
  */
2541
2564
  function toleranceShortLabel(preset) {
2565
+ if (preset === null || preset === undefined) return "";
2566
+ if (lang === "en" && typeof preset.labelEn === "string" && preset.labelEn.length > 0) {
2567
+ return preset.labelEn;
2568
+ }
2542
2569
  return tr(preset.label ?? preset.id ?? "");
2543
2570
  }
2544
2571
 
2545
2572
  /**
2546
- * 当前档位的**语义尾注**(轨道下方一行)。
2573
+ * 当前档位的**语义说明**(轨道下方一行)。
2547
2574
  *
2548
- * 只写「轻松」用户看不出它比默认更少打扰还是更多,所以这一行必须说清
2549
- * 「只问最前 1%」。比例由 `percentile` 算,因此加一档只改 tolerance.js。
2575
+ * T1 改口径:这一行**只说服务端下发的 summary**,不再把 `percentile`
2576
+ * 换算成「只问最前 N% 的调用」。换算对不上事实——冷启动时用的是固定
2577
+ * 阈值(不是分位数)、按点分义(retrieval / cotTranslate 走自己的规则)、
2578
+ * 权限强制的人工批准根本不受档位影响。用户按那个百分比调档,得到的结果
2579
+ * 与预期相反。
2550
2580
  *
2551
2581
  * @param preset - 当前档位元数据。
2552
- * @returns 带比例的说明;不按比例的档位返回其 summary。
2582
+ * @returns 这一档到底做什么。
2553
2583
  */
2554
2584
  function toleranceActiveSummary(preset) {
2555
2585
  if (preset === null || preset === undefined) return "";
2556
- if (preset.percentile === null || preset.percentile === undefined) {
2557
- return tr(preset.summary ?? "");
2586
+ if (lang === "en" && typeof preset.summaryEn === "string" && preset.summaryEn.length > 0) {
2587
+ return preset.summaryEn;
2558
2588
  }
2559
- const percent = Math.round((1 - preset.percentile) * 100);
2560
- return t("当前档位只问最前 {percent}% 的调用", { percent });
2589
+ return tr(preset.summary ?? "");
2561
2590
  }
2562
2591
 
2563
2592
  /**
2564
- * 人工审核比例滑块——**配置页与决策历史页共用这一个组件**(DEC-035)。
2593
+ * 安全档位滑块——**配置页与决策历史页共用这一个组件**(DEC-035)。
2565
2594
  *
2566
2595
  * 两处放同一个控件不是重复:用户看到「刚才又问我了」的地方(决策历史)
2567
2596
  * 正是他想调频率的地方,而配置页是「这个插件的设定」的正式入口。
@@ -2598,7 +2627,7 @@ window.__ModuleLoader__.load({
2598
2627
  type: "button",
2599
2628
  key: `tol-${id}`,
2600
2629
  disabled: props?.disabled === true,
2601
- title: tr(preset.summary ?? ""),
2630
+ title: toleranceActiveSummary(preset),
2602
2631
  "aria-pressed": active,
2603
2632
  onClick: () => {
2604
2633
  if (active || props?.disabled === true) return;
@@ -2614,16 +2643,18 @@ window.__ModuleLoader__.load({
2614
2643
  /**
2615
2644
  * 校准脚注:只在「这一档真的按比例走」时才说分位数。
2616
2645
  *
2617
- * 自治 / 默认两档不按比例(`percentile === null`),此时说「校准中」
2618
- * 会让用户以为它还没生效——而它本来就该用模型自己的判断。
2646
+ * 不问 / 默认两档不按比例(`percentile === null`),此时说「校准中」
2647
+ * 会让用户以为它还没生效——而它本来就该用模型自己的判断。冷启动那句
2648
+ * 必须点明「不是分位数」:用户看到「阈值 0.60」会以为那就是换算出来的
2649
+ * 分位数,于是把「比想象中安静」当成设置没生效。
2619
2650
  */
2620
2651
  let note = "";
2621
2652
  if (calibration !== null && typeof calibration === "object") {
2622
2653
  const preset = presets.find((item) => item.id === current);
2623
2654
  if (preset?.percentile === null || preset?.percentile === undefined) {
2624
- note = current === "autonomous"
2625
- ? t("自治档不按比例:从不询问用户")
2626
- : t("这一档沿用决策模型自己的判断,不需要校准");
2655
+ note = current === "default"
2656
+ ? t("沿用决策模型判断,不做比例校准")
2657
+ : t("这一档不做比例判断");
2627
2658
  } else if (calibration.ready === true && typeof calibration.threshold === "number") {
2628
2659
  note = t("已按这个模型校准:最前的 {percent}% ≈ 严重度 {threshold}({samples} 个样本)", {
2629
2660
  percent: Math.round((1 - preset.percentile) * 100),
@@ -2631,7 +2662,7 @@ window.__ModuleLoader__.load({
2631
2662
  samples: calibration.samples ?? 0,
2632
2663
  });
2633
2664
  } else {
2634
- note = t("校准中 {samples}/{min} 次授权决策(当前用冷启动阈值 {fallback})", {
2665
+ note = t("校准中 {samples}/{min} 次授权决策(当前用冷启动阈值 {fallback},不是分位数)", {
2635
2666
  samples: calibration.samples ?? 0,
2636
2667
  min: calibration.minSamples ?? 50,
2637
2668
  fallback: typeof calibration.fallbackSeverity === "number"
@@ -2660,12 +2691,13 @@ window.__ModuleLoader__.load({
2660
2691
  note.length > 0
2661
2692
  ? h("div", { style: { ...S.pointHint, marginTop: "3px", opacity: 0.55 } }, note)
2662
2693
  : null,
2663
- // 两条总则合成一行:拆成两行会在配置页占掉太多纵向空间,而它们
2664
- // 是「前提」而不是「主信息」,挤在一行里读起来仍然成立。
2694
+ // 总则(T1 改口径):旧文案说「高风险调用一律直接拦下(四档一致)」,
2695
+ // 那是错的——四档调的是**软性审查升不升级**,真正拦下高风险调用的是
2696
+ // 强制安全约束,与档位无关;而权限强制的人工批准/拒绝同样不受档位影响。
2665
2697
  h(
2666
2698
  "div",
2667
2699
  { style: { ...S.pointHint, marginTop: "3px", opacity: 0.42 } },
2668
- t("分位数窗口按 provider × model 分开,换模型会重新校准;高风险调用一律直接拦下(四档一致)"),
2700
+ t("分位数按 provider × model 分开校准;权限强制的人工批准与拒绝不受档位影响。"),
2669
2701
  ),
2670
2702
  );
2671
2703
  }
@@ -2691,6 +2723,106 @@ window.__ModuleLoader__.load({
2691
2723
  return item;
2692
2724
  }
2693
2725
 
2726
+ /* ============================================================
2727
+ * 设置页里「由服务端事实决定文案」的几个取值(T2 / T3)
2728
+ *
2729
+ * 这些都放在组件外面:它们只做「事实 → 文案」,与渲染无关,
2730
+ * 因此在 Node 渲染测试里可以直接被渲染路径调到。
2731
+ * ============================================================ */
2732
+
2733
+ /**
2734
+ * `think_mode` 能力位的三种说法(T2)。
2735
+ *
2736
+ * 为什么必须按能力位分三套:同一个下拉在三种 provider 上的真实行为完全
2737
+ * 不同——真发且真生效、发了但可能被忽略、根本没有这个协议。写一句
2738
+ * 笼统的「控制思考强度」会让用户以为在他那家服务上一定能调。
2739
+ */
2740
+ const THINK_MODE_SUPPORT_HINTS = {
2741
+ // 方向词写成「那一行」而不是「下方」:这一项在最后一组(高级),
2742
+ // 「自动审查回退」在它上面。指错方向比不指方向更糟。
2743
+ supported: "只控制本插件发给决策端点的 think_mode,不影响宿主主模型;宿主回退模型的推理档位在「自动审查回退」那一行。",
2744
+ unknown: "该端点未实测是否支持 think_mode:会照常发送,可能被忽略(不报错)。",
2745
+ unsupported: "该端点的公开 API 没有 think_mode 协议,此项对当前 provider 不生效。",
2746
+ };
2747
+
2748
+ /**
2749
+ * 当前 provider 的 `think_mode` 能力位(T2)。
2750
+ *
2751
+ * 值由服务端下发(`providerCatalog[].thinkModeSupport`,见 lib/providers.js)。
2752
+ * **拿不到时按 unknown**:老服务端还没这一格、或 `/status` 没回来时,
2753
+ * 「照常发送、可能被忽略」是唯一不会骗人的说法——说 supported 会让人
2754
+ * 以为一定生效,说 unsupported 会让人以为开关坏了。
2755
+ *
2756
+ * @param view - `/status` 回显。
2757
+ * @param provider - 当前 provider id。
2758
+ * @returns `"supported"` / `"unknown"` / `"unsupported"`。
2759
+ */
2760
+ function thinkModeSupportOf(view, provider) {
2761
+ const item = (view?.providerCatalog ?? []).find((row) => row.id === provider);
2762
+ const value = item?.thinkModeSupport;
2763
+ return value === "supported" || value === "unsupported" ? value : "unknown";
2764
+ }
2765
+
2766
+ /**
2767
+ * 日志分文件 / 日期分隔符的选项文案(T3)。
2768
+ *
2769
+ * 取值**由服务端下发**(`status.decisionsSplitModes` /
2770
+ * `decisionsDateSeparators`,与 thinkModes / telemetryLevels 同一套路),
2771
+ * 这里只负责「值 → 中文名」;服务端将来加一种分法,界面自动多一项,
2772
+ * 没有文案时退回显示原值(不是空白,也不是乱码)。
2773
+ */
2774
+ const SPLIT_MODE_TEXTS = {
2775
+ single: "single|单一文件",
2776
+ date: "date|按日期",
2777
+ session: "session|按会话",
2778
+ workspace: "workspace|按工作区",
2779
+ };
2780
+ const DATE_SEPARATOR_TEXTS = {
2781
+ "-": "短横线(-)",
2782
+ _: "下划线(_)",
2783
+ ".": "点(.)",
2784
+ };
2785
+
2786
+ /**
2787
+ * 「日志分文件」的路径样例(T3):让人一眼看出文件落在哪、叫什么。
2788
+ *
2789
+ * 日期样例由 `decisionsDateSeparator` 拼出来(- / _ / .):用户改了分隔符,
2790
+ * 这里必须跟着变,否则「我选了点,样例还是短横线」会被当成没生效。
2791
+ *
2792
+ * @param splitMode - `single` / `date` / `session` / `workspace`。
2793
+ * @param separator - 日期分隔符(只在 date 模式下用到)。
2794
+ */
2795
+ function splitModeHintOf(splitMode, separator) {
2796
+ if (splitMode === "single") return t("全部追加到同一个 decisions.jsonl(升级前的行为)");
2797
+ if (splitMode === "session") return t("按会话分文件:logs/<会话名>__<会话 id 前 8 位>.log,一个会话一个文件");
2798
+ if (splitMode === "workspace") return t("按工作区分文件:logs/<工作区名>.log,同一工作区的会话写在一起");
2799
+ return t("按日期分文件:logs/2026{sep}11{sep}15.log,一天一个文件,便于按天归档或删除", {
2800
+ sep: typeof separator === "string" && separator.length > 0 ? separator : "-",
2801
+ });
2802
+ }
2803
+
2804
+ /**
2805
+ * 「日志保留」的说明(T3):占用多少、多久清一次、文件名里有什么。
2806
+ *
2807
+ * `decisionsStorage` 由服务端下发(`{files, bytes}`,每 6 小时清理后的
2808
+ * 快照)。老服务端没有这一格时只说清理周期与文件名隐私——**不编数字**:
2809
+ * 显示「0 个文件」而实际有几十个,比不显示更糟。
2810
+ *
2811
+ * @param storage - `status.decisionsStorage`。
2812
+ */
2813
+ function retentionHintOf(storage) {
2814
+ const files = Number(storage?.files);
2815
+ const bytes = Number(storage?.bytes);
2816
+ if (Number.isFinite(files) && Number.isFinite(bytes)) {
2817
+ return t("超过保留天数的日志每 6 小时清理一次(0 = 不按天清理);当前 {files} 个文件 / {bytes};文件名会包含会话名或工作区名", {
2818
+ files: groupDigits(files),
2819
+ bytes: formatBytes(bytes),
2820
+ });
2821
+ }
2822
+ return t("超过保留天数的日志每 6 小时清理一次(0 = 不按天清理);文件名会包含会话名或工作区名");
2823
+ }
2824
+
2825
+
2694
2826
  /**
2695
2827
  * 各模型字段的原值,键就是 config.yml 里的字段名。
2696
2828
  *
@@ -2937,6 +3069,25 @@ window.__ModuleLoader__.load({
2937
3069
  // 可能根本没有这一项。因此提交**生效值**(而不是 undefined),
2938
3070
  // 保证「界面上看到的状态」与「落盘的状态」永远一致。
2939
3071
  decisionsTabEnabled: draft.decisionsTabEnabled !== false,
3072
+ // 日志分文件 / 保留(T3):与上面几个开关同款——**总是**提交生效值。
3073
+ //
3074
+ // 为什么总是提交:老配置文件里根本没有这三项(服务端按 DEFAULTS 生效)。
3075
+ // 提交 undefined 时,落盘状态与界面显示看起来一致,但一旦服务端将来
3076
+ // 改了默认值,或用户只改过其中一项,另外两项就会「界面显示 A、实际 B」。
3077
+ // 回填确定值,两边永远对得上;服务端对未知取值会 400,所以我们本地
3078
+ // 先按类型校验。
3079
+ //
3080
+ // 类型不对时**不提交**(首帧 / status 失败):硬塞兜底值会把用户存好的
3081
+ // 配置改掉——那正是「保存后回弹」的来源。
3082
+ ...(typeof draft.decisionsSplitMode === "string"
3083
+ ? { decisionsSplitMode: draft.decisionsSplitMode }
3084
+ : {}),
3085
+ ...(Number.isFinite(draft.decisionsRetentionDays)
3086
+ ? { decisionsRetentionDays: Math.trunc(draft.decisionsRetentionDays) }
3087
+ : {}),
3088
+ ...(typeof draft.decisionsDateSeparator === "string"
3089
+ ? { decisionsDateSeparator: draft.decisionsDateSeparator }
3090
+ : {}),
2940
3091
  // 审批卡正文可读化(DEC-042 / DEC-043):与决策历史同款——**总是**提交
2941
3092
  // 生效值。服务端 DEFAULTS 现在是 true,配置文件里可能没写这一项,
2942
3093
  // 「没写」等于「开」,所以这里必须按 `!== false` 折算:否则拨了开关也会
@@ -2952,7 +3103,7 @@ window.__ModuleLoader__.load({
2952
3103
  // 提交 undefined 会让「界面显示的状态」与「落盘的状态」在一段时间内
2953
3104
  // 不一致。提交 `=== true` 得到的是确定的布尔值,两种状态都对得上。
2954
3105
  serverProvider: draft.serverProvider === true,
2955
- // 人工审核比例(DEC-035):与上面两个开关同款——**总是**提交生效值。
3106
+ // 安全档位(DEC-035 / T1):与上面两个开关同款——**总是**提交生效值。
2956
3107
  //
2957
3108
  // 为什么总是提交:配置文件里可能根本没有这一项(服务端默认 `default`),
2958
3109
  // 提交 undefined 会让「界面显示的档位」与「落盘的档位」在一段时间内
@@ -2960,7 +3111,7 @@ window.__ModuleLoader__.load({
2960
3111
  //
2961
3112
  // 没拿到服务端下发的档位元数据时(首帧、接口失败)**不提交**:
2962
3113
  // 此时 `draft.tolerance` 也是 undefined,硬塞一个 `default` 会把用户
2963
- // 已经存好的「自治」覆盖掉——那正是「保存后档位回弹」的来源。
3114
+ // 已经存好的「不问」覆盖掉——那正是「保存后档位回弹」的来源。
2964
3115
  ...(typeof draft.tolerance === "string" ? { tolerance: draft.tolerance } : {}),
2965
3116
  // custom 的字段总是带上:用户可能先填地址再切 provider,
2966
3117
  // 分开提交会让中间状态被当成最终配置。
@@ -3029,13 +3180,20 @@ window.__ModuleLoader__.load({
3029
3180
  // `cfg.decisionsTabEnabled !== false` 这种**生效值**(不是原值),
3030
3181
  // 因此这里与它比是对的。
3031
3182
  ["decisionsTabEnabled", view.decisionsTabEnabled !== false],
3183
+ // 日志分文件 / 保留(T3):逐格与服务端生效值比,否则「选了模式但
3184
+ // 不触发保存」——界面显示已改、服务端还是旧的。默认值必须与服务端
3185
+ // DEFAULTS 一致(date / 30 / -),老服务端没有这些字段时不会误判成
3186
+ // 「有改动」而每次渲染都发一次 PUT。
3187
+ ["decisionsSplitMode", view.decisionsSplitMode ?? "date"],
3188
+ ["decisionsRetentionDays", view.decisionsRetentionDays ?? 30],
3189
+ ["decisionsDateSeparator", view.decisionsDateSeparator ?? "-"],
3032
3190
  // 审批卡正文可读化(DEC-042 / DEC-043):同样是生效值比较(缺省 = 开),
3033
3191
  // 否则拨了开关不触发保存。
3034
3192
  ["approvalDetailReadable", view.approvalDetailReadable !== false],
3035
3193
  // 跟随服务端下发(DEC-030):与服务端回传的**生效值**比(服务端回
3036
3194
  // `enabled === true` 归一化后的布尔值),否则拨开关不会触发保存。
3037
3195
  ["serverProvider", view.serverProvider?.enabled === true],
3038
- // 人工审核比例(DEC-035):与服务端的生效值比,否则点滑块不触发保存。
3196
+ // 安全档位(DEC-035 / T1):与服务端的生效值比,否则点滑块不触发保存。
3039
3197
  ["tolerance", view.tolerance?.level ?? "default"],
3040
3198
  // 模型字段逐格比较:键就是 config.yml 里的字段名(rawModels 下发)。
3041
3199
  // 用一张表而不是逐个 provider 写死,新增预设才不用改这里——漏一格
@@ -3084,6 +3242,21 @@ window.__ModuleLoader__.load({
3084
3242
  return `${Math.round(n * 1000) / 10}%`;
3085
3243
  }
3086
3244
 
3245
+ /**
3246
+ * 字节数的可读化(日志占用量提示用)。
3247
+ *
3248
+ * 用 1024 进制:用户会拿它对照文件管理器,而那是文件系统的口径。
3249
+ * 不用 `toLocaleString` 的原因与 `groupDigits` 相同(分组随 locale 变)。
3250
+ */
3251
+ function formatBytes(value) {
3252
+ const n = Number(value ?? 0);
3253
+ if (!Number.isFinite(n) || n <= 0) return "0 B";
3254
+ if (n < 1024) return `${Math.round(n)} B`;
3255
+ if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
3256
+ if (n < 1024 * 1024 * 1024) return `${Math.round(n / (1024 * 1024))} MB`;
3257
+ return `${Math.round(n / (1024 * 1024 * 1024))} GB`;
3258
+ }
3259
+
3087
3260
  /** ISO 串显示成本地「MM-DD HH:mm」,让用户看见预设解析成的真实边界。 */
3088
3261
  function formatLocalTime(iso) {
3089
3262
  const ms = Date.parse(String(iso ?? ""));
@@ -3152,104 +3325,171 @@ window.__ModuleLoader__.load({
3152
3325
  }
3153
3326
 
3154
3327
  /**
3155
- * 裁决词 → 颜色。
3156
- *
3157
- * 颜色只是辅助:**不能只靠颜色区分**(色觉障碍用户看不到)。因此每个裁决词
3158
- * 本身也写在标签里,颜色只是让它一眼可辨。
3159
- *
3160
- * 配色规则(用户要求「区分大一些」,2026-11-03):
3161
- *
3162
- * 1. **一个裁决词一个色相**,不再让 delegate / allow / keep 共用绿色、
3163
- * stop / inline / skip 共用灰色——共用色相时「扫一眼」读不出区别,
3164
- * 而这个页面的全部用途就是扫一眼看出哪几次被拦了。
3165
- * 2. **语义上靠近的放同族色**:放行类(allow / keep / delegate)落在
3166
- * 绿—青—蓝这一段,改写行为类(switch / redirect / advise / prune /
3167
- * summarize / continue)落在紫—粉段,中性不动作(stop / inline /
3168
- * skip / translate)落在灰—棕段,`ask` / `deny` / `degraded` 三个
3169
- * 「要人看一眼」的独占橙 / 红 / 主文本色。
3170
- * 3. **全部取 400–500 明度的浅色**:面板常年跑在深色主题上,深色在这个
3171
- * 底上等于看不见。
3172
- *
3173
- * `degraded` 特例:用户要的是「黑色」,但纯黑在深色主题下不可读。这里用
3174
- * 主题的**主文本色令牌**——浅色主题下它就是黑,深色主题下自动翻成浅色,
3175
- * 语义(最中性、不带任何色相的一种状态)保住,可见性也保住。
3176
- */
3177
- const VERDICT_COLORS = {
3178
- // —— 放行族 ——
3179
- allow: "#22c55e", // 绿:通过
3180
- keep: "#2dd4bf", // 青绿:保留原模型
3181
- delegate: "#3b82f6", // 蓝:交给主模型
3182
- // —— 改写行为族 ——
3183
- switch: "#0ea5e9", // 天蓝:换模型
3184
- redirect: "#8b5cf6", // 紫:改用别的技能
3185
- advise: "#d946ef", // 品红:建议分派
3186
- prune: "#a3e635", // 黄绿:只裁剪工具结果
3187
- summarize: "#fb923c", // 浅橙:走摘要压缩
3188
- continue: "#c084fc", // 淡紫:续一轮
3189
- translate: "#f472b6", // 粉:需要翻译
3190
- // —— 中性:什么都不改 ——
3191
- stop: "#94a3b8", // 灰蓝:收束本轮
3192
- inline: "#9ca3af", // 灰:自己接着做
3193
- skip: "#eab308", // 黄:跳过(不检索 / 不翻译)
3194
- // —— 要人看一眼的三种 ——
3195
- ask: "#f59e0b", // 橙:升级人工确认
3196
- deny: "#ef4444", // 红:拦截
3197
- degraded: "var(--dsw-alias-label-primary, #111111)", // 主文本色(浅色主题=黑)
3328
+ * 裁决格的**结果** → 颜色(T4 改口径)。
3329
+ *
3330
+ * 以前是「一个裁决词一个色相」(16 色),用户扫一眼读不出「这次到底过没过」:
3331
+ * 绿—青—蓝—紫—粉—黄—红—灰铺满一整列,而这张表的唯一用途就是扫一眼看出
3332
+ * 哪几次被拦了。现在颜色只表达四件事,与统计页 allow/ask/reject 三格同色同义:
3333
+ *
3334
+ * allow 绿 动作照常进行(换模型 / 换技能 / 裁剪 / 摘要 / 续轮…都在这里)
3335
+ * ask 橙 升级人工(只有工具授权的 ask)
3336
+ * reject 红 被拦下(authorize.deny、retrieval.skip、skillPick.redirect)
3337
+ * none 主文本灰 这次根本没有裁决(降级、老记录缺 action)
3338
+ *
3339
+ * 「不能只靠颜色区分」这条仍然成立:颜色只做辅助,每一格都有文字。
3340
+ *
3341
+ * `none` 用主题的**主文本色令牌**而不是写死一个灰:浅色主题下它是深灰、
3342
+ * 深色主题下自动翻成浅色,语义(最中性、不带色相)保住,可见性也保住。
3343
+ */
3344
+ const VERDICT_OUTCOME_COLORS = {
3345
+ allow: "#22c55e",
3346
+ ask: "#f59e0b",
3347
+ reject: "#ef4444",
3348
+ none: "var(--dsw-alias-label-primary, #111111)",
3198
3349
  };
3199
3350
 
3200
- /** 裁决词的兜底颜色:未登记的词用它,不至于看起来像出了错。 */
3201
- const DEFAULT_VERDICT_COLOR = "#64748b";
3202
-
3203
3351
  /**
3204
- * 裁决词 → 中文名。
3205
- *
3206
- * 裁决词是**机器词**:`action` 字段写进明细文件、上报遥测、出现在
3207
- * `detail` 的原因串里,全都是这个英文词。界面此前把它**原样**摆在
3208
- * 「裁决」列,于是中文界面里这一列整片是英文(`delegate` / `inline` /
3209
- * `advise` …),而它左右两列都是中文——中文用户读这张表时,最需要
3210
- * 一眼读懂的恰恰就是这一格。
3211
- *
3212
- * 做法:**只在渲染边界换成中文**,数据一个字节都不动。
3213
- *
3214
- * - 中文名同时登记进 `EN_LABELS`(值 = 机器词本身),所以英文界面
3215
- * 经 `tr()` 翻回的正是原来那个词——英文界面与改前逐字一致,
3216
- * 「英文零中文」那条断言不受影响。
3217
- * - 原始机器词仍然挂在 `title` 上:要对照明细文件、日志或
3218
- * `/session-decisions` 响应时,鼠标停上去就是那个词。
3219
- * - 词表与 `VERDICT_COLORS` 一一对应(同 16 个词),
3220
- * `test/client-decisions-view.test.js` 断言两表键集相同——
3221
- * 新增裁决词时漏了哪一张表都会立刻变红,而不是悄悄掉回英文。
3222
- *
3223
- * 中文名沿用 `VERDICT_COLORS` 注释里那套说法(「交给主模型」「自己接着做」
3224
- * 「改用别的技能」…),不另发明一套术语:那张注释已经是这套词的口径。
3352
+ * 裁决格的词表(T4):**独立于全局 `tr` 词典**。
3353
+ *
3354
+ * 为什么不登记进 `EN_LABELS`:这些词面与统计页重名却表达不同东西——
3355
+ * 统计页的「放行 / 转人工 / 拦截」是**结果分格**,裁决格说的是
3356
+ * **这一步做了什么**(「不换模型」不是「换模型」的同义色块,「不翻译」
3357
+ * 也不是「跳过」)。挤进同一张表必然互相覆盖:`EN_LABELS` 里一个中文面
3358
+ * 只能翻一次,总有一边读起来别扭。
3359
+ *
3360
+ * 因此这里自成一对 ZH/EN 表,由 `verdictText()` 按当前语言**直接取**:
3361
+ * 不走 `tr()`,也不依赖服务端下发(裁决词是机器词)。`h()` 随后还会对
3362
+ * 文本子节点调一次 `tr()`——这些英文不在中文词典里,是原样通过。
3363
+ *
3364
+ * `test/client-decisions-view.test.js` 按「中文界面看到哪个词、英文界面
3365
+ * 看到哪个词」逐格断言,且断言英文界面零中文:漏一条 EN 立刻变红。
3225
3366
  */
3226
- const VERDICT_LABELS = {
3227
- // —— 放行族 ——
3228
- allow: "照常放行",
3229
- keep: "保留原模型",
3230
- delegate: "交给主模型",
3231
- // —— 改写行为族 ——
3367
+ const VERDICT_TEXT_ZH = {
3368
+ // 结果:过去了 / 拦下了
3369
+ pass: "放行",
3370
+ ask: "转人工",
3371
+ deny: "拦截",
3372
+ none: "未裁决",
3373
+ // 「这一步做了什么」:放行族与改写族
3374
+ keep: "不换模型",
3232
3375
  switch: "换模型",
3233
- redirect: "改用别的技能",
3376
+ redirect: "换技能",
3234
3377
  advise: "建议分派",
3235
- prune: "裁剪结果",
3236
- summarize: "摘要压缩",
3237
- continue: "续一轮",
3378
+ prune: "裁剪",
3379
+ summarize: "摘要",
3380
+ continue: "续轮",
3238
3381
  translate: "翻译",
3239
- // —— 中性:什么都不改 ——
3240
- stop: "收束本轮",
3241
- inline: "自己接着做",
3382
+ stop: "结束",
3383
+ inline: "自己做",
3242
3384
  skip: "跳过",
3385
+ notranslate: "不翻译",
3243
3386
  // 让位(DEC-045):这一轮在等用户决定,插件主动退出,不代替用户选。
3244
- defer: "让位给用户",
3245
- // —— 要人看一眼的三种 ——
3246
- ask: "转人工确认",
3247
- deny: "拒绝执行",
3248
- // 与「标记」列的「已降级」刻意不同词:那一列说的是**这次请求**降级了,
3249
- // 这一格说的是**这次根本没有裁决**(统计里它进「未裁决」那一格)。
3250
- degraded: "降级未裁决",
3387
+ defer: "等你定",
3388
+ };
3389
+ const VERDICT_TEXT_EN = {
3390
+ pass: "Allow",
3391
+ ask: "Needs approval",
3392
+ deny: "Blocked",
3393
+ none: "Undecided",
3394
+ keep: "Keep model",
3395
+ switch: "Switch model",
3396
+ redirect: "Switch skill",
3397
+ advise: "Suggest split",
3398
+ prune: "Prune",
3399
+ summarize: "Summarize",
3400
+ continue: "Continue",
3401
+ translate: "Translate",
3402
+ stop: "End turn",
3403
+ inline: "Do it inline",
3404
+ skip: "Skip",
3405
+ notranslate: "No translation",
3406
+ defer: "Awaiting you",
3407
+ };
3408
+
3409
+ /**
3410
+ * 裁决词 → 词表条目。**新增裁决词时必须在这里登记**:漏登记的会退回
3411
+ * 原样英文机器词(不会报错,也不会消失),但那正是「中文界面里冒出一列
3412
+ * 英文」的老问题。
3413
+ */
3414
+ const VERDICT_TEXT_KEYS = {
3415
+ allow: "pass",
3416
+ delegate: "pass",
3417
+ keep: "keep",
3418
+ switch: "switch",
3419
+ redirect: "redirect",
3420
+ advise: "advise",
3421
+ prune: "prune",
3422
+ summarize: "summarize",
3423
+ continue: "continue",
3424
+ translate: "translate",
3425
+ stop: "stop",
3426
+ inline: "inline",
3427
+ skip: "skip",
3428
+ defer: "defer",
3429
+ ask: "ask",
3430
+ deny: "deny",
3251
3431
  };
3252
3432
 
3433
+ /** 取一条裁决词表文案(按当前语言)。 */
3434
+ function verdictText(key) {
3435
+ const table = lang === "zh" ? VERDICT_TEXT_ZH : VERDICT_TEXT_EN;
3436
+ return table[key] ?? table.none;
3437
+ }
3438
+
3439
+ /**
3440
+ * (决策点, 裁决词)→ 裁决格要写的字。
3441
+ *
3442
+ * 按**决策点**分义的两处(除此之外裁决词自己就够清楚):
3443
+ *
3444
+ * retrieval.skip → 拦截(这次检索真的被拦下了)
3445
+ * cotTranslate.skip → 不翻译(消息照常送达,只是没翻)
3446
+ * 其余 skip → 跳过
3447
+ *
3448
+ * `degraded` 不是裁决词:它说的是「这次请求降级了」(见「标记」列),
3449
+ * 这一格应当写「未裁决」——统计里它进的也是「未裁决」那一格。
3450
+ *
3451
+ * @param point - 决策点 id(`entry.point`)。
3452
+ * @param kind - 裁决词(`entry.action`)。
3453
+ * @returns 中文或英文的短标签;未登记的裁决词原样返回机器词。
3454
+ */
3455
+ function verdictLabel(point, kind) {
3456
+ if (typeof kind !== "string" || kind.length === 0) return verdictText("none");
3457
+ if (kind === "degraded") return verdictText("none");
3458
+ if (kind === "skip") {
3459
+ if (point === "retrieval") return verdictText("deny");
3460
+ if (point === "cotTranslate") return verdictText("notranslate");
3461
+ return verdictText("skip");
3462
+ }
3463
+ const key = VERDICT_TEXT_KEYS[kind];
3464
+ return key === undefined ? kind : verdictText(key);
3465
+ }
3466
+
3467
+ /**
3468
+ * (决策点, 裁决词)→ `"allow"` / `"ask"` / `"reject"` / `"none"`。
3469
+ *
3470
+ * 口径与 `lib/outcomes.js` 的 `OUTCOME_MATRIX` **逐条对齐**:统计页的裁决
3471
+ * 分格用的是服务端那一份,这里若与它不一致,同一个词在两张表上会是两种
3472
+ * 颜色(用户没法把「历史里那几次红格」对上「统计里 reject 那一格」)。
3473
+ *
3474
+ * 浏览器端 bundle 不能 `import` 服务端模块(只有宿主注入的 `require`),
3475
+ * 所以只能复述;两处一起改时**必须同时改**。
3476
+ *
3477
+ * 未登记的裁决词返回 `"none"` 而不是猜「放行」:放行是看起来最无害、
3478
+ * 实则最能掩盖问题的一格。
3479
+ */
3480
+ function verdictOutcome(point, kind) {
3481
+ if (typeof kind !== "string" || kind.length === 0) return "none";
3482
+ if (kind === "degraded") return "none";
3483
+ if (kind === "ask") return "ask";
3484
+ if (kind === "deny") return "reject";
3485
+ // skip / redirect 是**按点分义**的两类:同一个词在不同点上后果相反。
3486
+ if (kind === "skip") return point === "retrieval" ? "reject" : "allow";
3487
+ if (kind === "redirect") return "reject";
3488
+ // 其余登记过的词(放行族 / 改写族 / 中性)都不拦下任何东西。
3489
+ if (VERDICT_TEXT_KEYS[kind] === undefined) return "none";
3490
+ return "allow";
3491
+ }
3492
+
3253
3493
  /**
3254
3494
  * 一条明细记录的稳定标识。
3255
3495
  *
@@ -3306,11 +3546,14 @@ window.__ModuleLoader__.load({
3306
3546
  case "model":
3307
3547
  return String(entry?.model ?? "");
3308
3548
  case "action": {
3309
- const word = String(entry?.action ?? "");
3310
- // 按**显示名**排,不按机器词:这一列现在写的是中文,按英文词排
3311
- // 会出现「放行类被 adv/allow/deny 的字母序打散」。与「决策点」列同
3312
- // 一口径;机器词未登记时退回原词,不会排成空串。
3313
- return String(VERDICT_LABELS[word] ?? word);
3549
+ // 按**结果分组**排(T4):放行(0) → 转人工(1) → 拦截(2) → 未裁决(3)。
3550
+ //
3551
+ // 以前按显示名 `localeCompare`:中文名一改,排序语义就跟着漂
3552
+ // (「保留原模型」排在「照常放行」前面纯属字形巧合);而用户要的是
3553
+ // 「被拦下的聚在一起、一眼看到」,不是「按笔画排」。固定 rank 之后
3554
+ // 换词表不会再悄悄改掉排序。
3555
+ const rank = { allow: 0, ask: 1, reject: 2, none: 3 };
3556
+ return rank[verdictOutcome(entry?.point, entry?.action)] ?? 3;
3314
3557
  }
3315
3558
  case "latencyMs":
3316
3559
  return Number(entry?.latencyMs) || 0;
@@ -3350,6 +3593,16 @@ window.__ModuleLoader__.load({
3350
3593
  ? a - b
3351
3594
  : String(a).localeCompare(String(b));
3352
3595
  if (primary !== 0) return primary * direction;
3596
+ // 「裁决」列的次键是**决策点**(T4):同一结果里按点聚在一起,
3597
+ // 用户才能看出「被拦下的都是检索那个点」。其它列的次键仍是时间。
3598
+ if (key === "action") {
3599
+ const pointOf = (entry) => {
3600
+ const id = String(entry?.point ?? "");
3601
+ return String(pointLabels[id] ?? id);
3602
+ };
3603
+ const byPoint = pointOf(left.entry).localeCompare(pointOf(right.entry));
3604
+ if (byPoint !== 0) return byPoint;
3605
+ }
3353
3606
  const byTime = String(left.entry?.ts ?? "").localeCompare(String(right.entry?.ts ?? ""));
3354
3607
  if (byTime !== 0) return byTime;
3355
3608
  return (left.index - right.index) * (key === "ts" ? direction : 1);
@@ -3525,7 +3778,11 @@ window.__ModuleLoader__.load({
3525
3778
  function guardModelDisplay(entry) {
3526
3779
  const evaluation = entry?.scores?.modelEvaluation;
3527
3780
  if (!evaluation) return { provider: entry?.provider ?? "", model: entry?.model ?? "", latency: `${Number(entry?.latencyMs) || 0}ms` };
3528
- if (evaluation.status === "not_requested") return { provider: t("本地权限规则"), model: t("未调用模型"), latency: t("未调用"), verdict: entry?.action === "delegate" ? t("本地检查通过") : undefined };
3781
+ // 未调用模型的行(T4):裁决格写「放行」——这次调用确实过去了,
3782
+ // 拦它的是本地规则,不是模型。以前这里写「本地检查通过」,与「标记」列
3783
+ // 的「已降级」并排时读不出「过没过」;现在与其它行同一套结果词,
3784
+ // 「为什么没调模型」留在 title 与 provider/model 两格里。
3785
+ if (evaluation.status === "not_requested") return { provider: t("本地权限规则"), model: t("未调用模型"), latency: t("未调用"), verdict: entry?.action === "delegate" ? verdictText("pass") : undefined };
3529
3786
  const judge = evaluation.fallback?.provider || evaluation.fallback?.model ? evaluation.fallback : evaluation.primary;
3530
3787
  const readable = (value, missing) => typeof value === "string" && value && value !== "unknown" ? value : t(missing);
3531
3788
  return {
@@ -3535,6 +3792,41 @@ window.__ModuleLoader__.load({
3535
3792
  };
3536
3793
  }
3537
3794
 
3795
+ /**
3796
+ * 「记录文件」附注的文字(T3):一个会话可能跨多个分片。
3797
+ *
3798
+ * 改了分文件之后(按日期 / 按会话 / 按工作区),服务端在
3799
+ * `/session-decisions` 的 `files[]` 里给出这一页**实际读过**的分片;
3800
+ * 老服务端只有单个 `file` 字符串,仍然兼容。
3801
+ *
3802
+ * 多文件时只报「数量 + 目录」而不逐个列:按日期分时一个会话能有十几个
3803
+ * 分片,全列出来会把这条附注撑成一整段路径墙,而用户真正想知道的是
3804
+ * 「文件在哪个目录、大概有几个」。单个文件时仍显示完整路径——那正是
3805
+ * 「我要去翻这条记录」时唯一有用的信息。
3806
+ *
3807
+ * @param page - `/session-decisions` 的响应。
3808
+ * @returns 附注文本;没有文件信息时返回空串(调用方不渲染那一行)。
3809
+ */
3810
+ function recordsFileNote(page) {
3811
+ const files = Array.isArray(page?.files)
3812
+ ? page.files.filter((item) => typeof item === "string" && item.length > 0)
3813
+ : [];
3814
+ if (files.length === 1) {
3815
+ return t("记录文件:{path}", { path: files[0] });
3816
+ }
3817
+ if (files.length > 1) {
3818
+ const first = files[0];
3819
+ // 目录 = 最后一段分隔符之前的部分。两种分隔符都认:服务端可能运行在
3820
+ // Windows(`\`)或 Linux(`/`),而这条附注只是展示,不该假定平台。
3821
+ const cut = Math.max(first.lastIndexOf("/"), first.lastIndexOf("\\"));
3822
+ const dir = cut > 0 ? first.slice(0, cut) : first;
3823
+ return t("记录文件:{count} 个文件,目录 {dir}", { count: files.length, dir });
3824
+ }
3825
+ return typeof page?.file === "string" && page.file.length > 0
3826
+ ? t("记录文件:{path}", { path: page.file })
3827
+ : "";
3828
+ }
3829
+
3538
3830
  function DecisionsView(props) {
3539
3831
  const sessionId = typeof props?.sessionId === "string" ? props.sessionId : "";
3540
3832
  const [page, setPage] = react.useState(null);
@@ -3580,7 +3872,7 @@ window.__ModuleLoader__.load({
3580
3872
  const [sort, setSort] = react.useState({ key: "ts", dir: "desc" });
3581
3873
 
3582
3874
  /**
3583
- * 人工审核比例的本地草稿(DEC-035)。
3875
+ * 安全档位的本地草稿(DEC-035)。
3584
3876
  *
3585
3877
  * 与排序同一个理由受**序号即契约**约束:测试按 useState 序号注入,插在中间
3586
3878
  * 会把 `labels` / `sort` 之后的所有槽位挪位。
@@ -3747,10 +4039,22 @@ window.__ModuleLoader__.load({
3747
4039
  // 会与旧记录撞车)。重复 key 会让点击落到错的行上。
3748
4040
  const id = entryIdOf(entry, index);
3749
4041
  const action = typeof entry?.action === "string" ? entry.action : "";
3750
- const color = VERDICT_COLORS[action] ?? DEFAULT_VERDICT_COLOR;
3751
4042
  const point = typeof entry?.point === "string" ? entry.point : "";
3752
4043
  const time = typeof entry?.ts === "string" ? entry.ts : "";
3753
4044
  const modelDisplay = guardModelDisplay(entry);
4045
+ /**
4046
+ * 裁决格(T4):文字说「这一步做了什么」,颜色说「过没过」。
4047
+ *
4048
+ * guard 未调用模型的行由 `guardModelDisplay` 直接给「放行」——
4049
+ * 那次调用确实过去了,只是拦它的是本地规则。它的 title 换成
4050
+ * 「本地检查通过」:这一格不再显示机器词(那里本来就没有机器词),
4051
+ * 用户要看的是「为什么没调模型」。
4052
+ */
4053
+ const localRulePass = modelDisplay.verdict !== undefined;
4054
+ const verdictOutcomeName = localRulePass ? "allow" : verdictOutcome(point, action);
4055
+ const verdictColor = VERDICT_OUTCOME_COLORS[verdictOutcomeName];
4056
+ const verdictWord = localRulePass ? modelDisplay.verdict : verdictLabel(point, action);
4057
+ const verdictTitle = localRulePass ? t("本地检查通过") : action;
3754
4058
  // 本地时间(不是 UTC 挂钟):见 formatLocalDateTime 的说明。
3755
4059
  const local = formatLocalDateTime(time);
3756
4060
  return h(
@@ -3769,15 +4073,16 @@ window.__ModuleLoader__.load({
3769
4073
  h(
3770
4074
  "td",
3771
4075
  { style: S.decisionsTd },
3772
- action.length > 0
3773
- ? h(
3774
- "span",
3775
- // `title` 留**原始机器词**:明细文件与遥测里就是它,
3776
- // 需要对照时停一下鼠标就能看到。
3777
- { style: S.decisionsVerdict(color), title: action },
3778
- modelDisplay.verdict ?? VERDICT_LABELS[action] ?? action,
3779
- )
3780
- : "",
4076
+ // 永远渲染一格:没有 action(老记录)或降级时它写「未裁决」。
4077
+ // 以前这里渲染空串,于是「这次根本没裁决」与「这一列坏了」
4078
+ // 在屏幕上长得一模一样。
4079
+ h(
4080
+ "span",
4081
+ // `title` 留**原始机器词**:明细文件与遥测里就是它,
4082
+ // 需要对照时停一下鼠标就能看到。
4083
+ { style: S.decisionsVerdict(verdictColor), title: verdictTitle },
4084
+ verdictWord,
4085
+ ),
3781
4086
  ),
3782
4087
  h("td", { style: S.decisionsTd }, modelDisplay.latency),
3783
4088
  // 「内容」列:这次到底存没存内容。判据是 `capture` 字段——
@@ -3863,7 +4168,7 @@ window.__ModuleLoader__.load({
3863
4168
  }
3864
4169
 
3865
4170
  /**
3866
- * 人工审核比例(DEC-035):**决策历史页也要有**。
4171
+ * 安全档位(DEC-035):**决策历史页也要有**。
3867
4172
  *
3868
4173
  * 为什么放在这个页面:用户是在这里看到「刚才又问我了」的,于是想调
3869
4174
  * 频率的那个瞬间就在这个页面。让他为此切到插件配置页,等于承认这两处
@@ -3888,7 +4193,7 @@ window.__ModuleLoader__.load({
3888
4193
  h(
3889
4194
  "span",
3890
4195
  { style: { fontSize: "13px", fontWeight: 600 } },
3891
- t("人工审核比例"),
4196
+ t("安全档位"),
3892
4197
  ),
3893
4198
  toleranceSaving
3894
4199
  ? h("span", { style: { fontSize: "12px", opacity: 0.6 } }, t("保存中…"))
@@ -3938,7 +4243,7 @@ window.__ModuleLoader__.load({
3938
4243
  );
3939
4244
 
3940
4245
  /**
3941
- * 头部 = 人工审核比例面板(DEC-036 之后**只剩**这一块)。
4246
+ * 头部 = 安全档位面板(DEC-036 之后**只剩**这一块)。
3942
4247
  *
3943
4248
  * 原先这里还有一排「会话 xxx · 共 N 条决策 · 刷新」——用户明确要求删掉
3944
4249
  * 整排:会话名对一个「只显示本会话」的页面是废话,条数每 5 秒自己变、
@@ -4010,10 +4315,13 @@ window.__ModuleLoader__.load({
4010
4315
  }
4011
4316
  }
4012
4317
 
4318
+ // 记录文件附注(T3):一个会话可能跨多个分片,取法与渲染同一处算一次。
4319
+ const fileNote = recordsFileNote(page);
4320
+
4013
4321
  return h(
4014
4322
  "div",
4015
4323
  { style: S.decisionsWrap },
4016
- // 头部(人工审核比例)在滚动区之外:它不该跟着表格一起滚走。
4324
+ // 头部(安全档位)在滚动区之外:它不该跟着表格一起滚走。
4017
4325
  header,
4018
4326
  // 表头粘的就是这一层。它**一定会滚**(maxHeight 与父级高度无关),
4019
4327
  // 所以 `th { position: sticky }` 总能找到一个真正会滚的祖先,
@@ -4061,8 +4369,8 @@ window.__ModuleLoader__.load({
4061
4369
  page?.truncated === true
4062
4370
  ? h("div", { style: S.decisionsNote }, t("还有更多记录没有显示"))
4063
4371
  : null,
4064
- typeof page?.file === "string" && page.file.length > 0
4065
- ? h("div", { style: S.decisionsNote }, t("记录文件:{path}", { path: page.file }))
4372
+ fileNote.length > 0
4373
+ ? h("div", { style: S.decisionsNote }, fileNote)
4066
4374
  : null,
4067
4375
  ),
4068
4376
  );
@@ -4508,7 +4816,8 @@ window.__ModuleLoader__.load({
4508
4816
  h(charts.SegmentedBar, {
4509
4817
  data: [
4510
4818
  { label: t("放行"), value: allow },
4511
- { label: t("询问"), value: ask },
4819
+ // 与裁决格、统计卡片同一个词:ask 一律叫「转人工」(T4)。
4820
+ { label: t("转人工"), value: ask },
4512
4821
  { label: t("拦截"), value: reject },
4513
4822
  ],
4514
4823
  /**
@@ -4529,7 +4838,7 @@ window.__ModuleLoader__.load({
4529
4838
  width: 260,
4530
4839
  height: 20,
4531
4840
  label: "percent",
4532
- summary: t("裁决构成:放行 {allow}、询问 {ask}、拦截 {reject}", {
4841
+ summary: t("裁决构成:放行 {allow}、转人工 {ask}、拦截 {reject}", {
4533
4842
  allow: groupDigits(allow),
4534
4843
  ask: groupDigits(ask),
4535
4844
  reject: groupDigits(reject),
@@ -5521,11 +5830,15 @@ window.__ModuleLoader__.load({
5521
5830
  ? h(
5522
5831
  "div",
5523
5832
  { style: S.card },
5524
- h("div", { style: S.sectionTitle }, "裁决统计(allow / ask / reject)"),
5833
+ // T4:这一块以前把中文**裸字面量**直接挂进渲染树,靠 `h()` 的
5834
+ // 渲染边界翻译兜着。改成显式 `t()` 不是为了少一层接口,而是为了
5835
+ // 让「这里有一句要翻的话」在源码里看得见——尤其是下面表头那四格,
5836
+ // 漏一条就只剩那一格是中文。
5837
+ h("div", { style: S.sectionTitle }, t("裁决统计(allow / ask / reject)")),
5525
5838
  h(
5526
5839
  "div",
5527
5840
  { style: { fontSize: "11px", opacity: 0.6, marginTop: "-2px" } },
5528
- "决策模型的裁决结果:放行 = 动作照常进行;询问 = 升级人工确认;拦截 = 动作被拦下。",
5841
+ t("决策模型的裁决结果:放行 = 动作照常进行;转人工 = 升级人工确认;拦截 = 动作被拦下。"),
5529
5842
  ),
5530
5843
  /**
5531
5844
  * 裁决构成条:三格占比一眼可见。
@@ -5551,14 +5864,16 @@ window.__ModuleLoader__.load({
5551
5864
  { style: S.statsCards },
5552
5865
  ...[
5553
5866
  { label: "放行 allow", value: outcomes.allow, color: "#7dd3a0" },
5554
- { label: "询问 ask", value: outcomes.ask, color: "#f5b26b" },
5867
+ // 「ask」这一格统一叫「转人工」(T4):同一页面的裁决格、
5868
+ // 构成条、表头必须同一个词,否则用户以为它们是两件事。
5869
+ { label: "转人工 ask", value: outcomes.ask, color: "#f5b26b" },
5555
5870
  { label: "拦截 reject", value: outcomes.reject, color: "#e8797f" },
5556
5871
  { label: "未裁决", value: outcomes.none },
5557
5872
  ].map((item) =>
5558
5873
  h(
5559
5874
  "div",
5560
5875
  { style: S.statsCard, key: item.label },
5561
- h("div", { style: S.statsCardLabel }, item.label),
5876
+ h("div", { style: S.statsCardLabel }, t(item.label)),
5562
5877
  h(
5563
5878
  "div",
5564
5879
  {
@@ -5586,11 +5901,11 @@ window.__ModuleLoader__.load({
5586
5901
  h(
5587
5902
  "tr",
5588
5903
  null,
5589
- h("th", { style: { ...S.statsTh, textAlign: "left" } }, "决策点"),
5590
- h("th", { style: S.statsTh }, "放行"),
5591
- h("th", { style: S.statsTh }, "询问"),
5592
- h("th", { style: S.statsTh }, "拦截"),
5593
- h("th", { style: S.statsTh }, "未裁决"),
5904
+ h("th", { style: { ...S.statsTh, textAlign: "left" } }, t("决策点")),
5905
+ h("th", { style: S.statsTh }, t("放行")),
5906
+ h("th", { style: S.statsTh }, t("转人工")),
5907
+ h("th", { style: S.statsTh }, t("拦截")),
5908
+ h("th", { style: S.statsTh }, t("未裁决")),
5594
5909
  ),
5595
5910
  ),
5596
5911
  h(
@@ -6353,13 +6668,19 @@ window.__ModuleLoader__.load({
6353
6668
  thinkMode: next.thinkMode ?? "never",
6354
6669
  // 决策历史(DEC-022):与服务端一致,默认开。
6355
6670
  decisionsTabEnabled: next.decisionsTabEnabled !== false,
6671
+ // 日志分文件 / 保留(T3):**必须从服务端派生**,否则选完模式、
6672
+ // 改完保留天数,下一次保存成功后的 applyServerState 会把草稿
6673
+ // 覆盖回服务端旧值——界面上就是「选完自己弹回去」。
6674
+ decisionsSplitMode: next.decisionsSplitMode ?? "date",
6675
+ decisionsRetentionDays: next.decisionsRetentionDays ?? 30,
6676
+ decisionsDateSeparator: next.decisionsDateSeparator ?? "-",
6356
6677
  // 审批卡正文可读化(DEC-042 / DEC-043):与服务端一致(缺省 = 开),
6357
6678
  // 否则打开后回弹。
6358
6679
  approvalDetailReadable: next.approvalDetailReadable !== false,
6359
6680
  // 跟随服务端下发(DEC-030):默认关;与服务端保持一致,
6360
6681
  // 否则保存后开关会回弹。
6361
6682
  serverProvider: next.serverProvider?.enabled === true,
6362
- // 人工审核比例(DEC-035):同样从服务端派生,否则点滑块后回弹。
6683
+ // 安全档位(DEC-035):同样从服务端派生,否则点滑块后回弹。
6363
6684
  tolerance: next.tolerance?.level ?? "default",
6364
6685
  // 强制安全核心 / 工具授权三态 / 自动审查回退:整组从服务端派生
6365
6686
  // (见 guardDraftOf)。缺任何一格都会「保存后回弹」。
@@ -6583,6 +6904,12 @@ window.__ModuleLoader__.load({
6583
6904
  thinkMode: view.thinkMode ?? "never",
6584
6905
  // 决策历史(DEC-022):与服务端一致,默认开。
6585
6906
  decisionsTabEnabled: view.decisionsTabEnabled !== false,
6907
+ // 日志分文件 / 保留(T3):与服务端 DEFAULTS 逐字一致(date / 30 / -)。
6908
+ // 这几格缺了的表现很具体:首屏就按兜底值画控件,而用户一碰别的字段,
6909
+ // 提交的那份 patch 会把「他没碰过的存储配置」一起写成兜底值。
6910
+ decisionsSplitMode: view.decisionsSplitMode ?? "date",
6911
+ decisionsRetentionDays: view.decisionsRetentionDays ?? 30,
6912
+ decisionsDateSeparator: view.decisionsDateSeparator ?? "-",
6586
6913
  // 审批卡正文可读化(DEC-042 / DEC-043):同样默认开。缺了这一格,
6587
6914
  // 首屏会在服务端说「开」的同时把开关画成「关」。
6588
6915
  approvalDetailReadable: view.approvalDetailReadable !== false,
@@ -6606,6 +6933,26 @@ window.__ModuleLoader__.load({
6606
6933
  customApiKeyEnv: view.customApiKeyEnv ?? "",
6607
6934
  };
6608
6935
 
6936
+ /* ---------- 日志分文件 / 保留(T3):三格生效值的取值 ---------- */
6937
+
6938
+ // 与服务端 DEFAULTS 逐字一致(date / "-" / 30):老服务端没有这三格时
6939
+ // 界面按新默认显示,而且**提交的值与显示的值相同**——否则第一次改别的
6940
+ // 字段就会把用户存储配置改成 single(那是升级前的行为,等于开倒车)。
6941
+ const splitMode = typeof draft.decisionsSplitMode === "string"
6942
+ ? draft.decisionsSplitMode
6943
+ : (typeof view.decisionsSplitMode === "string" ? view.decisionsSplitMode : "date");
6944
+ const separator = typeof draft.decisionsDateSeparator === "string"
6945
+ ? draft.decisionsDateSeparator
6946
+ : (typeof view.decisionsDateSeparator === "string" ? view.decisionsDateSeparator : "-");
6947
+ const retentionDays = Number.isFinite(draft.decisionsRetentionDays)
6948
+ ? draft.decisionsRetentionDays
6949
+ : (Number.isFinite(view.decisionsRetentionDays) ? view.decisionsRetentionDays : 30);
6950
+
6951
+ // 决策模型思考(T2):当前 provider 的 think_mode 能力位,决定 hint 与
6952
+ // 下拉是否可用。取不到(老服务端 / status 未回来)时是 unknown。
6953
+ const thinkModeSupport = thinkModeSupportOf(view, providerDraft.provider);
6954
+
6955
+
6609
6956
  // 模型列表自动拉取:打开配置页就该看到当前服务可选的模型,
6610
6957
  // 而不是等用户发现并点「获取」(此前 loadModels 只有按钮一个调用点,
6611
6958
  // 迁移到 custom 的用户打开页面只会看到「自动」一项——正是反馈的问题)。
@@ -6666,6 +7013,12 @@ window.__ModuleLoader__.load({
6666
7013
  telemetryLevel: next.telemetryLevel ?? "daily",
6667
7014
  // 决策历史(DEC-022):同样必须从服务端派生,否则保存后开关会回弹。
6668
7015
  decisionsTabEnabled: next.decisionsTabEnabled !== false,
7016
+ // 日志分文件 / 保留(T3):保存成功之后**必须**用服务端回传值覆盖
7017
+ // 草稿。这个函数在每次保存成功后跑;缺这三格,选完模式下一次保存
7018
+ // 就把草稿覆盖回旧值——界面上看就是「选完自己弹回去」。
7019
+ decisionsSplitMode: next.decisionsSplitMode ?? "date",
7020
+ decisionsRetentionDays: next.decisionsRetentionDays ?? 30,
7021
+ decisionsDateSeparator: next.decisionsDateSeparator ?? "-",
6669
7022
  // 审批卡正文可读化(DEC-042 / DEC-043):必须从服务端派生。这个函数
6670
7023
  // 在**保存成功之后**才跑,缺了这一格,草稿丢掉的就不只是个显示值:
6671
7024
  // 开关当场画成「关」,下一次保存又把 `false` 提交上去——开关自己
@@ -6673,7 +7026,7 @@ window.__ModuleLoader__.load({
6673
7026
  approvalDetailReadable: next.approvalDetailReadable !== false,
6674
7027
  // 跟随服务端下发(DEC-030):同样是「生效值」,必须从服务端派生。
6675
7028
  serverProvider: next.serverProvider?.enabled === true,
6676
- // 人工审核比例(DEC-035):同样必须从服务端派生,否则保存后回弹。
7029
+ // 安全档位(DEC-035):同样必须从服务端派生,否则保存后回弹。
6677
7030
  tolerance: next.tolerance?.level ?? "default",
6678
7031
  // 强制安全核心 / 工具授权三态 / 自动审查回退:保存成功后必须整组
6679
7032
  // 对齐服务端,否则「生效」会画成「关闭」、下一次保存又把错值提交
@@ -7842,7 +8195,7 @@ window.__ModuleLoader__.load({
7842
8195
  ),
7843
8196
  // 选了「改用指定模型」才展开那三格:平时它们不该占版面。
7844
8197
  fallbackValue === "model" ? fallbackModelBlock() : null,
7845
- // 3d) 人工审核比例(DEC-035):**配置页的正式入口**。
8198
+ // 3d) 安全档位(DEC-035):**配置页的正式入口**。
7846
8199
  //
7847
8200
  // 放在「守卫行为」组的末尾——它与这一组里那几个开关是一对:
7848
8201
  // 「启用守卫 / 真正拦截」决定**判不判、拦不拦**,这一项决定**多频繁问你**。
@@ -7871,11 +8224,14 @@ window.__ModuleLoader__.load({
7871
8224
  h(
7872
8225
  "div",
7873
8226
  { style: S.rowText },
7874
- h("div", { style: S.rowTitle }, t("人工审核比例")),
8227
+ h("div", { style: S.rowTitle }, t("安全档位")),
7875
8228
  h(
7876
8229
  "div",
7877
8230
  { style: S.rowHint },
7878
- t("按比例校准这个模型的打扰频率。换决策模型不需要重调阈值:设定是模型无关的,插件会按该模型最近的分数量自己换算。"),
8231
+ // T1 的新 hint:说清「调的是什么、什么不受它影响、什么时候
8232
+ // 询问会变成拒绝」。旧 hint(「按比例校准…换算」)只对两档成立,
8233
+ // 且对「不问」这一档是反的。
8234
+ t("决定软性审查多频繁地升级;权限强制的人工批准与拒绝不受档位影响;审批策略为永不询问时任何询问都会变成拒绝。"),
7879
8235
  ),
7880
8236
  ),
7881
8237
  ToleranceSlider({
@@ -8006,7 +8362,7 @@ window.__ModuleLoader__.load({
8006
8362
  // 与「判不判」是两件事——观察模式下往往才是用户真正想要的。
8007
8363
  // 3c) 决策历史(DEC-022 / DEC-026):会话里的那个标签页。
8008
8364
  //
8009
- // 放在「真正拦截」之后、「人工审核比例」之前:它与守卫的开关是**两件事**
8365
+ // 放在「真正拦截」之后、「安全档位」之前:它与守卫的开关是**两件事**
8010
8366
  // ——守卫决定「判不判」,这个决定「记不记」,后者在观察模式下往往
8011
8367
  // 才是用户真正想要的。
8012
8368
  //
@@ -8035,9 +8391,111 @@ window.__ModuleLoader__.load({
8035
8391
  }),
8036
8392
  "decisionsTabEnabled",
8037
8393
  ),
8394
+ // 3c1) 日志分文件(T3):明细落到**哪个文件**。
8395
+ //
8396
+ // 放在「决策历史」之后:它只在记录开着时才有意义(关了就不写文件),
8397
+ // 而「写到哪」正是「记不记」之后的下一个问题。四个模式各有取舍:
8398
+ // 单一文件便于整份导出;按日期便于按天删;按会话/工作区便于回看
8399
+ // 「那个会话当时做了什么」。
8400
+ //
8401
+ // 取值由服务端下发(`status.decisionsSplitMode`),老服务端没这一格时
8402
+ // 落回 date——与服务端 DEFAULTS 一致,否则「没下发」会被当成 single
8403
+ // 提交上去,把用户的配置改掉。
8404
+ row(
8405
+ "日志分文件",
8406
+ splitModeHintOf(splitMode, separator),
8407
+ h(
8408
+ "select",
8409
+ {
8410
+ style: { ...S.input, minWidth: "190px", flex: "none" },
8411
+ value: splitMode,
8412
+ disabled: busy !== "",
8413
+ onChange: (event) => {
8414
+ const next = { ...draft, decisionsSplitMode: event.target.value };
8415
+ setDraft(next);
8416
+ // 下拉是离散控件:选完即存。
8417
+ scheduleSave({ draft: next }, true);
8418
+ },
8419
+ },
8420
+ // 选项由服务端下发(decisionsSplitModes),前端不硬编码取值——
8421
+ // 与 thinkModes / telemetryLevels 同一套路。老服务端没这一格时
8422
+ // 退回四个已知值;文案里同时写机器值与中文名,对照配置/日志时
8423
+ // 两边都认得出。
8424
+ ...(Array.isArray(view.decisionsSplitModes) && view.decisionsSplitModes.length > 0
8425
+ ? view.decisionsSplitModes
8426
+ : ["single", "date", "session", "workspace"]
8427
+ ).map((value) =>
8428
+ h("option", { key: value, value }, SPLIT_MODE_TEXTS[value] ?? value),
8429
+ ),
8430
+ ),
8431
+ "decisionsSplitMode",
8432
+ ),
8433
+ // 3c1b) 日期分隔符(T3):只在「按日期」时有意义,其它模式不显示——
8434
+ // 一个改了没有任何效果的控件比没有这个控件更糟。
8435
+ splitMode === "date"
8436
+ ? row(
8437
+ "日期分隔符",
8438
+ "分隔符只影响文件名(logs/2026-11-15.log),不影响已经写下的记录",
8439
+ h(
8440
+ "select",
8441
+ {
8442
+ style: { ...S.input, minWidth: "190px", flex: "none" },
8443
+ value: separator,
8444
+ disabled: busy !== "",
8445
+ onChange: (event) => {
8446
+ const next = { ...draft, decisionsDateSeparator: event.target.value };
8447
+ setDraft(next);
8448
+ scheduleSave({ draft: next }, true);
8449
+ },
8450
+ },
8451
+ // 分隔符清单同样由服务端下发(decisionsDateSeparators)。
8452
+ ...(Array.isArray(view.decisionsDateSeparators) && view.decisionsDateSeparators.length > 0
8453
+ ? view.decisionsDateSeparators
8454
+ : ["-", "_", "."]
8455
+ ).map((value) =>
8456
+ h("option", { key: value, value }, DATE_SEPARATOR_TEXTS[value] ?? value),
8457
+ ),
8458
+ ),
8459
+ "decisionsDateSeparator",
8460
+ )
8461
+ : null,
8462
+ // 3c1c) 日志保留(T3):多少天之后自动删。
8463
+ //
8464
+ // 数字输入 + 防抖(与文本框同路):逐字符立即落盘会把「1」这种中间态
8465
+ // 写进配置,而保留 1 天会立刻删掉昨天的记录。范围 0–3650 与服务端
8466
+ // 校验一致(0 = 不按天删,只受总量上限约束);越界的值本地先不提交
8467
+ //(服务端必然 400,那只是噪音)。
8468
+ row(
8469
+ "日志保留",
8470
+ retentionHintOf(view?.decisionsStorage),
8471
+ h(
8472
+ "input",
8473
+ {
8474
+ type: "number",
8475
+ min: 0,
8476
+ max: 3650,
8477
+ step: 1,
8478
+ style: { ...S.input, width: "110px", flex: "none" },
8479
+ value: String(retentionDays),
8480
+ disabled: busy !== "",
8481
+ onChange: (event) => {
8482
+ // 空输入不等于 0:`Number("")` 是 0,直接提交会把「用户还在
8483
+ // 删字」解释成「关掉按天清理」。空串一律不提交、不清草稿。
8484
+ if (String(event.target.value ?? "").trim().length === 0) return;
8485
+ const raw = Number(event.target.value);
8486
+ if (!Number.isFinite(raw)) return;
8487
+ const days = Math.min(3650, Math.max(0, Math.trunc(raw)));
8488
+ const next = { ...draft, decisionsRetentionDays: days };
8489
+ setDraft(next);
8490
+ scheduleSave({ draft: next });
8491
+ },
8492
+ },
8493
+ ),
8494
+ "decisionsRetentionDays",
8495
+ ),
8038
8496
  // 3c2) 审批卡正文可读化(DEC-042,DEC-043 起默认开启)。
8039
8497
  //
8040
- // 放在「决策历史」之后、「人工审核比例」之前:它与上面那些是**同一类**
8498
+ // 放在「决策历史」之后、「安全档位」之前:它与上面那些是**同一类**
8041
8499
  // 用户可见体验(审批卡长什么样),而不是守卫的判据。
8042
8500
  //
8043
8501
  // 默认开启:宿主把命令原文塞进一个会折叠换行的块里,heredoc 脚本读不出
@@ -8058,23 +8516,30 @@ window.__ModuleLoader__.load({
8058
8516
  ];
8059
8517
 
8060
8518
  const modelItems = [
8061
- // ④ 模型与统计:调优(思考模式)与匿名统计。
8519
+ // ④ 模型与统计:调优(决策模型思考)与匿名统计。
8062
8520
  // 设一次就忘的项放最后,且默认收起。
8063
- // 4a) 思考模式:never 最省延迟,always 判断更充分但每次多等约 5s。
8064
- // 这一项以前只能手改配置文件,界面没有入口(后端一直支持三档)。
8521
+ // 4a) 决策模型思考(T2,原「思考模式」):never 最省延迟,always 判断
8522
+ // 更充分但每次多等约 5s。这一项以前只能手改配置文件,界面没有入口
8523
+ //(后端一直支持三档)。
8524
+ //
8525
+ // 改名 + 按 provider 能力位分文案:这个下拉只改**本插件发给决策端点**
8526
+ // 的 think_mode。旧名字「思考模式」加上「判断更充分」让用户以为它调的是
8527
+ // 宿主主模型的推理强度——那两件事在同一台机器上根本不是同一个开关。
8528
+ // 能力位来自 providerCatalog(supported / unknown / unsupported):
8529
+ // unsupported 时**禁用下拉**,因为那个 provider 的公开 API 里没有
8530
+ // 这个字段,留着能点等于承诺一个不存在的效果。
8065
8531
  row(
8066
- "思考模式",
8067
- draft.thinkMode === "always"
8068
- ? "总是思考:判断更充分,但每次工具调用约多等 5 秒"
8069
- : draft.thinkMode === "auto"
8070
- ? "仅在模型自己判断需要时思考(折中)"
8071
- : "不思考:最快(约 1.9s),热路径首选",
8532
+ "决策模型思考",
8533
+ THINK_MODE_SUPPORT_HINTS[thinkModeSupport],
8072
8534
  h(
8073
8535
  "select",
8074
8536
  {
8075
8537
  style: { ...S.input, minWidth: "190px", flex: "none" },
8076
8538
  value: draft.thinkMode ?? "never",
8077
- disabled: busy !== "",
8539
+ // 端点根本没有 think_mode 协议:禁用而不是隐藏。隐藏会让用户
8540
+ // 以为「我记错了,这里本来没有这个设置」;禁用 + 下方一句解释
8541
+ // 才说清「有这一项,但对这家服务无效」。
8542
+ disabled: busy !== "" || thinkModeSupport === "unsupported",
8078
8543
  onChange: (event) => {
8079
8544
  const next = { ...draft, thinkMode: event.target.value };
8080
8545
  setDraft(next);
@@ -8090,11 +8555,14 @@ window.__ModuleLoader__.load({
8090
8555
  h(
8091
8556
  "option",
8092
8557
  { key: value, value },
8558
+ // T2 的取舍写进选项本身:never 最快、auto 由服务端决定(实测
8559
+ // 约 7.2s)、always 再多约 5s 而质量收益**未实测**。旧文案把
8560
+ // always 说成「判断更充分」,那是个没人验证过的承诺。
8093
8561
  value === "never"
8094
- ? "never(不思考)"
8562
+ ? "never|跳过思考(最快,默认)"
8095
8563
  : value === "auto"
8096
- ? "auto(按需思考)"
8097
- : "always(总是思考)",
8564
+ ? "auto|由服务端决定(实测约 7.2s,比 never 慢)"
8565
+ : "always|总是思考(约 +5s,质量收益未实测)",
8098
8566
  ),
8099
8567
  ),
8100
8568
  ),