dsh-second-review 0.0.0-stage → 1.3.2

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/client.js ADDED
@@ -0,0 +1,1814 @@
1
+ /**
2
+ * dsh-second-review — 客户端(原探针 dsh-second-review-probe,1.0.0 起改名)
3
+ *
4
+ * v3(2026-11-08 深夜):
5
+ * - 评审挂在每一轮回复下面的 turnTail 里,样式跟正文一致
6
+ * - 设置页:副模型 + 自动评审开关(注册在「插件 → 二次评审 → 配置」)
7
+ * - 自动评审:回复到达后自动评一次;页面刷新时不会把历史轮次全部重评
8
+ * - 「发给主模型」已摘除(它会把会话搞死,见 02 §三)
9
+ */
10
+
11
+ window.__ModuleLoader__.load({
12
+ id: 'dsh-second-review',
13
+ factory(require) {
14
+ const React = require('react')
15
+ const { useState, useEffect } = React
16
+ const API = '/plugins/dsh-second-review'
17
+
18
+ const CAT_COLOR = {
19
+ 补充: '#3b82f6',
20
+ 合理性: '#8b5cf6',
21
+ 可行性: '#10b981',
22
+ 容错率: '#f59e0b',
23
+ 质疑: '#ef4444',
24
+ 代价: '#ec4899',
25
+ 风险提示: '#f97316',
26
+ }
27
+
28
+ /* 字号(2026-11-08 22:57 他亲口要求):
29
+ 卡片里最小的字,也不能比主正文再小超过"1 号"——
30
+ 正文是 1em,0.9em 就当"小一号",这是全程下限,任何地方不许再往下走。
31
+ 用 em 而不是 px,他自己调了系统/界面字号,卡片跟着一起变。 */
32
+ const FS = { body: '1em', small: '0.95em', min: '0.9em' }
33
+
34
+ /* ---------------- 玻璃(方案 B:搬「开关」,不搬代码) ----------------
35
+ 壁纸插件 dsh-plugin-wallpaper-engine 把玻璃参数写成 <body> 上的 CSS 变量,
36
+ body 下面所有元素都能读到。我们只读这几个:
37
+ --we-glass-color 玻璃釉色 --we-glass-alpha 玻璃透明度
38
+ --we-blur 模糊半径 --we-accent 它的强调色
39
+ 读不到(它没装/装的是别家皮肤)就退回中性灰 + 自己的一点模糊 —— 朴素但不坏。
40
+ 三条共存铁律(SEC 项目卡 §3.9):
41
+ ① 不注入 <style>、不改别人类名、不碰 z-index,只给自己元素写内联样式;
42
+ ② 一律写成 var(--we-x, 兜底);
43
+ ③ 不把 --we-* 当公开接口:哪天人家改名,我们就退回兜底,不会坏。
44
+ ⚠️ --we-ink/--we-ui-radius/--we-ease 这几个只在它自己的 .we-picker 里定义,
45
+ body 上读不到,所以这里的圆角和文字色都用自己的值,别去读它们。 */
46
+
47
+ const R = 10 // "角圆润的方形"(他 2026-11-09 说的)—— 不是胶囊,也不是直角
48
+ const ACCENT = 'var(--we-accent, #3b82f6)'
49
+ const HAIRLINE = 'rgba(128,128,128,0.24)'
50
+ const HAIRLINE_SOFT = 'rgba(128,128,128,0.16)'
51
+ const INK_SOFT = 'rgba(128,128,128,0.18)'
52
+
53
+ /* 玻璃底:把釉色按它自己的透明度掺进去。color-mix 在 Electron 里可用(它自己也在用)。 */
54
+ const glassFill = (scale) =>
55
+ 'color-mix(in srgb, var(--we-glass-color, rgba(128,128,128,1)) calc(var(--we-glass-alpha, 0.12) * ' +
56
+ (scale || 100) +
57
+ '%), transparent)'
58
+
59
+ const glassCard = {
60
+ borderRadius: 12,
61
+ border: '1px solid ' + HAIRLINE_SOFT,
62
+ background: glassFill(100),
63
+ backdropFilter: 'blur(calc(var(--we-blur, 12px) * 0.55))',
64
+ WebkitBackdropFilter: 'blur(calc(var(--we-blur, 12px) * 0.55))',
65
+ fontSize: FS.body,
66
+ lineHeight: 1.65,
67
+ }
68
+
69
+ /* 2026-11-09 实测:用 linkStyle 画的"裸 <button>"在 DSH 回复区里点不动
70
+ (鼠标是文本光标、onClick 不触发),换成 Chip 立刻就点得动。
71
+ 所以这里一律不走裸按钮 —— 别再为了"低调"改回去。 */
72
+
73
+ /* 小按钮:圆角方形、细边、悬停才亮。
74
+ solid = 主操作(略实一点点),不传 = 次操作(透明细边)。 */
75
+
76
+ const chipStyle = {
77
+ display: 'inline-flex',
78
+ alignItems: 'center',
79
+ gap: 5,
80
+ height: 26,
81
+ padding: '0 12px',
82
+ borderRadius: R,
83
+ border: '1px solid ' + HAIRLINE,
84
+ background: 'transparent',
85
+ color: 'inherit',
86
+ font: 'inherit',
87
+ fontSize: FS.small,
88
+ lineHeight: '24px',
89
+ cursor: 'pointer',
90
+ verticalAlign: 'middle',
91
+ whiteSpace: 'nowrap',
92
+ }
93
+
94
+ function Chip({ onClick, children, dim, title, style, solid, disabled, hoverStyle }) {
95
+ const [hover, setHover] = useState(false)
96
+ const fixed = style || {}
97
+ const off = !!disabled
98
+ return React.createElement(
99
+ 'button',
100
+ {
101
+ type: 'button',
102
+ title: title || '',
103
+ onClick: off ? undefined : onClick,
104
+ disabled: off,
105
+ onMouseEnter: () => setHover(true),
106
+ onMouseLeave: () => setHover(false),
107
+ style: {
108
+ ...chipStyle,
109
+ ...fixed,
110
+ cursor: off ? 'default' : 'pointer',
111
+ // fixed.opacity 优先:三档按钮里的主按钮要"满血"(不然实心色被 0.84 冲淡)
112
+ opacity: fixed.opacity != null ? fixed.opacity : off ? 0.4 : dim ? 0.5 : hover && !dim ? 1 : 0.84,
113
+ background:
114
+ fixed.background ||
115
+ (hover && !dim && !off
116
+ ? 'color-mix(in srgb, currentColor 9%, transparent)'
117
+ : solid && !dim
118
+ ? glassFill(170)
119
+ : 'transparent'),
120
+ borderColor: fixed.borderColor || (solid && !dim ? 'rgba(128,128,128,0.34)' : HAIRLINE),
121
+ transition: 'opacity .15s ease, background .15s ease, border-color .15s ease, box-shadow .15s ease',
122
+ ...(hover && !off && hoverStyle ? hoverStyle : null),
123
+ },
124
+ },
125
+ children,
126
+ )
127
+ }
128
+
129
+ /* ---------------- 三档按钮(2026-11-10 晚,按 GPT 那份参考的"秩序"重排) ----------------
130
+ 主 pri:实心强调色 32px —— **同一屏只许一个**,它自己会站出来;
131
+ 次 sec:细边 28px —— 重评、让主模型读、副模型主刀、先看效果;
132
+ 轻 lite:无边框 24px —— 依据、它查过 N 处、收起这类"点开看"。
133
+ 高度/圆角/字号只在这里定,调用处别再各写各的(GPT 提的"圆角统一、别一个按钮一个样")。 */
134
+ const BTN_TIER = {
135
+ pri: { height: 32, padding: '0 16px', fontSize: '0.93em', fontWeight: 600 },
136
+ sec: { height: 28, padding: '0 12px', fontSize: '0.9em' },
137
+ lite: { height: 24, padding: '0 8px', fontSize: '0.88em' },
138
+ }
139
+
140
+ // 玻璃底:把强调色按比例掺进去 —— 不写死颜色,宿主换肤(壁纸插件的 --we-accent)跟着走
141
+ const accentGlass = (pct) => 'color-mix(in srgb, ' + ACCENT + ' ' + pct + '%, transparent)'
142
+
143
+ function Btn({ tier, onClick, children, title, disabled, active, style }) {
144
+ const t = BTN_TIER[tier] || BTN_TIER.sec
145
+ const pri = tier === 'pri'
146
+ return React.createElement(
147
+ Chip,
148
+ {
149
+ onClick,
150
+ title,
151
+ disabled,
152
+ // 悬停只提亮一层,不换颜色(玻璃不该"跳色")
153
+ hoverStyle: pri
154
+ ? { background: accentGlass(32), borderColor: 'color-mix(in srgb, ' + ACCENT + ' 82%, transparent)' }
155
+ : active
156
+ ? { background: accentGlass(20) }
157
+ : { background: 'color-mix(in srgb, currentColor 8%, transparent)' },
158
+ style: {
159
+ borderRadius: R,
160
+ ...t,
161
+ opacity: disabled ? 0.45 : pri ? 1 : undefined,
162
+ // 2026-11-10 晚:他把主按钮形容成"鲜艳的粉色挺好看",但要玻璃质感
163
+ // → 主按钮改成强调色玻璃:淡色釉 + 细亮边 + 顶部一道高光,不再是实心色块
164
+ background: pri ? accentGlass(20) : active ? accentGlass(12) : 'transparent',
165
+ borderColor: pri ? 'color-mix(in srgb, ' + ACCENT + ' 60%, transparent)' : active ? ACCENT : HAIRLINE,
166
+ color: 'inherit',
167
+ backdropFilter: pri ? 'blur(6px)' : undefined,
168
+ WebkitBackdropFilter: pri ? 'blur(6px)' : undefined,
169
+ boxShadow: pri ? 'inset 0 1px 0 rgba(255,255,255,0.22)' : undefined,
170
+ ...(style || {}),
171
+ },
172
+ },
173
+ children,
174
+ )
175
+ }
176
+
177
+
178
+ /* ---------------- 过渡(2026-11-10 21:4x,他的原话:别一帧不到就变一个样) ----------------
179
+ 不注入 <style>、不引库 —— 用 Web Animations API 直接给元素排时间轴:
180
+ reveal(delay):淡入 + 上移 4px,只跑一次(用 __srIn 记账,重渲染不会重播)
181
+ grow:卡片从 0 高度长出来,跑完把高度交回布局
182
+ Collapse:展开/收起走 grid 行高,内容常驻、只收高度
183
+ 尊重系统的"减少动态效果"。 */
184
+ const EASE_OUT = 'cubic-bezier(.2,.8,.2,1)'
185
+
186
+ function quietMotion() {
187
+ try {
188
+ return !!(typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches)
189
+ } catch {
190
+ return false
191
+ }
192
+ }
193
+
194
+ function reveal(delay, dy) {
195
+ return (el) => {
196
+ if (!el || el.__srIn || quietMotion() || typeof el.animate !== 'function') return
197
+ el.__srIn = true
198
+ el.animate(
199
+ [
200
+ { opacity: 0, transform: 'translateY(' + (dy == null ? 4 : dy) + 'px)' },
201
+ { opacity: 1, transform: 'translateY(0)' },
202
+ ],
203
+ { duration: 280, delay: delay || 0, easing: EASE_OUT, fill: 'both' },
204
+ )
205
+ }
206
+ }
207
+
208
+ function grow(el) {
209
+ if (!el || el.__srGrow || quietMotion() || typeof el.animate !== 'function') return
210
+ el.__srGrow = true
211
+ const h = el.offsetHeight
212
+ if (!h) return
213
+ const a = el.animate([{ height: '0px', opacity: 0 }, { height: h + 'px', opacity: 1 }], {
214
+ duration: 240,
215
+ easing: EASE_OUT,
216
+ fill: 'both',
217
+ })
218
+ a.onfinish = () => {
219
+ el.style.height = ''
220
+ try {
221
+ a.cancel()
222
+ } catch {}
223
+ }
224
+ }
225
+
226
+ function Collapse({ open, children }) {
227
+ return React.createElement(
228
+ 'div',
229
+ {
230
+ style: {
231
+ display: 'grid',
232
+ gridTemplateRows: open ? '1fr' : '0fr',
233
+ transition: 'grid-template-rows .22s ' + EASE_OUT,
234
+ overflow: 'hidden',
235
+ },
236
+ },
237
+ React.createElement('div', { style: { minHeight: 0, opacity: open ? 1 : 0, transition: 'opacity .2s ease' } }, children),
238
+ )
239
+ }
240
+
241
+
242
+ /* ---------------- 设置页的两个控件(原生控件的"手"和悬停,自己补) ----------------
243
+ 2026-11-09 他报:模型那两个方块和勾选框,鼠标放上去不变手指;点了下拉"很粗糙"。
244
+ → 手:`<select>` 和 `<input type=checkbox>` **不吃**外面 label 的 cursor,必须自己写;
245
+ → 粗糙:原生下拉的**弹出列表**是系统画的,插件改不动(真要圆润得自绘,下一轮);
246
+ 能改的是**关闭状态那个框**:去原生外观、自己画箭头、玻璃底、悬停变亮。
247
+ ⚠️ 别为了好看把 select 换成 div —— 键盘和系统菜单会一起丢。 */
248
+
249
+ function Select({ value, onChange, children, width }) {
250
+ const [hover, setHover] = useState(false)
251
+ return React.createElement(
252
+ 'span',
253
+ { style: { position: 'relative', display: 'inline-flex', alignItems: 'center', maxWidth: '100%' } },
254
+ React.createElement(
255
+ 'select',
256
+ {
257
+ value,
258
+ onChange,
259
+ onMouseEnter: () => setHover(true),
260
+ onMouseLeave: () => setHover(false),
261
+ style: {
262
+ appearance: 'none',
263
+ WebkitAppearance: 'none',
264
+ MozAppearance: 'none',
265
+ cursor: 'pointer',
266
+ color: 'inherit',
267
+ font: 'inherit',
268
+ fontSize: FS.small,
269
+ lineHeight: '20px',
270
+ background: hover ? glassFill(210) : glassFill(120),
271
+ border: '1px solid ' + (hover ? 'rgba(128,128,128,0.42)' : HAIRLINE),
272
+ borderRadius: R,
273
+ padding: '6px 30px 6px 11px',
274
+ width: width || 'auto',
275
+ maxWidth: 240,
276
+ minWidth: 0,
277
+ outline: 'none',
278
+ transition: 'background .15s ease, border-color .15s ease',
279
+ },
280
+ },
281
+ children,
282
+ ),
283
+ React.createElement(
284
+ 'span',
285
+ {
286
+ style: {
287
+ position: 'absolute',
288
+ right: 12,
289
+ pointerEvents: 'none',
290
+ opacity: hover ? 0.9 : 0.55,
291
+ fontSize: 9,
292
+ lineHeight: 1,
293
+ transform: hover ? 'translateY(1px)' : 'none',
294
+ transition: 'opacity .15s ease, transform .15s ease',
295
+ },
296
+ },
297
+ '▼',
298
+ ),
299
+ )
300
+ }
301
+
302
+ function Check({ checked, onChange, title, desc, style }) {
303
+ const [hover, setHover] = useState(false)
304
+ return React.createElement(
305
+ 'label',
306
+ {
307
+ onMouseEnter: () => setHover(true),
308
+ onMouseLeave: () => setHover(false),
309
+ style: {
310
+ display: 'flex',
311
+ gap: 9,
312
+ alignItems: 'flex-start',
313
+ cursor: 'pointer',
314
+ padding: '7px 9px',
315
+ margin: '0 -9px',
316
+ borderRadius: R,
317
+ background: hover ? 'color-mix(in srgb, currentColor 6%, transparent)' : 'transparent',
318
+ transition: 'background .15s ease',
319
+ ...(style || {}),
320
+ },
321
+ },
322
+ React.createElement('input', {
323
+ type: 'checkbox',
324
+ checked: !!checked,
325
+ onChange,
326
+ style: {
327
+ marginTop: 3,
328
+ cursor: 'pointer',
329
+ accentColor: ACCENT,
330
+ width: 15,
331
+ height: 15,
332
+ flex: 'none',
333
+ },
334
+ }),
335
+ React.createElement(
336
+ 'span',
337
+ { style: { minWidth: 0 } },
338
+ title,
339
+ desc ? React.createElement('div', { style: { opacity: 0.55, fontSize: FS.min, marginTop: 2 } }, desc) : null,
340
+ ),
341
+ )
342
+ }
343
+
344
+ /* 细线进度条:横贯的那一条,2px。
345
+ 有两种画法(0.9.0):
346
+ · 设了「最长等待」→ 走真实时间比例(elapsed / budget),到点转琥珀;
347
+ · 不设上限 → 不编百分比,改成一段光块从左扫到右,只表示"它还活着"。
348
+ 两种都不假造进度。 */
349
+ function ThinBar({ ratio, over, indeterminate, phase, warn, title }) {
350
+ if (indeterminate) {
351
+ // 光块位置由 elapsed 推出来(每 0.7 秒来一次进度),不注入 @keyframes
352
+ const pos = ((Number(phase) || 0) % 2200) / 2200
353
+ const left = pos * 72
354
+ return React.createElement(
355
+ 'div',
356
+ {
357
+ title: title || '',
358
+ style: { marginTop: 9, height: 2, borderRadius: 2, background: INK_SOFT, overflow: 'hidden', position: 'relative' },
359
+ },
360
+ React.createElement('div', {
361
+ style: {
362
+ position: 'absolute',
363
+ left: left.toFixed(2) + '%',
364
+ width: '28%',
365
+ height: '100%',
366
+ borderRadius: 2,
367
+ background: warn ? '#f59e0b' : 'currentColor',
368
+ opacity: warn ? 0.95 : 0.5,
369
+ transition: 'left .25s linear, background .3s ease',
370
+ },
371
+ }),
372
+ )
373
+ }
374
+ const pct = Math.max(0, Math.min(100, (Number(ratio) || 0) * 100))
375
+ return React.createElement(
376
+ 'div',
377
+ {
378
+ title: title || '',
379
+ style: { marginTop: 9, height: 2, borderRadius: 2, background: INK_SOFT, overflow: 'hidden' },
380
+ },
381
+ React.createElement('div', {
382
+ style: {
383
+ height: '100%',
384
+ width: pct.toFixed(2) + '%',
385
+ borderRadius: 2,
386
+ background: over ? '#f59e0b' : 'currentColor',
387
+ opacity: over ? 0.95 : 0.5,
388
+ transition: 'width .25s linear, background .3s ease',
389
+ },
390
+ }),
391
+ )
392
+ }
393
+
394
+ // 一枚小盾牌勾,14px,跟着文字颜色走
395
+ function Mark({ size }) {
396
+ const s = size || 13
397
+ return React.createElement(
398
+ 'svg',
399
+ {
400
+ width: s,
401
+ height: s,
402
+ viewBox: '0 0 24 24',
403
+ fill: 'none',
404
+ stroke: 'currentColor',
405
+ strokeWidth: 2,
406
+ strokeLinecap: 'round',
407
+ strokeLinejoin: 'round',
408
+ style: { flex: 'none', opacity: 0.85 },
409
+ },
410
+ React.createElement('path', { d: 'M12 3.2 19 6v6.1c0 4.2-2.9 7.6-7 9-4.1-1.4-7-4.8-7-9V6l7-2.8z' }),
411
+ React.createElement('path', { d: 'M8.8 12.4l2.2 2.2 4.2-4.7' }),
412
+ )
413
+ }
414
+
415
+ /* ---------------- 设置(模块级,整页共用一份) ---------------- */
416
+
417
+ const settings = { value: null, pending: null }
418
+
419
+ function loadSettings(force) {
420
+ if (!force && settings.value) return Promise.resolve(settings.value)
421
+ if (!force && settings.pending) return settings.pending
422
+ settings.pending = fetch(API + '/config')
423
+ .then((r) => r.json())
424
+ .then((j) => {
425
+ settings.value = (j && j.ok && j.config) || { auto: false }
426
+ return settings.value
427
+ })
428
+ .catch(() => {
429
+ settings.value = { auto: false }
430
+ return settings.value
431
+ })
432
+ .then((v) => {
433
+ settings.pending = null
434
+ return v
435
+ })
436
+ return settings.pending
437
+ }
438
+
439
+ /* ---------------- 副模型主刀(每个会话各存各的,互不覆盖) ---------------- */
440
+
441
+ const swap = { bySession: {}, pending: {} }
442
+
443
+ /* 同一会话里所有卡片共用一份"主刀开没开",不然 A 卡开了 B 卡还显示关 */
444
+ const swapShared = { value: {}, listeners: new Set() }
445
+
446
+ function useSwap(sidKey) {
447
+ const [on, setOn] = useState(!!swapShared.value[sidKey])
448
+ useEffect(() => {
449
+ const l = () => setOn(!!swapShared.value[sidKey])
450
+ swapShared.listeners.add(l)
451
+ l()
452
+ return () => {
453
+ swapShared.listeners.delete(l)
454
+ }
455
+ }, [sidKey])
456
+ return [
457
+ on,
458
+ (v) => {
459
+ swapShared.value[sidKey] = !!v
460
+ swapShared.listeners.forEach((l) => l())
461
+ },
462
+ ]
463
+ }
464
+
465
+ function loadSwap(sessionId) {
466
+ const k = String(sessionId || '')
467
+ if (Object.prototype.hasOwnProperty.call(swap.bySession, k)) return Promise.resolve(swap.bySession[k])
468
+ if (swap.pending[k]) return swap.pending[k]
469
+ swap.pending[k] = fetch(API + '/swap?sessionId=' + encodeURIComponent(k))
470
+ .then((r) => r.json())
471
+ .then((j) => {
472
+ if (j && j.ok) swap.bySession[k] = !!j.on
473
+ return !!swap.bySession[k]
474
+ })
475
+ .catch(() => !!swap.bySession[k])
476
+ .then((v) => {
477
+ delete swap.pending[k]
478
+ return v
479
+ })
480
+ return swap.pending[k]
481
+ }
482
+
483
+ /* ---------------- 每轮回复下面的评审块 ---------------- */
484
+ const store = {
485
+ data: {},
486
+ listeners: new Set(),
487
+ get(k) {
488
+ return this.data[k]
489
+ },
490
+ set(k, v) {
491
+ this.data[k] = v
492
+ this.listeners.forEach((l) => l())
493
+ },
494
+ }
495
+
496
+ function useEntry(key) {
497
+ const [, force] = useState(0)
498
+ useEffect(() => {
499
+ const l = () => force((n) => n + 1)
500
+ store.listeners.add(l)
501
+ return () => store.listeners.delete(l)
502
+ }, [])
503
+ return [store.get(key), (v) => store.set(key, v)]
504
+ }
505
+
506
+ // 页面打开的那一刻起的 5 秒内挂上的轮次算"历史",不自动评(避免刷新一次就全评一遍)
507
+ const bootAt = Date.now()
508
+ const AUTO_WINDOW_MS = 5000
509
+ const inFlight = new Set()
510
+ const running = new Set()
511
+ const initErrors = []
512
+
513
+ function alreadyAutoReviewed(sessionId, seq) {
514
+ try {
515
+ return sessionStorage.getItem('sr-auto:' + sessionId + ':' + seq) === '1'
516
+ } catch {
517
+ return false
518
+ }
519
+ }
520
+
521
+ function markAutoReviewed(sessionId, seq) {
522
+ try {
523
+ sessionStorage.setItem('sr-auto:' + sessionId + ':' + seq, '1')
524
+ } catch {}
525
+ }
526
+
527
+ function Item({ it, delay }) {
528
+ const [open, setOpen] = useState(false)
529
+ const color = CAT_COLOR[it.category] || '#888'
530
+ // "影响/建议"这种标签对齐成一列,比"影响:……"挤在一行里更像认真做过的界面
531
+ const label = (text) =>
532
+ React.createElement('span', { style: { opacity: 0.5, marginRight: 7, flex: 'none', fontSize: FS.small } }, text)
533
+ return React.createElement(
534
+ 'div',
535
+ { ref: reveal(delay || 0), style: { marginBottom: 14, fontSize: FS.body, lineHeight: 1.7 } },
536
+ React.createElement(
537
+ 'div',
538
+ { style: { display: 'flex', alignItems: 'baseline', gap: 8, flexWrap: 'wrap' } },
539
+ React.createElement(
540
+ 'span',
541
+ {
542
+ style: {
543
+ flex: 'none',
544
+ display: 'inline-flex',
545
+ alignItems: 'center',
546
+ height: 19,
547
+ padding: '0 7px',
548
+ borderRadius: 5,
549
+ fontSize: FS.min,
550
+ lineHeight: '19px',
551
+ color,
552
+ border: '1px solid ' + color,
553
+ opacity: 0.85,
554
+ },
555
+ },
556
+ it.category,
557
+ ),
558
+ React.createElement('span', { style: { flex: 1, minWidth: 0, fontWeight: 500 } }, it.point),
559
+ ),
560
+ it.impact
561
+ ? React.createElement(
562
+ 'div',
563
+ { style: { display: 'flex', marginTop: 5, fontSize: FS.small, lineHeight: 1.6, opacity: 0.85 } },
564
+ label('影响'),
565
+ React.createElement('span', null, it.impact),
566
+ )
567
+ : null,
568
+ it.suggestion
569
+ ? React.createElement(
570
+ 'div',
571
+ { style: { display: 'flex', marginTop: 2, fontSize: FS.small, lineHeight: 1.6, opacity: 0.7 } },
572
+ label('建议'),
573
+ React.createElement('span', null, it.suggestion),
574
+ )
575
+ : null,
576
+ it.evidence
577
+ ? React.createElement(
578
+ 'div',
579
+ { style: { marginTop: 5 } },
580
+ React.createElement(Btn, { tier: 'lite', onClick: () => setOpen(!open), title: '看这条依据的原文' }, open ? '收起依据' : '依据'),
581
+ React.createElement(
582
+ Collapse,
583
+ { open },
584
+ React.createElement('div', { style: { opacity: 0.6, fontSize: FS.small, marginTop: 5, lineHeight: 1.6 } }, it.evidence),
585
+ ),
586
+ )
587
+ : null,
588
+ )
589
+ }
590
+
591
+ /* 评审时查过什么(P0-9 改口径后的"可观测"):默认只报一行,点开看明细 */
592
+ function Steps({ steps }) {
593
+ const [open, setOpen] = useState(false)
594
+ if (!steps || !steps.length) return null
595
+ const tools = [...new Set(steps.map((s) => s.tool))]
596
+ return React.createElement(
597
+ 'div',
598
+ { style: { marginBottom: 10, fontSize: FS.min, opacity: 0.72 } },
599
+ React.createElement(
600
+ Btn,
601
+ { tier: 'lite', onClick: () => setOpen(!open), title: '看它查了哪些地方' },
602
+ '它查过 ' + steps.length + ' 处(' + tools.join('、') + ')' + (open ? ',收起' : ''),
603
+ ),
604
+ React.createElement(
605
+ Collapse,
606
+ { open },
607
+ React.createElement(
608
+ 'div',
609
+ { style: { marginTop: 4, lineHeight: 1.6 } },
610
+ ...steps.map((s, i) =>
611
+ React.createElement(
612
+ 'div',
613
+ { key: i },
614
+ s.tool + ' ' + JSON.stringify(s.args || {}) + ' → ' + (s.ok ? '成功' : '失败'),
615
+ ),
616
+ ),
617
+ ),
618
+ ),
619
+ )
620
+ }
621
+
622
+ /* 引文核验(1.1.0):机器查"它有没有编出你的话",不靠模型。
623
+ 来路:`记录中心\小问的bug.md` —— 小问幻觉出一条用户责骂消息,还照着行动了。
624
+ 这里只标红提示,不改结论:查不到出处 ≠ 一定是编的(也可能它改写太多)。 */
625
+ function Provenance({ pv }) {
626
+ if (!pv || !pv.checked) return null
627
+ const missing = Array.isArray(pv.missing) ? pv.missing : []
628
+ if (!missing.length) {
629
+ return React.createElement(
630
+ 'div',
631
+ { style: { opacity: 0.5, fontSize: FS.min, marginBottom: 8 } },
632
+ '引文核验:' + pv.checked + ' 句「你的话」都有出处',
633
+ )
634
+ }
635
+ return React.createElement(
636
+ 'div',
637
+ {
638
+ style: {
639
+ marginBottom: 10,
640
+ padding: '7px 10px',
641
+ borderRadius: R,
642
+ border: '1px solid rgba(239,68,68,0.45)',
643
+ background: 'color-mix(in srgb, #ef4444 8%, transparent)',
644
+ fontSize: FS.small,
645
+ lineHeight: 1.6,
646
+ },
647
+ },
648
+ React.createElement(
649
+ 'div',
650
+ { style: { color: '#ef4444', marginBottom: 4 } },
651
+ '⚠ 引文核验:有 ' + missing.length + ' 句「你的话」在会话记录里找不到出处 —— 先别照它改方向',
652
+ ),
653
+ ...missing.map((q, i) => React.createElement('div', { key: 'q' + i, style: { opacity: 0.75, fontSize: FS.min } }, '「' + q + '」')),
654
+ React.createElement(
655
+ 'div',
656
+ { style: { opacity: 0.55, fontSize: FS.min, marginTop: 4 } },
657
+ '机器只核它明确引成「你说的」那几句(这次 ' + pv.checked + ' 句);查不到 ≠ 一定是编的,但也别直接当真。',
658
+ ),
659
+ )
660
+ }
661
+
662
+ /* 小问题:默认收起。它们是"确实存在但不影响用"的,摊开会把他拖回抛光 */
663
+ function MinorGroup({ items }) {
664
+ const [open, setOpen] = useState(false)
665
+ if (!items || !items.length) return null
666
+ return React.createElement(
667
+ 'div',
668
+ { style: { marginBottom: 6 } },
669
+ React.createElement(
670
+ Btn,
671
+ { tier: 'lite', onClick: () => setOpen(!open), title: open ? '收起' : '点开看这几条小问题' },
672
+ '一针见血 ' + items.length + ' 条' + (open ? ',收起' : '(点开看)'),
673
+ ),
674
+ React.createElement(
675
+ Collapse,
676
+ { open },
677
+ React.createElement(
678
+ 'div',
679
+ { style: { marginTop: 8, opacity: 0.85 } },
680
+ ...items.map((it, i) => React.createElement(Item, { key: 'm' + i, it, delay: i * 40 })),
681
+ ),
682
+ ),
683
+ )
684
+ }
685
+
686
+ function TurnTail(props) {
687
+ const { seq, sessionId, inputActions } = props || {}
688
+ // 卡片的 key 必须带会话:两个会话的第 3 轮不是同一张卡
689
+ const key = String(sessionId || '') + ':' + String(seq)
690
+ const sidKey = String(sessionId || '')
691
+ const [entry, setEntry] = useEntry(key)
692
+ const [auto, setAuto] = useState(false)
693
+ // 当前副模型是谁:卡片这排要写出来,悬停提示也要带上(他 2026-11-10 晚要求)
694
+ const [reviewerName, setReviewerName] = useState(settings.value && settings.value.model ? String(settings.value.model) : '')
695
+ const [nudge, setNudge] = useState('')
696
+ const [showThink, setShowThink] = useState(false)
697
+ // 主刀开关是"会话级"的,但一张会话里有很多张卡:以前每张卡各存一份 useState,
698
+ // 在一张卡上开了,别的卡上的开关还显示"关"(GPT 4.6 第 4 条)。改成共用一份。
699
+ const [swapOn, setSwapOn] = useSwap(sidKey)
700
+
701
+ useEffect(() => {
702
+ let alive = true
703
+ loadSwap(sessionId).then((v) => {
704
+ if (alive) setSwapOn(!!v)
705
+ })
706
+ return () => {
707
+ alive = false
708
+ }
709
+ }, [sessionId])
710
+
711
+ // 开:下一轮由副模型来答(改的是会话的模型选择,不动消息归属);关:换回原来的
712
+ const toggleSwap = () => {
713
+ const next = !swapOn
714
+ setSwapOn(next)
715
+ fetch(API + '/swap', {
716
+ method: 'POST',
717
+ headers: { 'content-type': 'application/json' },
718
+ body: JSON.stringify({ sessionId, on: next }),
719
+ })
720
+ .then((r) => r.json())
721
+ .then((j) => {
722
+ if (j && j.ok) {
723
+ swap.bySession[sidKey] = !!j.on
724
+ setSwapOn(!!j.on)
725
+ } else {
726
+ setSwapOn(!next)
727
+ }
728
+ })
729
+ .catch(() => setSwapOn(!next))
730
+ }
731
+
732
+ const swapChip = () =>
733
+ React.createElement(
734
+ Btn,
735
+ {
736
+ tier: 'sec',
737
+ onClick: toggleSwap,
738
+ active: swapOn,
739
+ title: '打开后:下一轮由副模型来答;再点「二次评审」时,由原来的主模型来评。不改消息归属。',
740
+ },
741
+ '副模型主刀' + (swapOn ? ':开' : ''),
742
+ )
743
+ const state = entry && entry.state
744
+ const major = (entry && entry.major) || []
745
+ const minor = (entry && entry.minor) || []
746
+ const unclear = !!entry && entry.verdict === 'unclear'
747
+ const passed = !!entry && entry.verdict === 'pass' && major.length === 0 && minor.length === 0
748
+ const omitted = entry && entry.omitted
749
+ const omittedTotal = omitted ? Number(omitted.major || 0) + Number(omitted.minor || 0) : 0
750
+
751
+ useEffect(() => {
752
+ let alive = true
753
+ loadSettings().then((v) => {
754
+ if (alive) setAuto(!!(v && v.auto))
755
+ if (alive) setReviewerName(v && v.model ? String(v.model) : '')
756
+ })
757
+ return () => {
758
+ alive = false
759
+ }
760
+ }, [])
761
+
762
+ const run = (kind, force) => {
763
+ // 第一次花钱前先把话说清楚(他 2026-11-09 提的:别人装完第一次点就直接花他的钱)
764
+ const acked = !!(settings.value && settings.value.noticeAck)
765
+ if (kind !== 'mock' && !force && !acked) {
766
+ if (!settings.value) {
767
+ loadSettings().then(() => run(kind, force))
768
+ return
769
+ }
770
+ setEntry({ state: 'notice', pendingKind: kind })
771
+ return
772
+ }
773
+ // 同一张卡不许同时评两次(双点会付两次钱)
774
+ if (kind !== 'mock') {
775
+ if (running.has(key)) return
776
+ running.add(key)
777
+ }
778
+ const job = 'j' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7)
779
+ // 再评时不要把整张卡片拆掉:一塌一长,页面会跳一下(2026-11-09 他报的"整屏闪一下")
780
+ const cur = store.get(key)
781
+ const keep = cur && cur.state === 'done' ? cur : null
782
+ setEntry({
783
+ ...(keep || { major: [], minor: [] }),
784
+ state: 'loading',
785
+ refreshing: !!keep,
786
+ auto: kind === 'auto',
787
+ job,
788
+ startedAt: Date.now(),
789
+ progress: null,
790
+ })
791
+ // 只评"用户点的这一轮及以前":把 seq 一起送过去,后端只读到这里为止。
792
+ // 用 POST:评审是要花钱的,不能让别的网页拿 <img>/链接蹭一下。
793
+ const url = API + '/review'
794
+ fetch(url, {
795
+ method: 'POST',
796
+ headers: { 'content-type': 'application/json' },
797
+ body: JSON.stringify({ sessionId: sessionId || '', seq: seq, job, mock: kind === 'mock' }),
798
+ })
799
+ .then((r) => r.json())
800
+ .then((json) => {
801
+ if (json && json.ok) {
802
+ if (json.verdict === 'error') {
803
+ setEntry({
804
+ state: 'error',
805
+ major: [],
806
+ minor: [],
807
+ note: String(json.note || '副模型没调通'),
808
+ // 它已经说出来的那段(如果有)——别让这笔钱白花
809
+ partial: String(json.partial || ''),
810
+ reasoning: String(json.reasoning || ''),
811
+ reviewer: json.reviewer || null,
812
+ provenance: json.provenance || null,
813
+ })
814
+ return
815
+ }
816
+ setEntry({
817
+ state: 'done',
818
+ topic: json.topic || '',
819
+ verdict: json.verdict || ((json.major || []).length ? 'major' : (json.minor || []).length ? 'minor' : 'pass'),
820
+ note: json.note || '',
821
+ major: Array.isArray(json.major) ? json.major : json.items || [],
822
+ minor: Array.isArray(json.minor) ? json.minor : [],
823
+ omitted: json.omitted || null,
824
+ saved: json.saved || '',
825
+ // 是谁评的(他 2026-11-09 晚要的:换过副模型时得分得清)
826
+ reviewer: json.reviewer || null,
827
+ // 1.1.0:引文核验(机器查的)
828
+ provenance: json.provenance || null,
829
+ // 正在跑的是哪一版(改了没生效时一眼看出来)
830
+ version: json.version || '',
831
+ elapsedMs: Number(json.elapsedMs) || 0,
832
+ auto: kind === 'auto',
833
+ truncated: !!json.truncated,
834
+ timedOut: !!json.timedOut,
835
+ mock: !!json.mock,
836
+ steps: Array.isArray(json.steps) ? json.steps : [],
837
+ })
838
+ } else {
839
+ setEntry({ state: 'error', major: [], minor: [], note: String((json && json.error) || '未知原因') })
840
+ }
841
+ })
842
+ .catch((e) => setEntry({ state: 'error', major: [], minor: [], note: String((e && e.message) || e) }))
843
+ .finally(() => running.delete(key))
844
+ }
845
+
846
+ // 「知道了」:记住这次确认,然后真的开始评
847
+ const ackAndRun = () => {
848
+ const cur = store.get(key)
849
+ const pending = (cur && cur.pendingKind) || 'manual'
850
+ fetch(API + '/config', {
851
+ method: 'POST',
852
+ headers: { 'content-type': 'application/json' },
853
+ body: JSON.stringify({ noticeAck: true }),
854
+ })
855
+ .then((r) => r.json())
856
+ .then((j) => {
857
+ if (j && j.ok) settings.value = j.config
858
+ })
859
+ .catch(() => {})
860
+ if (settings.value) settings.value = { ...settings.value, noticeAck: true }
861
+ run(pending, true)
862
+ }
863
+
864
+ // 评审跑着的时候,每 0.7 秒问一次"它在想什么"(他 11-09 说的:什么都看不到会以为卡了)
865
+ const jobId = entry && entry.state === 'loading' ? entry.job : ''
866
+
867
+ /* 0.9.2:只靠 0.7 秒一次的轮询来画秒数和进度条,只要有一两次请求慢/失败,
868
+ 就会"卡住几秒、然后一下跳到 50 秒"(他 2026-11-10 报的)。
869
+ 这里加一个 200ms 的本地小表针:**秒数用本地时间接着走**,
870
+ 服务器每次回包只负责"对表"(把 elapsed 校准回来),动画就不会断。 */
871
+ const [, tickNow] = useState(0)
872
+ useEffect(() => {
873
+ if (!jobId) return
874
+ const t = setInterval(() => tickNow((n) => (n + 1) % 1000000), 200)
875
+ return () => clearInterval(t)
876
+ }, [jobId])
877
+
878
+ useEffect(() => {
879
+ if (!jobId) return
880
+ let alive = true
881
+ const tick = () => {
882
+ fetch(API + '/progress?job=' + encodeURIComponent(jobId))
883
+ .then((r) => r.json())
884
+ .then((j) => {
885
+ if (!alive || !j || !j.ok || j.missing) return
886
+ const cur = store.get(key)
887
+ if (cur && cur.state === 'loading') setEntry({ ...cur, progress: j, progressAt: Date.now() })
888
+ })
889
+ .catch(() => {})
890
+ }
891
+ tick()
892
+ const timer = setInterval(tick, 700)
893
+ return () => {
894
+ alive = false
895
+ clearInterval(timer)
896
+ }
897
+ }, [jobId, key])
898
+
899
+ // 自动评审:只评"页面加载之后才来的"新回复,且同一条只评一次
900
+ useEffect(() => {
901
+ if (!auto || state || !sessionId) return
902
+ if (Date.now() - bootAt < AUTO_WINDOW_MS) return
903
+ if (inFlight.has(key)) return
904
+ if (alreadyAutoReviewed(sessionId, seq)) return
905
+ inFlight.add(key)
906
+ markAutoReviewed(sessionId, seq)
907
+ run('auto')
908
+ // eslint-disable-next-line react-hooks/exhaustive-deps
909
+ }, [auto, state, sessionId, seq, key])
910
+
911
+ // 手动再评:允许一次。上次是假数据就继续用假数据(别让人点一下"重评"就悄悄花了钱)
912
+ const rerun = () => {
913
+ inFlight.add(key)
914
+ run(entry && entry.mock ? 'mock' : 'manual')
915
+ inFlight.delete(key)
916
+ }
917
+
918
+ // 停止这次评审(0.9.0):不设总时长之后,"能随时喊停"就是新的保险丝。
919
+ // 只给服务端发一个标志,流那边每一块之间看一眼就收手;已经花掉的调用收不回来。
920
+ const stop = () => {
921
+ const cur = store.get(key)
922
+ const job = cur && cur.job
923
+ if (!job) return
924
+ setEntry({ ...cur, stopping: true })
925
+ fetch(API + '/cancel', {
926
+ method: 'POST',
927
+ headers: { 'content-type': 'application/json' },
928
+ body: JSON.stringify({ job: job }),
929
+ }).catch(() => {})
930
+ }
931
+
932
+ // 让主模型读这份评审:优先用界面自己的输入框(setDraft + submit = 等同于你自己发一条), // 那条路走不通时才退到复制剪贴板。绝不再从 host 往会话里塞消息(2026-11-08 那次就是这么崩的)。
933
+ const sendToMain = () => {
934
+ // 每张卡都存了自己那份带时间戳的 md;别再一律读「最新.md」——那可能是别的轮的
935
+ // 1.0.1(小K 审出来):落盘失败时 entry.saved 是「保存失败:…」,
936
+ // 以前会被原样当成路径塞进输入框,主模型被叫去读一个不存在的文件——白花一次调用。
937
+ const saved = entry && entry.saved != null ? String(entry.saved) : ''
938
+ if (/^保存失败/.test(saved)) {
939
+ setNudge('nosave')
940
+ return
941
+ }
942
+ const target = saved || '二次评审\\最新.md'
943
+ const text =
944
+ '请读取工作区里的「' + target + '」——那是刚刚对这一轮回复的二次评审,读完直接回应它指出的问题,不用复述文件内容。'
945
+ try {
946
+ if (inputActions && typeof inputActions.setDraft === 'function' && typeof inputActions.submit === 'function') {
947
+ inputActions.setDraft(text)
948
+ inputActions.submit()
949
+ setNudge('sent')
950
+ return
951
+ }
952
+ } catch (e) {}
953
+ try {
954
+ navigator.clipboard.writeText(text).then(
955
+ () => setNudge('copied'),
956
+ () => setNudge('failed'),
957
+ )
958
+ } catch (e) {
959
+ setNudge('failed')
960
+ }
961
+ }
962
+
963
+ if (state === 'notice') {
964
+ const c = settings.value || {}
965
+ const who = (c.provider || '副模型') + ' / ' + (c.model || '默认模型')
966
+ return React.createElement(
967
+ 'div',
968
+ {
969
+ key: 'notice',
970
+ ref: grow,
971
+ style: {
972
+ ...glassCard,
973
+ marginTop: 10,
974
+ padding: '13px 16px',
975
+ borderLeft: '2px solid ' + ACCENT,
976
+ },
977
+ },
978
+ React.createElement('div', { style: { fontWeight: 600, marginBottom: 6 } }, '第一次用,先说清楚'),
979
+ React.createElement(
980
+ 'div',
981
+ { style: { opacity: 0.85, marginBottom: 4 } },
982
+ '点下去会把「这一轮及以前的对话」发给副模型(' + who + '),按它的价格计费。',
983
+ ),
984
+ React.createElement('div', { style: { opacity: 0.85, marginBottom: 10 } }, '评审结果只写成卡片和工作区里的 md,不改你的对话。'),
985
+ React.createElement(
986
+ 'div',
987
+ { style: { display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' } },
988
+ React.createElement(Btn, { tier: 'pri', onClick: ackAndRun, title: '记住这次确认,之后不再问' }, '知道了,评这一次'),
989
+ React.createElement(Btn, { tier: 'sec', onClick: () => run('mock'), title: '不调模型、不花钱、不留记录' }, '先用假数据看看(不花钱)'),
990
+ ),
991
+ )
992
+ }
993
+
994
+ if (state === 'loading' && !(entry && entry.refreshing)) {
995
+ const p = entry && entry.progress
996
+ // 0.9.0:budget 0 = 不设上限(他要求的默认),这时不画百分比
997
+ const budget = Math.max(0, Number((p && p.budget) || 0))
998
+ // 0.9.2:秒数=服务器上次报的 elapsed + 之后本地走过的时间(不再等下一次轮询)
999
+ const baseElapsed = Number((p && p.elapsed) || 0)
1000
+ const sincePoll = entry && entry.progressAt ? Math.max(0, Date.now() - entry.progressAt) : 0
1001
+ const elapsed = baseElapsed + sincePoll
1002
+ const secs = Math.max(0, Math.round(elapsed / 1000))
1003
+ const ratio = budget > 0 ? elapsed / budget : 0
1004
+ const over = budget > 0 && elapsed >= budget
1005
+ // 空闲闸:只提示"好像卡住了",真正的收手在服务端
1006
+ const idleMs = Number((p && p.idleMs) || 0)
1007
+ const idleFor = Number((p && p.idleFor) || 0) + sincePoll
1008
+ const stalling = idleMs > 0 && idleFor > Math.max(15000, idleMs * 0.5)
1009
+ const idleLeft = idleMs > 0 ? Math.max(0, Math.ceil((idleMs - idleFor) / 1000)) : 0
1010
+ const stopping = entry && entry.stopping
1011
+ const think = String((p && p.reasoning) || '').trim()
1012
+ const rows = think ? think.split('\n').filter((l) => l.trim()) : []
1013
+ const lastLine = rows.length ? rows[rows.length - 1].trim().slice(-90) : ''
1014
+ const hint = lastLine || (p && p.text ? '正在写结论…' : '')
1015
+ // 阶段只换一句话,不画假进度(他执意要留思考流,那句字幕就是它)
1016
+ const stage = stopping
1017
+ ? '正在停…'
1018
+ : over
1019
+ ? '到点了,在收尾…'
1020
+ : stalling
1021
+ ? '好像没动静了,' + idleLeft + ' 秒后自动收手'
1022
+ : p && p.text
1023
+ ? '正在写结论'
1024
+ : p && p.steps
1025
+ ? '查了 ' + p.steps + ' 处'
1026
+ : ''
1027
+ return React.createElement(
1028
+ 'div',
1029
+ { key: 'loading', ref: grow, style: { ...glassCard, marginTop: 8, padding: '11px 16px 12px' } },
1030
+ React.createElement(
1031
+ 'div',
1032
+ { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
1033
+ React.createElement(
1034
+ 'span',
1035
+ { style: { display: 'inline-flex', alignItems: 'center', gap: 6, flex: 'none', fontSize: FS.small, opacity: 0.85 } },
1036
+ React.createElement(Mark, { size: 14 }),
1037
+ (entry && entry.auto ? '自动评审中' : '评审中') + (secs ? ' ' + secs + 's' : ''),
1038
+ ),
1039
+ stage
1040
+ ? React.createElement(
1041
+ 'span',
1042
+ { style: { flex: 'none', fontSize: FS.min, opacity: 0.55, color: over || stalling ? '#f59e0b' : 'inherit' } },
1043
+ stage,
1044
+ )
1045
+ : null,
1046
+ hint
1047
+ ? React.createElement(
1048
+ Btn,
1049
+ {
1050
+ tier: 'lite',
1051
+ onClick: () => setShowThink(!showThink),
1052
+ title: '看完整的思考过程',
1053
+ // 字幕行:撑到一行宽就截断(省略号),别把整行顶成两行
1054
+ style: {
1055
+ maxWidth: 460,
1056
+ minWidth: 0,
1057
+ justifyContent: 'flex-start',
1058
+ textAlign: 'left',
1059
+ },
1060
+ },
1061
+ React.createElement(
1062
+ 'span',
1063
+ { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } },
1064
+ (showThink ? '▾ ' : '▸ ') + (hint.length > 46 ? hint.slice(0, 46) + '…' : hint),
1065
+ ),
1066
+ )
1067
+ : null,
1068
+ React.createElement(
1069
+ Btn,
1070
+ {
1071
+ tier: 'lite',
1072
+ onClick: stop,
1073
+ title: '立刻停止这次评审(已经花掉的调用收不回来,但不会继续等、也不会继续花)',
1074
+ style: { marginLeft: 'auto' },
1075
+ },
1076
+ stopping ? '停止中…' : '停止',
1077
+ ),
1078
+ ),
1079
+ // 思考字幕那一行底下的细线
1080
+ React.createElement(ThinBar, {
1081
+ ratio: ratio,
1082
+ over: over,
1083
+ indeterminate: !budget,
1084
+ phase: elapsed,
1085
+ warn: stalling || over,
1086
+ title: budget
1087
+ ? '已等 ' + secs + ' 秒,上限 ' + Math.round(budget / 1000) + ' 秒(真实时间,不是猜的)'
1088
+ : '已等 ' + secs + ' 秒,不设上限(这条线只表示它还活着)',
1089
+ }),
1090
+ think
1091
+ ? React.createElement(
1092
+ Collapse,
1093
+ { open: showThink },
1094
+ React.createElement(
1095
+ 'div',
1096
+ {
1097
+ style: {
1098
+ marginTop: 8,
1099
+ padding: '7px 10px',
1100
+ borderRadius: R,
1101
+ border: '1px solid ' + HAIRLINE_SOFT,
1102
+ background: glassFill(70),
1103
+ opacity: 0.72,
1104
+ fontSize: FS.small,
1105
+ lineHeight: 1.7,
1106
+ whiteSpace: 'pre-wrap',
1107
+ maxHeight: 240,
1108
+ overflowY: 'auto',
1109
+ },
1110
+ },
1111
+ think,
1112
+ ),
1113
+ )
1114
+ : null,
1115
+ )
1116
+ }
1117
+
1118
+ if (state === 'error') {
1119
+ const partial = String((entry && entry.partial) || '')
1120
+ return React.createElement(
1121
+ 'div',
1122
+ { key: 'error', ref: grow, style: { ...glassCard, marginTop: 8, padding: '13px 16px 12px', borderLeft: '2px solid rgba(245,158,11,0.55)' } },
1123
+ React.createElement(
1124
+ 'div',
1125
+ { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
1126
+ React.createElement(Btn, { tier: 'pri', onClick: rerun, title: '再试一次' }, React.createElement(Mark, null), '二次评审'),
1127
+ React.createElement(
1128
+ 'span',
1129
+ { style: { opacity: 0.7, fontSize: FS.small, wordBreak: 'break-word' }, title: String((entry && entry.note) || '') },
1130
+ '没评成:' + String((entry && entry.note) || '').slice(0, 200),
1131
+ ),
1132
+ ),
1133
+ React.createElement(Provenance, { pv: entry && entry.provenance }),
1134
+ partial
1135
+ ? React.createElement(
1136
+ 'div',
1137
+ { style: { marginTop: 8 } },
1138
+ React.createElement(
1139
+ Btn,
1140
+ {
1141
+ tier: 'lite',
1142
+ onClick: () => setShowThink(!showThink),
1143
+ title: '它被打断之前已经说出来的内容',
1144
+ },
1145
+ (showThink ? '▾ ' : '▸ ') + '看它已经说了什么(' + partial.length + ' 字)',
1146
+ ),
1147
+ React.createElement(
1148
+ Collapse,
1149
+ { open: showThink },
1150
+ React.createElement(
1151
+ 'div',
1152
+ {
1153
+ style: {
1154
+ marginTop: 6,
1155
+ padding: '7px 10px',
1156
+ borderRadius: R,
1157
+ border: '1px solid ' + HAIRLINE_SOFT,
1158
+ background: glassFill(70),
1159
+ opacity: 0.75,
1160
+ fontSize: FS.small,
1161
+ lineHeight: 1.7,
1162
+ whiteSpace: 'pre-wrap',
1163
+ maxHeight: 260,
1164
+ overflowY: 'auto',
1165
+ },
1166
+ },
1167
+ partial,
1168
+ ),
1169
+ ),
1170
+ )
1171
+ : null,
1172
+ )
1173
+ }
1174
+
1175
+ if (state === 'done' || (state === 'loading' && entry && entry.refreshing)) {
1176
+ // 主次:一剑封喉直接摊开;一针见血默认收起,别拿吹毛求疵占他的注意力(他 11-09 的要求)
1177
+ const head = unclear
1178
+ ? '没评成:它说不清这一轮在评什么'
1179
+ : passed
1180
+ ? '评审通过'
1181
+ : major.length
1182
+ ? '一剑封喉 ' + major.length + ' 条' + (minor.length ? ' · 一针见血 ' + minor.length + ' 条' : '')
1183
+ : '一针见血 ' + minor.length + ' 条'
1184
+ const edge = unclear
1185
+ ? 'rgba(245,158,11,0.55)'
1186
+ : passed
1187
+ ? 'rgba(16,185,129,0.5)'
1188
+ : major.length
1189
+ ? 'rgba(239,68,68,0.5)'
1190
+ : 'rgba(128,128,128,0.22)'
1191
+ const rv = (entry && entry.reviewer) || null
1192
+ const reviewerName = rv && rv.model ? String(rv.model) : ''
1193
+ const reviewerProvider = rv && rv.provider ? String(rv.provider) : ''
1194
+ const reviewerRole = rv && rv.role ? String(rv.role) : ''
1195
+ // 花了多久(服务器算的,不会随着卡片挂着继续涨)
1196
+ const spentMs = Number((entry && entry.elapsedMs) || 0)
1197
+ const spentText = spentMs
1198
+ ? spentMs >= 60000
1199
+ ? Math.floor(spentMs / 60000) + ' 分 ' + Math.round((spentMs % 60000) / 1000) + ' 秒'
1200
+ : Math.round(spentMs / 1000) + ' 秒'
1201
+ : ''
1202
+ return React.createElement(
1203
+ 'div',
1204
+ {
1205
+ key: 'done',
1206
+ ref: grow,
1207
+ style: {
1208
+ ...glassCard,
1209
+ marginTop: 12,
1210
+ padding: '13px 16px 11px',
1211
+ borderLeft: '2px solid ' + edge,
1212
+ },
1213
+ },
1214
+ React.createElement(
1215
+ 'div',
1216
+ { ref: reveal(0), style: { display: 'flex', alignItems: 'center', gap: 8, marginBottom: 9, flexWrap: 'wrap' } },
1217
+ React.createElement(
1218
+ 'span',
1219
+ { style: { display: 'inline-flex', alignItems: 'center', gap: 6, flex: 'none', fontSize: FS.small, opacity: 0.7 } },
1220
+ React.createElement(Mark, { size: 14 }),
1221
+ '二次评审',
1222
+ ),
1223
+ // 结论标签:一眼就知道这次是哪种(通过/一剑封喉/没评成)
1224
+ React.createElement(
1225
+ 'span',
1226
+ {
1227
+ style: {
1228
+ fontSize: FS.min,
1229
+ lineHeight: '20px',
1230
+ padding: '0 8px',
1231
+ borderRadius: 6,
1232
+ border: '1px solid ' + edge,
1233
+ color: unclear ? '#f59e0b' : passed ? '#10b981' : major.length ? '#ef4444' : 'inherit',
1234
+ opacity: 0.95,
1235
+ whiteSpace: 'nowrap',
1236
+ },
1237
+ },
1238
+ head,
1239
+ ),
1240
+ entry && entry.truncated
1241
+ ? React.createElement('span', { style: { opacity: 0.65, fontSize: FS.min } }, '(早期已截断)')
1242
+ : null,
1243
+ entry && entry.mock
1244
+ ? React.createElement(Btn, { tier: 'lite', onClick: rerun, title: '这是假数据(不花钱)。点一下换一组再看一遍' }, '假数据 · 再测一次')
1245
+ : null,
1246
+ // 右侧操作:一屏只留一个实心按钮,这些是"次操作"
1247
+ React.createElement('span', { style: { flex: 1, minWidth: 8 } }),
1248
+ state === 'loading'
1249
+ ? React.createElement(Btn, { tier: 'lite', disabled: true }, '重评中…')
1250
+ : entry && entry.mock
1251
+ ? null
1252
+ : React.createElement(
1253
+ Btn,
1254
+ { tier: 'sec', onClick: rerun, title: '再评一次(' + (reviewerName || '副模型') + ',会调用模型、花钱)' },
1255
+ '重评',
1256
+ ),
1257
+ entry && entry.mock
1258
+ ? null
1259
+ : React.createElement(
1260
+ Btn,
1261
+ { tier: 'sec', onClick: sendToMain, title: '以你的名义发一句,让主模型去读这份评审' },
1262
+ nudge === 'sent'
1263
+ ? '已发给主模型'
1264
+ : nudge === 'copied'
1265
+ ? '已复制,粘贴发送'
1266
+ : nudge === 'failed'
1267
+ ? '复制失败'
1268
+ : nudge === 'nosave'
1269
+ ? '这次没存下来,不能读'
1270
+ : '让主模型读',
1271
+ ),
1272
+ ),
1273
+ entry && entry.topic
1274
+ ? React.createElement('div', { ref: reveal(40), style: { opacity: 0.6, fontSize: FS.min, marginBottom: 8 } }, '评的是:' + entry.topic)
1275
+ : null,
1276
+ React.createElement(Provenance, { pv: entry && entry.provenance }),
1277
+ React.createElement(Steps, { steps: (entry && entry.steps) || [] }),
1278
+ omittedTotal > 0
1279
+ ? React.createElement(
1280
+ 'div',
1281
+ { ref: reveal(50), style: { opacity: 0.6, fontSize: FS.min, marginBottom: 8 } },
1282
+ '另有 ' + omittedTotal + ' 条同类被省略(大问题最多留 2 条、小问题最多留 1 条)',
1283
+ )
1284
+ : null,
1285
+ unclear
1286
+ ? React.createElement(
1287
+ 'div',
1288
+ { ref: reveal(60), style: { fontSize: '1.02em', lineHeight: 1.6, margin: '1px 0 8px', color: '#f59e0b', opacity: 0.95 } },
1289
+ (entry && entry.note) || '这次不算通过,也不算没问题——它没能说清这一轮在评什么。换一轮或再评一次试试。',
1290
+ )
1291
+ : null,
1292
+ passed
1293
+ ? React.createElement(
1294
+ 'div',
1295
+ { ref: reveal(60), style: { fontSize: '1.02em', lineHeight: 1.6, margin: '1px 0 8px', opacity: 0.85 } },
1296
+ (entry && entry.note) || '没发现必须改的地方。',
1297
+ )
1298
+ : null,
1299
+ major.length
1300
+ ? React.createElement(
1301
+ 'div',
1302
+ { style: { marginBottom: 10 } },
1303
+ React.createElement(
1304
+ 'div',
1305
+ { style: { fontSize: FS.min, color: '#ef4444', opacity: 0.9, marginBottom: 6 } },
1306
+ '一剑封喉 · 不改不行',
1307
+ ),
1308
+ ...major.map((it, i) => React.createElement(Item, { key: 'M' + i, it, delay: 90 + i * 60 })),
1309
+ )
1310
+ : null,
1311
+ minor.length ? React.createElement(MinorGroup, { items: minor }) : null,
1312
+ // 底栏:署名 + 用时 + 版本(左),主刀开关(右)—— 操作和"这是谁评的"分开摆
1313
+ React.createElement(
1314
+ 'div',
1315
+ {
1316
+ ref: reveal(200),
1317
+ style: {
1318
+ marginTop: 10,
1319
+ paddingTop: 8,
1320
+ borderTop: '1px solid ' + HAIRLINE_SOFT,
1321
+ display: 'flex',
1322
+ alignItems: 'center',
1323
+ gap: 8,
1324
+ flexWrap: 'wrap',
1325
+ fontSize: FS.min,
1326
+ },
1327
+ },
1328
+ reviewerName
1329
+ ? React.createElement(
1330
+ 'span',
1331
+ { title: reviewerProvider ? reviewerProvider + ' / ' + reviewerName : reviewerName, style: { display: 'inline-flex', gap: 6, alignItems: 'baseline', flexWrap: 'wrap', opacity: 0.55 } },
1332
+ React.createElement('span', null, '署名'),
1333
+ React.createElement('span', { style: { fontWeight: 600 } }, reviewerName),
1334
+ reviewerProvider ? React.createElement('span', null, '(' + reviewerProvider + ')') : null,
1335
+ reviewerRole ? React.createElement('span', null, '· ' + reviewerRole) : null,
1336
+ spentText ? React.createElement('span', null, '· 用了 ' + spentText) : null,
1337
+ entry && entry.version ? React.createElement('span', null, '· v' + entry.version) : null,
1338
+ )
1339
+ : null,
1340
+ React.createElement('span', { style: { flex: 1, minWidth: 8 } }),
1341
+ swapChip(),
1342
+ ),
1343
+ )
1344
+ }
1345
+
1346
+ // 未评:这一排就是"明面"——一个实心主操作,其余全部退成细边
1347
+ return React.createElement(
1348
+ 'div',
1349
+ { style: { marginTop: 10, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', fontSize: FS.body } },
1350
+ React.createElement(
1351
+ Btn,
1352
+ { tier: 'pri', onClick: () => run('manual'), title: '让 ' + (reviewerName || '副模型') + ' 挑一遍这一轮的毛病' },
1353
+ React.createElement(Mark, { size: 14 }),
1354
+ '二次评审',
1355
+ ),
1356
+ React.createElement(
1357
+ Btn,
1358
+ { tier: 'sec', onClick: () => run('mock'), title: '用假数据看一遍卡片长什么样:不调模型、不花钱、不留记录' },
1359
+ '先看效果(不花钱)',
1360
+ ),
1361
+ swapChip(),
1362
+ // 当前副模型:他 2026-11-10 晚说"这总得说清楚"
1363
+ reviewerName
1364
+ ? React.createElement('span', { style: { opacity: 0.45, fontSize: FS.min, whiteSpace: 'nowrap' } }, '当前副模型:' + reviewerName)
1365
+ : null,
1366
+ initErrors.length
1367
+ ? React.createElement(
1368
+ 'span',
1369
+ { style: { color: '#ef4444', opacity: 0.8, fontSize: FS.min } },
1370
+ '(设置页没挂上:' + initErrors.join(';') + ')',
1371
+ )
1372
+ : null,
1373
+ )
1374
+ }
1375
+
1376
+ /* ---------------- 设置页(插件 → 二次评审 → 配置) ---------------- */
1377
+
1378
+ const labelStyle = { opacity: 0.7, minWidth: 88 }
1379
+
1380
+ /* 设置页的一段:01 模型 / 02 评审模式 / 03 权限 / 04 关于。
1381
+ 写成普通函数而不是组件 —— 在 render 里定义组件类型会让子元素每次重渲染都重挂载
1382
+ (下拉框会掉焦点)。 */
1383
+ const sectionTitle = {
1384
+ display: 'flex',
1385
+ alignItems: 'center',
1386
+ gap: 8,
1387
+ margin: '0 0 8px',
1388
+ }
1389
+ function section(no, title, ...children) {
1390
+ return React.createElement(
1391
+ 'div',
1392
+ { style: { marginBottom: 16 } },
1393
+ React.createElement(
1394
+ 'div',
1395
+ { style: sectionTitle },
1396
+ React.createElement('span', { style: { fontSize: FS.min, opacity: 0.45, fontVariantNumeric: 'tabular-nums' } }, no),
1397
+ React.createElement('span', { style: { fontSize: FS.small, opacity: 0.8, letterSpacing: '0.06em' } }, title),
1398
+ React.createElement('span', { style: { flex: 1, height: 1, background: HAIRLINE_SOFT } }),
1399
+ ),
1400
+ React.createElement('div', { style: { ...glassCard, padding: '12px 14px' } }, ...children),
1401
+ )
1402
+ }
1403
+
1404
+ /* 出错了也不要变成"整页空白":DSH 会把崩掉的条目整条摘掉,页面上只剩一片白。
1405
+ 这里自己兜一层,把错误原文摊出来 —— 2026-11-09 那次整页空白就是这么找不到原因的。 */
1406
+ class PageBoundary extends React.Component {
1407
+ constructor(props) {
1408
+ super(props)
1409
+ this.state = { err: null }
1410
+ }
1411
+
1412
+ static getDerivedStateFromError(err) {
1413
+ return { err: err }
1414
+ }
1415
+
1416
+ render() {
1417
+ if (this.state.err) {
1418
+ return React.createElement(
1419
+ 'div',
1420
+ { style: { padding: '8px 0', color: '#ef4444', fontSize: FS.body, lineHeight: 1.7 } },
1421
+ React.createElement('div', { style: { fontWeight: 600, marginBottom: 6 } }, '这张设置页出错了(不是你的操作问题)'),
1422
+ React.createElement(
1423
+ 'div',
1424
+ { style: { opacity: 0.85, fontSize: FS.small, whiteSpace: 'pre-wrap', wordBreak: 'break-word' } },
1425
+ String((this.state.err && this.state.err.message) || this.state.err),
1426
+ ),
1427
+ React.createElement('div', { style: { opacity: 0.6, fontSize: FS.min, marginTop: 8 } }, '把这段字发给小D就行。'),
1428
+ )
1429
+ }
1430
+ return this.props.children
1431
+ }
1432
+ }
1433
+
1434
+ /* 判断"有没有改动没保存":只比对真正会写进 config.json 的那几项。
1435
+ 他 2026-11-10 晚的原话:很多用户没这个意识、甚至看不到保存按钮 ——
1436
+ 所以这条提示要跟着改动出现,按钮要显眼,还得钉在视野里。 */
1437
+ function cfgFingerprint(c) {
1438
+ if (!c) return ''
1439
+ return JSON.stringify([
1440
+ String(c.provider || ''),
1441
+ String(c.model || ''),
1442
+ !!c.auto,
1443
+ !!c.mock,
1444
+ !!c.allowWrite,
1445
+ Number(c.budgetSec) || 0,
1446
+ Number(c.idleSec) || 0,
1447
+ c.checkQuotes !== false,
1448
+ ])
1449
+ }
1450
+
1451
+ function ConfigPage({ view }) {
1452
+ const [cfg, setCfg] = useState(null)
1453
+ const [providers, setProviders] = useState([])
1454
+ const [models, setModels] = useState([])
1455
+ const [note, setNote] = useState('')
1456
+ const [busy, setBusy] = useState(false)
1457
+ const [ver, setVer] = useState('')
1458
+
1459
+ useEffect(() => {
1460
+ fetch(API + '/config')
1461
+ .then((r) => r.json())
1462
+ .then((j) => {
1463
+ if (j && j.ok) {
1464
+ setCfg(j.config)
1465
+ // 记一份"盘上现在是啥",用来判断有没有改动没保存
1466
+ settings.value = j.config
1467
+ } else setNote('读不到配置:' + ((j && j.error) || '未知'))
1468
+ // 版本号由宿主带出来(0.7.0 起)—— 「关于」显示的就是正在跑的那份后端
1469
+ if (j && j.version) setVer(String(j.version))
1470
+ })
1471
+ .catch((e) => setNote('读不到配置:' + String((e && e.message) || e)))
1472
+ fetch(API + '/models')
1473
+ .then((r) => r.json())
1474
+ .then((j) => {
1475
+ if (j && j.ok) setProviders(j.providers || [])
1476
+ })
1477
+ .catch(() => {})
1478
+ }, [])
1479
+
1480
+ const provider = cfg && cfg.provider
1481
+ useEffect(() => {
1482
+ if (!provider) return
1483
+ // 1.0.1(小K 审出来):快速切 provider 时旧回包会盖掉新列表
1484
+ // (选的是 B,模型下拉里却是 A 的模型)→ 加 alive flag,和 TurnTail 里一个写法
1485
+ let alive = true
1486
+ fetch(API + '/models?provider=' + encodeURIComponent(provider))
1487
+ .then((r) => r.json())
1488
+ .then((j) => {
1489
+ if (alive && j && j.ok) setModels(j.models || [])
1490
+ })
1491
+ .catch(() => {
1492
+ if (alive) setModels([])
1493
+ })
1494
+ return () => {
1495
+ alive = false
1496
+ }
1497
+ }, [provider])
1498
+
1499
+ const save = async () => {
1500
+ if (!cfg) return
1501
+ setBusy(true)
1502
+ setNote('')
1503
+ try {
1504
+ const r = await fetch(API + '/config', {
1505
+ method: 'POST',
1506
+ headers: { 'content-type': 'application/json' },
1507
+ body: JSON.stringify({
1508
+ provider: cfg.provider,
1509
+ model: cfg.model,
1510
+ auto: !!cfg.auto,
1511
+ mock: !!cfg.mock,
1512
+ allowWrite: !!cfg.allowWrite,
1513
+ budgetSec: Number(cfg.budgetSec) || 0,
1514
+ idleSec: Number(cfg.idleSec) || 0,
1515
+ checkQuotes: cfg.checkQuotes !== false,
1516
+ }),
1517
+ })
1518
+ const j = await r.json()
1519
+ if (j && j.ok) {
1520
+ setCfg(j.config)
1521
+ settings.value = j.config
1522
+ setNote('已保存')
1523
+ } else {
1524
+ setNote('没保存成:' + ((j && j.error) || '未知'))
1525
+ }
1526
+ } catch (e) {
1527
+ setNote('没保存成:' + String((e && e.message) || e))
1528
+ }
1529
+ setBusy(false)
1530
+ }
1531
+
1532
+ if (!cfg) return React.createElement('div', { style: { opacity: 0.7 } }, note || '读取配置中…')
1533
+
1534
+ // /models 给的是 [{ id, name }] 对象,不是字符串。原来直接把对象当文字渲染 →
1535
+ // React error #31(Objects are not valid as a React child)→ 整条条目被摘掉 → 设置页整页空白。
1536
+ const providerOptions = (providers.length ? providers : [{ id: cfg.provider || '', name: cfg.provider || '' }])
1537
+ .map((p) => (typeof p === 'string' ? { id: p, name: p } : { id: String((p && p.id) || ''), name: String((p && p.name) || (p && p.id) || '') }))
1538
+ .filter((p) => p.id)
1539
+
1540
+ // 有没有改动没保存(他 2026-11-10 晚:很多用户没这个意识)
1541
+ const dirty = !!cfg && !!settings.value && cfgFingerprint(cfg) !== cfgFingerprint(settings.value)
1542
+ const failedSave = /^没保存成/.test(note)
1543
+ const hintText = failedSave
1544
+ ? note
1545
+ : dirty
1546
+ ? '有改动还没保存 —— 不点保存,上面的改动不会生效'
1547
+ : note || '改动不会自动保存'
1548
+ const hintColor = failedSave ? '#ef4444' : dirty ? '#f59e0b' : 'inherit'
1549
+
1550
+ return React.createElement(
1551
+ 'div',
1552
+ { style: { padding: '4px 0', maxWidth: 620 } },
1553
+
1554
+ section(
1555
+ '01',
1556
+ '模型',
1557
+ React.createElement(
1558
+ 'div',
1559
+ { style: { display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' } },
1560
+ React.createElement('span', { style: labelStyle }, '副模型'),
1561
+ // 控件推到右边:左边是"项",右边是"值",像一张设置表而不是一串表单
1562
+ React.createElement(
1563
+ 'span',
1564
+ { style: { display: 'inline-flex', gap: 10, alignItems: 'center', flexWrap: 'wrap', marginLeft: 'auto' } },
1565
+ React.createElement(
1566
+ Select,
1567
+ {
1568
+ value: cfg.provider || '',
1569
+ onChange: (e) => setCfg({ ...cfg, provider: e.target.value, model: '' }),
1570
+ },
1571
+ ...providerOptions.map((p) =>
1572
+ React.createElement('option', { key: p.id, value: p.id }, p.name === p.id ? p.id : p.name + '(' + p.id + ')'),
1573
+ ),
1574
+ ),
1575
+ React.createElement(
1576
+ Select,
1577
+ {
1578
+ value: cfg.model || '',
1579
+ onChange: (e) => setCfg({ ...cfg, model: e.target.value }),
1580
+ },
1581
+ cfg.model ? null : React.createElement('option', { key: '__none', value: '' }, '(选一个)'),
1582
+ ...models
1583
+ .map((m) => (typeof m === 'string' ? { id: m, name: m } : { id: String((m && m.id) || ''), name: String((m && m.name) || (m && m.id) || '') }))
1584
+ .filter((m) => m.id)
1585
+ .map((m) => React.createElement('option', { key: m.id, value: m.id }, (m.name && m.name !== m.id ? m.name + '(' + m.id + ')' : m.id))),
1586
+ ),
1587
+ ),
1588
+ ),
1589
+ React.createElement('div', { style: { opacity: 0.55, fontSize: FS.min, marginTop: 6 } }, '评审由这个模型来做;「副模型主刀」打开时,下一轮答复也换成它。'),
1590
+ ),
1591
+
1592
+ section(
1593
+ '02',
1594
+ '评审模式',
1595
+ React.createElement(Check, {
1596
+ checked: cfg.auto,
1597
+ onChange: (e) => setCfg({ ...cfg, auto: e.target.checked }),
1598
+ title: '每条回复结束后自动评一次',
1599
+ desc: '会调用副模型,产生费用',
1600
+ style: { marginBottom: 2 },
1601
+ }),
1602
+ React.createElement(Check, {
1603
+ checked: cfg.mock,
1604
+ onChange: (e) => setCfg({ ...cfg, mock: e.target.checked }),
1605
+ title: '测试模式:用假数据',
1606
+ desc: '不调用模型、不花钱、不落盘 —— 只验界面和流程时打开',
1607
+ style: { marginBottom: 2 },
1608
+ }),
1609
+ // 1.1.0:引文核验(小问那场的补丁)。纯本地比对,不调模型、不花钱
1610
+ React.createElement(Check, {
1611
+ checked: cfg.checkQuotes !== false,
1612
+ onChange: (e) => setCfg({ ...cfg, checkQuotes: e.target.checked }),
1613
+ title: '核对引文:助手引用的「你的话」要有出处',
1614
+ desc: '在会话记录里找不到出处的,卡上标红;机器比对,不调模型、不花钱',
1615
+ style: { marginBottom: 10 },
1616
+ }),
1617
+ // 0.9.0:45 秒那道总时长闸被他骂掉了,改成"你自己选 + 能随时喊停"
1618
+ React.createElement(
1619
+ 'div',
1620
+ { style: { borderTop: '1px solid ' + HAIRLINE_SOFT, paddingTop: 10, display: 'flex', flexDirection: 'column', gap: 8 } },
1621
+ React.createElement(
1622
+ 'div',
1623
+ { style: { display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' } },
1624
+ React.createElement('span', { style: { opacity: 0.7, minWidth: 88 } }, '最长等待'),
1625
+ React.createElement(
1626
+ 'span',
1627
+ { style: { marginLeft: 'auto' } },
1628
+ React.createElement(
1629
+ Select,
1630
+ {
1631
+ value: String(Number(cfg.budgetSec) || 0),
1632
+ onChange: (e) => setCfg({ ...cfg, budgetSec: Number(e.target.value) || 0 }),
1633
+ },
1634
+ ...[
1635
+ ['0', '不设上限(默认)'],
1636
+ ['180', '3 分钟'],
1637
+ ['300', '5 分钟'],
1638
+ ['45', '45 秒(旧版)'],
1639
+ ].map(([v, t]) => React.createElement('option', { key: v, value: v }, t)),
1640
+ ),
1641
+ ),
1642
+ ),
1643
+ React.createElement(
1644
+ 'div',
1645
+ { style: { opacity: 0.5, fontSize: FS.min, marginTop: 4 } },
1646
+ '不设上限时,卡上那条线不显示百分比,只表示"它还活着";随时可以点「停止」',
1647
+ ),
1648
+ React.createElement(
1649
+ 'div',
1650
+ { style: { display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap', marginTop: 8 } },
1651
+ React.createElement('span', { style: { opacity: 0.7, minWidth: 88 } }, '没动静就收手'),
1652
+ React.createElement(
1653
+ 'span',
1654
+ { style: { marginLeft: 'auto' } },
1655
+ React.createElement(
1656
+ Select,
1657
+ {
1658
+ value: String(Number(cfg.idleSec) || 0),
1659
+ onChange: (e) => setCfg({ ...cfg, idleSec: Number(e.target.value) || 0 }),
1660
+ },
1661
+ ...[
1662
+ ['0', '永不收手(默认)'],
1663
+ ['120', '2 分钟没一点新字'],
1664
+ ['300', '5 分钟'],
1665
+ ['600', '10 分钟'],
1666
+ ].map(([v, t]) => React.createElement('option', { key: v, value: v }, t)),
1667
+ ),
1668
+ ),
1669
+ ),
1670
+ React.createElement(
1671
+ 'div',
1672
+ { style: { opacity: 0.5, fontSize: FS.min, marginTop: 4 } },
1673
+ '这是"确认它真的不动了",不是限它想多久——慢但一直在吐字的,永远不会碰到它。',
1674
+ ),
1675
+ ),
1676
+ ),
1677
+
1678
+ section(
1679
+ '03',
1680
+ '权限',
1681
+ React.createElement(Check, {
1682
+ checked: cfg.allowWrite,
1683
+ onChange: (e) => setCfg({ ...cfg, allowWrite: e.target.checked }),
1684
+ title: '允许副模型改文件',
1685
+ desc: '默认关:关着时它连 write_file 这个工具都拿不到;工作区外的路径一律拦。',
1686
+ }),
1687
+ ),
1688
+
1689
+ section(
1690
+ '04',
1691
+ '关于',
1692
+ React.createElement(
1693
+ 'div',
1694
+ { style: { display: 'flex', alignItems: 'baseline', gap: 8, marginBottom: 6, flexWrap: 'wrap' } },
1695
+ React.createElement('span', { style: { fontWeight: 600 } }, '二次评审'),
1696
+ React.createElement('span', { style: { opacity: 0.5, fontSize: FS.min } }, 'v' + (ver || '1.3.2')),
1697
+ React.createElement('span', { style: { opacity: 0.45, fontSize: FS.min } }, '看不到版本号 = 页面还是旧的,重开一次 DSH 窗口'),
1698
+ ),
1699
+ React.createElement(
1700
+ 'div',
1701
+ { style: { opacity: 0.6, fontSize: FS.small, lineHeight: 1.8 } },
1702
+ '评审只写卡片,不改消息、不动会话记录;结果另存一份在 工作区\\二次评审\\。',
1703
+ React.createElement('br'),
1704
+ '外观会跟着「壁纸插件」的玻璃走;没装就退回中性色,朴素但不坏。',
1705
+ ),
1706
+ ),
1707
+
1708
+ /* 保存条(2026-11-10 晚):以前按钮夹在 03 和 04 中间,小、淡、滚两屏就看不见了。
1709
+ 现在挪到最底下钉住(sticky),按钮按"有没有改动"换脸色,旁边一行字说清"不点不生效"。 */
1710
+ React.createElement(
1711
+ 'div',
1712
+ {
1713
+ style: {
1714
+ position: 'sticky',
1715
+ bottom: 0,
1716
+ zIndex: 1,
1717
+ display: 'flex',
1718
+ gap: 10,
1719
+ alignItems: 'center',
1720
+ flexWrap: 'wrap',
1721
+ marginTop: 4,
1722
+ padding: '10px 2px 12px',
1723
+ borderTop: '1px solid ' + HAIRLINE_SOFT,
1724
+ background: glassFill(240),
1725
+ backdropFilter: 'blur(6px)',
1726
+ WebkitBackdropFilter: 'blur(6px)',
1727
+ },
1728
+ },
1729
+ React.createElement(
1730
+ Chip,
1731
+ {
1732
+ onClick: save,
1733
+ disabled: busy || !cfg.model,
1734
+ title: !cfg.model ? '先选一个模型' : dirty ? '把改动写进配置' : '保存到配置',
1735
+ style: {
1736
+ height: 32,
1737
+ minWidth: 124,
1738
+ justifyContent: 'center',
1739
+ padding: '0 18px',
1740
+ fontSize: FS.small,
1741
+ fontWeight: 600,
1742
+ // 有改动时也是"强调色玻璃",不是实心色块(他 2026-11-10 晚定的质感)
1743
+ background: dirty ? accentGlass(26) : glassFill(200),
1744
+ borderColor: dirty ? 'color-mix(in srgb, ' + ACCENT + ' 62%, transparent)' : HAIRLINE,
1745
+ color: 'inherit',
1746
+ boxShadow: dirty ? 'inset 0 1px 0 rgba(255,255,255,0.22)' : 'none',
1747
+ },
1748
+ },
1749
+ busy ? '保存中…' : dirty ? '保存改动' : '保存',
1750
+ ),
1751
+ React.createElement(
1752
+ 'span',
1753
+ { style: { fontSize: FS.min, color: hintColor, opacity: failedSave || dirty ? 0.95 : 0.55 } },
1754
+ hintText,
1755
+ ),
1756
+ ),
1757
+ )
1758
+ }
1759
+
1760
+ return {
1761
+ inject: ['slots'],
1762
+ apply(ctx) {
1763
+ const slots = ctx.get('slots')
1764
+ if (!slots) return
1765
+
1766
+ slots.inject('conversation.chat.turnTail', () =>
1767
+ slots.register(
1768
+ {
1769
+ name: 'conversation.chat.turnTail',
1770
+ id: 'second-review-tail',
1771
+ order: 30,
1772
+ inject: (sessionId) => ({ sessionId }),
1773
+ },
1774
+ TurnTail,
1775
+ ),
1776
+ )
1777
+
1778
+ // 设置页:在「插件」页加一个自己的标签页(列表页第一排标签:已安装 / 二次评审)。
1779
+ // 试过 plugins.bundle.config 和 plugins.detail.section,前者认不出键、后者拿到的 subject 形状对不上,
1780
+ // 标签页这条路不依赖任何 subject,最稳。
1781
+ const guard = (slotName, factory) => {
1782
+ try {
1783
+ slots.inject(slotName, () => {
1784
+ try {
1785
+ factory()
1786
+ } catch (e) {
1787
+ initErrors.push(slotName + ':' + String((e && e.message) || e))
1788
+ }
1789
+ })
1790
+ } catch (e) {
1791
+ initErrors.push(slotName + '(inject):' + String((e && e.message) || e))
1792
+ }
1793
+ }
1794
+
1795
+ guard('plugins.tab', () => {
1796
+ slots.register(
1797
+ { name: 'plugins.tab', id: 'second-review-tab', label: '二次评审', order: 40 },
1798
+ (props) =>
1799
+ React.createElement(
1800
+ PageBoundary,
1801
+ null,
1802
+ React.createElement(
1803
+ 'div',
1804
+ { style: { padding: '8px 0' } },
1805
+ React.createElement('div', { style: { fontSize: '1.05em', marginBottom: 12 } }, '二次评审 · 设置'),
1806
+ React.createElement(ConfigPage, { view: 'page', ...(props || {}) }),
1807
+ ),
1808
+ ),
1809
+ )
1810
+ })
1811
+ },
1812
+ }
1813
+ },
1814
+ })