双盲复算前端(四):设置页「核查」一节

- pages/VerifySettings.tsx(新):核查模型(照证据裁判的写法:两项都空时醒目提示将使用证据裁判的模型,只填模型时写明
  按模型名用哪个接入);双盲复算(启用、允许的相对误差、每项最多查询次数、时长上限);反方审稿(启用、每份报告最多
  审几句、每句最多查询次数、时长上限);审批质询(启用、每次最多查询次数、时长上限);每日金额上限(可不限)和今日
  核查花费(GET /api/settings/verify/spend)。上限照 LimitField 的写法:勾「不限」框变灰、写明还受什么约束,勾掉填回
  原来的数;只校验改过的项;字段名就是服务端 VERIFY_SETTING_LABEL 的叫法,422 的报错对得上
- SettingsPage:PrefsTab 另持一份核查草稿,和其余设置共用保存条(改动计数、项名、放弃、校验都算上),保存时整组写
  verify(不限存 null),存完再取一次今日花费;老服务端没有 verify 一组时不显示
- check-verify 加设置页读回各项、叫法逐项比对、整组写的请求体、错了不让存、422 照实说、老服务端不显示、
  390px 不溢出、对比度
This commit is contained in:
ThearchyHelios
2026-10-05 14:31:16 +08:00
parent 7a9dc93dde
commit 5093e98c49
3 changed files with 559 additions and 8 deletions
+29 -8
View File
@@ -22,8 +22,9 @@ import type { Provider } from '../types'
import { normalizeTheme } from '../lib/theme'
import { useLeaveGuard } from '../lib/leave'
import { localActor, setLocalActor } from '../lib/actor'
import { AGENT_GUARD_TEXT, JUDGE_SETTING_TEXT, TOOL_GATE_TEXT, judgeByModelText, judgeUnlimitedText } from '../lib/terms'
import { AGENT_GUARD_TEXT, JUDGE_SETTING_TEXT, TOOL_GATE_TEXT, VERIFY_SETTING_TEXT, judgeByModelText, judgeUnlimitedText } from '../lib/terms'
import type { ThemePref } from '../lib/theme'
import { VerifySection, verifyChanged, verifyDraftOf, verifyErrors, verifyLabels, verifyPayload, type VerifyDraft } from './VerifySettings'
// 提到模块级:tab 名同时是 URL 的最后一段,两处各写一份迟早对不上。
// 数据源搬去了顶级的「数据」页(/data),/settings/datasources 跳过去
@@ -615,6 +616,9 @@ function PrefsTab() {
jClaims: string | null; jCost: string | null; jTimeout: string | null; jClick: string | null; jDaily: string | null
} | null>(null)
const [spend, setSpend] = useState<{ usd?: number; calls?: number; unpriced_calls?: number } | null>(null)
// 核查一组(双盲复算、反方审稿、审批质询)的草稿另放一份,和上面的共用保存条;verifySaves 是存过几次,今日核查花费跟着再取
const [verifyDraft, setVerifyDraft] = useState<VerifyDraft | null>(null)
const [verifySaves, setVerifySaves] = useState(0)
const [saving, setSaving] = useState(false)
const [savedFlash, setSavedFlash] = useState(0)
const [saveError, setSaveError] = useState<unknown>(null)
@@ -686,9 +690,14 @@ function PrefsTab() {
jClick: judgeError('jClick'), jDaily: judgeError('jDaily'),
}
const judgeInvalid = Object.values(judgeErrors).some(Boolean)
const invalid = guardInvalid || judgeInvalid
const verifySaved = verifyDraftOf(values?.verify)
const verifyCur = verifyDraft ?? verifySaved
const verifyKeys = verifyChanged(verifyCur, verifySaved)
const verifyErrs = verifyErrors(verifyCur, verifySaved)
const verifyInvalid = Object.values(verifyErrs).some(Boolean)
const invalid = guardInvalid || judgeInvalid || verifyInvalid
const changed = (Object.keys(saved) as (keyof typeof saved)[]).filter((k) => cur[k] !== saved[k])
const dirty = values ? changed.length : 0
const dirty = values ? changed.length + verifyKeys.length : 0
// 外壳唯一的 blocker 在地址(pathname)变化时问:切标签、导航、⌘K、⌥ 数字、后退都算
useLeaveGuard(dirty > 0, () => confirmLeave(dirty))
@@ -702,6 +711,10 @@ function PrefsTab() {
setSaveError(null)
setDraft({ ...cur, ...patch })
}
const editVerify = (patch: Partial<VerifyDraft>) => {
setSaveError(null)
setVerifyDraft({ ...verifyCur, ...patch })
}
/**
* 署名只在这台浏览器里,先落本机:它不该跟着后端一起「没存上」。运行默认值
@@ -730,7 +743,9 @@ function PrefsTab() {
click_max_cost_usd: parseLimit(cur.jClick),
daily_max_usd: parseLimit(cur.jDaily),
} : null
if (runChanged || judge) {
// 核查一组同样整组写(VerifySettings 的 verifyPayload)
const verify = verifyKeys.length ? verifyPayload(verifyCur) : null
if (runChanged || judge || verify) {
const run = {
...(values.run ?? {}),
default_memory_scope: cur.scope,
@@ -742,16 +757,19 @@ function PrefsTab() {
agent_budget_tokens: cur.budgetTokens.trim() === '' ? null : Number(cur.budgetTokens),
agent_budget_usd: cur.budgetUsd.trim() === '' ? null : Number(cur.budgetUsd),
}
const out = await api.settings.put({ ...(runChanged ? { run } : {}), ...(judge ? { judge } : {}) })
const out = await api.settings.put({ ...(runChanged ? { run } : {}), ...(judge ? { judge } : {}), ...(verify ? { verify } : {}) })
setValues((v) => ({
...(v ?? {}),
...(runChanged ? { run: out?.run ?? run } : {}),
...(judge ? { judge: out?.judge ?? judge } : {}),
...(verify ? { verify: out?.verify ?? verify } : {}),
}))
// 每日上限改了:旁边那句「今天已花」的分母跟着变,顺手再取一次
if (judge) void loadSpend()
if (verify) setVerifySaves((n) => n + 1)
}
setDraft(null)
setVerifyDraft(null)
setSavedFlash(Date.now())
} catch (e) {
// 署名已经存上了:留在草稿里的只剩运行默认值,条上的计数跟着变少
@@ -1029,6 +1047,8 @@ function PrefsTab() {
</div>
</section>
)}
{values.verify && <VerifySection cur={verifyCur} errors={verifyErrs} onEdit={editVerify} refresh={verifySaves} />}
</>
)}
@@ -1052,7 +1072,7 @@ function PrefsTab() {
judgeProvider: '裁判模型的接入', judgeModel: '裁判模型',
jClaims: '每份报告最多裁判句数', jCost: '每份报告的金额上限', jTimeout: '每份报告的时长上限',
jClick: '每次点击的金额上限', jDaily: '每日金额上限',
})[k]).join('、')}
})[k]).concat(verifyLabels(verifyKeys)).join('、')}
</span>
</span>
{saveError != null && (
@@ -1060,10 +1080,11 @@ function PrefsTab() {
)}
<span className="flex-1" />
<button className="btn btn-sm btn-ghost" disabled={saving}
onClick={() => { setDraft(null); setSaveError(null) }}>放弃</button>
onClick={() => { setDraft(null); setVerifyDraft(null); setSaveError(null) }}>放弃</button>
<button className="btn btn-sm btn-primary" disabled={saving || invalid}
title={guardInvalid ? '护栏设置中有无效值,请修正后再保存'
: judgeInvalid ? '证据裁判的上限有无效值,请修正后再保存' : undefined}
: judgeInvalid ? '证据裁判的上限有无效值,请修正后再保存'
: verifyInvalid ? VERIFY_SETTING_TEXT.section.saveInvalid : undefined}
onClick={() => void save()}>
{saving ? <Spinner size={11} /> : <Check size={12} aria-hidden />} 保存设置
</button>
+337
View File
@@ -0,0 +1,337 @@
import { useEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { AlertTriangle } from 'lucide-react'
import { get } from '../api/http'
import { modelOptions, providerOfModel, useCatalog } from '../store/catalog'
import { Field, SectionBar } from '../components/ui'
import { VERIFY_SETTING_TEXT } from '../lib/terms'
import type { VerifySpend } from '../types'
/*
* 设置页「偏好设置」里「核查」一节:双盲复算、反方审稿、审批质询共用的核查模型、各自的开关和上限、每日金额上限
* (服务端 api/settings.py 的 verify 一组)。
*
* 草稿由 PrefsTab 持有、和其余设置共用一条保存条(这一节只画、只报改动),存的时候整组写 verify:服务端对没给的项
* 回落到默认值,只发改过的那一项会把别的项冲回默认。字段名就是服务端 VERIFY_SETTING_LABEL 的叫法,422 的报错
* 写的也是它,人找得到是哪一项
*/
const T = VERIFY_SETTING_TEXT
const S = VERIFY_SETTING_TEXT.section
type Limit = string | null
/** 草稿:开关是布尔,相对误差是正在填的字符串,上限是字符串或 null(不限)。键就是服务端叫法表里的键 */
export interface VerifyDraft {
provider: string
model: string
'dual_check.enabled': boolean
'dual_check.tolerance': string
'dual_check.max_queries': Limit
'dual_check.timeout_s': Limit
'challenge.enabled': boolean
'challenge.max_claims': Limit
'challenge.max_queries': Limit
'challenge.timeout_s': Limit
'inquiry.enabled': boolean
'inquiry.max_queries': Limit
'inquiry.timeout_s': Limit
daily_max_usd: Limit
}
export type VerifyKey = keyof VerifyDraft
/** 能设成不限的几项 */
type LimitKey = 'dual_check.max_queries' | 'dual_check.timeout_s' | 'challenge.max_claims' | 'challenge.max_queries'
| 'challenge.timeout_s' | 'inquiry.max_queries' | 'inquiry.timeout_s' | 'daily_max_usd'
type Part = 'dual_check' | 'challenge' | 'inquiry'
/** 勾掉「不限」时填回的数:同服务端 VERIFY_DEFAULTS */
const FALLBACK: Record<LimitKey, string> = {
'dual_check.max_queries': '6', 'dual_check.timeout_s': '120', 'challenge.max_claims': '5', 'challenge.max_queries': '4',
'challenge.timeout_s': '90', 'inquiry.max_queries': '6', 'inquiry.timeout_s': '120', daily_max_usd: '2',
}
const COUNTS = new Set<VerifyKey>(['dual_check.max_queries', 'challenge.max_claims', 'challenge.max_queries', 'inquiry.max_queries'])
const TIMEOUTS = new Set<VerifyKey>(['dual_check.timeout_s', 'challenge.timeout_s', 'inquiry.timeout_s'])
const isRecord = (v: unknown): v is Record<string, any> => !!v && typeof v === 'object' && !Array.isArray(v)
/** 一项上限 → 草稿:null 是不限,没有这一项(老服务端、存坏了)当空串 */
const limitText = (v: unknown): Limit => (v === null ? null : v === undefined ? '' : String(v))
/** GET /settings 的 verify 一组(服务端给的是引擎实际生效的样子:存坏了的显示默认,null 是不限)→ 草稿 */
export function verifyDraftOf(group: unknown): VerifyDraft {
const g = isRecord(group) ? group : {}
const part = (p: Part): Record<string, any> => (isRecord(g[p]) ? g[p] : {})
const tol = part('dual_check').tolerance
return {
provider: typeof g.provider === 'string' ? g.provider : '',
model: typeof g.model === 'string' ? g.model : '',
'dual_check.enabled': part('dual_check').enabled !== false,
'dual_check.tolerance': typeof tol === 'number' ? String(tol) : '',
'dual_check.max_queries': limitText(part('dual_check').max_queries),
'dual_check.timeout_s': limitText(part('dual_check').timeout_s),
'challenge.enabled': part('challenge').enabled !== false,
'challenge.max_claims': limitText(part('challenge').max_claims),
'challenge.max_queries': limitText(part('challenge').max_queries),
'challenge.timeout_s': limitText(part('challenge').timeout_s),
'inquiry.enabled': part('inquiry').enabled !== false,
'inquiry.max_queries': limitText(part('inquiry').max_queries),
'inquiry.timeout_s': limitText(part('inquiry').timeout_s),
daily_max_usd: limitText(g.daily_max_usd),
}
}
/** 改过的项(按草稿里的先后) */
export function verifyChanged(cur: VerifyDraft, saved: VerifyDraft): VerifyKey[] {
return (Object.keys(saved) as VerifyKey[]).filter((k) => cur[k] !== saved[k])
}
/** 保存条上这几项的叫法:和字段名、服务端报错同一套 */
export const verifyLabels = (keys: VerifyKey[]): string[] => keys.map((k) => T[k])
/** 各项的问题。同裁判那一组:只校验改过的(存着的值不合规不该挡住别的设置保存),null(不限)总是对的 */
export function verifyErrors(cur: VerifyDraft, saved: VerifyDraft): Partial<Record<VerifyKey, string>> {
const out: Partial<Record<VerifyKey, string>> = {}
for (const k of verifyChanged(cur, saved)) {
const v = cur[k]
if (typeof v !== 'string') continue
const n = Number(v.trim())
if (k === 'dual_check.tolerance') {
if (!(v.trim() !== '' && Number.isFinite(n) && n >= 0)) out[k] = S.invalid.tolerance
} else if (COUNTS.has(k)) {
if (!(/^\d+$/.test(v.trim()) && n >= 1)) out[k] = S.invalid.count
} else if (TIMEOUTS.has(k)) {
if (!(v.trim() !== '' && Number.isFinite(n) && n > 0)) out[k] = S.invalid.seconds
} else if (k === 'daily_max_usd') {
if (!(v.trim() !== '' && Number.isFinite(n) && n > 0)) out[k] = S.invalid.usd
}
}
return out
}
/** 草稿 → 存进去的整组 verify:不限存 null,空着的接入、模型存 null(沿用证据裁判模型) */
export function verifyPayload(cur: VerifyDraft): Record<string, any> {
const limit = (v: Limit) => (v === null ? null : Number(v.trim()))
return {
provider: cur.provider || null,
model: cur.model.trim() || null,
dual_check: {
enabled: cur['dual_check.enabled'],
tolerance: Number(cur['dual_check.tolerance'].trim()),
max_queries: limit(cur['dual_check.max_queries']),
timeout_s: limit(cur['dual_check.timeout_s']),
},
challenge: {
enabled: cur['challenge.enabled'],
max_claims: limit(cur['challenge.max_claims']),
max_queries: limit(cur['challenge.max_queries']),
timeout_s: limit(cur['challenge.timeout_s']),
},
inquiry: {
enabled: cur['inquiry.enabled'],
max_queries: limit(cur['inquiry.max_queries']),
timeout_s: limit(cur['inquiry.timeout_s']),
},
daily_max_usd: limit(cur.daily_max_usd),
}
}
/** 今天花了多少美元:小额按分以下的精度写,不四舍五入成 0(同裁判那一行) */
const formatSpend = (usd: number | undefined): string =>
usd == null || !Number.isFinite(usd) ? '0' : usd >= 1 ? usd.toFixed(2) : String(Number(usd.toFixed(4)))
const fieldId = (k: VerifyKey) => `pref-verify-${k.replace('.', '-')}`
/**
* 「核查」一节。refresh 变了(刚存过 verify 一组)就再取一次今日花费:每日上限变了,那一行的分母跟着变。
* 取不到(老服务端没有这个接口)就不写那一行
*/
export function VerifySection({ cur, errors, onEdit, refresh }: {
cur: VerifyDraft
errors: Partial<Record<VerifyKey, string>>
onEdit: (patch: Partial<VerifyDraft>) => void
refresh: number
}) {
const providers = useCatalog((s) => s.providers)
const [spend, setSpend] = useState<VerifySpend | null>(null)
useEffect(() => {
let alive = true
get<VerifySpend>('/settings/verify/spend').then((s) => { if (alive) setSpend(s) }, () => { if (alive) setSpend(null) })
return () => { alive = false }
}, [refresh])
// 核查模型的接入:两项都留空时服务端沿用证据裁判模型的取值链;只填了模型时这一级整组生效,按模型名找接入
//(verify_model_spec 不跨级拼)——留空那一项不能还叫「跟随证据裁判模型」
const enabledProviders = providers.filter((p) => p.enabled)
const defaultProvider = enabledProviders.find((p) => p.kind !== 'mock') ?? enabledProviders[0]
const provider = cur.provider ? providers.find((p) => p.name === cur.provider) : undefined
const providerMissing = !!cur.provider && providers.length > 0 && !provider
const modelName = cur.model.trim()
const byModel = !cur.provider && !!modelName
const notSet = !cur.provider && !modelName
const owner = byModel ? providerOfModel(providers, modelName) : undefined
const byModelHint = byModel && providers.length > 0
? S.byModel(modelName, owner && { name: owner.provider.name, enabled: owner.enabled }, defaultProvider?.name) : ''
const models = provider ? (provider.models ?? []).map((m) => m.id) : [...new Set(modelOptions(providers).map((o) => o.value))]
const pickProvider = (name: string) => {
// 换了接入,原来填的模型不在新接入的清单里就清掉:拿 A 家的模型名去调 B 家必然失败
const next = name ? providers.find((p) => p.name === name) : undefined
const keep = !name || !cur.model || (next?.models ?? []).some((m) => m.id === cur.model) || next?.default_model === cur.model
onEdit({ provider: name, ...(keep ? {} : { model: '' }) })
}
/** 某项设成不限时,它还受哪些眼下设着的上限约束 */
const note = (k: LimitKey): string => {
if (TIMEOUTS.has(k)) return S.unlimitedNote('timeout', [])
if (k === 'challenge.max_claims') return S.unlimitedNote('claims', [S.bound.claims])
if (k === 'daily_max_usd') {
const counts = [...COUNTS].some((c) => cur[c] !== null)
const times = [...TIMEOUTS].some((c) => cur[c] !== null)
return S.unlimitedNote('daily', [counts ? S.bound.queries : '', times ? S.bound.timeouts : ''].filter(Boolean))
}
const part = k.split('.')[0] as Part
return S.unlimitedNote('queries', [cur[`${part}.timeout_s` as LimitKey] !== null ? S.bound.timeout : '',
cur.daily_max_usd !== null ? S.bound.daily : ''].filter(Boolean))
}
const limit = (k: LimitKey, extra?: ReactNode) => (
<LimitInput key={k} k={k} value={cur[k]} fallback={FALLBACK[k]} integer={COUNTS.has(k)} error={errors[k]}
unlimited={note(k)} onChange={(v) => onEdit({ [k]: v } as Partial<VerifyDraft>)} extra={extra} />
)
const toggle = (k: 'dual_check.enabled' | 'challenge.enabled' | 'inquiry.enabled') => (
<label className="mb-2 flex cursor-pointer items-center gap-2 text-xs">
<input type="checkbox" checked={cur[k]} data-verify-toggle={k} onChange={(e) => onEdit({ [k]: e.target.checked })} />
<span>{T[k]}</span>
</label>
)
return (
<section data-verify-settings="">
<SectionBar title={S.title} hint={S.hint} />
<div role="group" aria-labelledby="pref-verify-model-label" aria-describedby="pref-verify-model-hint" data-verify-model="">
<div id="pref-verify-model-label" className="text-xs font-medium">{S.model}</div>
{notSet ? (
// 两项都没填:核查会用证据裁判的模型(没配裁判模型时就是助手的模型)。醒目地说,并给出建议
<div id="pref-verify-model-hint" role="note" data-verify-not-set=""
className="mb-2 mt-1 flex items-start gap-1.5 rounded border-l-2 py-0.5 pl-2 text-2xs leading-relaxed"
style={{ borderColor: 'var(--st-waiting)' }}>
<AlertTriangle size={12} aria-hidden className="mt-0.5 shrink-0" style={{ color: 'var(--st-waiting)' }} />
<span>
<span className="font-medium">{S.notSet}</span>
<span className="block text-dim">{S.notSetAdvice}</span>
</span>
</div>
) : (
<p id="pref-verify-model-hint" className="mb-2 mt-0.5 text-2xs leading-relaxed text-faint">{S.differ}</p>
)}
<div className="grid grid-cols-2 gap-3 max-sm:grid-cols-1">
<Field label={T.provider} htmlFor={fieldId('provider')}
error={providerMissing ? S.providerGone(cur.provider) : undefined}
hint={byModelHint ? (
<span data-verify-by-model={owner ? (owner.enabled ? 'found' : 'disabled') : 'default'}
style={owner?.enabled ? undefined : { color: 'var(--st-waiting)' }}>{byModelHint}</span>
) : undefined}>
{(p) => (
<select {...p} className="field" value={cur.provider} onChange={(e) => pickProvider(e.target.value)}>
<option value="">{byModel ? S.providerFromModel : S.providerDefault}</option>
{providerMissing && <option value={cur.provider}>{S.providerGoneOption(cur.provider)}</option>}
{providers.map((pv) => <option key={pv.id} value={pv.name}>{pv.name}{pv.enabled ? '' : S.providerOff}</option>)}
</select>
)}
</Field>
<Field label={T.model} htmlFor={fieldId('model')} hint={provider?.default_model ? S.modelOf(provider.default_model) : undefined}>
{(p) => (
<>
<input {...p} className="field mono text-xs" value={cur.model} list="pref-verify-models" spellCheck={false}
placeholder={cur.provider ? S.modelDefault : S.modelFollow} onChange={(e) => onEdit({ model: e.target.value })} />
<datalist id="pref-verify-models">{models.map((m) => <option key={m} value={m} />)}</datalist>
</>
)}
</Field>
</div>
</div>
<PartGroup part="dual_check">
{toggle('dual_check.enabled')}
<div className="grid grid-cols-3 gap-3 max-sm:grid-cols-1">
<Field label={T['dual_check.tolerance']} htmlFor={fieldId('dual_check.tolerance')} error={errors['dual_check.tolerance']}
hint={S.toleranceHint}>
{(p) => (
<input {...p} className="field tnum" inputMode="decimal" value={cur['dual_check.tolerance']}
onChange={(e) => onEdit({ 'dual_check.tolerance': e.target.value })} />
)}
</Field>
{limit('dual_check.max_queries')}
{limit('dual_check.timeout_s')}
</div>
</PartGroup>
<PartGroup part="challenge">
{toggle('challenge.enabled')}
<div className="grid grid-cols-3 gap-3 max-sm:grid-cols-1">
{limit('challenge.max_claims')}
{limit('challenge.max_queries')}
{limit('challenge.timeout_s')}
</div>
</PartGroup>
<PartGroup part="inquiry">
{toggle('inquiry.enabled')}
<div className="grid grid-cols-3 gap-3 max-sm:grid-cols-1">
{limit('inquiry.max_queries')}
{limit('inquiry.timeout_s')}
</div>
</PartGroup>
<div className="mt-4 grid grid-cols-3 gap-3 max-sm:grid-cols-1" data-verify-daily="">
{limit('daily_max_usd', spend && (
<div className="mt-1 text-2xs leading-relaxed text-dim" data-verify-spend="">
{spend.calls ? S.spend(formatSpend(spend.usd), spend.calls) : S.spendNone}
{!!spend.unpriced_calls && (
<span className="block" style={{ color: 'var(--st-waiting)' }} data-verify-unpriced="">{S.unpriced(spend.unpriced_calls)}</span>
)}
</div>
))}
</div>
</section>
)
}
/** 三项各一组:标题是服务端叫法表里那一组的叫法,下面一句说它做什么 */
function PartGroup({ part, children }: { part: Part; children: ReactNode }) {
return (
<div className="mt-4" role="group" aria-labelledby={`pref-verify-${part}-label`} aria-describedby={`pref-verify-${part}-hint`}
data-verify-part={part}>
<div id={`pref-verify-${part}-label`} className="text-xs font-medium">{T[part]}</div>
<p id={`pref-verify-${part}-hint`} className="mb-2 mt-0.5 text-2xs leading-relaxed text-faint">{S.parts[part]}</p>
{children}
</div>
)
}
/**
* 一项上限:数字框加「不限」(照裁判那一组的 LimitField)。勾了不限,框变灰,下面写明不设上限之后还受什么约束——
* 不许悄悄生效;勾掉时填回之前的数(没有就填默认值)
*/
function LimitInput({ k, value, fallback, integer, error, unlimited, onChange, extra }: {
k: LimitKey; value: Limit; fallback: string; integer?: boolean; error?: string; unlimited: string
onChange: (v: Limit) => void; extra?: ReactNode
}) {
const last = useRef(value ?? fallback)
if (value !== null) last.current = value || last.current
const off = value === null
const label = T[k]
return (
<div className="min-w-0" data-verify-limit={k} data-unlimited={off ? '' : undefined}>
<Field label={label} htmlFor={fieldId(k)} error={error}
hint={off ? <span style={{ color: 'var(--st-waiting)' }} data-verify-unlimited="">{unlimited}</span> : undefined}>
{(p) => (
<div className="flex items-center gap-2">
<input {...p} className="field tnum min-w-0 flex-1" inputMode={integer ? 'numeric' : 'decimal'}
value={off ? '' : value} disabled={off} placeholder={off ? S.unlimited : undefined}
onChange={(e) => onChange(e.target.value)} />
<label className="flex shrink-0 cursor-pointer items-center gap-1 text-xs">
<input type="checkbox" checked={off} aria-label={`${label}:${S.unlimited}`} aria-describedby={p['aria-describedby']}
data-verify-unlimited-toggle="" onChange={(e) => onChange(e.target.checked ? null : last.current || fallback)} />
{S.unlimited}
</label>
</div>
)}
</Field>
{extra}
</div>
)
}
+193
View File
@@ -523,6 +523,199 @@ await section('检查器:360px 栏、390px 窄屏不横向溢出,深浅色
}
})
// ---------------------------------------------------------------- 设置页「核查」一节
/**
* 设置页(真页面):设置的读写、今日核查花费、模型接入按夹具答,PUT 记下请求体、合进存着的那份再回给页面;
* put 给了就由它答(模拟 422)。别的 GET 放行到服务端,别的写请求一律拦掉
*/
async function settingsPage({ width = 1280, height = 900, theme = 'dark', verify = fx.verify, put = null } = {}) {
const ctx = await browser.newContext({ viewport: { width, height }, colorScheme: theme, timezoneId: 'Asia/Shanghai' })
opened.add(ctx)
ctx.setDefaultTimeout(6000)
await ctx.addInitScript((t) => {
try { localStorage.setItem('agentlab.theme', t); localStorage.setItem('agentlab_actor', '张工') } catch { /* 隐私窗口 */ }
}, theme)
const page = await ctx.newPage()
const errors = []
const sent = []
let spendGets = 0
let stored = {
ui: { theme }, run: { default_memory_scope: 'default', default_collection: 'default', confirm_dangerous_tools: true,
tool_gate_provider: null, tool_gate_model: null, agent_max_steps: 100, agent_budget_tokens: 2000000, agent_budget_usd: null },
limits: { max_graph_steps: 200, max_agent_steps: 100, max_run_seconds: 1800 },
judge: { provider: 'judge-provider-x', model: null, report_max_claims: 40, report_max_cost_usd: 0.05, report_timeout_s: 30,
click_max_cost_usd: 0.01, daily_max_usd: 2 },
...(verify ? { verify } : {}),
}
page.on('pageerror', (e) => errors.push(e.message))
page.on('dialog', (d) => { errors.push(`原生对话框:${d.message()}`); void d.dismiss() })
await page.route((u) => new URL(u).pathname.startsWith('/api/'), async (route) => {
const req = route.request()
const url = new URL(req.url())
if (url.pathname === '/api/settings' && req.method() === 'GET') return route.fulfill({ json: stored })
if (url.pathname === '/api/settings' && req.method() === 'PUT') {
const body = req.postDataJSON()
sent.push({ path: url.pathname, body })
if (put) {
const out = await put(body)
if (out) return route.fulfill({ status: out.status ?? 200, json: out.json })
}
stored = { ...stored, ...body.values }
return route.fulfill({ json: stored })
}
if (url.pathname === '/api/settings/verify/spend') {
spendGets += 1
return route.fulfill({ json: { date: '2026-10-05', usd: 0.0456, calls: 12, unpriced_calls: 1, daily_max_usd: stored.verify?.daily_max_usd ?? null } })
}
if (url.pathname === '/api/settings/judge/spend') return route.fulfill({ json: { date: '2026-10-05', usd: 0, calls: 0, unpriced_calls: 0 } })
if (url.pathname === '/api/providers') return route.fulfill({ json: fx.providers })
if (req.method() !== 'GET') {
sent.push({ path: url.pathname, blocked: true, method: req.method() })
return route.abort()
}
return route.continue()
})
await page.goto(`${WEB}/settings/prefs`, { waitUntil: 'domcontentloaded' })
await page.waitForSelector('[data-prefs-bar], [data-judge-settings]', { timeout: 15000 })
return { ctx, page, errors, sent, spends: () => spendGets }
}
await section('设置页「核查」一节:读回各项、叫法和服务端一致', async () => {
const { page, errors } = await settingsPage()
await page.waitForSelector('[data-verify-settings]')
const box = page.locator('[data-verify-settings]')
const { VERIFY_SETTING_TEXT: T } = await import(await load('/src/lib/terms.ts'))
check('偏好设置里有「核查」一节,说明三项共用核查模型和每日金额上限', (await text(box.locator('h2'))) === '核查'
&& (await text(box)).includes('共用核查模型和每日金额上限'))
const label = async (id) => text(page.locator(`label[for="${id}"]`))
check('核查模型:接入、模型两项,叫法和服务端一致', await label('pref-verify-provider') === T.provider && await label('pref-verify-model') === T.model)
check('……接入留空时跟随证据裁判模型,并醒目提示未单独配置', (await page.locator('#pref-verify-provider option').first().innerText()) === '跟随证据裁判模型'
&& (await text(page.locator('[data-verify-not-set]'))).includes('未单独配置,将使用证据裁判的模型'))
const parts = await page.locator('[data-verify-part]').evaluateAll((els) => els.map((e) => e.dataset.verifyPart))
check('三项各一组:双盲复算、反方审稿、审批质询', parts.join(',') === 'dual_check,challenge,inquiry', parts.join(','))
const v = (id) => page.locator(`#${id}`).inputValue()
check('双盲复算:启用、允许的相对误差、每项最多查询次数、时长上限', await page.locator('[data-verify-toggle="dual_check.enabled"]').isChecked()
&& await v('pref-verify-dual_check-tolerance') === '0.000001' && await v('pref-verify-dual_check-max_queries') === '6'
&& await v('pref-verify-dual_check-timeout_s') === '120')
check('反方审稿:启用、每份报告最多审几句、每句最多查询次数、时长上限', await page.locator('[data-verify-toggle="challenge.enabled"]').isChecked()
&& await v('pref-verify-challenge-max_claims') === '5' && await v('pref-verify-challenge-max_queries') === '4'
&& await v('pref-verify-challenge-timeout_s') === '90')
check('审批质询:启用、每次最多查询次数、时长上限', await page.locator('[data-verify-toggle="inquiry.enabled"]').isChecked()
&& await v('pref-verify-inquiry-max_queries') === '6' && await v('pref-verify-inquiry-timeout_s') === '120')
check('每日金额上限', await v('pref-verify-daily_max_usd') === '2')
const ids = ['dual_check-tolerance', 'dual_check-max_queries', 'dual_check-timeout_s', 'challenge-max_claims', 'challenge-max_queries',
'challenge-timeout_s', 'inquiry-max_queries', 'inquiry-timeout_s', 'daily_max_usd']
const wrong = []
for (const id of ids) if (await label(`pref-verify-${id}`) !== T[id.replace('-', '.')]) wrong.push(id)
for (const k of ['dual_check.enabled', 'challenge.enabled', 'inquiry.enabled']) {
if ((await text(page.locator(`[data-verify-toggle="${k}"]`).locator('xpath=..'))) !== T[k]) wrong.push(k)
}
check('……每一项的字段名就是服务端报错时写的叫法', !wrong.length, wrong.join('、'))
const spend = await text(page.locator('[data-verify-spend]'))
check('每日上限旁边写今日核查花费', spend.includes('今日已花费 $0.0456(12 次调用)'), spend)
check('……估不出金额的调用另说', (await text(page.locator('[data-verify-unpriced]'))).includes('另有 1 次调用无法估算金额'))
check('没有运行时报错', errors.length === 0, errors.join(';'))
await shot(page, 'settings-dark')
})
await section('设置页「核查」一节:改了之后整组写 verify,不限存 null,错了不让存', async () => {
const { page, sent, errors, spends } = await settingsPage()
await page.waitForSelector('[data-verify-settings]')
const save = page.getByRole('button', { name: /保存设置/ })
await page.locator('[data-verify-toggle="challenge.enabled"]').uncheck()
await page.locator('#pref-verify-dual_check-tolerance').fill('0.001')
// 键盘勾「不限」:Tab 到复选框上按空格
await page.locator('[data-verify-limit="inquiry.timeout_s"] [data-verify-unlimited-toggle]').focus()
await page.keyboard.press('Space')
check('勾了「不限」:框变灰,写明还受什么约束', await page.locator('#pref-verify-inquiry-timeout_s').isDisabled()
&& (await text(page.locator('[data-verify-limit="inquiry.timeout_s"] [data-verify-unlimited]'))) === '不设上限,时长仅受模型接口自身的超时约束')
await page.locator('#pref-verify-challenge-max_claims').fill('0')
check('句数填 0:标红、保存不可用', await save.isDisabled()
&& (await text(page.locator('[data-verify-limit="challenge.max_claims"]'))).includes('请填写正整数,或勾选「不限」'))
await page.locator('#pref-verify-challenge-max_claims').fill('8')
await page.locator('#pref-verify-dual_check-tolerance').fill('-1')
check('相对误差填负数:标红、保存不可用', await save.isDisabled()
&& (await text(page.locator('[data-verify-part="dual_check"]'))).includes('请填写不小于 0 的数'))
await page.locator('#pref-verify-dual_check-tolerance').fill('0.001')
await page.locator('#pref-verify-provider').selectOption('云端接入')
await page.locator('#pref-verify-model').fill('model-b')
check('填了核查模型:「未单独配置」的提示收起', await page.locator('[data-verify-not-set]').count() === 0)
const bar = (await text(page.locator('[data-prefs-bar]')))
check('保存条写明改了哪几项(用的是同一套叫法)', bar.includes('有 6 项未保存') && bar.includes('反方审稿 · 启用') && bar.includes('双盲复算 · 允许的相对误差')
&& bar.includes('审批质询 · 每次时长上限(秒)') && bar.includes('核查模型 · 接入'), bar)
const before = spends()
await save.click()
await page.waitForFunction(() => document.querySelector('[data-prefs-bar]')?.textContent?.includes('已保存'))
const put = sent.filter((s) => s.path === '/api/settings' && !s.blocked).at(-1)?.body?.values ?? {}
check('保存:只写 verify 一组(运行默认值、裁判没改就不碰)', Object.keys(put).join(',') === 'verify', Object.keys(put).join(','))
const want = {
provider: '云端接入', model: 'model-b',
dual_check: { enabled: true, tolerance: 0.001, max_queries: 6, timeout_s: 120 },
challenge: { enabled: false, max_claims: 8, max_queries: 4, timeout_s: 90 },
inquiry: { enabled: true, max_queries: 6, timeout_s: null },
daily_max_usd: 2,
}
check('……整组写(没改的项照原值),不限存 null,数存成数', JSON.stringify(put.verify) === JSON.stringify(want), JSON.stringify(put.verify))
check('……存完再取一次今日花费', spends() > before, `${before} → ${spends()}`)
check('……存完读回来的就是刚存的', await page.locator('#pref-verify-model').inputValue() === 'model-b'
&& !(await page.locator('[data-verify-toggle="challenge.enabled"]').isChecked()))
await page.locator('[data-verify-limit="daily_max_usd"] [data-verify-unlimited-toggle]').check()
const daily = await text(page.locator('[data-verify-limit="daily_max_usd"] [data-verify-unlimited]'))
check('每日上限设成不限:写明每日费用还受什么约束', daily.startsWith('不设上限,每日核查费用仅受'), daily)
await page.locator('[data-verify-limit="daily_max_usd"] [data-verify-unlimited-toggle]').uncheck()
check('勾掉「不限」:填回原来的数', await page.locator('#pref-verify-daily_max_usd').inputValue() === '2')
check('没有别的写请求', !sent.some((s) => s.blocked), JSON.stringify(sent.filter((s) => s.blocked)))
check('没有运行时报错', errors.length === 0, errors.join(';'))
})
await section('设置页「核查」一节:服务端拒收时照实说(报错里的叫法对得上字段名)', async () => {
const detail = '「反方审稿 · 每份报告最多审几句」需要填写正整数(句数),留空表示不限,当前为「2.5」'
const { page } = await settingsPage({ put: async () => ({ status: 422, json: { detail } }) })
await page.waitForSelector('[data-verify-settings]')
await page.locator('[data-verify-toggle="inquiry.enabled"]').uncheck()
await page.getByRole('button', { name: /保存设置/ }).click()
await page.waitForSelector('[data-prefs-bar] [role="alert"]')
const bar = await text(page.locator('[data-prefs-bar]'))
check('保存失败:保存条照实说,改动还在', bar.includes('保存失败') && bar.includes('有 1 项未保存')
&& !(await page.locator('[data-verify-toggle="inquiry.enabled"]').isChecked()), bar)
check('……报错里写的叫法就是页面上的字段名', (await text(page.locator('label[for="pref-verify-challenge-max_claims"]'))) === '反方审稿 · 每份报告最多审几句')
})
await section('设置页「核查」一节:老服务端没有这一组时不显示;390px 不横向溢出、深浅色可读', async () => {
const old = await settingsPage({ verify: null })
await old.page.waitForSelector('[data-judge-settings]')
check('老服务端没有 verify 一组:这一节不出现', await old.page.locator('[data-verify-settings]').count() === 0)
for (const theme of ['dark', 'light']) {
const { page, errors } = await settingsPage({ width: 390, height: 844, theme })
await page.waitForSelector('[data-verify-settings]')
await page.locator('[data-verify-limit="challenge.max_queries"] [data-verify-unlimited-toggle]').check()
await page.locator('#pref-verify-model').fill('model-zzz')
await page.locator('[data-verify-settings]').scrollIntoViewIfNeeded()
// 设置页的外壳在 390px 下本来就有一处隐形提示条伸出视口(没有这一节时也一样),这里只看这一节自己:
// 自己不出横向滚动条,里面的每个元素都不越过视口右边
const w = await page.evaluate(() => {
const box = document.querySelector('[data-verify-settings]')
const vw = document.documentElement.clientWidth
const right = Math.max(...[box, ...box.querySelectorAll('*')].map((e) => e.getBoundingClientRect().right))
return { box: box.scrollWidth - box.clientWidth, beyond: Math.round(right - vw) }
})
check(`390px(${theme}):「核查」一节不横向溢出`, w.box <= 0 && w.beyond <= 0, JSON.stringify(w))
const c = await page.evaluate(() => {
const base = getComputedStyle(document.body).backgroundColor
const pick = (sel) => { const el = document.querySelector(sel); return el && getComputedStyle(el).color }
return { base, unlimited: pick('[data-verify-unlimited]'), hint: pick('[data-verify-by-model]'), spend: pick('[data-verify-spend]') }
})
for (const key of ['unlimited', 'hint', 'spend']) {
const ratio = contrast(c[key], c.base, c.base)
check(`${theme}:${({ unlimited: '不限的说明', hint: '只填了模型的提醒', spend: '今日花费' })[key]}对比度 ≥ 4.5`, ratio >= 4.5,
`${ratio.toFixed(2)}(${c[key]} on ${c.base})`)
}
await shot(page, `settings-narrow-${theme}`)
check(`390px(${theme}):没有运行时报错`, errors.length === 0, errors.join(';'))
}
})
await browser.close()
console.log(failed ? `\n✗ 双盲复算与反方审稿:${failed} 项未通过` : '\n✓ 双盲复算与反方审稿:全部通过')
process.exit(failed ? 1 : 0)