Files
AgentLab/frontend/src/run/EvidenceDoc.tsx
T
ThearchyHelios 6a54ab06c2 J3 复核修正(第 1 轮):过期的证据图重取一次,只有反方标记的句子在面板里先写判断,没反查成的不说成反方模型的判断
证据图缓存会过期:正文有运行就取证据图,之后一直用缓存。审批卡里展开过报告的话,缓存的是运行停在审批上那一刻的图:报告
改写后(第二次审批、同一张卡换稿)新稿在那份图里找不到,一个标记都挂不上;审批之后才审完的反方审稿,成果区也看不到;证据页
挂上时只管「运行已结束、图没封存」,停在第二次审批上的运行、别处正在取的那份都不管,画的是旧稿、审计表却是新取的。改成:
- EvidenceDoc(useRunGraph)挂上、换稿那一刻手里已有的那份,找不到正文这一稿,或者正文是运行的成果(EvidenceField 给
  settled)而那份没封存,就按(运行、报告、工件)重取一次;重取回来还是这样就作罢,挂上时还没有、正在取的那份不算过期;
- 证据页挂上时手里那份不是「封存了、封存的就是这一版(manifest_seq)」就重取,不看运行停在哪;别处正在取的取回来再看一眼;
  重取回来之前占位,第一次渲染就按挂上那一刻手里的那份定,不先画一帧旧稿。

整句面板的反方审稿:只有反方标记的句子(没有可点片段、也没有裁判判定)只能从标记点进来,面板却只认片段接口的
unit.challenge,还在取时这一节空着,答 500 时整节消失、也不说为什么。EvidencePanel 收下正文的标记(marks),片段接口还没答、
答不了、答了却没有可用内容时先按标记写出判断(ChallengeSummary 导出的 ClaimVerdictPending),说正在获取、未能获取(带重试,
重试后焦点落到「反方的理由与查询」或新的「重试」上)、无法查看;拿到 unit.challenge 换成完整的单句展示。

没反查成的句子:单句展示里一定没反查成的那一句不再写「判断由反方模型给出」(那是服务端按规则记的无法判断,反方模型没看过);
无法判断的句末标记悬停说明、先行版的落款写「可能是反方模型的判断,也可能是未能反查时记下的」,说不准的仍写判断出自反方模型。

其余几处:
- 挂上时一个键盘目标都没有、反方标记随证据图后来才到:Tab 位交给第一枚标记(以前三枚都是 tabindex=-1,Tab 跳过整份报告);
- 只有反方标记的句子,读屏不再把「反方审稿」念两遍;
- 带字的醒目标记悬停、展开时不铺浅底(10px 的失败色、提醒色字叠在 14% 的浅底上,浅色主题只有 4.0 左右),改描 1px 内边,
  和裁判徽标并在一起的同样;无法判断换成省略号盾牌,不再和裁判的「?」、质询的问号挤成三个问号,也和经受住的勾分得开。

检查:check-evidence 新增 mark-panel(片段接口压着、答 500、重试,390px 不溢出)、mark-tab(标记晚到进 Tab 顺序)、stale(审批卡
换稿、同一张卡换稿、成果区、证据页停在审批上和别处正在取,各重取一次、对上了不再取、旧稿一帧都不画)三节,marks 一节补读屏原文
逐字比、展开和悬停时的对比度、图标形状、无法判断的说法,verify 一节改等单句展示(原来等的选择器单句模式里没有,每遍白等 4 秒);
新预览用例 evidence-marks-text、evidence-marks-stale(dev/fixtures/evidence_marks.tsx)。check-challenge 新增 challenge-claim-quiet:
单句、不谈出具,给齐正式运行、运行 id、degrades 也不写降档、不取运行类别(以前这个开关在哪个用例里都观察不到)。
2026-10-06 14:36:21 +08:00

1178 lines
62 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 {
Fragment, forwardRef, memo, useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo,
useRef, useState, type CSSProperties, type FocusEvent as ReactFocusEvent,
type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type RefObject,
} from 'react'
import { createPortal } from 'react-dom'
import {
ListChecks, MessageCircleQuestion, ShieldAlert, ShieldCheck, ShieldEllipsis, ShieldMinus, ShieldX, type LucideIcon,
} from 'lucide-react'
import clsx from 'clsx'
import { rovingTarget } from '../components/ui'
import {
EVIDENCE_STATE, alertTier, claimCounts, claimLabel, claimProblems, conflicting, docTally, segmentKind, segmentLabel,
segmentState, tallySummary, unitText, isJudged, limitOf, unitVerdict, verdictMissing, verdictState, withVerdicts,
type EvidenceStateCode, type EvidenceTally as Tally,
} from '../lib/evidence'
import { formatNumber } from '../lib/format'
import {
CHALLENGE_TEXT, EVIDENCE_STATE_LABEL, EVIDENCE_TEXT, INQUIRY_TEXT, JUDGE_TEXT, claimTally, evidenceTally,
type ClaimTallyCounts,
} from '../lib/terms'
import { useEvidence, useVerdicts, type Slot } from '../store/evidence'
import type {
ChallengeVerdict, EvidenceBlock, EvidenceChallengeVerdicts, EvidenceDocData, EvidenceGraph, EvidenceSegment, EvidenceUnit,
EvidenceVerdict,
} from '../types'
import { EvidencePanel, type PanelMode, type PanelView } from './EvidencePanel'
import { askAbout, type InquiryAccess } from './Inquiry'
import { BlockView, Markdown, StyledSlice, inlineStyles, numericColumns, type InlineStyles } from './Markdown'
/**
* 报告撰写节点的文档:逐段可点的证据。
*
* 按后端切好的块、句、片段画,不再解析 Markdown:片段的边界、每个数字的出处都是后端
* 算定、进了封存的,前端重新解析一遍只会切出另一套边界。块的外观借 Markdown.tsx 的
* BlockView,和普通答案长得一模一样;有出处的数字画细实线,没有出处的画点状线、
* 句末挂「无证据」。
*
* 结论句的判定(四期)只在句末挂一枚小徽标(◆ ◇ ! ○ ?),整句不画线;证据相矛盾(和旧的证据不支持)的整句
* 铺一层浅底。徽标也是按钮:点开是这一句的「模型的解释」。判定是模型给的,颜色一律不用确定性的绿。
* 被反方审过的句子(期 4,证据图的 reports[].challenge_verdicts)同样在句末挂标记:被推翻、被削弱带字、醒目,经受住、
* 无法判断只有淡色图标;有判定的句子并在同一枚徽标里。点开都是整句面板,没有可点片段的句子也打得开。证据图有运行就取
* 一次;手里那份是别处早先取的、已经过期(报告改写过、运行收尾之前取的)时重取一次,见 useRunGraph。
*
* 键盘:整份报告只占一个 Tab 位(roving tabindex)。←/→ 在有状态的片段、句末徽标间走,↑/↓ 按
* 句子走(表格里按列走到上一行 / 下一行,出了表格接着按句子走),n / N 跳到下一处 /
* 上一处有问题的地方(无证据、可疑名称、证据相矛盾、部分有依据;证据不足的排在这些之后),回车打开证据面板,
* Esc 关掉并留在原片段;面板开着时方向键走到哪、面板跟到哪,Tab 进面板。
*
* 长报告:事件都挂在容器上(片段只带 data-seg),块用 memo,外加
* content-visibility: auto;超过 TEXT_CAP 的部分先折叠,跳转到折叠里的片段时自动展开。
*/
export interface EvidenceDocHandle {
/** 跳到下一处(dir=-1 上一处)无证据并打开面板。一处都画不出来时打开违规清单;什么都没有返回 false */
next: (dir?: 1 | -1) => boolean
/** 打开违规清单。没有违规返回 false */
list: () => boolean
/** 打开某个片段的面板(记录页的审计表点一行)。focus 为 true 时焦点也挪到正文里那一段;没有这个片段返回 false */
open: (segId: string, opts?: { focus?: boolean }) => boolean
}
/** 和 AssistantStream 的长文本折叠同一个量级:先显示这么多字,后面的折起来 */
const TEXT_CAP = 3000
/** 句末徽标在键盘顺序里的键:片段 id 是 s0…,不会撞 */
const badgeKey = (unit: string) => `claim:${unit}`
const unitOfKey = (key: string) => (key.startsWith('claim:') ? key.slice(6) : null)
/**
* 审批质询的「质询」在键盘顺序里的键。只有没有可点片段、也没有徽标的句子,「质询」本身进键盘顺序(方向键走得到这一句);
* 别的句子的「质询」跟在这一句后面,焦点在这一句上时按 Tab 到达,不多占键盘顺序
*/
const askKey = (unit: string) => `ask:${unit}`
const unitOfAsk = (key: string) => (key.startsWith('ask:') ? key.slice(4) : null)
/**
* 哪些句子挂「质询」:结论句(老文档没有 kind 的按有字的句子),标题、代码块里的不挂。表格里的格子也不挂:每格一个
* 图标会把列撑宽、满表都是图标;问表里的数可以划选那一格,或者就整份报告提问
*/
const askable = (unit: EvidenceUnit, block: EvidenceBlock) =>
(unit.kind ? unit.kind === 'claim' : !!unitText(unit).trim()) && !['heading', 'code', 'hr', 'table'].includes(block.type)
/**
* 句末徽标:结论句裁判的判定(verdict、state),和 / 或反方审稿的判断(challenge)。两样都有的并在一个徽标里,不挂两个;
* 只被反方审过的句子(没开裁判的报告、裁判没送的句子)只有 challenge
*/
interface Badge {
unit: EvidenceUnit; block: number
verdict?: EvidenceVerdict; state?: EvidenceStateCode
challenge?: string
}
interface Model {
/** 可点的片段和句末徽标,按正文顺序 */
order: string[]
index: Map<string, number>
seg: Map<string, { seg: EvidenceSegment; unit: EvidenceUnit; block: number }>
/** 句末徽标:键是 claim:<unit>,带着这一句眼下的判定 */
badge: Map<string, Badge>
/** 进了键盘顺序的「质询」(审批质询):键是 ask:<unit> */
ask: Map<string, { unit: EvidenceUnit; block: number }>
/** 每个有可点片段的句子里第一个可点片段,按正文顺序(↑/↓ 用) */
unitFirst: string[]
unitOf: Map<string, number>
/**
* 有问题的片段和句末徽标(n / N 用):先第 1 轮(无证据、可疑名称、证据相矛盾、部分有依据),再第 2 轮
* (证据不足),每一轮里按正文顺序
*/
alerts: string[]
/** alerts 里每一处在第几轮 */
tier: Map<string, 1 | 2>
/** 每块到它为止的累计字数(折叠用) */
chars: number[]
/** 表格里的片段在第几行第几列(表头 row = -1 不进来);↑/↓ 在表格里按列走 */
cell: Map<string, { block: number; row: number; col: number }>
/** `块:行:列` → 那一格里第一个可点的片段 */
grid: Map<string, string>
}
function buildModel(blocks: EvidenceBlock[], overlay?: Record<string, EvidenceVerdict>, ask = false,
marks?: Record<string, string>): Model {
const order: string[] = []
const index = new Map<string, number>()
const seg = new Map<string, { seg: EvidenceSegment; unit: EvidenceUnit; block: number }>()
const badge = new Map<string, Badge>()
const asks = new Map<string, { unit: EvidenceUnit; block: number }>()
const unitFirst: string[] = []
const unitOf = new Map<string, number>()
const tiered: [string, 1 | 2][] = []
const chars: number[] = []
const cell = new Map<string, { block: number; row: number; col: number }>()
const grid = new Map<string, string>()
let total = 0
blocks.forEach((block, b) => {
for (const unit of block.units ?? []) {
let first = true
const loc = block.type === 'table' && unit.loc && unit.loc.row >= 0 ? unit.loc : null
for (const s of unit.segments ?? []) {
if (s.kind !== 'structural') total += s.text.length
const state = segmentState(s)
if (!state) continue
seg.set(s.id, { seg: s, unit, block: b })
index.set(s.id, order.length)
order.push(s.id)
if (first) { unitFirst.push(s.id); first = false }
unitOf.set(s.id, unitFirst.length - 1)
const t = alertTier(state)
if (t) tiered.push([s.id, t])
if (loc) {
cell.set(s.id, { block: b, row: loc.row, col: loc.col })
const key = `${b}:${loc.row}:${loc.col}`
if (!grid.has(key)) grid.set(key, s.id)
}
}
// 句末徽标排在这一句的片段后面:←/→ 走到句尾就是它,证据相矛盾、部分有依据、证据不足的也进 n / N。被反方审过的
// 句子同样挂(和裁判的判定并在一个徽标里):没有可点的片段、也没有判定的句子靠它才打开得了整句面板。n / N 只按
// 裁判的判定走——横幅、证据条的「定位下一处」只数证据和判定的问题,反方的判断在面板和审批卡里看
const verdict = unitVerdict(unit, overlay)
const state = verdictState(verdict)
const challenge = marks?.[unit.id]
if ((verdict && state) || challenge) {
const key = badgeKey(unit.id)
badge.set(key, { unit, block: b, ...(verdict && state ? { verdict, state } : {}), ...(challenge ? { challenge } : {}) })
index.set(key, order.length)
order.push(key)
if (first) { unitFirst.push(key); first = false }
unitOf.set(key, unitFirst.length - 1)
const t = alertTier(state)
if (t) tiered.push([key, t])
}
// 审批质询:一个可点的都没有的结论句,「质询」本身进键盘顺序,不然键盘用户问不了这一句
if (ask && first && askable(unit, block)) {
const key = askKey(unit.id)
asks.set(key, { unit, block: b })
index.set(key, order.length)
order.push(key)
unitFirst.push(key)
unitOf.set(key, unitFirst.length - 1)
}
}
chars.push(total)
})
// 按(轮次、正文顺序)排:tiered 本来就是正文顺序,稳定排序只按轮次挪
const sorted = [...tiered].sort((a, b) => a[1] - b[1])
return { order, index, seg, badge, ask: asks, unitFirst, unitOf, alerts: sorted.map(([k]) => k), tier: new Map(sorted), chars,
cell, grid }
}
/** 片段或徽标在第几块 */
const blockOf = (model: Model, key: string | null | undefined): number =>
(key ? model.seg.get(key)?.block ?? model.badge.get(key)?.block ?? model.ask.get(key)?.block : undefined) ?? -1
/**
* ↑/↓ 该去哪。表格里按列走:下一行同一列(那一格空着就找这一行离它最近的一格);
* 走出表格的上下边,接着按句子走到表格前后那一句——不在表格里一格一格地横着挪
*/
function verticalTarget(model: Model, id: string, down: boolean): string | null {
const n = model.unitFirst.length
if (!n) return null
const at = model.cell.get(id)
if (at) {
const rows = [...new Set([...model.cell.values()].filter((c) => c.block === at.block).map((c) => c.row))].sort((a, b) => a - b)
const next = rows[rows.indexOf(at.row) + (down ? 1 : -1)]
if (next !== undefined) {
const same = model.grid.get(`${at.block}:${next}:${at.col}`)
if (same) return same
const near = [...model.cell.entries()].filter(([, c]) => c.block === at.block && c.row === next)
.sort((a, b) => Math.abs(a[1].col - at.col) - Math.abs(b[1].col - at.col))[0]
if (near) return near[0]
}
// 出了表格:往下找表格之后的第一句,往上找表格之前的最后一句
const u = model.unitOf.get(id) ?? 0
const list = down ? model.unitFirst.slice(u + 1) : model.unitFirst.slice(0, u).reverse()
const out = list.find((sid) => blockOf(model, sid) !== at.block)
if (out) return out
// 表格就是整份报告的首尾:和句子一样首尾相接
const wrap = down ? model.unitFirst : [...model.unitFirst].reverse()
return wrap.find((sid) => blockOf(model, sid) !== at.block) ?? null
}
const u = model.unitOf.get(id) ?? 0
return model.unitFirst[(u + (down ? 1 : -1) + n) % n] ?? null
}
type GraphReport = NonNullable<EvidenceGraph['reports']>[number]
/**
* 证据图里的这一项说的是不是正文这一份:同一个报告节点,封存的就是这一稿(工件 id 对得上)。证据图说的是这个报告节点最后
* 写的那一稿,正文是另一稿时同一个句子编号说的可能是别的话。正文没记报告节点、没给工件 id 的(预览、老文档)不按它比
*/
const sameReport = (r: GraphReport | undefined, report: string | undefined, artifact: string | undefined) =>
(!report || !r?.node_id || r.node_id === report) && (!artifact || r?.doc_artifact === artifact)
/**
* 这次运行的证据图(正文的反方标记、按需判过的句子都从这里来):有运行就取一次,同一次运行只取一回,不逐句请求。
*
* 挂上(或换了一稿)那一刻手里已经有的那一份是别处早先取的,可能过期了。审批卡里展开过报告,取的就是运行停在审批上那一刻
* 的图:之后报告改写了(第二次审批、同一张卡换稿),正文这一稿在那份图里找不到,一个标记都挂不上;正文是运行的成果
* (settled)而那份图没封存,审批之后才审完的反方审稿不在里面。这两种按(运行、报告、工件)重取一次,重取回来还是这样
* 就作罢(正文是改写前的原件时本来就对不上),不反复请求。挂上时还没有、正在取的那一份是这时候取的,不算过期。证据页自己
* 管重取(pages/runs/EvidencePane)
*/
function useRunGraph(runId: string | undefined, report: string | undefined, artifact: string | undefined, settled: boolean):
EvidenceGraph | undefined {
const slot = useEvidence((s) => (runId ? s.graphs[runId] : undefined))
const loadGraph = useEvidence((s) => s.loadGraph)
const reloadGraph = useEvidence((s) => s.reloadGraph)
const key = runId ? `${runId}\u0000${report ?? ''}\u0000${artifact ?? ''}` : ''
// 挂上、换了一稿那一刻手里已经取到的那一份:只有它可能过期,看过一次就放下
const held = useRef<{ key: string; slot: Slot<EvidenceGraph> | null }>({ key: '', slot: null })
useEffect(() => {
if (!runId || held.current.key === key) return
const cur = useEvidence.getState().graphs[runId]
held.current = { key, slot: cur?.status === 'ok' ? cur : null }
void loadGraph(runId)
}, [runId, key, loadGraph])
useEffect(() => {
const h = held.current
if (!runId || h.key !== key || !h.slot || slot !== h.slot) return
h.slot = null
const graph = slot.data
const mine = (graph?.reports ?? []).some((r) => sameReport(r, report, artifact))
if (!mine || (settled && !graph?.seal?.sealed)) void reloadGraph(runId)
}, [runId, key, slot, report, artifact, settled, reloadGraph])
return slot?.data
}
/**
* 这份报告里被反方审过的句子 → 判断(证据图 reports[].challenge_verdicts),只认说的就是正文这一份的那一项(sameReport)。
* 没有运行、证据图还没取到(或取不到)、一句都没审、老服务端都是 undefined
*/
function challengeMarks(graph: EvidenceGraph | undefined, report: string | undefined, artifact: string | undefined):
EvidenceChallengeVerdicts | undefined {
const hit = (graph?.reports ?? []).find((r) => sameReport(r, report, artifact))
const raw: unknown = hit?.challenge_verdicts
if (!raw || typeof raw !== 'object') return undefined
const marks = Object.fromEntries(Object.entries(raw as Record<string, unknown>)
.filter((e): e is [string, string] => !!e[0] && typeof e[1] === 'string' && !!e[1]))
return Object.keys(marks).length ? marks : undefined
}
/** 句末标记读屏念的那一句:截到 40 字(同 lib/evidence 的 claimLabel) */
const markSentence = (unit: EvidenceUnit) => {
const text = unitText(unit).trim()
return text.length > 40 ? `${text.slice(0, 39)}…` : text
}
/** 反方审稿判断的叫法:认不出的照写原值(同审批卡) */
const challengeLabel = (v: string) => (Object.hasOwn(CHALLENGE_TEXT.verdict, v)
? CHALLENGE_TEXT.verdict[v as keyof typeof CHALLENGE_TEXT.verdict] : CHALLENGE_TEXT.verdictUnknown(v))
/**
* 句末徽标的读屏名:有判定的照裁判的说法(claimLabel),被反方审过的接着说反方审稿的判断;只被反方审过的
* 说「结论句「…」,反方审稿:被推翻」
*/
function badgeLabel(badge: Badge): string {
const said = badge.challenge ? CHALLENGE_TEXT.markLabel(challengeLabel(badge.challenge)) : ''
if (!badge.verdict) return JUDGE_TEXT.badgeLabel(markSentence(badge.unit), said, false)
const judged = claimLabel(badge.unit, badge.verdict)
return said ? `${judged},${said}` : judged
}
/** 片段的外观:线型走 data 属性(CSS 里只管线型),颜色写成变量交给 index.css(引文前的引号用 --ev-glyph) */
function segStyle(state: EvidenceStateCode): CSSProperties {
const meta = EVIDENCE_STATE[state]
return { '--ev-line': meta.decoration, '--ev-soft': meta.soft, '--ev-glyph': meta.color } as CSSProperties
}
/** 句末小标签:哪几种异常态在句末挂字形加文字(去掉颜色也读得出)。可疑名字和无证据分开挂 */
const TAG_TEXT: Partial<Record<EvidenceStateCode, string>> = {
none: EVIDENCE_STATE_LABEL.none,
suspect: EVIDENCE_TEXT.suspectTag,
}
export const EvidenceDoc = forwardRef<EvidenceDocHandle, {
doc: EvidenceDocData
/**
* 这份文档的工件 id(成果上 _evidence.doc_artifact 指的那件)。证据接口会报它答的是哪份
* 封存的文档,两边不一致时面板不信接口给的链和封存状态
*/
artifact?: string
/** 属于哪次运行。有它才能取片段的证据链和封存状态;没有(预览、导出的文档)只看文档里记下的出处 */
runId?: string
dense?: boolean
/** 成果字段名,读屏用 */
label?: string
/**
* 面板放哪:窄栏里栏内展开,宽屏从右侧弹出,窄屏从底部抽出,dock 放进页面给的 dock 元素里(记录页
* 的常驻面板)。auto 按 dense 和屏宽定
*/
panel?: 'auto' | PanelMode
/** panel 为 dock 时面板挂在这个元素里 */
dock?: HTMLElement | null
/** 面板开了、关了(dock 的空位据此显示提示) */
onView?: (open: boolean) => void
/** 自己显示「N/N 数字有出处」那一条。上面已经有出具横幅(它会写这一行)时不用 */
tally?: boolean
/**
* 运行类别(formal / exploratory):「请模型判断这句」只在探索运行里有。开了裁判的文档自己记着
* (on_demand / inline),没开的看这里,不给就按运行 id 取一次
*/
runClass?: string
/**
* 这次出具的档位(output._issuance.tier)。不是完整出具时,「N 个数字都有出处」不画成绿色:
* 数字的出处都对得上,档位却因为别的原因(比如查询没通过 SQL 检查)降了
*/
tier?: string
/**
* 正文是运行的成果(成果区、问数据的回答,EvidenceField 给):运行已经结束。手里那份证据图要是没封存,就是收尾之前取的
* (比如审批卡里展开报告时),重取一次,见 useRunGraph
*/
settled?: boolean
/**
* 审批质询(期 4):给了就在每句结论后面挂「质询」(悬停、焦点在这一句上时出现),划选原文时浮出「质询」,点了打开
* 这份报告的提问框。能不能问由页面按运行状态定(审批卡:运行停在审批上;运行详情:封存了的探索运行),approvalId 是
* 运行停在的那张审批。给了它,页面上要同时挂这份报告的 ReportInquiry(问答串和提问框在那里)
*/
inquiry?: InquiryAccess | null
}>(function EvidenceDoc({ doc, artifact, runId, dense = false, label, panel = 'auto', dock, onView, tally = false, runClass,
tier, settled = false, inquiry }, ref) {
const blocks = useMemo(() => doc.blocks ?? [], [doc])
const report = doc.node_id || undefined
// 质询要知道是哪次运行的哪份报告:两样缺一样就不挂入口
const ask = !!inquiry && !!runId && !!report
// 封存之后按需追加的判定:盖过文档里的「未裁判 · 按需」。只换句末徽标,封存的文档一个字不改
const overlay = useVerdicts(runId, report)
// 有运行就取这次运行的证据图(同一次运行只取一次,不逐句请求;过期了重取一次):它带着被反方审过的句子
// (reports[].challenge_verdicts,正式、探索运行都可能有,反方审稿在报告写完之后才审,文档里没有这份记录),探索运行的
// 报告还带着之前按需判过的句子(reports[].post_seal_verdicts,useVerdicts 读),重新打开页面时标记、徽标都还在
const graph = useRunGraph(runId, report, artifact, settled)
// 被反方审过的句子:句末挂标记(和裁判的判定并在一个徽标里),点开是整句面板
const marks = useMemo(() => challengeMarks(graph, report, artifact), [graph, report, artifact])
const model = useMemo(() => buildModel(blocks, overlay, ask, marks), [blocks, overlay, ask, marks])
const counts = useMemo(() => docTally(doc), [doc])
const claims = useMemo(() => claimCounts(doc, overlay), [doc, overlay])
const rootRef = useRef<HTMLDivElement>(null)
const instance = useId()
const panelId = `${instance}-panel`
const summaryId = `${instance}-summary`
const [current, setCurrent] = useState<string | null>(() => model.order[0] ?? null)
const [view, setView] = useState<PanelView | null>(null)
const [announce, setAnnounce] = useState('')
const [expanded, setExpanded] = useState(false)
const pendingFocus = useRef<string | null>(null)
const mode = usePanelMode(panel, dense)
// 换了一份文档:焦点位回到第一个片段,面板关掉。只认文档本身——判定回来了(徽标变了)不算换文档,
// 面板正开着这一句的解释,不能因此收起
useEffect(() => {
setCurrent(model.order[0] ?? null)
setView(null)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [blocks])
// 拿着 Tab 位的徽标没了(判成「不是结论句」),或者挂上时一个键盘目标都没有(没有可点片段、没开裁判、没给质询)、
// 反方标记随证据图后来才到:Tab 位交给第一个,整份报告不能一个 Tab 位都没有
useEffect(() => {
if (current ? !model.index.has(current) : model.order.length > 0) setCurrent(model.order[0] ?? null)
}, [model, current])
// 同一时刻只开一个面板:另一份报告打开了自己的面板,这边的就收起
const owner = useEvidence((s) => s.owner)
const claim = useEvidence((s) => s.claim)
useEffect(() => {
if (view && owner && owner !== instance) setView(null)
}, [owner, instance, view])
const foldAt = useMemo(() => {
const total = model.chars[model.chars.length - 1] ?? 0
if (total <= TEXT_CAP * 1.2) return blocks.length
const at = model.chars.findIndex((c) => c > TEXT_CAP)
return Math.max(1, at < 0 ? blocks.length : at)
}, [model, blocks.length])
const shownBlocks = expanded ? blocks.length : foldAt
const totalChars = model.chars[model.chars.length - 1] ?? 0
const segEl = useCallback((id: string) => {
const unit = unitOfKey(id)
const asked = unitOfAsk(id)
const sel = unit ? `[data-ev-claim="${CSS.escape(unit)}"]`
: asked ? `[data-inquiry-ask="${CSS.escape(asked)}"]` : `[data-seg="${CSS.escape(id)}"]`
return rootRef.current?.querySelector<HTMLElement>(sel) ?? null
}, [])
/** 把焦点挪到某个片段(或句末徽标)上。在折叠里的先展开,渲染出来再聚焦 */
const focusSeg = useCallback((id: string, opts?: { flash?: boolean }) => {
const block = blockOf(model, id)
if (block < 0) return
setCurrent(id)
if (block >= shownBlocks) {
pendingFocus.current = id
setExpanded(true)
return
}
const el = segEl(id)
if (!el) return
el.focus({ preventScroll: false })
if (opts?.flash) flashOnce(el)
}, [model, shownBlocks, segEl])
useLayoutEffect(() => {
const id = pendingFocus.current
if (!id || !expanded) return
pendingFocus.current = null
const el = segEl(id)
if (el) { el.focus(); flashOnce(el) }
}, [expanded, segEl])
const openSeg = useCallback((id: string) => {
const badge = model.badge.get(id)
if (badge) {
claim(instance)
setView({ kind: 'unit', id: badge.unit.id })
// 只有反方标记的句子:读屏名本身就说了「反方审稿:被推翻」,前面不再加一个标题,免得「反方审稿」念两遍
setAnnounce(badge.verdict ? `${JUDGE_TEXT.section}:${badgeLabel(badge)}` : badgeLabel(badge))
return
}
const hit = model.seg.get(id)
if (!hit) return
claim(instance)
setView({ kind: 'seg', id })
setAnnounce(`${EVIDENCE_TEXT.panelTitle}:${segmentLabel(hit.seg, doc)}`)
}, [model, doc, claim, instance])
const openViolations = useCallback(() => {
claim(instance)
setView({ kind: 'violations' })
setAnnounce(`${EVIDENCE_TEXT.violations}:${formatNumber(doc.violations?.length ?? 0)} 条`)
}, [claim, instance, doc])
const close = useCallback((refocus = true) => {
const back = view?.kind === 'seg' ? view.id : view?.kind === 'unit' ? badgeKey(view.id) : current
setView(null)
setAnnounce('')
if (refocus && back) requestAnimationFrame(() => segEl(back)?.focus())
}, [view, current, segEl])
/**
* n / N 的下一处:按(轮次、正文位置)排的一圈,走到头接回开头。当前位置不是问题(普通片段、有依据的徽标)时
* 按第 1 轮算——第 1 轮里它后面没有了,就接着走第 2 轮(证据不足)
*/
const step = useCallback((list: string[], from: string | null, dir: 1 | -1): string | null => {
if (!list.length) return null
const rank = (id: string | null): [number, number] => [id ? model.tier.get(id) ?? 1 : 1, id ? model.index.get(id) ?? -1 : -1]
const cmp = (a: [number, number], b: [number, number]) => a[0] - b[0] || a[1] - b[1]
const cur = rank(from)
if (dir > 0) return list.find((id) => cmp(rank(id), cur) > 0) ?? list[0]
for (let i = list.length - 1; i >= 0; i--) if (cmp(rank(list[i]), cur) < 0) return list[i]
return list[list.length - 1]
}, [model])
// 面板开关告诉页面(dock 的空位据此显示「点片段看出处」)
const open = !!view
useEffect(() => { onView?.(open) }, [open, onView])
const viewKey = view?.kind === 'seg' ? view.id : view?.kind === 'unit' ? badgeKey(view.id) : null
useImperativeHandle(ref, () => ({
open: (id, opts) => {
if (!model.seg.has(id) && !model.badge.has(id)) return false
if (opts?.focus) focusSeg(id, { flash: true })
else setCurrent(id)
openSeg(id)
return true
},
next: (dir = 1) => {
const target = step(model.alerts, viewKey ?? current, dir)
if (target) {
focusSeg(target, { flash: true })
openSeg(target)
return true
}
if (doc.violations?.length) { openViolations(); return true }
return false
},
list: () => {
if (!doc.violations?.length) return false
openViolations()
return true
},
}), [step, model, viewKey, current, focusSeg, openSeg, openViolations, doc])
// 事件委托:片段只带 data-seg、句末徽标只带 data-ev-claim,点击和按键都在容器上认。「质询」只有进了键盘顺序的才算
// (跟在有可点片段的句子后面的那种不算:方向键在它上面不动,Tab 照常离开)
const segOf = (target: EventTarget | null): string | null => {
const el = (target as HTMLElement | null)?.closest?.<HTMLElement>('[data-seg], [data-ev-claim], [data-inquiry-ask]')
if (!el || !rootRef.current?.contains(el)) return null
if (el.dataset.inquiryAsk) return model.index.has(askKey(el.dataset.inquiryAsk)) ? askKey(el.dataset.inquiryAsk) : null
return el.dataset.seg ?? (el.dataset.evClaim ? badgeKey(el.dataset.evClaim) : null)
}
/** 点了句末的「质询」:打开这份报告的提问框,问的是这一句 */
const askUnit = (el: HTMLElement) => {
const id = el.dataset.inquiryAsk
if (!id || !inquiry || !runId || !report) return
const unit = blocks.flatMap((b) => b.units ?? []).find((u) => u.id === id)
if (model.index.has(askKey(id))) setCurrent(askKey(id))
askAbout(runId, report, inquiry, { unit: id, quote: null, text: unit ? unitText(unit).trim() : '' }, el)
}
const onClick = (e: ReactMouseEvent) => {
const asking = (e.target as HTMLElement | null)?.closest?.<HTMLElement>('[data-inquiry-ask]')
if (asking && rootRef.current?.contains(asking)) {
e.preventDefault()
askUnit(asking)
return
}
const id = segOf(e.target)
if (!id) return
e.preventDefault()
setCurrent(id)
if (viewKey === id) close(false)
else openSeg(id)
}
const onFocus = (e: ReactFocusEvent) => {
const id = segOf(e.target)
if (id && id !== current) setCurrent(id)
}
const onKeyDown = (e: ReactKeyboardEvent) => {
const id = segOf(e.target)
if (!id || e.altKey || e.metaKey || e.ctrlKey) return
const i = model.index.get(id) ?? 0
let target: string | null = null
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'Home' || e.key === 'End') {
const to = rovingTarget(e, i, model.order.length, 'horizontal')
target = to >= 0 ? model.order[to] : null
} else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
target = verticalTarget(model, id, e.key === 'ArrowDown')
} else if (e.key === 'n' || e.key === 'N') {
target = step(model.alerts, id, e.shiftKey || e.key === 'N' ? -1 : 1)
if (!target) return
} else if (e.key === 'Escape' && view) {
e.preventDefault()
e.stopPropagation()
close()
return
} else if (e.key === 'Tab' && !e.shiftKey && view && mode !== 'inline') {
// 面板在页面末尾(portal)或页面另一栏(dock):Tab 直接进面板,不让人穿过整页去找
const heading = document.getElementById(`${panelId}-title`)
if (heading) { e.preventDefault(); heading.focus() }
return
} else {
return
}
e.preventDefault()
e.stopPropagation()
if (!target) return
focusSeg(target)
// 面板开着:跟着走,读屏从 live region 听到新片段
if (view) openSeg(target)
}
const activeSeg = viewKey
const activeBlock = blockOf(model, activeSeg)
const currentBlock = blockOf(model, current)
const visual = blocks.length > 40
const panelNode = view && (
<EvidencePanel
id={panelId}
mode={mode}
doc={doc}
artifact={artifact}
runId={runId}
runClass={runClass}
marks={marks}
view={view}
onClose={() => close()}
onLocate={(id) => { focusSeg(id, { flash: true }); openSeg(id) }}
onViolations={openViolations}
/>
)
// 审批质询:划选一段原文,浮出「质询」,问的是选中的这段(带上它起头的那一句)
const pickAnchor = useRef<HTMLDivElement>(null)
const [pick, dropPick] = useSelectionPick(rootRef, pickAnchor, ask)
const askPick = () => {
if (!pick || !inquiry || !runId || !report) return
// 浮出的按钮点完就没了:Esc 之后焦点回到那一句的「质询」
const back = pick.unit ? rootRef.current?.querySelector<HTMLElement>(`[data-inquiry-ask="${CSS.escape(pick.unit)}"]`) : null
document.getSelection()?.removeAllRanges()
dropPick()
askAbout(runId, report, inquiry, { unit: pick.unit, quote: pick.quote, text: pick.quote }, back)
}
// 画不了线的两种分开说:列表序号、代码块标签里的数字,和正文里根本没有对应字的(句末依据里的引用)
const summary = [tallySummary({ ...counts, claims }, model.order.length > 0), ask ? INQUIRY_TEXT.keysHint : '']
.filter(Boolean).join('。')
return (
<div ref={rootRef} data-evidence-doc="" className="min-w-0">
{ask && (
// 划选原文浮出的「质询」挂在文档里(高度为 0 的定位锚点下面,按锚点算坐标),不挂到页面末尾:画布上的审批浮层
// 按下在它外面就收起,挂在外面的按钮一按,浮层连同报告一起收掉。也因此会被报告框裁切,位置按可见范围摆(见
// useSelectionPick)
<div ref={pickAnchor} className="relative" data-inquiry-pick-anchor="">
{pick && (
// 按下时不抢焦点、不清掉选区:点下去才读选区
<button type="button" className="btn btn-xs absolute z-20 whitespace-nowrap shadow-elev-3" data-inquiry-selection=""
style={{ left: pick.left, top: pick.top }} aria-label={INQUIRY_TEXT.askSelection}
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => { e.stopPropagation(); askPick() }}>
<MessageCircleQuestion size={11} aria-hidden /> {INQUIRY_TEXT.ask}
</button>
)}
</div>
)}
<p id={summaryId} className="sr-only" data-evidence-summary="">{summary}</p>
<div className="sr-only" aria-live="polite" aria-atomic="true">{announce}</div>
{tally && (counts.total > 0 || counts.other > 0 || !!counts.suspect || !!claims?.total) && (
<EvidenceTally counts={{ ...counts, claims }} tier={tier} onNext={() => {
const target = step(model.alerts, current, 1)
if (target) { focusSeg(target, { flash: true }); openSeg(target) } else openViolations()
}} onList={doc.violations?.length ? openViolations : undefined} />
)}
<div
role="group"
aria-label={label ? `报告:${label}` : '报告'}
aria-describedby={summaryId}
className={clsx('space-y-2 leading-relaxed', dense ? 'text-[11.5px]' : 'text-sm')}
onClick={onClick}
onKeyDown={onKeyDown}
onFocus={onFocus}
>
{blocks.slice(0, shownBlocks).map((block, b) => (
// 栏内面板紧跟在片段所在的块后面:片段和它的证据在视线上挨着
<Fragment key={block.id ?? b}>
<DocBlock block={block} dense={dense} doc={doc} visual={visual}
current={b === currentBlock ? current : null}
active={b === activeBlock ? activeSeg : null}
panelId={panelId} model={model} ask={ask} />
{mode === 'inline' && b === activeBlock && panelNode}
</Fragment>
))}
{mode === 'inline' && view?.kind === 'violations' && panelNode}
</div>
{shownBlocks < blocks.length && (
<div className="mt-1 flex items-center gap-2 text-2xs">
<span className="text-dim">…以下内容已折叠</span>
<button type="button" className="text-[var(--accent)] hover:underline" onClick={() => setExpanded(true)}>
展开全部({formatNumber(totalChars)} 字)
</button>
</div>
)}
{expanded && foldAt < blocks.length && (
<div className="mt-1 text-2xs">
<button type="button" className="text-[var(--accent)] hover:underline" onClick={() => setExpanded(false)}>收起</button>
</div>
)}
{mode === 'dock' && panelNode && dock && createPortal(panelNode, dock)}
{mode !== 'inline' && mode !== 'dock' && panelNode && createPortal(panelNode, document.body)}
</div>
)
})
/** 划选的一段原文:要问的字、它起头的那一句、浮出按钮的位置(相对定位锚点) */
interface SelectionPick { quote: string; unit: string | null; left: number; top: number }
/** 浮出按钮的大致尺寸(宁大勿小):放在选区末尾下方,贴边时收进可见范围,下方放不下时放到选区上方 */
const PICK_W = 64
const PICK_H = 26
/** 按钮和选区、和可见范围边上留的空 */
const PICK_GAP = 6
/**
* 一个元素眼下看得见的范围(视口坐标):视口,再和每一层会裁切内容的祖先(overflow 不是 visible 的,比如审批卡里
* 限高滚动的报告框)取交集。浮出的按钮挂在文档里,超出这个范围的部分会被裁掉
*/
function visibleBox(el: Element): { top: number; bottom: number; left: number; right: number } {
const box = { top: 0, left: 0, bottom: window.innerHeight, right: window.innerWidth }
for (let p: Element | null = el; p && p !== document.documentElement; p = p.parentElement) {
const style = getComputedStyle(p)
if (style.overflowX === 'visible' && style.overflowY === 'visible') continue
const r = p.getBoundingClientRect()
box.top = Math.max(box.top, r.top)
box.left = Math.max(box.left, r.left)
box.bottom = Math.min(box.bottom, r.bottom)
box.right = Math.min(box.right, r.right)
}
return box
}
/**
* 报告正文里划选了一段原文:返回要问的字和浮出按钮的位置,选区收起、移出正文、滚出可见范围时为 null。只认报告正文
* 里的字——栏内展开的证据面板也在这个容器里,那里的字不算。要问的字按选区克隆出来算,去掉句末徽标、「无证据」小标签、
* 读屏文字和「质询」本身(它们在 DOM 里,却不是报告的原文)。
*
* 位置相对 anchor(文档里高度为 0 的定位锚点)算:按钮挂在文档里,会被限高滚动的报告框裁切,所以按可见范围摆——
* 默认在选区末尾下方,下方放不下放到选区上方,左右收进文档的宽度(不把报告框撑出横向滚动)和可见范围
*/
function useSelectionPick(root: RefObject<HTMLElement | null>, anchor: RefObject<HTMLElement | null>,
enabled: boolean): [SelectionPick | null, () => void] {
const [pick, setPick] = useState<SelectionPick | null>(null)
useEffect(() => {
if (!enabled) { setPick(null); return }
let frame = 0
const read = () => {
frame = 0
const el = root.current
const origin = anchor.current
const sel = document.getSelection()
if (!el || !origin || !sel || sel.isCollapsed || !sel.rangeCount) { setPick(null); return }
const range = sel.getRangeAt(0)
const at = (n: Node) => (n instanceof Element ? n : n.parentElement)
const inside = (x: Element | null) => !!x && el.contains(x) && !!x.closest('[data-block]') && !x.closest('[data-evidence-panel]')
const start = at(range.startContainer)
if (!inside(start) || !inside(at(range.endContainer))) { setPick(null); return }
const frag = range.cloneContents()
frag.querySelectorAll('[data-inquiry-ask], .ev-tag, .ev-badge, .sr-only').forEach((n) => n.remove())
const quote = (frag.textContent ?? '').replace(/\s+/g, ' ').trim()
if ([...quote].length < 2) { setPick(null); return }
const rects = range.getClientRects()
const last = rects[rects.length - 1] ?? range.getBoundingClientRect()
const first = rects[0] ?? last
const view = visibleBox(el)
// 选区整个滚出了可见范围:按钮也不留(滚回来再量出来)
if (last.bottom <= view.top || first.top >= view.bottom) { setPick(null); return }
const doc = el.getBoundingClientRect()
const lo = Math.max(doc.left, view.left)
const hi = Math.min(doc.right, view.right) - PICK_W
const x = Math.max(lo, Math.min(last.right - PICK_W / 2, hi))
const fitsBelow = last.bottom + PICK_GAP + PICK_H <= view.bottom
const fitsAbove = first.top - PICK_GAP - PICK_H >= view.top
const y = fitsBelow || !fitsAbove ? Math.min(last.bottom + PICK_GAP, Math.max(view.top, view.bottom - PICK_H))
: first.top - PICK_GAP - PICK_H
const o = origin.getBoundingClientRect()
const next: SelectionPick = {
quote, unit: start?.closest('[data-ev-unit]')?.getAttribute('data-ev-unit') ?? null,
left: Math.round(x - o.left), top: Math.round(y - o.top),
}
// 滚动时每一帧都会量一次:没变就不换对象,整份报告不跟着重画
setPick((cur) => (cur && cur.quote === next.quote && cur.unit === next.unit && cur.left === next.left && cur.top === next.top
? cur : next))
}
// 选区一变、页面或报告框一滚、窗口一变都重新量:可见范围变了,按钮放上方还是下方跟着变。文档自己的尺寸变了也量:
// 悬停一句时它的「质询」出现、那一段多折一行,下面的字整体下移,既不滚动也不改选区,按钮却得跟着挪
const schedule = () => { if (!frame) frame = requestAnimationFrame(read) }
document.addEventListener('selectionchange', schedule)
window.addEventListener('scroll', schedule, true)
window.addEventListener('resize', schedule)
const resized = typeof ResizeObserver === 'function' ? new ResizeObserver(schedule) : null
if (root.current) resized?.observe(root.current)
return () => {
cancelAnimationFrame(frame)
document.removeEventListener('selectionchange', schedule)
window.removeEventListener('scroll', schedule, true)
window.removeEventListener('resize', schedule)
resized?.disconnect()
}
}, [root, anchor, enabled])
return [pick, useCallback(() => setPick(null), [])]
}
/** 定位到的片段描一下边(CSS 的 [data-flash]),同 AssistantStream 的 flash */
function flashOnce(el: HTMLElement) {
el.dataset.flash = 'focus'
setTimeout(() => { if (el.dataset.flash === 'focus') delete el.dataset.flash }, 1600)
}
/** 面板放哪。auto:窄栏(dense)里栏内展开,宽屏侧边,窄屏底部抽屉;屏宽变了跟着变 */
function usePanelMode(panel: 'auto' | PanelMode, dense: boolean): PanelMode {
// dock 由页面自己定宽窄(记录页窄的时候换成 drawer 再传进来),这里原样用
const query = '(min-width: 900px)'
const [wide, setWide] = useState(() => typeof matchMedia !== 'function' || matchMedia(query).matches)
useEffect(() => {
if (panel !== 'auto' || dense || typeof matchMedia !== 'function') return
const mq = matchMedia(query)
const on = () => setWide(mq.matches)
on()
mq.addEventListener('change', on)
return () => mq.removeEventListener('change', on)
}, [panel, dense])
if (panel !== 'auto') return panel
if (dense) return 'inline'
return wide ? 'side' : 'drawer'
}
/**
* 证据条:没有出具横幅时由文档自己说「7/12 数字有出处 · 无证据 5 · 另有 1 处引用解析不了」,
* 有结论句判定时接着说「结论 4 句(支持 3 · 不支持 1)」
*/
export function EvidenceTally({ counts, onNext, onList, tier }: {
counts: Pick<Tally, 'total' | 'cited' | 'none' | 'other' | 'suspect' | 'claims'>
onNext?: () => void
onList?: () => void
/** 出具档位:给了、又不是完整出具时,数字都有出处也不用绿色 */
tier?: string
}) {
const suspect = counts.suspect ?? 0
const claims = counts.claims
const clean = !counts.none && !counts.other && !suspect && !claimProblems(claims)
const numbers = evidenceTally(counts.cited, counts.total, counts.other)
return (
<div className="mb-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-2xs" data-evidence-tally="">
{numbers && (
<span className="tnum" data-evidence-tally-numbers=""
style={{ color: counts.none || counts.other ? 'var(--st-waiting)' : !tier || tier === 'formal' ? 'var(--st-done)' : 'var(--text-dim)' }}>
{numbers}
</span>
)}
{/* 可疑名字另起一句:它不是数字,混进「无证据 M」就算不平了 */}
{suspect > 0 && (
<span className="tnum" style={{ color: 'var(--st-waiting)' }} data-evidence-tally-suspect="">
{numbers ? '· ' : ''}{EVIDENCE_STATE.suspect.glyph} {EVIDENCE_TEXT.suspectTag} {formatNumber(suspect)}
</span>
)}
{claims && claims.total > 0 && <ClaimTally claims={claims} lead={!!numbers || suspect > 0} />}
{!clean && onNext && (
<button type="button" className="btn btn-xs" data-evidence-next="" onClick={onNext}>
{EVIDENCE_TEXT.locateNext}
</button>
)}
{onList && (
<button type="button" className="btn btn-xs btn-ghost" onClick={onList} data-evidence-list="">
<ListChecks size={11} aria-hidden /> {EVIDENCE_TEXT.violations}
</button>
)}
</div>
)
}
/**
* 「结论 4 句(有依据 3 · 证据相矛盾 1)」:和数字那一段同一种说法。判定是模型给的:全都支持也不用确定性的绿,
* 有问题(证据相矛盾、旧的证据不支持、部分有依据、证据不足)、没挂依据的用提醒色,其余用暗字色(它是概率性的,
* 不该和「有出处」一样笃定)
*/
export function ClaimTally({ claims, lead }: { claims: ClaimTallyCounts; lead: boolean }) {
const text = claimTally(claims)
if (!text) return null
const warn = claimProblems(claims) > 0 || claims.uncited > 0
return (
<span className="tnum" style={{ color: warn ? 'var(--st-waiting)' : 'var(--text-dim)' }} data-evidence-claims=""
title={JUDGE_TEXT.badge(null)}>
{lead ? '· ' : ''}{text}
</span>
)
}
// -------------------------------------------------------------------------
// 块与片段
// -------------------------------------------------------------------------
/** 列表项的开头:结构片段里带着行首的 - / 1. 。列表项被分成几句时,后面几句接在同一项里 */
const ITEM_START = /(^|\n)[ \t]*([-*+]|\d+[.)])[ \t]+$/
const DocBlock = memo(function DocBlock({ block, dense, doc, visual, current, active, panelId, model, ask }: {
block: EvidenceBlock
dense: boolean
doc: EvidenceDocData
/** 句末徽标从这里取:这一句眼下的判定(封存后追加的盖过文档里的) */
model: Model
/** 审批质询:结论句后面挂「质询」 */
ask: boolean
/**
* 长文档:块上加 content-visibility,屏幕外的块不排版不绘制。短文档不加:它自带 paint
* containment,贴着块边的片段的焦点描边会被裁掉一截,而短文档本来就不慢
*/
visual: boolean
/** 这一块里拿着 Tab 位的片段 */
current: string | null
/** 这一块里面板正开着的片段 */
active: string | null
panelId: string
}) {
const units = block.units ?? []
const root = {
'data-block': block.id,
...(visual ? { style: { contentVisibility: 'auto', containIntrinsicSize: 'auto 2.5em' } as CSSProperties } : {}),
}
const unit = (u: EvidenceUnit, opts?: { code?: boolean; tag?: boolean }) => (
<UnitView key={u.id} unit={u} doc={doc} current={current} active={active} panelId={panelId}
code={opts?.code} tag={opts?.tag ?? true} badge={model.badge.get(badgeKey(u.id))}
ask={ask && askable(u, block) ? (model.ask.has(askKey(u.id)) ? 'roving' : 'follow') : undefined} />
)
switch (block.type) {
case 'heading':
return <BlockView dense={dense} root={root} shape={{ kind: 'h', level: block.level ?? 2, body: units.map((u) => unit(u)) }} />
case 'hr':
return <BlockView dense={dense} root={root} shape={{ kind: 'hr' }} />
case 'quote':
return <BlockView dense={dense} root={root} shape={{ kind: 'quote', body: units.map((u) => unit(u)) }} />
case 'code':
return (
<BlockView dense={dense} root={root} shape={{
kind: 'code', lang: block.lang ?? '', code: units.map(unitText).join(''),
body: units.map((u) => unit(u, { code: true, tag: false })),
}} />
)
case 'list': {
const items: { key: string; depth: number; units: EvidenceUnit[] }[] = []
for (const u of units) {
const lead = u.segments?.[0]
const starts = !items.length || (lead?.kind === 'structural' && ITEM_START.test(lead.text))
if (starts) items.push({ key: u.id, depth: u.depth ?? 0, units: [u] })
else items[items.length - 1].units.push(u)
}
return (
<BlockView dense={dense} root={root} shape={{
kind: 'list', ordered: !!block.ordered, start: block.start, fitMarkers: true,
items: items.map((it) => ({ key: it.key, depth: it.depth, body: it.units.map((u) => unit(u)) })),
}} />
)
}
case 'table': {
// 表头 row = -1;一格可能被分成几句,按位置归拢
const cells = new Map<string, EvidenceUnit[]>()
let cols = 0
const rowIds: number[] = []
for (const u of units) {
const loc = u.loc ?? { row: 0, col: 0 }
const k = `${loc.row}:${loc.col}`
cells.set(k, [...(cells.get(k) ?? []), u])
cols = Math.max(cols, loc.col + 1)
if (loc.row >= 0 && !rowIds.includes(loc.row)) rowIds.push(loc.row)
}
rowIds.sort((a, b) => a - b)
const text = (row: number, col: number) => (cells.get(`${row}:${col}`) ?? []).map(unitText).join('')
const node = (row: number, col: number) =>
(cells.get(`${row}:${col}`) ?? []).map((u) => unit(u, { tag: false }))
const head = Array.from({ length: cols }, (_, c) => text(-1, c))
const rows = rowIds.map((r) => Array.from({ length: cols }, (_, c) => text(r, c)))
return (
<BlockView dense={dense} root={root} shape={{
kind: 'table',
head: Array.from({ length: cols }, (_, c) => node(-1, c)),
rows: rowIds.map((r) => Array.from({ length: cols }, (_, c) => node(r, c))),
numeric: numericColumns(head, rows),
csv: () => [head, ...rows].map((r) => r.map((v) => (/[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v)).join(',')).join('\n'),
}} />
)
}
default:
return <BlockView dense={dense} root={root} shape={{ kind: 'p', body: units.map((u) => unit(u)) }} />
}
})
/** 反方审稿的判断在句末徽标里的样子。soft 是悬停、展开时铺的浅底,醒目的那两种不铺(见 LOUD_RING) */
interface MarkLook { icon: LucideIcon; color: string; soft: string; loud: boolean }
/**
* 被推翻、被削弱醒目:盾牌图标加判断两个字,用失败色、提醒色;经受住、无法判断低调:只有淡色的盾牌图标(悬停、读屏照样
* 说得出是哪一种)。四种图标各不相同,去掉颜色也分得开:无法判断用省略号不用问号——句末常常已经有裁判的「?」(未裁判、
* 证据不足)和「质询」的问号,再添一个问号就分不清了,和经受住的勾也更好分。判断是反方模型给的,经受住也不用确定性的绿。
* 认不出的判断按低调画
*/
const MARK_LOOK: Record<ChallengeVerdict, MarkLook> = {
refuted: { icon: ShieldX, color: 'var(--st-failed)', soft: 'transparent', loud: true },
weakened: { icon: ShieldMinus, color: 'var(--st-waiting)', soft: 'transparent', loud: true },
stands: { icon: ShieldCheck, color: 'var(--text-faint)', soft: 'var(--st-idle-soft)', loud: false },
inconclusive: { icon: ShieldEllipsis, color: 'var(--text-faint)', soft: 'var(--st-idle-soft)', loud: false },
}
const markLook = (verdict: string): MarkLook => (Object.hasOwn(MARK_LOOK, verdict) ? MARK_LOOK[verdict as ChallengeVerdict]
: { icon: ShieldAlert, color: 'var(--text-faint)', soft: 'var(--st-idle-soft)', loud: false })
/**
* 带字的醒目标记悬停、展开时不铺浅底(index.css 的 .ev-badge 按 --ev-soft 铺),改描一圈细边:10px 的失败色、提醒色字叠在
* 14% 的浅底上,浅色主题里对比度只有 4.0 左右(页面底色上),面板开着时这枚标记一直是展开态。和裁判徽标并在一起的同样
* 不铺(浅底是裁判判定的颜色,字是反方的颜色)。细边用徽标的字色,和焦点环(2px 强调色、外扩)分得开
*/
const LOUD_RING = 'hover:shadow-[inset_0_0_0_1px_currentColor] aria-expanded:shadow-[inset_0_0_0_1px_currentColor]'
/**
* 句末徽标里反方审稿的那一截。和裁判的字形并在一个徽标里时跟在字形后面(after)。醒目的那两种连字一起写:字号定成
* 10px(同句末的「无证据」小标签),窄栏里 0.72em 的上标只有 8px,汉字认不清
*/
function ChallengeMark({ verdict, look, after }: { verdict: string; look: MarkLook; after: boolean }) {
const Icon = look.icon
return (
<span aria-hidden="true" className={clsx('inline-flex items-center gap-px whitespace-nowrap', after && 'ml-0.5')}
style={{ color: look.color }} data-ev-challenge-look={look.loud ? 'loud' : 'quiet'}>
<Icon size={10} strokeWidth={2.25} className="shrink-0" />
{look.loud && <span className="text-[10px]">{challengeLabel(verdict)}</span>}
</span>
)
}
/**
* 一句话:文字按行内 Markdown 渲染(整句算一次样式,跨片段的 `code`、**粗体** 不会
* 被切坏),有状态的片段包成按钮。含无证据片段的句子末尾挂「? 无证据」;有判定的结论句末尾挂
* 一枚徽标(也是按钮),证据相矛盾(和旧的证据不支持)的整句铺浅底——字本身不画线。被反方审过的
* 句子同样挂徽标(和判定并在一起),点开都是整句面板
*/
function UnitView({ unit, doc, current, active, panelId, code, tag, badge, ask }: {
unit: EvidenceUnit; doc: EvidenceDocData; current: string | null; active: string | null; panelId: string
code?: boolean; tag: boolean; badge?: Badge
/**
* 审批质询的「质询」:roving 是这一句一个可点的都没有,「质询」本身进键盘顺序;follow 跟在这一句后面,焦点在这一句上时
* 按 Tab 到达
*/
ask?: 'roving' | 'follow'
}) {
const segs = (unit.segments ?? []).filter((s) => s.kind !== 'structural')
const text = segs.map((s) => s.text).join('')
const styles = useMemo<InlineStyles | null>(() => (code ? null : inlineStyles(text)), [text, code])
let pos = 0
const alerts = new Set<EvidenceStateCode>()
const nodes = segs.map((s) => {
const from = pos
pos += s.text.length
const state = segmentState(s)
const body = styles
? <StyledSlice text={text} styles={styles} from={from} to={pos} links={!state} />
: s.text
if (!state) return <span key={s.id}>{s.strong ? <strong className="font-semibold">{body}</strong> : body}</span>
const meta = EVIDENCE_STATE[state]
if (meta.alert && TAG_TEXT[state]) alerts.add(state)
const open = active === s.id
return (
<button
key={s.id}
type="button"
className="ev-seg"
data-seg={s.id}
data-ev-state={state}
data-ev-line={meta.line}
// 实体、引文:线型和颜色照状态,另有自己的样子(引文前挂引号,见 index.css)
data-ev-kind={segmentKind(s) ?? undefined}
tabIndex={current === s.id ? 0 : -1}
aria-label={segmentLabel(s, doc)}
aria-expanded={open}
aria-controls={open ? panelId : undefined}
style={segStyle(state)}
>
{s.strong ? <strong className="font-semibold">{body}</strong> : body}
</button>
)
})
const key = badge ? badgeKey(unit.id) : ''
const meta = badge?.state ? EVIDENCE_STATE[badge.state] : null
const open = !!badge && active === key
const verdict = badge?.verdict
// 模型判过的写明谁判的、非确定(证据不足的接着写缺什么);模型没判过的(按需还没点、到上限、没跑成)只说
// 这是什么状态——到上限没判的也记着裁判模型,但不能写成「(模型 · 非确定)」,像是它判过一样
const judgedTitle = !verdict || !meta ? ''
: isJudged(verdict)
? `${meta.label}${verdict.post_seal ? ` · ${JUDGE_TEXT.postSeal}` : ''}(${verdict.judge || JUDGE_TEXT.judgeModel} · 非确定)${
verdictMissing(verdict) ? `。${JUDGE_TEXT.missing(verdictMissing(verdict))}` : ''}`
: limitOf(verdict) ? `${meta.label}:${JUDGE_TEXT.limitNotJudged}` : `${meta.label}:${meta.hint}`
// 无法判断的句子可能根本没反查成(服务端按规则记的无法判断,反方模型没看过),正文里分不出是哪一种:不说成反方模型的判断
const challengeTitle = !badge?.challenge ? ''
: badge.challenge === 'inconclusive' ? CHALLENGE_TEXT.markTitleUnsure(challengeLabel(badge.challenge))
: CHALLENGE_TEXT.markTitle(challengeLabel(badge.challenge))
const look = badge?.challenge ? markLook(badge.challenge) : null
const body = (
<>
{conflicting(badge?.state)
// 证据相矛盾(和旧的证据不支持):整句铺一层浅底(折行时每行各自带圆角),字色、字重都不动,也不画线
? <span className="ev-claim" data-ev-claim-shade={unit.id} style={{ '--ev-soft': meta!.soft } as CSSProperties}>{nodes}</span>
: nodes}
{tag && [...alerts].map((state) => (
<span key={state} className="ev-tag" aria-hidden="true" title={EVIDENCE_STATE[state].hint} data-ev-tag={state}>
{EVIDENCE_STATE[state].glyph}{TAG_TEXT[state]}
</span>
))}
{badge && (meta || look) && (
<button
type="button"
className={clsx('ev-badge', look?.loud && LOUD_RING)}
data-ev-claim={unit.id}
data-ev-verdict={badge.state}
data-ev-post-seal={verdict?.post_seal ? '' : undefined}
data-ev-challenge-mark={badge.challenge}
tabIndex={current === key ? 0 : -1}
aria-label={badgeLabel(badge)}
aria-expanded={open}
aria-controls={open ? panelId : undefined}
title={[judgedTitle, challengeTitle].filter(Boolean).join('。')}
// 字形的颜色照裁判的判定;只被反方审过的照反方的判断(悬停的浅底同理)。带着醒目的反方标记时不铺浅底(LOUD_RING)
style={meta ? { ...segStyle(badge.state!), ...(look?.loud ? { '--ev-soft': 'transparent' } : {}) } as CSSProperties
: { '--ev-glyph': look!.color, '--ev-soft': look!.soft } as CSSProperties}
>
{meta && <span aria-hidden="true">{meta.glyph}</span>}
{look && <ChallengeMark verdict={badge.challenge!} look={look} after={!!meta} />}
</button>
)}
</>
)
if (!ask) return body
// 「质询」跟在句末,是一个固定宽度的小图标:平时淡淡的,悬停这一句、焦点落在这一句上时显出来(强调色、描边)。
// 平时也得看得清它是个可点的东西(WCAG 1.4.11 要求界面组件对背景 ≥ 3:1):最淡的字色再压到 60% 不透明,深色约 2.75、
// 浅色约 2.33,看不出来。改用次一级的暗字色压到 70%:深浅两套、报告摆在页面底色或面板底色上都在 3.3 以上,仍比正文
// 淡得多;不透明度留在 0.75 以下,和悬停、聚焦时显出来(不透明、强调色)分得开。
// 显没显出来只换颜色,宽度一直是那么宽——悬停时撑开的话,后面的字整体右移,鼠标底下的数字跟着挪走,点不到。
// 触屏没有悬停,一直显出来,并写上「质询」两个字。
// 跟随的那种只在焦点在这一句上时进 Tab 顺序,整份报告不因此多出一串 Tab 位
const here = current != null && (current === key || current === askKey(unit.id)
|| (unit.segments ?? []).some((sg) => sg.id === current))
return (
<span className="group/evu" data-ev-unit={unit.id}>
{body}
<button type="button" data-inquiry-ask={unit.id} tabIndex={here ? 0 : -1}
aria-label={INQUIRY_TEXT.askUnit(unitText(unit).trim())} title={INQUIRY_TEXT.askTitle}
className={'ml-1 inline-flex select-none items-center gap-0.5 whitespace-nowrap rounded border border-transparent px-[3px] '
+ 'align-middle text-[10px] font-medium leading-[1.4] text-dim opacity-70 outline-none '
+ 'hover:bg-hover group-hover/evu:border-[color:var(--border)] group-hover/evu:text-[color:var(--accent)] group-hover/evu:opacity-100 '
+ 'group-focus-within/evu:border-[color:var(--border)] group-focus-within/evu:text-[color:var(--accent)] group-focus-within/evu:opacity-100 '
+ 'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[color:var(--accent)] '
+ 'pointer-coarse:border-[color:var(--border)] pointer-coarse:text-[color:var(--accent)] pointer-coarse:opacity-100'}>
<MessageCircleQuestion size={11} aria-hidden />
<span aria-hidden="true" className="hidden pointer-coarse:inline">{INQUIRY_TEXT.ask}</span>
</button>
</span>
)
}
/**
* 成果里带证据的那个字段:按工件 id 取文档,取到之前和取不到时照普通答案显示——
* 证据是锦上添花,拿不到不能让答案本身消失。以下情况也不画,免得把证据挂到别的字上:
* - 文档的正文和字段对不上(字段被模板加工过),或者文档根本没记正文;
* - 文档记着的运行和这份成果的运行不是同一个(成果上的 doc_artifact 是可以改写的一列)
*/
export const EvidenceField = forwardRef<EvidenceDocHandle, {
artifact: string; text: string; runId?: string; dense?: boolean; label?: string; tally?: boolean
/** 交给出具横幅数数的文档:封存之后按需追加的判定已经叠上去了(只给计数用,正文照样按封存的那份画) */
onDoc?: (doc: EvidenceDocData | null) => void
runClass?: string
}>(function EvidenceField({ artifact, text, runId, dense, label, tally, onDoc, runClass }, ref) {
const slot = useEvidence((s) => s.docs[artifact])
const loadDoc = useEvidence((s) => s.loadDoc)
useEffect(() => { void loadDoc(artifact) }, [artifact, loadDoc])
const doc = slot?.status === 'ok' ? slot.data ?? null : null
const mismatch = !!doc && (typeof doc.markdown !== 'string' || doc.markdown.trim() !== text.trim())
const otherRun = !!doc && !!runId && typeof doc.run_id === 'string' && !!doc.run_id && doc.run_id !== runId
const usable = doc && !mismatch && !otherRun ? doc : null
const overlay = useVerdicts(runId, usable?.node_id || undefined)
const counted = useMemo(() => (usable ? withVerdicts(usable, overlay) : null), [usable, overlay])
useEffect(() => { onDoc?.(counted) }, [counted, onDoc])
if (usable) {
// 成果只在运行结束之后才有:手里那份证据图要是没封存,就是收尾之前取的(settled,见 useRunGraph)
return <EvidenceDoc ref={ref} doc={usable} artifact={artifact} runId={runId} dense={dense} label={label} tally={tally}
runClass={runClass} settled />
}
const note = slot?.status === 'error' ? EVIDENCE_TEXT.docMissing
: otherRun ? EVIDENCE_TEXT.docOtherRun : mismatch ? EVIDENCE_TEXT.docMismatch : ''
return (
<div data-evidence-fallback={otherRun ? 'other-run' : mismatch ? 'mismatch' : slot?.status ?? 'loading'}>
<Markdown text={text} dense={dense} />
{note && <div className="mt-1 text-2xs text-faint">{note}</div>}
</div>
)
})