From 5093e98c49d9b3d7e641bd0b7ef0ab7d8ddaf4db Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 5 Oct 2026 14:31:16 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=8C=E7=9B=B2=E5=A4=8D=E7=AE=97=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=EF=BC=88=E5=9B=9B=EF=BC=89=EF=BC=9A=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E9=A1=B5=E3=80=8C=E6=A0=B8=E6=9F=A5=E3=80=8D=E4=B8=80=E8=8A=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pages/VerifySettings.tsx(新):核查模型(照证据裁判的写法:两项都空时醒目提示将使用证据裁判的模型,只填模型时写明 按模型名用哪个接入);双盲复算(启用、允许的相对误差、每项最多查询次数、时长上限);反方审稿(启用、每份报告最多 审几句、每句最多查询次数、时长上限);审批质询(启用、每次最多查询次数、时长上限);每日金额上限(可不限)和今日 核查花费(GET /api/settings/verify/spend)。上限照 LimitField 的写法:勾「不限」框变灰、写明还受什么约束,勾掉填回 原来的数;只校验改过的项;字段名就是服务端 VERIFY_SETTING_LABEL 的叫法,422 的报错对得上 - SettingsPage:PrefsTab 另持一份核查草稿,和其余设置共用保存条(改动计数、项名、放弃、校验都算上),保存时整组写 verify(不限存 null),存完再取一次今日花费;老服务端没有 verify 一组时不显示 - check-verify 加设置页读回各项、叫法逐项比对、整组写的请求体、错了不让存、422 照实说、老服务端不显示、 390px 不溢出、对比度 --- frontend/src/pages/SettingsPage.tsx | 37 ++- frontend/src/pages/VerifySettings.tsx | 337 ++++++++++++++++++++++++++ scripts/check-verify.mjs | 193 +++++++++++++++ 3 files changed, 559 insertions(+), 8 deletions(-) create mode 100644 frontend/src/pages/VerifySettings.tsx diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 7de3b707..61849480 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -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(null) + const [verifySaves, setVerifySaves] = useState(0) const [saving, setSaving] = useState(false) const [savedFlash, setSavedFlash] = useState(0) const [saveError, setSaveError] = useState(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) => { + 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() { )} + + {values.verify && } )} @@ -1052,7 +1072,7 @@ function PrefsTab() { judgeProvider: '裁判模型的接入', judgeModel: '裁判模型', jClaims: '每份报告最多裁判句数', jCost: '每份报告的金额上限', jTimeout: '每份报告的时长上限', jClick: '每次点击的金额上限', jDaily: '每日金额上限', - })[k]).join('、')} + })[k]).concat(verifyLabels(verifyKeys)).join('、')} {saveError != null && ( @@ -1060,10 +1080,11 @@ function PrefsTab() { )} + onClick={() => { setDraft(null); setVerifyDraft(null); setSaveError(null) }}>放弃 diff --git a/frontend/src/pages/VerifySettings.tsx b/frontend/src/pages/VerifySettings.tsx new file mode 100644 index 00000000..e410375c --- /dev/null +++ b/frontend/src/pages/VerifySettings.tsx @@ -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 = { + '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(['dual_check.max_queries', 'challenge.max_claims', 'challenge.max_queries', 'inquiry.max_queries']) +const TIMEOUTS = new Set(['dual_check.timeout_s', 'challenge.timeout_s', 'inquiry.timeout_s']) + +const isRecord = (v: unknown): v is Record => !!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 => (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> { + const out: Partial> = {} + 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 { + 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> + onEdit: (patch: Partial) => void + refresh: number +}) { + const providers = useCatalog((s) => s.providers) + const [spend, setSpend] = useState(null) + useEffect(() => { + let alive = true + get('/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) => ( + onEdit({ [k]: v } as Partial)} extra={extra} /> + ) + const toggle = (k: 'dual_check.enabled' | 'challenge.enabled' | 'inquiry.enabled') => ( + + ) + + return ( +
+ +
+
{S.model}
+ {notSet ? ( + // 两项都没填:核查会用证据裁判的模型(没配裁判模型时就是助手的模型)。醒目地说,并给出建议 +
+ + + {S.notSet} + {S.notSetAdvice} + +
+ ) : ( +

{S.differ}

+ )} +
+ {byModelHint} + ) : undefined}> + {(p) => ( + + )} + + + {(p) => ( + <> + onEdit({ model: e.target.value })} /> + {models.map((m) => + + )} + +
+
+ + + {toggle('dual_check.enabled')} +
+ + {(p) => ( + onEdit({ 'dual_check.tolerance': e.target.value })} /> + )} + + {limit('dual_check.max_queries')} + {limit('dual_check.timeout_s')} +
+
+ + {toggle('challenge.enabled')} +
+ {limit('challenge.max_claims')} + {limit('challenge.max_queries')} + {limit('challenge.timeout_s')} +
+
+ + {toggle('inquiry.enabled')} +
+ {limit('inquiry.max_queries')} + {limit('inquiry.timeout_s')} +
+
+
+ {limit('daily_max_usd', spend && ( +
+ {spend.calls ? S.spend(formatSpend(spend.usd), spend.calls) : S.spendNone} + {!!spend.unpriced_calls && ( + {S.unpriced(spend.unpriced_calls)} + )} +
+ ))} +
+
+ ) +} + +/** 三项各一组:标题是服务端叫法表里那一组的叫法,下面一句说它做什么 */ +function PartGroup({ part, children }: { part: Part; children: ReactNode }) { + return ( +
+
{T[part]}
+

{S.parts[part]}

+ {children} +
+ ) +} + +/** + * 一项上限:数字框加「不限」(照裁判那一组的 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 ( +
+ {unlimited} : undefined}> + {(p) => ( +
+ onChange(e.target.value)} /> + +
+ )} +
+ {extra} +
+ ) +} diff --git a/scripts/check-verify.mjs b/scripts/check-verify.mjs index 5598ca18..487dced5 100644 --- a/scripts/check-verify.mjs +++ b/scripts/check-verify.mjs @@ -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)