mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 21:51:58 +00:00
e4f2693918
「引用即渲染」:写报告的模型只写引用标记([[m:指标id]]、[[i:输入字段]]、[[see:…]]),数值由系统按口径卡
填进去,每个数字都挂着一条能复算的证据链。本期是数字层闭环;实体、结论句的概率性证据放在后续几期。
内核(engine/evidence.py,口径卡、出具契约、表达式等)
- 口径卡记录每个指标的来源:原式、代入式(每个输入写成「值 ← 路径」)、复算结果、口径与版本
- 出具契约的数值匹配改为按位置标注;同一个值有几个出处时记为「出处不唯一」并列出候选,不再随便挑一个
- 证据 id 由内容派生,事后对不上就是被改过;零值、结构性数字(列表序号、代码块标签)单独处理
- 口径卡新增 on_missing:缺输入时默认仍是整个节点失败,可以改成记为空值、交给契约判档
报告撰写节点(report)与引用模式的出具契约
- 流式生成时 token 里就是真实数字,不会漏出原始标记;裸数字、不存在的引用当场判违规,交回去重写
(max_repairs,默认 1 次);仍不过时按 on_violation 失败(正式运行默认)或把违规标在报告里(探索运行默认)
- report.checked 事件记下核对结果,落在封存范围内;fail 模式下仍不过时带 failed,运行记录里画成失败
- 出具契约写了 report_from 就按引用核对:取回文档时复验哈希,独立重算一遍,全部带引用才判完整出具
- 成果字段逐字等于报告正文时标注 _evidence;复核只能加说明,不能改写带证据的答案(前后端各拦一道)
证据接口
- GET /api/runs/{id}/evidence 返回证据图;GET …/evidence/segments/{id} 返回一个片段的证据链
- 只认封存范围内的事件能追到的工件,不读 artifacts 表;封存被改过时不把任何一项标成已封存
- 没有报告的旧运行降级为 none 或 legacy_contract
界面
- EvidenceDoc 按块、句、片段渲染报告,片段是和正文一样的按钮;整份报告只占一个 Tab 位,
←/→ 在片段间移动、↑/↓ 按句、n/N 跳到下一处「无证据」、Enter 打开面板、Esc 关闭并把焦点还回去
- EvidencePanel:宽屏侧边、窄屏底部抽屉、360px 画布右栏里栏内展开;显示指标步骤、代入式、复算结果、
封存状态。正文不是封存的那份报告时明确说出来,不拿别的报告的封存画绿
- 出具横幅多一行「N/N 数字有出处 · 无证据 M」和「定位下一处」;没有 _evidence 的旧回答渲染一字不差
- 画布认得报告撰写节点;Copilot 手册补上口径卡、报告节点、出具契约的写法;新增模板「⑨ 可追溯周报」
验证(沙箱):tsc 干净,生产构建通过,pytest 1601 passed,check-all 16 项检查全绿(新增 check-evidence)。
574 lines
24 KiB
TypeScript
574 lines
24 KiB
TypeScript
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,
|
||
} from 'react'
|
||
import { createPortal } from 'react-dom'
|
||
import { ListChecks } from 'lucide-react'
|
||
import clsx from 'clsx'
|
||
import { rovingTarget } from '../components/ui'
|
||
import {
|
||
EVIDENCE_STATE, docTally, segmentLabel, segmentState, tallySummary, unitText, type EvidenceStateCode,
|
||
type EvidenceTally as Tally,
|
||
} from '../lib/evidence'
|
||
import { formatNumber } from '../lib/format'
|
||
import { EVIDENCE_STATE_LABEL, EVIDENCE_TEXT, evidenceTally } from '../lib/terms'
|
||
import { useEvidence } from '../store/evidence'
|
||
import type { EvidenceBlock, EvidenceDocData, EvidenceSegment, EvidenceUnit } from '../types'
|
||
import { EvidencePanel, type PanelMode, type PanelView } from './EvidencePanel'
|
||
import { BlockView, Markdown, StyledSlice, inlineStyles, numericColumns, type InlineStyles } from './Markdown'
|
||
|
||
/**
|
||
* 报告撰写节点的文档:逐段可点的证据。
|
||
*
|
||
* 按后端切好的块、句、片段画,不再解析 Markdown:片段的边界、每个数字的出处都是后端
|
||
* 算定、进了封存的,前端重新解析一遍只会切出另一套边界。块的外观借 Markdown.tsx 的
|
||
* BlockView,和普通答案长得一模一样;有出处的数字画细实线,没有出处的画点状线、
|
||
* 句末挂「无证据」。
|
||
*
|
||
* 键盘:整份报告只占一个 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
|
||
}
|
||
|
||
/** 和 AssistantStream 的长文本折叠同一个量级:先显示这么多字,后面的折起来 */
|
||
const TEXT_CAP = 3000
|
||
|
||
interface Model {
|
||
/** 可点的片段,按正文顺序 */
|
||
order: string[]
|
||
index: Map<string, number>
|
||
seg: Map<string, { seg: EvidenceSegment; unit: EvidenceUnit; block: number }>
|
||
/** 每个有可点片段的句子里第一个可点片段,按正文顺序(↑/↓ 用) */
|
||
unitFirst: string[]
|
||
unitOf: Map<string, number>
|
||
/** 无证据的片段,按正文顺序(n / N 用) */
|
||
alerts: string[]
|
||
/** 每块到它为止的累计字数(折叠用) */
|
||
chars: number[]
|
||
}
|
||
|
||
function buildModel(blocks: EvidenceBlock[]): Model {
|
||
const order: string[] = []
|
||
const index = new Map<string, number>()
|
||
const seg = new Map<string, { seg: EvidenceSegment; unit: EvidenceUnit; block: number }>()
|
||
const unitFirst: string[] = []
|
||
const unitOf = new Map<string, number>()
|
||
const alerts: string[] = []
|
||
const chars: number[] = []
|
||
let total = 0
|
||
blocks.forEach((block, b) => {
|
||
for (const unit of block.units ?? []) {
|
||
let first = true
|
||
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)
|
||
if (EVIDENCE_STATE[state].alert) alerts.push(s.id)
|
||
}
|
||
}
|
||
chars.push(total)
|
||
})
|
||
return { order, index, seg, unitFirst, unitOf, alerts, chars }
|
||
}
|
||
|
||
/** 片段的外观:线型走 data 属性(CSS 里只管线型),颜色写成两个变量交给 index.css */
|
||
function segStyle(state: EvidenceStateCode): CSSProperties {
|
||
const meta = EVIDENCE_STATE[state]
|
||
return { '--ev-line': meta.decoration, '--ev-soft': meta.soft } as CSSProperties
|
||
}
|
||
|
||
export const EvidenceDoc = forwardRef<EvidenceDocHandle, {
|
||
doc: EvidenceDocData
|
||
/**
|
||
* 这份文档的工件 id(成果上 _evidence.doc_artifact 指的那件)。证据接口会报它答的是哪份
|
||
* 封存的文档,两边不一致时面板不信接口给的链和封存状态
|
||
*/
|
||
artifact?: string
|
||
/** 属于哪次运行。有它才能取片段的证据链和封存状态;没有(预览、导出的文档)只看文档里记下的出处 */
|
||
runId?: string
|
||
dense?: boolean
|
||
/** 成果字段名,读屏用 */
|
||
label?: string
|
||
/** 面板放哪:窄栏里栏内展开,宽屏从右侧弹出,窄屏从底部抽出。auto 按 dense 和屏宽定 */
|
||
panel?: 'auto' | PanelMode
|
||
/** 自己显示「N/N 数字有出处」那一条。上面已经有出具横幅(它会写这一行)时不用 */
|
||
tally?: boolean
|
||
}>(function EvidenceDoc({ doc, artifact, runId, dense = false, label, panel = 'auto', tally = false }, ref) {
|
||
const blocks = useMemo(() => doc.blocks ?? [], [doc])
|
||
const model = useMemo(() => buildModel(blocks), [blocks])
|
||
const counts = useMemo(() => docTally(doc), [doc])
|
||
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)
|
||
}, [model])
|
||
|
||
// 同一时刻只开一个面板:另一份报告打开了自己的面板,这边的就收起
|
||
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) =>
|
||
rootRef.current?.querySelector<HTMLElement>(`[data-seg="${CSS.escape(id)}"]`) ?? null, [])
|
||
|
||
/** 把焦点挪到某个片段上。在折叠里的先展开,渲染出来再聚焦 */
|
||
const focusSeg = useCallback((id: string, opts?: { flash?: boolean }) => {
|
||
const hit = model.seg.get(id)
|
||
if (!hit) return
|
||
setCurrent(id)
|
||
if (hit.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 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 : current
|
||
setView(null)
|
||
setAnnounce('')
|
||
if (refocus && back) requestAnimationFrame(() => segEl(back)?.focus())
|
||
}, [view, current, segEl])
|
||
|
||
const step = useCallback((list: string[], from: string | null, dir: 1 | -1): string | null => {
|
||
if (!list.length) return null
|
||
const pos = from ? model.index.get(from) ?? -1 : -1
|
||
if (dir > 0) return list.find((id) => (model.index.get(id) ?? 0) > pos) ?? list[0]
|
||
for (let i = list.length - 1; i >= 0; i--) if ((model.index.get(list[i]) ?? 0) < pos) return list[i]
|
||
return list[list.length - 1]
|
||
}, [model])
|
||
|
||
useImperativeHandle(ref, () => ({
|
||
next: (dir = 1) => {
|
||
const target = step(model.alerts, view?.kind === 'seg' ? view.id : 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, view, current, focusSeg, openSeg, openViolations, doc])
|
||
|
||
// 事件委托:片段只带 data-seg,点击和按键都在容器上认
|
||
const segOf = (target: EventTarget | null): string | null => {
|
||
const el = (target as HTMLElement | null)?.closest?.<HTMLElement>('[data-seg]')
|
||
return el && rootRef.current?.contains(el) ? el.dataset.seg ?? null : null
|
||
}
|
||
const onClick = (e: ReactMouseEvent) => {
|
||
const id = segOf(e.target)
|
||
if (!id) return
|
||
e.preventDefault()
|
||
setCurrent(id)
|
||
if (view?.kind === 'seg' && view.id === 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') {
|
||
const u = model.unitOf.get(id) ?? 0
|
||
const n = model.unitFirst.length
|
||
target = model.unitFirst[(u + (e.key === 'ArrowDown' ? 1 : -1) + n) % n] ?? null
|
||
} 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):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 = view?.kind === 'seg' ? view.id : null
|
||
const activeBlock = activeSeg ? model.seg.get(activeSeg)?.block ?? -1 : -1
|
||
const currentBlock = current ? model.seg.get(current)?.block ?? -1 : -1
|
||
const visual = blocks.length > 40
|
||
|
||
const panelNode = view && (
|
||
<EvidencePanel
|
||
id={panelId}
|
||
mode={mode}
|
||
doc={doc}
|
||
artifact={artifact}
|
||
runId={runId}
|
||
view={view}
|
||
onClose={() => close()}
|
||
onLocate={(id) => { focusSeg(id, { flash: true }); openSeg(id) }}
|
||
onViolations={openViolations}
|
||
/>
|
||
)
|
||
|
||
// 画不了线的两种分开说:列表序号、代码块标签里的数字,和正文里根本没有对应字的(句末依据里的引用)
|
||
const summary = tallySummary(counts, model.order.length > 0)
|
||
|
||
return (
|
||
<div ref={rootRef} data-evidence-doc="" className="min-w-0">
|
||
<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) && (
|
||
<EvidenceTally counts={counts} 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} />
|
||
{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 !== 'inline' && panelNode && createPortal(panelNode, document.body)}
|
||
</div>
|
||
)
|
||
})
|
||
|
||
/** 定位到的片段描一下边(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 {
|
||
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 处引用解析不了」 */
|
||
export function EvidenceTally({ counts, onNext, onList }: {
|
||
counts: Pick<Tally, 'total' | 'cited' | 'none' | 'other'>
|
||
onNext?: () => void
|
||
onList?: () => void
|
||
}) {
|
||
const clean = !counts.none && !counts.other
|
||
return (
|
||
<div className="mb-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-2xs" data-evidence-tally="">
|
||
<span className="tnum" style={{ color: clean ? 'var(--st-done)' : 'var(--st-waiting)' }}>
|
||
{evidenceTally(counts.cited, counts.total, counts.other)}
|
||
</span>
|
||
{!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>
|
||
)
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// 块与片段
|
||
// -------------------------------------------------------------------------
|
||
|
||
/** 列表项的开头:结构片段里带着行首的 - / 1. 。列表项被分成几句时,后面几句接在同一项里 */
|
||
const ITEM_START = /(^|\n)[ \t]*([-*+]|\d+[.)])[ \t]+$/
|
||
|
||
const DocBlock = memo(function DocBlock({ block, dense, doc, visual, current, active, panelId }: {
|
||
block: EvidenceBlock
|
||
dense: boolean
|
||
doc: EvidenceDocData
|
||
/**
|
||
* 长文档:块上加 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} />
|
||
)
|
||
|
||
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)) }} />
|
||
}
|
||
})
|
||
|
||
/**
|
||
* 一句话:文字按行内 Markdown 渲染(整句算一次样式,跨片段的 `code`、**粗体** 不会
|
||
* 被切坏),有状态的片段包成按钮。含无证据片段的句子末尾挂「? 无证据」
|
||
*/
|
||
function UnitView({ unit, doc, current, active, panelId, code, tag }: {
|
||
unit: EvidenceUnit; doc: EvidenceDocData; current: string | null; active: string | null; panelId: string
|
||
code?: boolean; tag: boolean
|
||
}) {
|
||
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
|
||
let alert = false
|
||
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) alert = true
|
||
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}
|
||
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>
|
||
)
|
||
})
|
||
return (
|
||
<>
|
||
{nodes}
|
||
{alert && tag && (
|
||
<span className="ev-tag" aria-hidden="true" title={EVIDENCE_STATE.none.hint}>
|
||
{EVIDENCE_STATE.none.glyph}{EVIDENCE_STATE_LABEL.none}
|
||
</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
|
||
}>(function EvidenceField({ artifact, text, runId, dense, label, tally, onDoc }, 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
|
||
useEffect(() => { onDoc?.(usable) }, [usable, onDoc])
|
||
if (usable) {
|
||
return <EvidenceDoc ref={ref} doc={usable} artifact={artifact} runId={runId} dense={dense} label={label} tally={tally} />
|
||
}
|
||
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>
|
||
)
|
||
})
|