Files
AgentLab/frontend/src/canvas/DualCheckFields.tsx
T
ThearchyHelios 7a9dc93dde 双盲复算前端(三):检查器字段、节点卡片摘要,以及检查器与节点卡片的钩子
- nodeDefs:照约定一字不差地加 FieldDef.render 与 FieldRenderProps;NodeDef 加可选的 summary(ctx) 钩子
  (ctx:id、config、画布上的 nodes 与 edges,只返回字符串);dual_check 换成真字段:要复算的口径卡、复算的指标、
  口径说明、启用、审核人不采用原流程的结果时(基本),盲算模型的接入与模型、允许的相对误差(高级)
- Inspector:照约定一字不差地在 FieldInput 的 switch 前交给 field.render
- canvas/DualCheckFields.tsx(新):口径卡下拉分上游、其余两组,不在上游或不在画布上照实提醒;指标逐个勾,
  全勾回到缺省不写、最后一个不让取消;口径说明逐个指标写,被取消的指标上已写的说明列出来可删;盲算模型照「结论句
  裁判」的写法(只填模型时写明按模型名用哪个接入);相对误差的占位写出设置里的值;启用三态,跟随设置不留 null。
  每个控件只写自己的键,留空写回 undefined
- NodeCard:用只返回字符串的 studio 选择器调摘要钩子,有就用、没有照旧 summarize;摘要写「复算「卡名」 · N 个指标」,
  关闭时标出;去掉 dual_check 的占位 case
- 预览用例加检查器(小画布用真的节点卡片 + 360px 检查器),check-verify 加检查器写配置、摘要跟着变、窄屏和对比度
- 去掉 nodeDefs 里已经不成立的「下面两种还是占位」注释
2026-10-05 14:23:17 +08:00

378 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import { X } from 'lucide-react'
import clsx from 'clsx'
import { api } from '../api/client'
import { useStudio } from '../store/studio'
import { modelOptions, providerOfModel, useCatalog } from '../store/catalog'
import { useRadioGroup } from '../components/ui'
import { DUAL_CHECK_TEXT } from '../lib/terms'
import type { VerifySettings } from '../types'
import type { FieldRenderProps, NodeSummaryContext } from './nodeDefs'
/*
* 双盲复算节点的检查器控件(nodeDefs 里 dual_check 各字段的 render)和节点卡片的摘要。
*
* 口径卡、要复算的指标、口径说明都得看画布上别的节点:从 studio store 读,不往检查器的 props 里加字段。
* 每个控件只写自己那个键(FieldRenderProps.onChange);写回 undefined 就是「留空 = 跟随默认」,配置里不留空值。
* 后端认的键和缺省见 types.ts 的 DualCheckConfig
*/
const T = DUAL_CHECK_TEXT
type CanvasNode = NodeSummaryContext['nodes'][number]
type CanvasEdge = NodeSummaryContext['edges'][number]
const isRecord = (v: unknown): v is Record<string, any> => !!v && typeof v === 'object' && !Array.isArray(v)
const ids = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string' && !!x) : [])
/** 某个节点的全部上游(沿连线往回走),不含它自己 */
function upstreamOf(id: string, edges: readonly CanvasEdge[]): Set<string> {
const parents = new Map<string, string[]>()
for (const e of edges) parents.set(e.target, [...(parents.get(e.target) ?? []), e.source])
const seen = new Set<string>()
const stack = [...(parents.get(id) ?? [])]
while (stack.length) {
const cur = stack.pop()!
if (seen.has(cur) || cur === id) continue
seen.add(cur)
stack.push(...(parents.get(cur) ?? []))
}
return seen
}
/** 画布上的口径卡:本节点上游的在前(复算只能复算它们),其余的另列 */
function cardsOf(id: string, nodes: readonly CanvasNode[], edges: readonly CanvasEdge[]) {
const up = upstreamOf(id, edges)
const cards = nodes.filter((n) => n.data.nodeType === 'metrics')
return { upstream: cards.filter((n) => up.has(n.id)), other: cards.filter((n) => !up.has(n.id)) }
}
/**
* 实际复算哪张卡:写了 card 就是它(不在画布上时 node 为 null);没写取上游的口径卡,上游只有一张时就是它,
* 好几张时说不准,node 为 null、由调用方照实说
*/
function targetOf(config: Record<string, any>, id: string, nodes: readonly CanvasNode[], edges: readonly CanvasEdge[]) {
const { upstream } = cardsOf(id, nodes, edges)
const card = typeof config.card === 'string' && config.card ? config.card : ''
if (card) return { node: nodes.find((n) => n.id === card && n.data.nodeType === 'metrics') ?? null, card, upstream }
return { node: upstream.length === 1 ? upstream[0] : null, card: '', upstream }
}
/** 一张口径卡在摘要里的名字:节点名,没起名的用口径名,再没有用 id */
const cardName = (n: CanvasNode): string =>
String(n.data.label || n.data.config?.caliber || n.id)
/** 口径卡里定义的指标;引用别的工作流发布的口径(caliber_from)时指标运行时才知道,返回 null */
function metricsOf(n: CanvasNode): { id: string; name: string }[] | null {
const cfg = n.data.config ?? {}
if (isRecord(cfg.caliber_from)) return null
return (Array.isArray(cfg.metrics) ? cfg.metrics : [])
.filter((m: any) => isRecord(m) && typeof m.id === 'string' && m.id)
.map((m: any) => ({ id: String(m.id), name: typeof m.name === 'string' ? m.name : '' }))
}
/**
* 节点卡片的摘要:复算哪张口径卡、几个指标(NodeDef.summary)。NodeCard 在 studio store 的选择器里调它,
* 每条事件都会跑一遍:只做几次数组查找,返回字符串
*/
export function dualCheckSummary({ id, config, nodes, edges }: NodeSummaryContext): string {
const S = T.summary
const { node, card, upstream } = targetOf(config, id, nodes, edges)
const picked = ids(config.metrics)
let text: string
if (node) {
const all = metricsOf(node)
text = S.card(cardName(node), picked.length ? picked.length : null, all ? all.length : null)
} else if (card) {
text = S.card(card, picked.length ? picked.length : null, null)
} else {
text = upstream.length > 1 ? S.many(upstream.length) : S.noCard
}
return config.enabled === false ? `${text} · ${S.off}` : text
}
// ---------------------------------------------------------------- 设置里的默认值
/**
* 设置里核查一组的值:相对误差、启用的「跟随设置」要写出眼下是多少。整个会话取一次(同检查器的运行默认值),
* 取不到就不写,不挡编辑
*/
let verifyPending: Promise<VerifySettings | null> | null = null
let verifyValue: VerifySettings | null | undefined
function useVerifyDefaults(): VerifySettings | null {
const [value, setValue] = useState<VerifySettings | null>(verifyValue ?? null)
useEffect(() => {
if (verifyValue !== undefined) return
verifyPending ??= api.settings.get().then(
(s) => (verifyValue = isRecord(s?.verify) ? s.verify as VerifySettings : null),
() => { verifyPending = null; return null }, // 这次没取到,下次打开再试
)
let alive = true
void verifyPending.then((v) => { if (alive) setValue(v) })
return () => { alive = false }
}, [])
return verifyValue ?? value
}
/** 字段下面的一行提醒(所选口径卡不在上游之类),提醒色 */
function Warn({ children, attr }: { children: ReactNode; attr: string }) {
return (
<div className="mt-1 text-2xs leading-snug" style={{ color: 'var(--st-waiting)' }} {...{ [attr]: '' }}>{children}</div>
)
}
// ---------------------------------------------------------------- 各字段
/** 要复算的口径卡:下拉选画布上的口径卡,上游的一组、其余的一组;留空取上游的口径卡 */
function CardField({ id, nodeId, value, invalid, describedBy, onChange }: FieldRenderProps) {
const nodes = useStudio((s) => s.nodes)
const edges = useStudio((s) => s.edges)
const { upstream, other } = useMemo(() => cardsOf(nodeId, nodes, edges), [nodeId, nodes, edges])
const current = typeof value === 'string' ? value : ''
const known = [...upstream, ...other].some((n) => n.id === current)
const option = (n: CanvasNode) => (
<option key={n.id} value={n.id}>{T.cardOption(String(n.data.label || n.id), n.data.config?.caliber)}</option>
)
return (
<>
<select id={id} className="field" value={current} aria-invalid={invalid || undefined} aria-describedby={describedBy}
data-dual-field="card" onChange={(e) => onChange(e.target.value || undefined)}>
<option value="">{T.cardDefault(upstream.length === 1 ? cardName(upstream[0]) : null, upstream.length)}</option>
{upstream.length > 0 && <optgroup label={T.cardUpstream}>{upstream.map(option)}</optgroup>}
{other.length > 0 && <optgroup label={T.cardOther}>{other.map(option)}</optgroup>}
{/* 写着的卡已经不在画布上:照实列出来(不让它看着像「默认」),下面再说一句 */}
{current && !known && <option value={current}>{T.cardGone(current)}</option>}
</select>
{current && known && !upstream.some((n) => n.id === current) && <Warn attr="data-dual-card-warn">{T.cardNotUpstream}</Warn>}
{current && !known && <Warn attr="data-dual-card-warn">{T.cardMissing(current)}</Warn>}
</>
)
}
/**
* 复算的指标:所选口径卡的指标逐个勾。没写(全部勾着)是缺省——复算输入来自查询的全部指标;全部勾回来时写回
* undefined,配置里不留一份和缺省等价的清单。最后一个不让取消:一个都不勾和缺省分不清
*/
function MetricsField({ id, nodeId, value, config, describedBy, onChange }: FieldRenderProps) {
const nodes = useStudio((s) => s.nodes)
const edges = useStudio((s) => s.edges)
const target = useMemo(() => targetOf(config, nodeId, nodes, edges), [config, nodeId, nodes, edges])
const picked = ids(value)
const metrics = target.node ? metricsOf(target.node) : undefined
if (!target.node || metrics === undefined) {
return (
<div id={id} className="text-2xs leading-snug text-faint" aria-describedby={describedBy} data-dual-metrics="none">
{target.upstream.length > 1 && !target.card ? T.summary.many(target.upstream.length) : T.metricsNoCard}
</div>
)
}
if (metrics === null || !metrics.length) {
return (
<div id={id} aria-describedby={describedBy} data-dual-metrics={metrics === null ? 'pinned' : 'empty'}>
<div className="text-2xs leading-snug text-faint">{metrics === null ? T.metricsPinned : T.metricsNone}</div>
{picked.length > 0 && (
<button type="button" className="btn btn-xs mt-1" onClick={() => onChange(undefined)}>{T.metricsClear}</button>
)}
</div>
)
}
const known = new Set(metrics.map((m) => m.id))
const valid = picked.filter((p) => known.has(p))
const stale = picked.filter((p) => !known.has(p))
const all = !picked.length
const checked = new Set(all ? metrics.map((m) => m.id) : valid)
const write = (next: Set<string>) => {
const ordered = metrics.map((m) => m.id).filter((m) => next.has(m))
onChange(ordered.length === metrics.length ? undefined : ordered)
}
return (
<div id={id} role="group" aria-label={T.field.metrics} aria-describedby={describedBy} data-dual-metrics="list">
<div className="space-y-0.5 rounded border px-2 py-1.5">
{metrics.map((m) => {
const on = checked.has(m.id)
const last = on && checked.size === 1
return (
<label key={m.id} className={clsx('flex items-center gap-2 py-0.5 text-xs', last ? 'cursor-not-allowed' : 'cursor-pointer')}
title={last ? T.metricsLast : undefined} data-dual-metric-option={m.id}>
<input type="checkbox" className="accent-[var(--accent)]" checked={on} disabled={last}
onChange={(e) => {
const next = new Set(checked)
if (e.target.checked) next.add(m.id)
else next.delete(m.id)
write(next)
}} />
<span className="min-w-0 flex-1 truncate">{T.metricOption(m.name, m.id)}</span>
</label>
)
})}
</div>
<div className="mt-1 text-2xs leading-snug text-faint" data-dual-metrics-state={all ? 'default' : 'picked'}>
{all ? T.metricsDefault : T.metricsPicked(checked.size, metrics.length)}
</div>
{stale.length > 0 && (
<Warn attr="data-dual-metrics-stale">
{T.metricsStale(stale)}
<button type="button" className="btn btn-xs ml-1 align-baseline" onClick={() => onChange(valid.length ? valid : undefined)}>
{T.metricsClear}
</button>
</Warn>
)}
</div>
)
}
/**
* 口径说明:要复算的每个指标一段,给盲算模型看的业务定义(它看不到原流程的 SQL)。存成 {指标 id: 说明},
* 清空的那一项去掉,全清空写回 undefined。已经写了、指标却不在复算范围里的照样列出来,好让人看见并删掉
*/
function DefinitionsField({ id, nodeId, value, config, describedBy, onChange }: FieldRenderProps) {
const nodes = useStudio((s) => s.nodes)
const edges = useStudio((s) => s.edges)
const target = useMemo(() => targetOf(config, nodeId, nodes, edges), [config, nodeId, nodes, edges])
const defs: Record<string, string> = isRecord(value)
? Object.fromEntries(Object.entries(value).filter(([, v]) => typeof v === 'string')) as Record<string, string> : {}
const metrics = target.node ? metricsOf(target.node) : undefined
const picked = ids(config.metrics)
const rows = metrics ? (picked.length ? metrics.filter((m) => picked.includes(m.id)) : metrics) : []
const gone = Object.keys(defs).filter((k) => !rows.some((r) => r.id === k))
const set = (metric: string, text: string | null) => {
const next = { ...defs }
if (text != null && text.trim()) next[metric] = text
else delete next[metric]
onChange(Object.keys(next).length ? next : undefined)
}
if (!rows.length && !gone.length) {
return (
<div id={id} className="text-2xs leading-snug text-faint" aria-describedby={describedBy} data-dual-definitions="none">
{metrics === null ? T.definitionsPinned : T.definitionsNone}
</div>
)
}
return (
<div id={id} role="group" aria-label={T.field.definitions} aria-describedby={describedBy} className="space-y-1.5"
data-dual-definitions="list">
{rows.map((m) => (
<div key={m.id} data-dual-definition={m.id}>
<label className="mb-0.5 block truncate text-2xs text-dim" htmlFor={`${id}-${m.id}`}>{T.metricOption(m.name, m.id)}</label>
<textarea id={`${id}-${m.id}`} className="field text-xs" rows={2} value={defs[m.id] ?? ''}
placeholder={T.definitionPlaceholder(m.name || m.id)} onChange={(e) => set(m.id, e.target.value)} />
</div>
))}
{gone.map((k) => (
<Warn key={k} attr="data-dual-definition-gone">
<span className="flex items-start gap-1">
<span className="min-w-0 flex-1">{T.definitionGone(k)}</span>
<button type="button" className="shrink-0 rounded p-0.5 hover:bg-hover" aria-label={`${T.definitionRemove}:${k}`}
title={T.definitionRemove} onClick={() => set(k, null)}>
<X size={11} aria-hidden />
</button>
</span>
</Warn>
))}
</div>
)
}
/**
* 盲算模型 · 接入。照报告节点「结论句裁判」的写法:留空跟随设置;只填了模型时接入按模型名找,留空那一项改叫
* 「按模型名找接入」(verify_model_spec 哪一级写了就整组用哪一级,不跨级拼)
*/
function ProviderField({ id, value, config, invalid, describedBy, onChange }: FieldRenderProps) {
const providers = useCatalog((s) => s.providers)
const provider = typeof value === 'string' ? value : ''
const known = providers.some((p) => p.name === provider)
const byModel = !provider && typeof config.model === 'string' && !!config.model.trim()
return (
<select id={id} className="field" value={provider} aria-invalid={invalid || undefined} aria-describedby={describedBy}
data-dual-field="provider" onChange={(e) => onChange(e.target.value || undefined)}>
<option value="">{byModel ? T.providerFromModel : T.providerFollow}</option>
{provider && !known && providers.length > 0 && <option value={provider}>{T.providerGone(provider)}</option>}
{providers.map((p) => <option key={p.id} value={p.name}>{p.name}{p.enabled ? '' : T.providerOff}</option>)}
</select>
)
}
/** 盲算模型 · 模型:可以手填,下拉给所选接入(没选接入时给全部启用接入)的模型;只填了模型时写明实际用哪个接入 */
function ModelField({ id, value, config, invalid, describedBy, onChange }: FieldRenderProps) {
const providers = useCatalog((s) => s.providers)
const provider = typeof config.provider === 'string' ? config.provider : ''
const pv = provider ? providers.find((p) => p.name === provider) : undefined
const model = typeof value === 'string' ? value : ''
const byModel = !provider && !!model.trim()
const owner = byModel ? providerOfModel(providers, model.trim()) : undefined
const enabled = providers.filter((p) => p.enabled)
const fallback = enabled.find((p) => p.kind !== 'mock') ?? enabled[0]
const hint = byModel && providers.length > 0
? T.byModel(model.trim(), owner && { name: owner.provider.name, enabled: owner.enabled }, fallback?.name) : ''
const models = pv ? (pv.models ?? []).map((m) => m.id) : [...new Set(modelOptions(providers).map((o) => o.value))]
return (
<>
<input id={id} className="field mono text-xs" list={`${id}-models`} value={model} spellCheck={false}
placeholder={provider ? T.modelOfProvider : T.modelFollow} aria-invalid={invalid || undefined}
aria-describedby={describedBy} data-dual-field="model"
onChange={(e) => onChange(e.target.value.trim() ? e.target.value : undefined)} />
<datalist id={`${id}-models`}>{models.map((m) => <option key={m} value={m} />)}</datalist>
{hint && (
<div className="mt-1 text-2xs leading-snug" data-dual-by-model={owner ? (owner.enabled ? 'found' : 'disabled') : 'default'}
style={owner?.enabled ? undefined : { color: 'var(--st-waiting)' }}>
<span className={owner?.enabled ? 'text-faint' : undefined}>{hint}</span>
</div>
)}
</>
)
}
/** 允许的相对误差:留空跟随设置,占位里写出设置里眼下是多少;负数标出来(服务端也不收) */
function ToleranceField({ id, value, invalid, describedBy, onChange }: FieldRenderProps) {
const settings = useVerifyDefaults()
const bad = typeof value === 'number' && (!Number.isFinite(value) || value < 0)
return (
<>
<input id={id} className="field tnum" type="number" min={0} step="any" value={value ?? ''}
placeholder={T.tolerancePlaceholder(settings?.dual_check?.tolerance)}
aria-invalid={invalid || bad || undefined} aria-describedby={[describedBy, bad ? `${id}-bad` : ''].filter(Boolean).join(' ') || undefined}
style={bad ? { borderColor: 'var(--err)' } : undefined} data-dual-field="tolerance"
onChange={(e) => onChange(e.target.value === '' ? undefined : Number(e.target.value))} />
{bad && <div id={`${id}-bad`} className="mt-1 text-2xs leading-snug" style={{ color: 'var(--err)' }}>{T.toleranceInvalid}</div>}
</>
)
}
const ENABLED = ['follow', 'on', 'off'] as const
type Enabled = typeof ENABLED[number]
/** 启用:跟随设置 / 开启 / 关闭三态(null 或不写、true、false)。跟随设置时写回 undefined,不在配置里留 null */
function EnabledField({ id, value, describedBy, onChange }: FieldRenderProps) {
const settings = useVerifyDefaults()
const current: Enabled = value === true ? 'on' : value === false ? 'off' : 'follow'
const pick = (v: Enabled) => onChange(v === 'on' ? true : v === 'off' ? false : undefined)
const radio = useRadioGroup(ENABLED, current, pick)
return (
<div id={id} role="radiogroup" aria-label={T.field.enabled} aria-describedby={describedBy}
className="flex flex-wrap gap-0.5 rounded border p-0.5" data-dual-field="enabled">
{ENABLED.map((v) => (
<button key={v} type="button" {...radio(v)} data-value={v}
// 按字数分宽度、不折行:「跟随设置(当前开启)」比「开启」长得多,平分会把它挤成两行;太窄时整段换到下一行
className={clsx('flex-auto whitespace-nowrap rounded px-2 py-1 text-2xs transition-colors',
current === v ? 'font-medium text-fg' : 'text-dim hover:bg-hover')}
style={current === v ? { background: 'var(--accent-soft)', boxShadow: 'inset 0 0 0 1px var(--accent)' } : undefined}
onClick={() => pick(v)}>
{v === 'follow' ? T.enabledFollow(settings?.dual_check?.enabled) : T.enabled[v]}
</button>
))}
</div>
)
}
/** nodeDefs 里 dual_check 各字段的 render */
export const dualCheckFields = {
card: (p: FieldRenderProps) => <CardField {...p} />,
metrics: (p: FieldRenderProps) => <MetricsField {...p} />,
definitions: (p: FieldRenderProps) => <DefinitionsField {...p} />,
provider: (p: FieldRenderProps) => <ProviderField {...p} />,
model: (p: FieldRenderProps) => <ModelField {...p} />,
tolerance: (p: FieldRenderProps) => <ToleranceField {...p} />,
enabled: (p: FieldRenderProps) => <EnabledField {...p} />,
}