dsh-second-review 1.3.4 → 1.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  > Independent second-opinion review for DeepSeek Harness.
4
4
  > 主模型负责执行,副模型负责复核;结论以卡片形式附在原回复下方。
5
5
 
6
- ![version](https://img.shields.io/badge/version-1.3.4-blue)
6
+ ![version](https://img.shields.io/badge/version-1.3.6-blue)
7
7
  ![license](https://img.shields.io/badge/license-MIT-green)
8
8
  ![platform](https://img.shields.io/badge/platform-Windows-lightgrey)
9
9
  ![dsh](https://img.shields.io/badge/DSH-0.2.2--rc.1%20verified-informational)
@@ -60,7 +60,8 @@ SEC 是一个**主执行者 + 独立监督者**的组合:主模型完成回答
60
60
  | 功能 | 作用 | 费用 |
61
61
  |-|-|-|
62
62
  | **二次评审** | 复核当前轮(含此前对话) | 一次副模型调用 |
63
- | **自动评审** | 新回复到达后自动复核一次(默认可关闭) | 同上,按条计 |
63
+ | **自动评审** | 新回复到达后自动复核一次(**默认关闭**);范围可选「每条」或「只有动过手」 | 同上,按条计 |
64
+ | **用量** | 卡片末尾写出本轮主模型与本次复核的 token | 无(从日志算,不调模型) |
64
65
  | **副模型主刀** | 交换角色:下一轮由副模型回答,原主模型复核 | 按对应模型计 |
65
66
  | **让主模型读** | 将评审结论交给主模型,促其正面回应 | 一次主模型调用 |
66
67
  | **停止** | 中止进行中的复核;已生成内容保留可查 | 已产生费用不退回 |
@@ -129,7 +130,7 @@ SEC 是一个**主执行者 + 独立监督者**的组合:主模型完成回答
129
130
  | 反复挑标点、路径、措辞 | 多为模型话多,换一个即可,不必修改需求 |
130
131
  | 长对话中持续变慢 | 部分模型会逐句过一遍,属模型行为,不是插件故障 |
131
132
 
132
- > 模型名称为写作时的示例。实际可选范围以你 DSH 中已配置的 provider 为准;未指定时默认 `deepseek-flash`。
133
+ > 模型名称为写作时的示例。实际可选范围以你 DSH 中已配置的 provider 为准;不指定时默认走 **Pro 档**(自动跟随,见 07 `modelAuto`)。
133
134
 
134
135
  ---
135
136
 
@@ -173,11 +174,11 @@ file:C:\Users\<用户名>\Documents\dsh-second-review
173
174
 
174
175
  **方式三:压缩包**
175
176
 
176
- 将 `dsh-second-review-1.3.4.tgz` 发给对方,在「添加插件」中选择该压缩包。
177
+ 将 `dsh-second-review-1.3.6.tgz` 发给对方,在「添加插件」中选择该压缩包。
177
178
 
178
179
  **设置页**位于 **插件 → 二次评审**,分四段:01 模型 / 02 评审模式 / 03 权限 / 04 关于。修改后需点击底部「保存」,未保存时页面会提示。
179
180
 
180
- **首次使用**:建议自动评审保持关闭,先点「先看效果(不花钱)」,再做第一次真实复核(首次会展示费用与数据去向说明)。
181
+ **首次使用**:建议自动评审保持关闭,先点「先看效果(不花钱)」,再做第一次真实复核(首次会展示费用与数据去向说明)。副模型默认就是 **Pro 档**、跟着版本走;主模型如果也是它,点评审时会先弹一个窗让你换——同一双眼睛看不见自己。
181
182
 
182
183
  **更新**:以新版本覆盖同一目录,再重新添加一次相同路径,然后重启 DSH。配置与评审记录保存在插件目录之外,不会因覆盖丢失。
183
184
 
@@ -189,8 +190,11 @@ file:C:\Users\<用户名>\Documents\dsh-second-review
189
190
 
190
191
  | 字段 | 默认 | 说明 |
191
192
  |-|-|-|
192
- | `provider` / `model` | `deepseek-account` / `deepseek-flash` | 副模型 |
193
+ | `provider` / `model` | `deepseek-account` / `deepseek-v4-pro` | 副模型(`modelAuto` 打开时以自动挑到的 Pro 档为准) |
194
+ | `modelAuto` | `true` | 自动挑这个 provider 里名字带 `pro` 的最新一版;关掉就用 `model` 里那个具体 id |
195
+ | `hideSameSourceModal` | `false` | 主副重合提示窗的「不再提示」 |
193
196
  | `auto` | `false` | 新回复自动复核 |
197
+ | `autoMode` | `every` | 自动评审范围:`every` 每条都评/`actions` 只有动过手(写文件、跑命令、发消息、工作区有改动)才评 |
194
198
  | `mock` | `false` | 测试模式:模拟数据,不调模型、不落盘 |
195
199
  | `allowWrite` | `false` | 是否向副模型提供写文件工具 |
196
200
  | `budgetSec` | `0` | 总时长上限(秒),`0` 为不限 |
@@ -285,7 +289,7 @@ file:C:\Users\<用户名>\Documents\dsh-second-review
285
289
 
286
290
  ## 12 · 版本
287
291
 
288
- 当前 **1.3.4**。
292
+ 当前 **1.3.6**。
289
293
 
290
294
  | 版本 | 主要内容 |
291
295
  |-|-|
@@ -305,6 +309,8 @@ file:C:\Users\<用户名>\Documents\dsh-second-review
305
309
  | **1.3.2** | 上架 npm;补仓库与 Issue 入口;README 增补 30 秒速览、设计动机、卡片示例图、故障排查、反馈渠道 |
306
310
  | **1.3.3** | 仓库根目录化后同步:repository 去掉 directory,README 图片改回相对路径(由 npm 依仓库地址解析) |
307
311
  | **1.3.4** | 安装文档补「按包名安装」;修正压缩包版本号与更新日志里的错字 |
312
+ | **1.3.5** | 副模型与主模型是同一个模型时,卡片标出「与主模型相同 · 自评」;设置页补默认值提醒;修正「自动评审默认可关闭」这句歧义 |
313
+ | **1.3.6** | 副模型默认改走 **Pro 档**、且 `modelAuto` 跟着 DSH 的模型清单自动换代;点「二次评审」前若主副重合,弹一个明暗跟随皮肤的黑白窗,可直接换模型(可勾「不再提示」);评审中随机轮播九句碎碎念;卡片末尾显示用量(本轮主模型+本次复核,全从日志和回执算,不额外调模型);自动评审加「范围」开关:每条都评/只有动过手才评,跳过的轮次卡片上写明;设置页加三个预设(只改开关,不换模型) |
308
314
 
309
315
  ---
310
316
 
package/client.js CHANGED
@@ -31,6 +31,21 @@ window.__ModuleLoader__.load({
31
31
  用 em 而不是 px,他自己调了系统/界面字号,卡片跟着一起变。 */
32
32
  const FS = { body: '1em', small: '0.95em', min: '0.9em' }
33
33
 
34
+ /* ---------------- 思考中的碎碎念(1.3.6) ----------------
35
+ ↓↓↓ 文案填这里:一行一句,随便加、随便删,随机轮着放 ↓↓↓
36
+ 他 2026-11-11 亲手写的九句,就按这个顺序存,每次随机挑一句、不连着重复。 */
37
+ const THINK_LINES = [
38
+ '嗯……帮你理一理',
39
+ '正在认真评审,稍等哦',
40
+ '让我沉下来想一想',
41
+ '试着从这边看看',
42
+ '搬个小板凳坐下来想',
43
+ '嗯……这个说法稳妥吗',
44
+ '让我多问自己一个为什么',
45
+ '补一块砖,站得更稳',
46
+ '调调焦距,看得更清',
47
+ ]
48
+
34
49
  /* ---------------- 玻璃(方案 B:搬「开关」,不搬代码) ----------------
35
50
  壁纸插件 dsh-plugin-wallpaper-engine 把玻璃参数写成 <body> 上的 CSS 变量,
36
51
  body 下面所有元素都能读到。我们只读这几个:
@@ -46,6 +61,21 @@ window.__ModuleLoader__.load({
46
61
 
47
62
  const R = 10 // "角圆润的方形"(他 2026-11-09 说的)—— 不是胶囊,也不是直角
48
63
  const ACCENT = 'var(--we-accent, #3b82f6)'
64
+
65
+ /* 1.3.6:弹窗要"非黑即白" —— 跟着现在的皮肤判个明暗,不猜也不写死深色 */
66
+ const isDarkUI = () => {
67
+ try {
68
+ const bg = getComputedStyle(document.body).backgroundColor || ''
69
+ const m = bg.match(/\d+(\.\d+)?/g)
70
+ if (!m || m.length < 3) return true
71
+ const r = Number(m[0])
72
+ const g = Number(m[1])
73
+ const b = Number(m[2])
74
+ return 0.299 * r + 0.587 * g + 0.114 * b < 128
75
+ } catch {
76
+ return true
77
+ }
78
+ }
49
79
  const HAIRLINE = 'rgba(128,128,128,0.24)'
50
80
  const HAIRLINE_SOFT = 'rgba(128,128,128,0.16)'
51
81
  const INK_SOFT = 'rgba(128,128,128,0.18)'
@@ -414,7 +444,7 @@ window.__ModuleLoader__.load({
414
444
 
415
445
  /* ---------------- 设置(模块级,整页共用一份) ---------------- */
416
446
 
417
- const settings = { value: null, pending: null }
447
+ const settings = { value: null, pending: null, resolved: '' }
418
448
 
419
449
  function loadSettings(force) {
420
450
  if (!force && settings.value) return Promise.resolve(settings.value)
@@ -423,6 +453,8 @@ window.__ModuleLoader__.load({
423
453
  .then((r) => r.json())
424
454
  .then((j) => {
425
455
  settings.value = (j && j.ok && j.config) || { auto: false }
456
+ // 1.3.6:自动 Pro 档时,后端会告诉我们最终落到哪个模型(卡片和弹窗都要显示它)
457
+ settings.resolved = (j && j.ok && j.resolvedModel) || ''
426
458
  return settings.value
427
459
  })
428
460
  .catch(() => {
@@ -438,7 +470,7 @@ window.__ModuleLoader__.load({
438
470
 
439
471
  /* ---------------- 副模型主刀(每个会话各存各的,互不覆盖) ---------------- */
440
472
 
441
- const swap = { bySession: {}, pending: {} }
473
+ const swap = { bySession: {}, pending: {}, main: {} }
442
474
 
443
475
  /* 同一会话里所有卡片共用一份"主刀开没开",不然 A 卡开了 B 卡还显示关 */
444
476
  const swapShared = { value: {}, listeners: new Set() }
@@ -469,7 +501,11 @@ window.__ModuleLoader__.load({
469
501
  swap.pending[k] = fetch(API + '/swap?sessionId=' + encodeURIComponent(k))
470
502
  .then((r) => r.json())
471
503
  .then((j) => {
472
- if (j && j.ok) swap.bySession[k] = !!j.on
504
+ if (j && j.ok) {
505
+ swap.bySession[k] = !!j.on
506
+ // 1.3.6:顺手把"这个会话现在的主模型"留住 —— 点评审前要拿它比是否重合
507
+ if (j.current && typeof j.current === 'object') swap.main[k] = { provider: String(j.current.provider || ''), model: String(j.current.model || '') }
508
+ }
473
509
  return !!swap.bySession[k]
474
510
  })
475
511
  .catch(() => !!swap.bySession[k])
@@ -698,6 +734,22 @@ window.__ModuleLoader__.load({
698
734
  // 在一张卡上开了,别的卡上的开关还显示"关"(GPT 4.6 第 4 条)。改成共用一份。
699
735
  const [swapOn, setSwapOn] = useSwap(sidKey)
700
736
 
737
+ // 碎碎念:正在评的时候才轮播;停了/到点了就不逗他(他 2026-11-11 要的"轻松一点")
738
+ const [quipIdx, setQuipIdx] = useState(0)
739
+ const quipOn = !!(entry && entry.state === 'loading')
740
+ useEffect(() => {
741
+ if (!quipOn) return undefined
742
+ const t = setInterval(() => {
743
+ setQuipIdx((i) => {
744
+ if (THINK_LINES.length < 2) return 0
745
+ let n = i
746
+ while (n === i) n = Math.floor(Math.random() * THINK_LINES.length)
747
+ return n
748
+ })
749
+ }, 3200)
750
+ return () => clearInterval(t)
751
+ }, [quipOn])
752
+
701
753
  useEffect(() => {
702
754
  let alive = true
703
755
  loadSwap(sessionId).then((v) => {
@@ -740,6 +792,33 @@ window.__ModuleLoader__.load({
740
792
  },
741
793
  '副模型主刀' + (swapOn ? ':开' : ''),
742
794
  )
795
+ /* 1.3.6:这张卡这次花了多少 —— 全从日志和返回里算,不为显示数字多调一次模型。
796
+ 复核=副模型那次(要它真给了用量才显示);本轮=主模型这一轮的账(日志里本来就有)。 */
797
+ const fmtTok = (n) => {
798
+ const v = Number(n) || 0
799
+ if (!v) return '0'
800
+ return v >= 1000 ? (v / 1000).toFixed(v >= 10000 ? 0 : 1).replace(/\.0$/, '') + 'k' : String(v)
801
+ }
802
+ const usageChip = (u) => {
803
+ if (!u || typeof u !== 'object') return null
804
+ const r = u.review
805
+ const t = u.turn
806
+ const parts = []
807
+ if (r && (r.input || r.output)) parts.push('复核 ' + fmtTok(r.input) + '进/' + fmtTok(r.output) + '出')
808
+ if (t && (t.input || t.output)) parts.push('本轮 ' + fmtTok(t.input) + '进/' + fmtTok(t.output) + '出')
809
+ if (!parts.length) return null
810
+ const cache = []
811
+ if (r && r.cacheRead) cache.push('复核缓存读 ' + fmtTok(r.cacheRead))
812
+ if (t && t.cacheRead) cache.push('本轮缓存读 ' + fmtTok(t.cacheRead))
813
+ return React.createElement(
814
+ 'span',
815
+ {
816
+ style: { opacity: 0.5, fontSize: FS.min, whiteSpace: 'nowrap' },
817
+ title: '这些数字是从会话日志和调用回执里算的,没有为显示它们多调一次模型' + (cache.length ? ';' + cache.join(',') : ''),
818
+ },
819
+ parts.join(' · '),
820
+ )
821
+ }
743
822
  const state = entry && entry.state
744
823
  const major = (entry && entry.major) || []
745
824
  const minor = (entry && entry.minor) || []
@@ -752,7 +831,7 @@ window.__ModuleLoader__.load({
752
831
  let alive = true
753
832
  loadSettings().then((v) => {
754
833
  if (alive) setAuto(!!(v && v.auto))
755
- if (alive) setReviewerName(v && v.model ? String(v.model) : '')
834
+ if (alive) setReviewerName(v && (v.resolved || v.model) ? String(v.resolved || v.model) : '')
756
835
  })
757
836
  return () => {
758
837
  alive = false
@@ -794,11 +873,22 @@ window.__ModuleLoader__.load({
794
873
  fetch(url, {
795
874
  method: 'POST',
796
875
  headers: { 'content-type': 'application/json' },
797
- body: JSON.stringify({ sessionId: sessionId || '', seq: seq, job, mock: kind === 'mock' }),
876
+ body: JSON.stringify({ sessionId: sessionId || '', seq: seq, job, mock: kind === 'mock', auto: kind === 'auto' }),
798
877
  })
799
878
  .then((r) => r.json())
800
879
  .then((json) => {
801
880
  if (json && json.ok) {
881
+ // 1.3.6:闸门跳过 —— 没花钱,也别装成评过了
882
+ if (json.skipped) {
883
+ setEntry({
884
+ state: 'skipped',
885
+ major: [],
886
+ minor: [],
887
+ note: String(json.reason || '自动评审跳过了这一轮'),
888
+ auto: kind === 'auto',
889
+ })
890
+ return
891
+ }
802
892
  if (json.verdict === 'error') {
803
893
  setEntry({
804
894
  state: 'error',
@@ -834,6 +924,8 @@ window.__ModuleLoader__.load({
834
924
  timedOut: !!json.timedOut,
835
925
  mock: !!json.mock,
836
926
  steps: Array.isArray(json.steps) ? json.steps : [],
927
+ // 1.3.6:这一次的账(主模型那轮 + 复核自己),拿不到就是 null
928
+ usage: json.usage || null,
837
929
  })
838
930
  } else {
839
931
  setEntry({ state: 'error', major: [], minor: [], note: String((json && json.error) || '未知原因') })
@@ -911,10 +1003,233 @@ window.__ModuleLoader__.load({
911
1003
  // 手动再评:允许一次。上次是假数据就继续用假数据(别让人点一下"重评"就悄悄花了钱)
912
1004
  const rerun = () => {
913
1005
  inFlight.add(key)
914
- run(entry && entry.mock ? 'mock' : 'manual')
1006
+ startReview(entry && entry.mock ? 'mock' : 'manual')
915
1007
  inFlight.delete(key)
916
1008
  }
917
1009
 
1010
+ /* ---------------- 1.3.6:主副重合 → 点之前拦一下 ----------------
1011
+ 重合的判断:这个会话的当前主模型(/swap 顺手带回来的)和副模型是同一个。
1012
+ 只拦"用户自己点评审"这一刻;自动评审照旧在卡片上标出来,不弹窗打扰。 */
1013
+ const [fix, setFix] = useState(null)
1014
+ const [fixNoMore, setFixNoMore] = useState(false)
1015
+ const [fixProviders, setFixProviders] = useState([])
1016
+ const [fixModels, setFixModels] = useState([])
1017
+ const fixProvider = fix ? String(fix.provider || '') : ''
1018
+ useEffect(() => {
1019
+ if (!fix) return undefined
1020
+ let alive = true
1021
+ fetch(API + '/models')
1022
+ .then((r) => r.json())
1023
+ .then((j) => {
1024
+ if (alive && j && j.ok) setFixProviders(j.providers || [])
1025
+ })
1026
+ .catch(() => {})
1027
+ return () => {
1028
+ alive = false
1029
+ }
1030
+ }, [!!fix])
1031
+ useEffect(() => {
1032
+ if (!fix || !fixProvider) return undefined
1033
+ let alive = true
1034
+ fetch(API + '/models?provider=' + encodeURIComponent(fixProvider))
1035
+ .then((r) => r.json())
1036
+ .then((j) => {
1037
+ if (alive && j && j.ok) setFixModels(j.models || [])
1038
+ })
1039
+ .catch(() => {})
1040
+ return () => {
1041
+ alive = false
1042
+ }
1043
+ }, [fixProvider])
1044
+
1045
+ const modelIdOf = (m) => String((m && typeof m === 'object' ? m.model || m.id : m) || '').trim().toLowerCase()
1046
+ const sameAsMain = () => {
1047
+ const main = swap.main[sidKey]
1048
+ const s = settings.value
1049
+ if (!main || !s || s.hideSameSourceModal) return false
1050
+ // 自动 Pro 档时,比的是后端最终会用的那个模型(settings.resolved),不是配置里那个旧 id
1051
+ const sub = s.modelAuto ? settings.resolved : s.model
1052
+ return !!modelIdOf(main) && modelIdOf(main) === modelIdOf(sub)
1053
+ }
1054
+ const startReview = (kind) => {
1055
+ const acked = !!(settings.value && settings.value.noticeAck)
1056
+ if (kind !== 'mock' && acked && sameAsMain()) {
1057
+ const s = settings.value
1058
+ setFix({
1059
+ provider: String(s.provider || ''),
1060
+ model: String(s.modelAuto ? settings.resolved || s.model || '' : s.model || ''),
1061
+ kind,
1062
+ })
1063
+ setFixNoMore(false)
1064
+ return
1065
+ }
1066
+ run(kind)
1067
+ }
1068
+ const closeFix = (save) => {
1069
+ const patch = { hideSameSourceModal: !!fixNoMore }
1070
+ if (save && fix) {
1071
+ patch.provider = fix.provider
1072
+ patch.model = fix.model
1073
+ patch.modelAuto = false
1074
+ }
1075
+ fetch(API + '/config', {
1076
+ method: 'POST',
1077
+ headers: { 'content-type': 'application/json' },
1078
+ body: JSON.stringify(patch),
1079
+ })
1080
+ .then((r) => r.json())
1081
+ .then((j) => {
1082
+ if (j && j.ok) {
1083
+ settings.value = j.config
1084
+ if (j.config && j.config.model) setReviewerName(String(j.config.model))
1085
+ }
1086
+ })
1087
+ .catch(() => {})
1088
+ const kind = fix && fix.kind
1089
+ setFix(null)
1090
+ if (save && kind) run(kind)
1091
+ }
1092
+ const fixOptions = (list, fallbackId) => {
1093
+ const arr = (list && list.length ? list : [{ id: fallbackId || '', name: fallbackId || '' }])
1094
+ .map((p) => (typeof p === 'string' ? { id: p, name: p } : { id: String((p && p.id) || ''), name: String((p && p.name) || (p && p.id) || '') }))
1095
+ .filter((p) => p.id)
1096
+ return arr.map((p) => React.createElement('option', { key: p.id, value: p.id }, p.name === p.id ? p.id : p.name + '(' + p.id + ')'))
1097
+ }
1098
+ const fixModal = () => {
1099
+ if (!fix) return null
1100
+ const dark = isDarkUI()
1101
+ const bg = dark ? '#151515' : '#fdfdfd'
1102
+ const fg = dark ? '#f3f3f3' : '#111'
1103
+ const sub = dark ? 'rgba(243,243,243,0.62)' : 'rgba(17,17,17,0.6)'
1104
+ const line = dark ? 'rgba(255,255,255,0.16)' : 'rgba(0,0,0,0.14)'
1105
+ const inputBg = dark ? '#1f1f1f' : '#fff'
1106
+ const selectStyle = {
1107
+ width: '100%',
1108
+ padding: '7px 8px',
1109
+ borderRadius: 8,
1110
+ border: '1px solid ' + line,
1111
+ background: inputBg,
1112
+ color: fg,
1113
+ fontSize: FS.small,
1114
+ marginTop: 6,
1115
+ }
1116
+ return React.createElement(
1117
+ 'div',
1118
+ {
1119
+ // 把整页压暗,只留这一个窗是亮的
1120
+ style: {
1121
+ position: 'fixed',
1122
+ inset: 0,
1123
+ background: 'rgba(0,0,0,0.58)',
1124
+ display: 'flex',
1125
+ alignItems: 'center',
1126
+ justifyContent: 'center',
1127
+ zIndex: 9999,
1128
+ padding: 16,
1129
+ },
1130
+ onClick: (e) => {
1131
+ if (e && e.target === e.currentTarget) closeFix(false)
1132
+ },
1133
+ },
1134
+ React.createElement(
1135
+ 'div',
1136
+ {
1137
+ style: {
1138
+ width: 420,
1139
+ maxWidth: '100%',
1140
+ background: bg,
1141
+ color: fg,
1142
+ border: '1px solid ' + line,
1143
+ borderRadius: 12,
1144
+ padding: '18px 18px 14px',
1145
+ boxShadow: '0 24px 60px rgba(0,0,0,0.45)',
1146
+ fontSize: FS.body,
1147
+ lineHeight: 1.7,
1148
+ },
1149
+ },
1150
+ React.createElement('div', { style: { fontWeight: 600, fontSize: '1.05em', marginBottom: 4 } }, '当前主副模型重合'),
1151
+ React.createElement(
1152
+ 'div',
1153
+ { style: { color: sub, fontSize: FS.small, marginBottom: 12 } },
1154
+ '副模型和主模型是同一个(' +
1155
+ String((swap.main[sidKey] && swap.main[sidKey].model) || fix.model || '') +
1156
+ '),等于让同一双眼睛检查自己 —— 复核效果不佳。换一个再评。',
1157
+ ),
1158
+ React.createElement('div', { style: { color: sub, fontSize: FS.min, marginBottom: 2 } }, '服务商'),
1159
+ React.createElement(
1160
+ 'select',
1161
+ {
1162
+ value: fix.provider || '',
1163
+ style: selectStyle,
1164
+ onChange: (e) => setFix({ ...fix, provider: e.target.value, model: '' }),
1165
+ },
1166
+ ...fixOptions(fixProviders, fix.provider),
1167
+ ),
1168
+ React.createElement('div', { style: { color: sub, fontSize: FS.min, margin: '10px 0 2px' } }, '副模型'),
1169
+ React.createElement(
1170
+ 'select',
1171
+ {
1172
+ value: fix.model || '',
1173
+ style: selectStyle,
1174
+ onChange: (e) => setFix({ ...fix, model: e.target.value }),
1175
+ },
1176
+ fix.model ? null : React.createElement('option', { key: '__none', value: '' }, '(选一个)'),
1177
+ ...fixOptions(fixModels, ''),
1178
+ ),
1179
+ React.createElement(
1180
+ 'label',
1181
+ { style: { display: 'flex', alignItems: 'center', gap: 6, marginTop: 12, color: sub, fontSize: FS.min, cursor: 'pointer' } },
1182
+ React.createElement('input', {
1183
+ type: 'checkbox',
1184
+ checked: !!fixNoMore,
1185
+ style: { accentColor: ACCENT },
1186
+ onChange: (e) => setFixNoMore(!!e.target.checked),
1187
+ }),
1188
+ '不再提示',
1189
+ ),
1190
+ React.createElement(
1191
+ 'div',
1192
+ { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 16 } },
1193
+ React.createElement(
1194
+ 'button',
1195
+ {
1196
+ type: 'button',
1197
+ onClick: () => closeFix(false),
1198
+ style: {
1199
+ padding: '8px 16px',
1200
+ borderRadius: 8,
1201
+ background: 'transparent',
1202
+ color: fg,
1203
+ border: '1px solid ' + line,
1204
+ fontSize: FS.small,
1205
+ cursor: 'pointer',
1206
+ },
1207
+ },
1208
+ '取消',
1209
+ ),
1210
+ React.createElement(
1211
+ 'button',
1212
+ {
1213
+ type: 'button',
1214
+ onClick: () => closeFix(true),
1215
+ style: {
1216
+ padding: '8px 16px',
1217
+ borderRadius: 8,
1218
+ background: ACCENT,
1219
+ color: '#fff',
1220
+ border: '1px solid transparent',
1221
+ fontSize: FS.small,
1222
+ fontWeight: 600,
1223
+ cursor: 'pointer',
1224
+ },
1225
+ },
1226
+ '保存并评审',
1227
+ ),
1228
+ ),
1229
+ ),
1230
+ )
1231
+ }
1232
+
918
1233
  // 停止这次评审(0.9.0):不设总时长之后,"能随时喊停"就是新的保险丝。
919
1234
  // 只给服务端发一个标志,流那边每一块之间看一眼就收手;已经花掉的调用收不回来。
920
1235
  const stop = () => {
@@ -1087,6 +1402,23 @@ window.__ModuleLoader__.load({
1087
1402
  ? '已等 ' + secs + ' 秒,上限 ' + Math.round(budget / 1000) + ' 秒(真实时间,不是猜的)'
1088
1403
  : '已等 ' + secs + ' 秒,不设上限(这条线只表示它还活着)',
1089
1404
  }),
1405
+ // 碎碎念:一句一行地换,像有人在旁边用大白话垫着(到点/卡住/停止时不出现)
1406
+ !over && !stalling && !stopping
1407
+ ? React.createElement(
1408
+ 'div',
1409
+ {
1410
+ style: {
1411
+ marginTop: 6,
1412
+ fontSize: FS.min,
1413
+ opacity: 0.45,
1414
+ whiteSpace: 'nowrap',
1415
+ overflow: 'hidden',
1416
+ textOverflow: 'ellipsis',
1417
+ },
1418
+ },
1419
+ '· ' + THINK_LINES[quipIdx % THINK_LINES.length],
1420
+ )
1421
+ : null,
1090
1422
  think
1091
1423
  ? React.createElement(
1092
1424
  Collapse,
@@ -1115,6 +1447,20 @@ window.__ModuleLoader__.load({
1115
1447
  )
1116
1448
  }
1117
1449
 
1450
+ // 1.3.6:闸门跳过(没花钱,也别装成评过了)
1451
+ if (state === 'skipped') {
1452
+ return React.createElement(
1453
+ 'div',
1454
+ {
1455
+ key: 'skipped',
1456
+ ref: grow,
1457
+ style: { marginTop: 10, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', fontSize: FS.min, opacity: 0.6 },
1458
+ },
1459
+ React.createElement('span', { style: { opacity: 0.9 } }, '自动评审跳过:' + String((entry && entry.note) || '这一轮没动过手').slice(0, 80)),
1460
+ React.createElement(Btn, { tier: 'lite', onClick: () => run('manual'), title: '这一轮还是评一次(会调用副模型、花钱)' }, '还是评一下'),
1461
+ )
1462
+ }
1463
+
1118
1464
  if (state === 'error') {
1119
1465
  const partial = String((entry && entry.partial) || '')
1120
1466
  return React.createElement(
@@ -1337,9 +1683,25 @@ window.__ModuleLoader__.load({
1337
1683
  entry && entry.version ? React.createElement('span', null, '· v' + entry.version) : null,
1338
1684
  )
1339
1685
  : null,
1686
+ usageChip(entry && entry.usage),
1687
+ // 1.3.5:副模型和主模型是同一个 → 这次复核基本等于自评,先说清楚(他 2026-11-11 要的)
1688
+ entry && entry.sameSource
1689
+ ? React.createElement(
1690
+ 'span',
1691
+ {
1692
+ title:
1693
+ '主模型和副模型是同一个模型' +
1694
+ (entry.main && entry.main.model ? '(' + String(entry.main.model) + ')' : '') +
1695
+ ',独立视角接近于零。换一个不同来源、或至少不同档位的副模型才有用。',
1696
+ style: { color: '#f59e0b', fontSize: FS.min, whiteSpace: 'nowrap', opacity: 0.95 },
1697
+ },
1698
+ '与主模型相同 · 自评',
1699
+ )
1700
+ : null,
1340
1701
  React.createElement('span', { style: { flex: 1, minWidth: 8 } }),
1341
1702
  swapChip(),
1342
1703
  ),
1704
+ fixModal(),
1343
1705
  )
1344
1706
  }
1345
1707
 
@@ -1349,7 +1711,7 @@ window.__ModuleLoader__.load({
1349
1711
  { style: { marginTop: 10, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', fontSize: FS.body } },
1350
1712
  React.createElement(
1351
1713
  Btn,
1352
- { tier: 'pri', onClick: () => run('manual'), title: '让 ' + (reviewerName || '副模型') + ' 挑一遍这一轮的毛病' },
1714
+ { tier: 'pri', onClick: () => startReview('manual'), title: '让 ' + (reviewerName || '副模型') + ' 挑一遍这一轮的毛病' },
1353
1715
  React.createElement(Mark, { size: 14 }),
1354
1716
  '二次评审',
1355
1717
  ),
@@ -1370,6 +1732,7 @@ window.__ModuleLoader__.load({
1370
1732
  '(设置页没挂上:' + initErrors.join(';') + ')',
1371
1733
  )
1372
1734
  : null,
1735
+ fixModal(),
1373
1736
  )
1374
1737
  }
1375
1738
 
@@ -1440,6 +1803,8 @@ window.__ModuleLoader__.load({
1440
1803
  String(c.provider || ''),
1441
1804
  String(c.model || ''),
1442
1805
  !!c.auto,
1806
+ String(c.autoMode || 'every'),
1807
+ c.modelAuto !== false,
1443
1808
  !!c.mock,
1444
1809
  !!c.allowWrite,
1445
1810
  Number(c.budgetSec) || 0,
@@ -1508,6 +1873,8 @@ window.__ModuleLoader__.load({
1508
1873
  provider: cfg.provider,
1509
1874
  model: cfg.model,
1510
1875
  auto: !!cfg.auto,
1876
+ autoMode: cfg.autoMode === 'actions' ? 'actions' : 'every',
1877
+ modelAuto: cfg.modelAuto !== false,
1511
1878
  mock: !!cfg.mock,
1512
1879
  allowWrite: !!cfg.allowWrite,
1513
1880
  budgetSec: Number(cfg.budgetSec) || 0,
@@ -1547,10 +1914,30 @@ window.__ModuleLoader__.load({
1547
1914
  : note || '改动不会自动保存'
1548
1915
  const hintColor = failedSave ? '#ef4444' : dirty ? '#f59e0b' : 'inherit'
1549
1916
 
1917
+ /* 1.3.6:预设只改开关,绝不偷偷换模型 —— 最终决定权在用户手上(他 2026-11-11 的原话) */
1918
+ const applyPreset = (p) => {
1919
+ if (!cfg) return
1920
+ if (p === 'light') setCfg({ ...cfg, auto: true, autoMode: 'actions', mock: false })
1921
+ else if (p === 'delivery') setCfg({ ...cfg, auto: false, autoMode: 'every', mock: false })
1922
+ else if (p === 'peek') setCfg({ ...cfg, auto: false, autoMode: 'every', mock: true })
1923
+ setNote(p === 'peek' ? '已切到「只看不评」:接下来评的是假数据、不花钱' : '已套用预设 —— 点下面的保存才生效')
1924
+ }
1925
+
1550
1926
  return React.createElement(
1551
1927
  'div',
1552
1928
  { style: { padding: '4px 0', maxWidth: 620 } },
1553
1929
 
1930
+ // 1.3.6:三个预设摆最上面,装完不用读文档也能选
1931
+ React.createElement(
1932
+ 'div',
1933
+ { style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', margin: '0 0 14px' } },
1934
+ React.createElement('span', { style: { opacity: 0.7, minWidth: 88 } }, '预设'),
1935
+ React.createElement(Btn, { tier: 'sec', onClick: () => applyPreset('light'), title: '日常:不每条都评,只有它动过手(写文件、跑命令、发消息)才自动评' }, '日常轻量'),
1936
+ React.createElement(Btn, { tier: 'sec', onClick: () => applyPreset('delivery'), title: '关键交付:不自动评,你点哪一轮评哪一轮' }, '关键交付'),
1937
+ React.createElement(Btn, { tier: 'lite', onClick: () => applyPreset('peek'), title: '先看看卡片长什么样:假数据、不花钱、不留记录' }, '只看不评'),
1938
+ React.createElement('span', { style: { opacity: 0.45, fontSize: FS.min } }, '只改开关,模型还是你自己选'),
1939
+ ),
1940
+
1554
1941
  section(
1555
1942
  '01',
1556
1943
  '模型',
@@ -1566,7 +1953,7 @@ window.__ModuleLoader__.load({
1566
1953
  Select,
1567
1954
  {
1568
1955
  value: cfg.provider || '',
1569
- onChange: (e) => setCfg({ ...cfg, provider: e.target.value, model: '' }),
1956
+ onChange: (e) => setCfg({ ...cfg, provider: e.target.value, model: '', modelAuto: true }),
1570
1957
  },
1571
1958
  ...providerOptions.map((p) =>
1572
1959
  React.createElement('option', { key: p.id, value: p.id }, p.name === p.id ? p.id : p.name + '(' + p.id + ')'),
@@ -1575,10 +1962,16 @@ window.__ModuleLoader__.load({
1575
1962
  React.createElement(
1576
1963
  Select,
1577
1964
  {
1578
- value: cfg.model || '',
1579
- onChange: (e) => setCfg({ ...cfg, model: e.target.value }),
1965
+ // 1.3.6:默认「自动 Pro 档」—— 不存具体 id,DSH 升了版本它自己跟着走
1966
+ value: cfg.modelAuto === false ? cfg.model || '' : '__auto__',
1967
+ onChange: (e) => {
1968
+ const v = e.target.value
1969
+ if (v === '__auto__') setCfg({ ...cfg, modelAuto: true })
1970
+ else setCfg({ ...cfg, model: v, modelAuto: false })
1971
+ },
1580
1972
  },
1581
- cfg.model ? null : React.createElement('option', { key: '__none', value: '' }, '(选一个)'),
1973
+ React.createElement('option', { key: '__auto__', value: '__auto__' }, '自动:Pro 档'),
1974
+ cfg.modelAuto === false && cfg.model ? null : React.createElement('option', { key: '__none', value: '' }, '(手动选一个)'),
1582
1975
  ...models
1583
1976
  .map((m) => (typeof m === 'string' ? { id: m, name: m } : { id: String((m && m.id) || ''), name: String((m && m.name) || (m && m.id) || '') }))
1584
1977
  .filter((m) => m.id)
@@ -1587,6 +1980,14 @@ window.__ModuleLoader__.load({
1587
1980
  ),
1588
1981
  ),
1589
1982
  React.createElement('div', { style: { opacity: 0.55, fontSize: FS.min, marginTop: 6 } }, '评审由这个模型来做;「副模型主刀」打开时,下一轮答复也换成它。'),
1983
+ // 1.3.6:默认 Pro 档、自动跟随;手动选了别的就按你选的来
1984
+ React.createElement(
1985
+ 'div',
1986
+ { style: { opacity: 0.5, fontSize: FS.min, marginTop: 2 } },
1987
+ cfg.modelAuto === false
1988
+ ? '当前是手动指定:' + String(cfg.model || '未选') + '。想省事可以切回「自动:Pro 档」,DSH 升级后它自己跟着换。'
1989
+ : '默认走 Pro 档(' + String((models.find((m) => /pro/i.test(String((m && m.id) || m))) || {}).id || '按你的 provider 实际清单') + '),DSH 出了新一代 Pro 会自动跟着换;主模型如果也是它,这次复核等于自评。',
1990
+ ),
1590
1991
  ),
1591
1992
 
1592
1993
  section(
@@ -1595,10 +1996,34 @@ window.__ModuleLoader__.load({
1595
1996
  React.createElement(Check, {
1596
1997
  checked: cfg.auto,
1597
1998
  onChange: (e) => setCfg({ ...cfg, auto: e.target.checked }),
1598
- title: '每条回复结束后自动评一次',
1599
- desc: '会调用副模型,产生费用',
1999
+ title: '自动评审',
2000
+ desc: '会调用副模型,产生费用;评到哪一步由下面那行决定',
1600
2001
  style: { marginBottom: 2 },
1601
2002
  }),
2003
+ // 1.3.6:闸门 —— 用户决定"每条都评"还是"只有动过手才评"
2004
+ React.createElement(
2005
+ 'div',
2006
+ { style: { display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap', margin: '2px 0 4px' } },
2007
+ React.createElement('span', { style: { opacity: 0.7, minWidth: 88 } }, '自动评审范围'),
2008
+ React.createElement(
2009
+ 'span',
2010
+ { style: { marginLeft: 'auto' } },
2011
+ React.createElement(
2012
+ Select,
2013
+ {
2014
+ value: cfg.autoMode === 'actions' ? 'actions' : 'every',
2015
+ onChange: (e) => setCfg({ ...cfg, autoMode: e.target.value }),
2016
+ },
2017
+ React.createElement('option', { key: 'every', value: 'every' }, '每条回复都评(默认)'),
2018
+ React.createElement('option', { key: 'actions', value: 'actions' }, '只有动过手的那一轮才评'),
2019
+ ),
2020
+ ),
2021
+ ),
2022
+ React.createElement(
2023
+ 'div',
2024
+ { style: { opacity: 0.5, fontSize: FS.min, marginBottom: 6 } },
2025
+ '「动过手」=写文件、跑命令、发消息,或这一轮工作区有改动;纯聊天、纯只读的问答不评。判断全在本地,不花钱。',
2026
+ ),
1602
2027
  React.createElement(Check, {
1603
2028
  checked: cfg.mock,
1604
2029
  onChange: (e) => setCfg({ ...cfg, mock: e.target.checked }),
package/index.js CHANGED
@@ -89,14 +89,20 @@ const MAX_CHARS = 60000
89
89
  const LEGACY_BUDGET_MS = 45000
90
90
 
91
91
  /* 版本号(0.7.0 起):/config 会带出去,设置页「关于」直接显示,不用去翻 package.json */
92
- const VERSION = '1.3.4'
92
+ const VERSION = '1.3.6'
93
93
 
94
94
  /* ---------------- 配置(副模型 + 自动评审 + 主刀) ---------------- */
95
95
 
96
96
  const DEFAULT_CONFIG = {
97
97
  provider: 'deepseek-account',
98
- model: 'deepseek-flash',
98
+ /* 1.3.6:副模型默认走 Pro 档,而且"跟着版本走" —— modelAuto 打开时不用存具体 id,
99
+ 每次评审现查这个 provider 的模型清单,挑名字里带 pro 的最新一代。
100
+ DSH 哪天把 DeepSeek-V4-Pro 换成 V4.1-Pro,装插件的人不用改设置也跟着变。 */
101
+ model: 'deepseek-v4-pro',
102
+ modelAuto: true,
99
103
  auto: false,
104
+ // 1.3.6:重合提示弹窗的"不再提示"(只在用户自己勾了才为 true)
105
+ hideSameSourceModal: false,
100
106
  mock: false,
101
107
  /* 副模型能不能改文件:默认关(2026-11-09 他选的 B)。
102
108
  关着的时候工具表里根本没有 write_file,模型连"想调"都无从下手;
@@ -115,6 +121,10 @@ const DEFAULT_CONFIG = {
115
121
  她把一条自己编出来的用户消息当成用户说的,还照着它删了信、改了记忆。
116
122
  纯本地字符串比对 —— 不调模型、不花钱、不拖慢评审。 */
117
123
  checkQuotes: true,
124
+ /* 1.3.6:自动评审走到哪一步才触发(用户自己选,插件只负责执行)。
125
+ 'every' = 每条回复都评(1.3.5 及以前的行为,默认,别偷偷改人家的习惯)
126
+ 'actions' = 只有这一轮真动过手(写文件/跑命令/发消息/工作区有变化)才评 */
127
+ autoMode: 'every',
118
128
  }
119
129
 
120
130
  const CONFIG_FILE = path.join(os.homedir(), '.dsh', 'dsh-second-review', 'config.json')
@@ -927,6 +937,144 @@ export function verifyUserQuotes(rows) {
927
937
  return result
928
938
  }
929
939
 
940
+ /* 1.3.5:主模型和副模型是不是"同一个"。
941
+ 只判完全相同这一层:同一个 model 换个 provider,还是同一双眼睛;
942
+ 不同 provider 的模型命名各异,硬猜"同源"会误报(小问 2026-11-10 的判断,我认同)。
943
+ 只用来提示,不用来拦截:只有一个 Key 的人照样允许重选。 */
944
+ export function sameModel(a, b) {
945
+ const pick = (x) => String((x && typeof x === 'object' ? x.model : x) || '').trim().toLowerCase()
946
+ const am = pick(a)
947
+ const bm = pick(b)
948
+ return !!am && am === bm
949
+ }
950
+
951
+ /* 1.3.6:Pro 档自动跟随 —— 从模型清单里挑名字带 pro 的最新一版。
952
+ 查不到清单/清单里没有 pro,就退回配置里存的 id,绝不乱猜、也不擅自换成便宜档。 */
953
+ export function pickProModel(list) {
954
+ const ids = (Array.isArray(list) ? list : [])
955
+ .map((m) => String((m && typeof m === 'object' ? m.id : m) || ''))
956
+ .filter((id) => id && /pro/i.test(id))
957
+ if (!ids.length) return ''
958
+ const ver = (id) => (id.match(/\d+(?:[._]\d+)*/g) || []).join('.').split(/[._]/).filter(Boolean).map(Number)
959
+ ids.sort((a, b) => {
960
+ const va = ver(a)
961
+ const vb = ver(b)
962
+ for (let i = 0; i < Math.max(va.length, vb.length); i++) {
963
+ const x = va[i] || 0
964
+ const y = vb[i] || 0
965
+ if (x !== y) return y - x
966
+ }
967
+ return a.localeCompare(b)
968
+ })
969
+ return ids[0]
970
+ }
971
+
972
+ export async function resolveReviewModel(llm, cfg) {
973
+ if (!cfg || cfg.modelAuto !== true) return cfg && cfg.model
974
+ try {
975
+ const list = await llm.listModels(cfg.provider)
976
+ const pro = pickProModel(list)
977
+ if (pro) return pro
978
+ } catch {}
979
+ return cfg.model
980
+ }
981
+
982
+ /* ---------------- 1.3.6:会话说得清的账,都从日志里算,不再问模型 ----------------
983
+ 会话日志里每一轮事件都带 seq / turn,assistant/message 上带 usage:
984
+ {inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, totalTokens}
985
+ (2026-11-11 从本机真实会话解出来验过)。所以"用了多少 token"可以纯代码算,
986
+ 不额外调一次模型、不花钱。 */
987
+
988
+ function logItems(session) {
989
+ const log = (session && session.log) || {}
990
+ return Object.entries(log)
991
+ .map(([k, e]) => ({ seq: Number(e && e.seq) || Number(k), e }))
992
+ .filter((x) => Number.isFinite(x.seq))
993
+ .sort((a, b) => a.seq - b.seq)
994
+ }
995
+
996
+ /* 被评的那条回复属于第几轮(turn) */
997
+ export function turnOf(session, beforeSeq) {
998
+ const limit = Number(beforeSeq)
999
+ let turn = null
1000
+ for (const { seq, e } of logItems(session)) {
1001
+ if (Number.isFinite(limit) && seq > limit) break
1002
+ if (e && e.type === 'assistant/message' && e.data && typeof e.data.turn === 'number') turn = e.data.turn
1003
+ }
1004
+ return turn
1005
+ }
1006
+
1007
+ /* 这一轮主模型花了多少 token(多步/多轮的都按 turn 加总) */
1008
+ export function usageOfTurn(session, beforeSeq) {
1009
+ const turn = turnOf(session, beforeSeq)
1010
+ if (turn === null) return null
1011
+ const sum = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0, messages: 0 }
1012
+ for (const { e } of logItems(session)) {
1013
+ if (!e || e.type !== 'assistant/message' || !e.data || e.data.turn !== turn) continue
1014
+ const u = e.data.usage
1015
+ if (!u) continue
1016
+ sum.input += Number(u.inputTokens) || 0
1017
+ sum.output += Number(u.outputTokens) || 0
1018
+ sum.cacheRead += Number(u.cacheReadTokens) || 0
1019
+ sum.cacheWrite += Number(u.cacheWriteTokens) || 0
1020
+ sum.total += Number(u.totalTokens) || 0
1021
+ sum.messages += 1
1022
+ }
1023
+ return sum.messages ? sum : null
1024
+ }
1025
+
1026
+ /* 这一轮到底动没动手:写文件/跑命令/发消息/工作区有变化才算。
1027
+ 纯聊天、纯只读的问答不算 —— 这正是"省钱的闸门"的判据。 */
1028
+ const ACTION_TOOL_RE = /(write|edit|delete|remove|move|rename|mkdir|patch|shell|bash|pwsh|powershell|exec|spawn|install|upload|send|post|publish|run)/i
1029
+
1030
+ export function turnTouched(session, beforeSeq) {
1031
+ const turn = turnOf(session, beforeSeq)
1032
+ if (turn === null) return false
1033
+ let touched = false
1034
+ for (const { e } of logItems(session)) {
1035
+ if (!e || !e.data || e.data.turn !== turn) continue
1036
+ if (e.type === 'workspace/changes') return true
1037
+ if (e.type === 'tool/call' && ACTION_TOOL_RE.test(String(e.data.name || ''))) touched = true
1038
+ }
1039
+ return touched
1040
+ }
1041
+
1042
+ /* 评审自己那几次调用,finish 里要是带 usage 就取出来;没带就返回 null(不编数字)。 */
1043
+ export function usageOfFinish(finish) {
1044
+ if (!finish || typeof finish !== 'object') return null
1045
+ const u =
1046
+ finish.usage ||
1047
+ (finish.message && finish.message.usage) ||
1048
+ (finish.result && finish.result.usage) ||
1049
+ (finish.data && finish.data.usage) ||
1050
+ null
1051
+ if (!u || typeof u !== 'object') return null
1052
+ const n = (x) => Number(x) || 0
1053
+ const input = n(u.inputTokens || u.input_tokens || u.promptTokens)
1054
+ const output = n(u.outputTokens || u.output_tokens || u.completionTokens)
1055
+ const cacheRead = n(u.cacheReadTokens || u.cache_read_tokens)
1056
+ const cacheWrite = n(u.cacheWriteTokens || u.cache_write_tokens)
1057
+ if (!input && !output && !cacheRead && !cacheWrite) return null
1058
+ return { input, output, cacheRead, cacheWrite, total: input + output }
1059
+ }
1060
+
1061
+ /* 把一次评审里每一步的用量加起来(带工具时会有好几次调用) */
1062
+ export function sumUsage(list) {
1063
+ if (!Array.isArray(list) || !list.length) return null
1064
+ const sum = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }
1065
+ let any = false
1066
+ for (const u of list) {
1067
+ if (!u) continue
1068
+ any = true
1069
+ sum.input += Number(u.input) || 0
1070
+ sum.output += Number(u.output) || 0
1071
+ sum.cacheRead += Number(u.cacheRead) || 0
1072
+ sum.cacheWrite += Number(u.cacheWrite) || 0
1073
+ sum.total += Number(u.total) || 0
1074
+ }
1075
+ return any ? sum : null
1076
+ }
1077
+
930
1078
  export function saveReview(rootDir, review, steps, meta) {
931
1079
  try {
932
1080
  const dir = path.join(rootDir, '二次评审')
@@ -1249,14 +1397,22 @@ export function apply(ctx) {
1249
1397
  if (body && typeof body.mock === 'boolean') patch.mock = body.mock
1250
1398
  if (body && typeof body.allowWrite === 'boolean') patch.allowWrite = body.allowWrite
1251
1399
  if (body && typeof body.noticeAck === 'boolean') patch.noticeAck = body.noticeAck
1400
+ // 1.3.6:Pro 档自动跟随;重合弹窗的"不再提示"
1401
+ if (body && typeof body.modelAuto === 'boolean') patch.modelAuto = body.modelAuto
1402
+ if (body && typeof body.hideSameSourceModal === 'boolean') patch.hideSameSourceModal = body.hideSameSourceModal
1252
1403
  // 0.9.0:两个闸都能从设置页改,夹到合理区间(0 = 不设)
1253
1404
  if (body && Number.isFinite(Number(body.budgetSec))) patch.budgetSec = Math.max(0, Math.min(1800, Math.round(Number(body.budgetSec))))
1254
1405
  if (body && Number.isFinite(Number(body.idleSec))) patch.idleSec = Math.max(0, Math.min(1800, Math.round(Number(body.idleSec))))
1255
1406
  // 1.1.0:引文核验的开关(默认开,纯本地比对,不花钱)
1256
1407
  if (body && typeof body.checkQuotes === 'boolean') patch.checkQuotes = body.checkQuotes
1257
- return sendJson(res, 200, { ok: true, config: writeConfig(patch), file: CONFIG_FILE, version: VERSION })
1408
+ // 1.3.6:自动评审范围,只认这两个值(别让乱七八糟的值把闸门关死)
1409
+ if (body && (body.autoMode === 'every' || body.autoMode === 'actions')) patch.autoMode = body.autoMode
1410
+ const saved = writeConfig(patch)
1411
+ // 1.3.6:顺手把"自动跟着走"最终落到哪个模型告诉前端(设置页和卡片都要显示它)
1412
+ return sendJson(res, 200, { ok: true, config: saved, resolvedModel: await resolveReviewModel(scope.get('llm'), saved), file: CONFIG_FILE, version: VERSION })
1258
1413
  }
1259
- return sendJson(res, 200, { ok: true, config: readConfig(), file: CONFIG_FILE, version: VERSION })
1414
+ const cfg = readConfig()
1415
+ return sendJson(res, 200, { ok: true, config: cfg, resolvedModel: await resolveReviewModel(scope.get('llm'), cfg), file: CONFIG_FILE, version: VERSION })
1260
1416
  } catch (error) {
1261
1417
  return sendErr(res, error)
1262
1418
  }
@@ -1306,6 +1462,29 @@ export function apply(ctx) {
1306
1462
  }
1307
1463
  }
1308
1464
 
1465
+ /* 1.3.5:这个会话"现在的主模型"是谁。
1466
+ 走 DSH 自己的 agent.model.current()(副模型主刀也用它);
1467
+ 读不到就返回 null —— 卡片上不显示提示,绝不为了一句提示去猜。 */
1468
+ const currentMain = (sid) => {
1469
+ try {
1470
+ const agents = scope.get('agents')
1471
+ const live = agents && typeof agents.list === 'function' ? agents.list() : []
1472
+ const normId = (v) => String(v || '').replace(/^session-/, '')
1473
+ const hit = (Array.isArray(live) ? live : []).find((a) => {
1474
+ try {
1475
+ return a && (normId(a.id) === normId(sid) || (a.session && normId(a.session.id) === normId(sid)))
1476
+ } catch {
1477
+ return false
1478
+ }
1479
+ })
1480
+ const cur = hit && hit.model && typeof hit.model.current === 'function' ? hit.model.current() : null
1481
+ if (!cur) return null
1482
+ return { provider: String(cur.provider || ''), model: String(cur.model || '') }
1483
+ } catch {
1484
+ return null
1485
+ }
1486
+ }
1487
+
1309
1488
  // 副模型主刀:把这一轮"谁在答"换成副模型(走 DSH 官方的 agent.model.select,不碰消息归属)
1310
1489
  // 状态按会话分开存:A 会话开了主刀,不会把 B 会话的"原来的模型"覆盖掉。
1311
1490
  const swap = async (req, res) => {
@@ -1383,6 +1562,8 @@ export function apply(ctx) {
1383
1562
  if (!llm) return sendJson(res, 503, { ok: false, error: 'llm service missing' })
1384
1563
 
1385
1564
  let rows = []
1565
+ // 1.3.6:闸门和用量都要会话原始日志,先把找到的那份留住(demo 探针没有,就是 null)
1566
+ let sessionObj = null
1386
1567
  const cfg = readConfig()
1387
1568
 
1388
1569
  // 进度:前端带着 job=来的,就边跑边往里写"想什么"(只进内存,不进记录)
@@ -1423,8 +1604,11 @@ export function apply(ctx) {
1423
1604
  const entry = sidKey ? readSwaps(cfg)[sidKey] : null
1424
1605
  const reviewCfg =
1425
1606
  !demo && entry && entry.saved && entry.saved.provider && entry.saved.model
1426
- ? { ...cfg, provider: entry.saved.provider, model: entry.saved.model }
1607
+ ? { ...cfg, provider: entry.saved.provider, model: entry.saved.model, modelAuto: false }
1427
1608
  : cfg
1609
+ // 1.3.6:Pro 档跟着版本走(只在 modelAuto 打开时生效;主刀互换时用的是原主模型,不动)
1610
+ const runModel = await resolveReviewModel(llm, reviewCfg)
1611
+ const runCfg = runModel && runModel !== reviewCfg.model ? { ...reviewCfg, model: runModel } : reviewCfg
1428
1612
 
1429
1613
  let rootDir = null
1430
1614
  if (demo) {
@@ -1459,6 +1643,7 @@ export function apply(ctx) {
1459
1643
  if (hit && hit.session) {
1460
1644
  rootDir = makeRoot(rootOf(hit.session))
1461
1645
  rows = transcriptOf(hit.session, anchor)
1646
+ sessionObj = hit.session
1462
1647
  } else {
1463
1648
  return sendJson(res, 404, {
1464
1649
  ok: false,
@@ -1469,9 +1654,21 @@ export function apply(ctx) {
1469
1654
  } else {
1470
1655
  rootDir = makeRoot(rootOf(session))
1471
1656
  rows = transcriptOf(session, anchor)
1657
+ sessionObj = session
1472
1658
  }
1473
1659
  }
1474
1660
 
1661
+ /* 1.3.6:闸门。用户把 autoMode 设成 'actions' 时,纯聊天的轮次不调副模型 —— 直接跳过。
1662
+ 判断全在本地(看这轮有没有写文件/跑命令/发消息),不花钱、不猜。 */
1663
+ if (!demo && body.auto === true && String(cfg.autoMode || 'every') === 'actions' && !turnTouched(sessionObj, anchor)) {
1664
+ return sendJson(res, 200, {
1665
+ ok: true,
1666
+ skipped: true,
1667
+ reason: '这一轮没动过手(没写文件、没跑命令、没发消息),自动评审跳过了',
1668
+ gate: 'actions',
1669
+ })
1670
+ }
1671
+
1475
1672
  if (rows.length === 0) return sendJson(res, 200, { ok: true, empty: true, verdict: 'pass', note: '这一轮还没有对话内容', major: [], minor: [], steps: 0 })
1476
1673
 
1477
1674
  // 引文核验(1.1.0):在动模型之前先把"有没有编出来的用户原话"查一遍 —— 纯本地,零成本
@@ -1493,6 +1690,8 @@ export function apply(ctx) {
1493
1690
  const allowWrite = cfg.allowWrite === true
1494
1691
  const steps = []
1495
1692
  const finishes = []
1693
+ // 1.3.6:评审自己每次调用的用量(finish 里带就收,不带就空着,绝不估算)
1694
+ const usages = []
1496
1695
  let finalText = ''
1497
1696
  let budget = 0
1498
1697
  let timedOut = false
@@ -1523,7 +1722,8 @@ export function apply(ctx) {
1523
1722
  break
1524
1723
  }
1525
1724
  // 工具不设次数上限(他 2026-11-09 明确说过:磨洋工是"看不该看的、说不该说的",不是限次数)
1526
- const r = await streamWithRetry(llm, messages, tools, reviewCfg, onDelta, deadlineAt, stopOpts)
1725
+ const r = await streamWithRetry(llm, messages, tools, runCfg, onDelta, deadlineAt, stopOpts)
1726
+ usages.push(usageOfFinish(r.finish))
1527
1727
  finishes.push({
1528
1728
  step: i,
1529
1729
  kind: r.finish && r.finish.kind,
@@ -1551,7 +1751,7 @@ export function apply(ctx) {
1551
1751
  messages.push({
1552
1752
  role: 'assistant',
1553
1753
  id: randomUUID(),
1554
- source: { kind: 'assistant', provider: reviewCfg.provider, model: reviewCfg.model },
1754
+ source: { kind: 'assistant', provider: runCfg.provider, model: runCfg.model },
1555
1755
  ...(r.replayState ? { replayState: r.replayState } : {}),
1556
1756
  content: blocks,
1557
1757
  })
@@ -1650,12 +1850,16 @@ export function apply(ctx) {
1650
1850
  const swapped = !demo && !!(entry && entry.saved && entry.saved.provider && entry.saved.model)
1651
1851
  const reviewer = {
1652
1852
  provider: String(reviewCfg.provider || ''),
1653
- model: String(reviewCfg.model || ''),
1853
+ model: String(runCfg.model || ''),
1654
1854
  role: swapped ? '原主模型(副模型主刀时互换)' : '副模型',
1655
1855
  }
1856
+ /* 1.3.5:这一评是"换了个脑子",还是"同一双眼睛又看了一遍"?
1857
+ 主模型从 DSH 的 agent.model.current() 现取;读不到就是 null,卡片上不提示 —— 不猜。 */
1858
+ const main = demo ? null : currentMain(sessionId)
1859
+ const sameSource = !!(main && sameModel(main, reviewer))
1656
1860
  const saved = demo
1657
1861
  ? ''
1658
- : saveReview(rootDir, parsed, steps, { sessionId, seq: anchor, model: reviewCfg.provider + ' / ' + reviewCfg.model, reviewer, provenance })
1862
+ : saveReview(rootDir, parsed, steps, { sessionId, seq: anchor, model: runCfg.provider + ' / ' + runCfg.model, reviewer, provenance })
1659
1863
  const payload = {
1660
1864
  ok: true,
1661
1865
  topic: parsed.topic,
@@ -1670,12 +1874,20 @@ export function apply(ctx) {
1670
1874
  items: [...parsed.major, ...parsed.minor],
1671
1875
  saved,
1672
1876
  reviewer,
1877
+ // 1.3.5:主模型是谁、和副模型是不是同一个(同一个=这次复核基本是自评)
1878
+ main,
1879
+ sameSource,
1673
1880
  // 1.1.0:引文核验结果(机器查的,不靠模型)。checked=0 就是这一轮它没引你的话
1674
1881
  provenance,
1675
1882
  // 这张卡是哪个版本画出来的(0.9.0 加)—— 他遇到过一次"改了没生效"
1676
1883
  version: VERSION,
1677
1884
  // 这一轮到底花了多久(0.9.1):5 分钟那种,卡上得看得见
1678
1885
  elapsedMs: Date.now() - startedAt,
1886
+ // 1.3.6:这一轮主模型的用量(从会话日志算,零成本)+ 复核自己的用量(finish 带才算)
1887
+ usage: {
1888
+ turn: demo ? null : usageOfTurn(sessionObj, anchor),
1889
+ review: sumUsage(usages),
1890
+ },
1679
1891
  parseFailed: parsed.parseFailed,
1680
1892
  truncated,
1681
1893
  timedOut,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-second-review",
3
- "version": "1.3.4",
3
+ "version": "1.3.6",
4
4
  "description": "二次评审:每条回复下面挂一张独立的副模型评审卡。让一个模型干活、另一个模型检查,结果挂在原回复下面,不重复回答、不抢占主模型。",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -102,6 +102,11 @@ function barOf(el) {
102
102
  const bar = walk(el).find((n) => n.props && n.props.style && n.props.style.height === 2)
103
103
  return bar && bar.kids && bar.kids[0]
104
104
  }
105
+ function tiersOf(el) {
106
+ return walk(el)
107
+ .filter((n) => typeof n.type === 'function' && n.type.name === 'Btn')
108
+ .map((n) => n.props.tier)
109
+ }
105
110
  function assert(cond, msg) {
106
111
  if (!cond) throw new Error(msg)
107
112
  }
@@ -132,11 +137,27 @@ const cases = [
132
137
  // 1.1.0:引文核验(机器查的,不靠模型)
133
138
  provenance: { checked: 2, found: 1, missing: ['我什么时候说让你回信了'], userCount: 6 },
134
139
  reviewer: { provider: 'deepseek-account', model: 'deepseek-flash', role: '副模型' },
140
+ // 1.3.6:这一次的账(复核+本轮)
141
+ usage: { review: { input: 1234, output: 567, cacheRead: 0 }, turn: { input: 1534, output: 443, cacheRead: 14720 } },
135
142
  },
136
143
  ],
137
144
  ['完成 通过', { state: 'done', verdict: 'pass', note: '没发现必须改的地方。', major: [], minor: [] }],
145
+ [
146
+ '完成 与主模型相同',
147
+ {
148
+ state: 'done',
149
+ verdict: 'pass',
150
+ note: '',
151
+ major: [],
152
+ minor: [],
153
+ reviewer: { provider: 'deepseek-account', model: 'deepseek-flash', role: '副模型' },
154
+ main: { provider: 'deepseek-account', model: 'deepseek-flash' },
155
+ sameSource: true,
156
+ },
157
+ ],
138
158
  ['完成 说不清', { state: 'done', verdict: 'unclear', note: '它说不清这一轮在评什么', major: [], minor: [] }],
139
159
  ['失败', { state: 'error', note: 'provider 断流' }],
160
+ ['闸门跳过', { state: 'skipped', note: '这一轮没动过手(没写文件、没跑命令、没发消息),自动评审跳过了' }],
140
161
  ['首次告知', { state: 'notice', pendingKind: 'manual' }],
141
162
  ]
142
163
 
@@ -154,6 +175,7 @@ for (const [label, value] of cases) {
154
175
  assert(fill.props.style.width === '51.11%', '细线宽度不对:' + fill.props.style.width + '(应为 23000/45000)')
155
176
  assert(fill.props.style.background !== '#f59e0b', '还没到点就变琥珀了')
156
177
  assert(text.indexOf('字幕最后一行在这里') >= 0, '字幕没露出来')
178
+ assert(text.indexOf('帮你理一理') >= 0, '碎碎念(思考中的那句)没画出来')
157
179
  }
158
180
  if (label === '评审中 到点') {
159
181
  const fill = barOf(el)
@@ -178,7 +200,17 @@ for (const [label, value] of cases) {
178
200
  assert(tiers.filter((t) => t === 'pri').length === 1, '未评这排应该正好一个主按钮,实际 ' + JSON.stringify(tiers))
179
201
  assert(tiers.filter((t) => t === 'sec').length >= 1, '次按钮没画出来:' + JSON.stringify(tiers))
180
202
  }
203
+ if (label === '完成 与主模型相同') {
204
+ assert(text.indexOf('与主模型相同') >= 0, '副模型和主模型是同一个时,卡片上没提示')
205
+ }
206
+ if (label === '闸门跳过') {
207
+ assert(text.indexOf('自动评审跳过') >= 0, '跳过了却没写出来')
208
+ assert(tiersOf(el).indexOf('pri') < 0, '跳过以后不该再摆一个主按钮(还没花钱,别催他)')
209
+ }
181
210
  if (label === '完成 一剑封喉') {
211
+ assert(text.indexOf('复核 1.2k进') >= 0, '卡片末尾没显示复核的 token')
212
+ assert(text.indexOf('本轮 1.5k进') >= 0, '卡片末尾没显示这一轮的 token')
213
+ assert(text.indexOf('与主模型相同') < 0, '两模型不同却报了"与主模型相同"')
182
214
  assert(text.indexOf('deepseek-flash') >= 0, '卡片上没有署名(模型名)')
183
215
  assert(text.indexOf('署名') >= 0, '卡片上没写"署名"两个字')
184
216
  assert(text.indexOf('引文核验') >= 0, '卡片上没有引文核验那一行')
@@ -25,7 +25,7 @@ fs.writeFileSync(path.join(ROOT, '记忆卡', '00-核心记忆卡.md'), '私人
25
25
  fs.writeFileSync(path.join(ROOT, '普通文档.md'), '公开的')
26
26
 
27
27
  const mod = await import(new URL('../index.js', import.meta.url).href)
28
- const { parseReview, sanitizeReview, blockedPath, clipTranscript, transcriptOf, toolsFor, runTool, saveReview, shouldNudge, streamWithRetry, verifyUserQuotes, READ_TOOLS, WRITE_TOOL } = mod
28
+ const { parseReview, sanitizeReview, blockedPath, clipTranscript, transcriptOf, toolsFor, runTool, saveReview, shouldNudge, streamWithRetry, verifyUserQuotes, sameModel, turnOf, usageOfTurn, turnTouched, usageOfFinish, sumUsage, pickProModel, resolveReviewModel, READ_TOOLS, WRITE_TOOL } = mod
29
29
 
30
30
  test.after(() => {
31
31
  try {
@@ -764,3 +764,120 @@ test('落盘 md 带引文核验一节', () => {
764
764
  fs.rmSync(dir, { recursive: true, force: true })
765
765
  }
766
766
  })
767
+
768
+ /* ---------------- 1.3.5:主副模型是不是同一个 ---------------- */
769
+
770
+ test('同一个模型:忽略大小写和首尾空格', () => {
771
+ assert.equal(sameModel({ provider: 'deepseek-account', model: 'deepseek-flash' }, { provider: 'deepseek-account', model: ' DeepSeek-Flash ' }), true)
772
+ })
773
+
774
+ test('同一个模型名、provider 不同也算同一个(还是同一双眼睛)', () => {
775
+ assert.equal(sameModel({ provider: 'a', model: 'deepseek-flash' }, { provider: 'b', model: 'deepseek-flash' }), true)
776
+ })
777
+
778
+ test('不同模型不算;缺模型也不算(宁可不说,不许瞎说)', () => {
779
+ assert.equal(sameModel({ provider: 'a', model: 'deepseek-flash' }, { provider: 'a', model: 'deepseek-pro' }), false)
780
+ assert.equal(sameModel({ provider: 'a', model: '' }, { provider: 'a', model: '' }), false)
781
+ assert.equal(sameModel(null, { provider: 'a', model: 'deepseek-flash' }), false)
782
+ assert.equal(sameModel(undefined, undefined), false)
783
+ })
784
+
785
+ test('也吃字符串(别的地方可能只传模型名)', () => {
786
+ assert.equal(sameModel('deepseek-flash', { model: 'deepseek-flash' }), true)
787
+ assert.equal(sameModel('deepseek-flash', 'deepseek-pro'), false)
788
+ })
789
+
790
+ /* ---------------- 1.3.6:账从日志里算(不调模型)+自动评审闸门 ---------------- */
791
+
792
+ function fakeSession(extra = {}) {
793
+ return {
794
+ log: {
795
+ 1: { type: 'user/message', seq: 1, data: { turn: 1 } },
796
+ 2: { type: 'assistant/message', seq: 2, data: { turn: 1, usage: { inputTokens: 10, outputTokens: 5, cacheReadTokens: 3, totalTokens: 15 } } },
797
+ 3: { type: 'tool/call', seq: 3, data: { turn: 1, name: 'read_file', arguments: '{}' } },
798
+ 4: { type: 'assistant/message', seq: 4, data: { turn: 1, usage: { inputTokens: 20, outputTokens: 7, cacheReadTokens: 1, totalTokens: 27 } } },
799
+ ...extra,
800
+ },
801
+ }
802
+ }
803
+
804
+ test('轮次:按被评的那条消息往前找 turn', () => {
805
+ assert.equal(turnOf(fakeSession(), 4), 1)
806
+ assert.equal(turnOf(fakeSession(), 2), 1)
807
+ assert.equal(turnOf({ log: {} }, 4), null)
808
+ })
809
+
810
+ test('这一轮主模型用了多少 token:按 turn 加总,不调模型', () => {
811
+ const u = usageOfTurn(fakeSession(), 4)
812
+ assert.equal(u.input, 30)
813
+ assert.equal(u.output, 12)
814
+ assert.equal(u.cacheRead, 4)
815
+ assert.equal(u.messages, 2)
816
+ assert.equal(usageOfTurn({ log: {} }, 4), null)
817
+ })
818
+
819
+ test('闸门:只读工具不算动手', () => {
820
+ assert.equal(turnTouched(fakeSession(), 4), false)
821
+ })
822
+
823
+ test('闸门:写文件/跑命令/工作区变动都算动手', () => {
824
+ assert.equal(turnTouched(fakeSession({ 3: { type: 'tool/call', seq: 3, data: { turn: 1, name: 'write_file' } } }), 4), true)
825
+ assert.equal(turnTouched(fakeSession({ 3: { type: 'tool/call', seq: 3, data: { turn: 1, name: 'pwsh' } } }), 4), true)
826
+ assert.equal(turnTouched(fakeSession({ 5: { type: 'workspace/changes', seq: 5, data: { turn: 1 } } }), 4), true)
827
+ })
828
+
829
+ test('评审自己的用量:finish 里带就取,几种形状都认,没有就 null(不许估)', () => {
830
+ assert.deepEqual(usageOfFinish({ usage: { inputTokens: 100, outputTokens: 20, cacheReadTokens: 900 } }), {
831
+ input: 100,
832
+ output: 20,
833
+ cacheRead: 900,
834
+ cacheWrite: 0,
835
+ total: 120,
836
+ })
837
+ assert.equal(usageOfFinish({ message: { usage: { input_tokens: 5, output_tokens: 6 } } }).total, 11)
838
+ assert.equal(usageOfFinish({ kind: 'completed' }), null)
839
+ assert.equal(usageOfFinish(null), null)
840
+ })
841
+
842
+ test('多步评审的用量按次相加', () => {
843
+ const sum = sumUsage([
844
+ { input: 100, output: 20, cacheRead: 0, cacheWrite: 0, total: 120 },
845
+ null,
846
+ { input: 50, output: 5, cacheRead: 30, cacheWrite: 0, total: 55 },
847
+ ])
848
+ assert.equal(sum.input, 150)
849
+ assert.equal(sum.output, 25)
850
+ assert.equal(sum.cacheRead, 30)
851
+ assert.equal(sumUsage([null, undefined]), null)
852
+ assert.equal(sumUsage([]), null)
853
+ })
854
+
855
+ /* ---------------- 1.3.6:默认副模型=自动 Pro 档 ---------------- */
856
+
857
+ test('从模型清单里挑 Pro 档', () => {
858
+ const list = [{ id: 'deepseek-flash' }, { id: 'deepseek-v4-pro', name: 'V4-Pro' }]
859
+ assert.equal(pickProModel(list), 'deepseek-v4-pro')
860
+ })
861
+
862
+ test('出了新一代 Pro,自动选新的(DSH 升级不用改设置)', () => {
863
+ assert.equal(pickProModel(['deepseek-v4-pro', 'deepseek-v4.1-pro']), 'deepseek-v4.1-pro')
864
+ assert.equal(pickProModel([{ id: 'deepseek-v5-pro' }, { id: 'deepseek-v4.1-pro' }]), 'deepseek-v5-pro')
865
+ })
866
+
867
+ test('清单里没有 Pro:不乱选一个便宜的充数', () => {
868
+ assert.equal(pickProModel(['deepseek-flash']), '')
869
+ assert.equal(pickProModel([]), '')
870
+ assert.equal(pickProModel(null), '')
871
+ })
872
+
873
+ test('手动指定模型时不去查清单、也不改人家的选择', async () => {
874
+ let called = 0
875
+ const llm = { listModels: async () => (called++, []) }
876
+ assert.equal(await resolveReviewModel(llm, { provider: 'x', model: 'my-model', modelAuto: false }), 'my-model')
877
+ assert.equal(called, 0)
878
+ })
879
+
880
+ test('自动档但查不到清单:退回配置里的 id,不报错', async () => {
881
+ const llm = { listModels: async () => { throw new Error('offline') } }
882
+ assert.equal(await resolveReviewModel(llm, { provider: 'x', model: 'deepseek-v4-pro', modelAuto: true }), 'deepseek-v4-pro')
883
+ })