审批质询(前端)复核整改(第 1 轮):同一张卡挂两处、换了审批、报告改写前的问答各归其位,句末入口不再挤动正文

- 提问框记下打开它的那一处(host)和那张审批:画布的审批浮层和右栏同时挂着同一张卡时,提问框只画在点的那一处,焦点不跳到另一处;另一处写明「提问框已在另一处打开」,可「在这里继续」;浮层关了由右栏接过去,不抢焦点。换了审批(同一份报告的下一级审批、驳回改写后再审)旧的提问框不再显示,提交一律带页面此刻的审批 id,不会再拿旧 id 一直 409
- 问答串按每条问的是哪一稿报告分开:别的审批里问的,按审批列表里那张审批审的文档对一下,对上了才按句子编号挂;改写前的、对不上的单独一组「报告其他版本上的质询」,不拿当前报告同编号的句子当抬头,回答里引用报告出处的数也不去当前这一稿里找。运行详情同理(封存之后问的认封存的那一稿)
- 句末的「质询」改成固定宽度的淡色图标,悬停、聚焦只换颜色和描边,不再从宽 0 撑开:后文不右移、表格列不跳;表格格子不挂(问表里的数划选或就整份报告提问)
- Esc、「取消」之后焦点回到那一句的「质询」并滚回视野(页面和限高的报告框都滚)
- 回答期间另开的提问框不再被失败的那一条覆盖:失败的问题和原因挂在原来那一句下面,提问框关着时可以放回提问框重问
- 写进运行记录后:画布正显示这次运行、流已收尾时按已有的最大序号补接一次;运行详情补拉一次事件(pages/runs/useRunDetail.ts 订阅一个计数)。时间线因此出现「审批人质询」
- 运行详情对所有运行只读列出已有问答(正式运行审批时问过的随运行封存,审批处理后也看得到查过的 SQL、行数、快照),入口仍只给封存了的探索运行;设置里关了审批质询就不给入口,已有问答照列并写明已关闭
- 小项:读屏连续两次回答都播报;「已等待 N 秒」挪出播报区;花费照 runCostText 写(估不出不写 $0);查询列表收着时也写有几次被截断;报告没有逐段证据时提示只说可以就整份报告提问;列表取回来之前不出「还没有问答」的提示;回答里核对不了的名称、猜测的来源照报告的线型画;回答期间批准 / 驳回下面那句原因在浅色主题下对比度够了
- RunPanel 改传这张卡的审批 id 和是否待处理(处理过的卡也认得哪几条问的是卡上这一稿);check-all 里 check-inquiry 的预计时长按实测改成 60 秒
- check-inquiry 补上各项:悬停前后正文和表格不移位、焦点回去后在视野里、同一张卡挂两处、先后两张审批、改写前的问答单独一组、失败不覆盖新写的、连续播报、花费、截断、无文档、设置关闭、遮罩原值不外露(含导出的 CSV)、补接事件流;新增预览用例 inquiry-approval-twice / -steps / -steps-same / -nodoc
This commit is contained in:
ThearchyHelios
2026-10-06 09:57:34 +08:00
parent 0726a97512
commit eb71513924
10 changed files with 1169 additions and 167 deletions
+65 -6
View File
@@ -1,4 +1,5 @@
import type { JSX } from 'react'
import { useState, type JSX } from 'react'
import { createPortal } from 'react-dom'
import { EvidencePane } from '../../pages/runs/EvidencePane'
import { ApprovalCard } from '../../run/RunPanel'
import type { Approval, ApprovalReport, Run } from '../../types'
@@ -10,28 +11,33 @@ import fx from './inquiry.json'
*
* 审批卡(运行停在人工审批上,审的是报告「写月报」):
* - ?syn=inquiry-approval:探索运行。核对概况下面是问答串(进入时按列表接口恢复)和「就整份报告提问」;展开逐段证据后,
* 每句结论悬停 / 焦点在它上面时出现「质询」,划选原文时浮出「质询」
* 每句结论后面有「质询」图标(悬停 / 焦点在这一句上时显出来),划选原文时浮出「质询」
* - ?syn=inquiry-approval-formal:正式运行的审批,同样能问(审批阶段还没封存)
* - ?syn=inquiry-approval-dense:同第一个,摆在 360px 的右栏里
* - ?syn=inquiry-approval-done:审批已经处理过(不是待处理的那张):只列问答,不给提问
* - ?syn=inquiry-approval-nodoc:审的报告没有逐段证据(没有报告文档):只能就整份报告提问
* - ?syn=inquiry-approval-twice:同一张待审批卡同时挂在两处,同编排页里画布的审批浮层(挂到页面末尾、role=dialog,
* React 树里排在前面)和右栏;「收起浮层」按钮开关浮层
* - ?syn=inquiry-approval-steps:同一份报告先后两张审批(主管审批 → 总监审批),「处理掉这张」换下一张;卡按审批 id
* 重新挂(同右栏)。?syn=inquiry-approval-steps-same:同一个卡片实例换审批(同画布的审批浮层,不按 id 重新挂)
*
* 运行详情的「证据」页签(pages/runs/EvidencePane,是否可问由它按运行状态判断):
* - ?syn=inquiry-run-explore:封存了的探索运行,报告上给入口,问答追加在封存之后
* - ?syn=inquiry-run-formal:封存了的正式运行,不给入口
* - ?syn=inquiry-run-formal:封存了的正式运行,不给入口,审批时问过的照列
*
* 列表、提问、质询工件、报告文档、证据图都由检查脚本用 page.route 伪造;直接打开时问答串照实说无法读取。
* 列表、提问、质询工件、报告文档、证据图、审批列表、设置都由检查脚本用 page.route 伪造;直接打开时问答串照实说无法读取。
*/
const RUNS = fx.runs
const reports = fx.reports as unknown as Record<keyof typeof RUNS, ApprovalReport>
function approvalOf(id: string, runId: string, runClass: 'formal' | 'exploratory', report: ApprovalReport,
status = 'pending'): Approval {
status = 'pending', label = '主管审批'): Approval {
return {
id, run_id: runId, node_id: 'review', mode: 'approve', title: '9 月月报可以发出吗?', status, response: {},
payload: { kind: 'human_node', message: '请审核 9 月月报。', reports: [report] },
created_at: new Date(Date.now() - 300_000).toISOString().replace('Z', ''),
workflow_name: '月报', node_label: '主管审批', run_class: runClass,
workflow_name: '月报', node_label: label, run_class: runClass,
}
}
@@ -45,6 +51,50 @@ function Card({ approval, name, dense = false }: { approval: Approval; name: str
)
}
/** 同一张待审批卡挂两处:浮层挂到页面末尾(同 canvas/NodeCard 的审批浮层),右栏在页面里 */
function TwoPlaces({ approval }: { approval: Approval }) {
const [pop, setPop] = useState(true)
return (
<div className="flex min-h-screen" data-fixture="inquiry-approval-twice">
{pop && createPortal(
<div role="dialog" aria-label="审批卡" data-fixture-pop=""
className="fixed left-4 top-4 z-50 max-h-[90vh] w-[380px] overflow-y-auto rounded-lg border bg-panel shadow-elev-3">
<ApprovalCard approval={approval} showWorkflow={false} />
</div>,
document.body,
)}
<div className="flex-1 p-4">
<button type="button" className="btn btn-xs ml-[400px]" data-fixture-toggle="" onClick={() => setPop((v) => !v)}>
{pop ? '收起浮层' : '打开浮层'}
</button>
</div>
<aside className="w-[380px] shrink-0 border-l bg-panel" data-fixture-panel="">
<ApprovalCard approval={approval} showWorkflow={false} />
</aside>
</div>
)
}
/** 同一份报告先后两张审批:「处理掉这张」之后换下一张(keyed:按审批 id 重新挂,同右栏;否则同一个卡片实例换审批) */
function Steps({ name, keyed }: { name: string; keyed: boolean }) {
const list = [
approvalOf(fx.approval_id, RUNS.approve, 'exploratory', reports.approve),
approvalOf('ap-inq-3', RUNS.approve, 'exploratory', reports.approve, 'pending', '总监审批'),
]
const [at, setAt] = useState(0)
const approval = list[Math.min(at, list.length - 1)]
return (
<div className="mx-auto max-w-3xl p-4" data-fixture={name}>
<button type="button" className="btn btn-xs mb-2" data-fixture-next="" onClick={() => setAt((n) => n + 1)}>处理掉这张</button>
<div className="overflow-hidden rounded-lg border" style={{ borderColor: 'var(--warn)' }}>
{keyed
? <ApprovalCard key={approval.id} approval={approval} onResolved={() => undefined} />
: <ApprovalCard approval={approval} onResolved={() => undefined} />}
</div>
</div>
)
}
/** 运行详情:一次已经结束、封存了的运行 */
function runOf(id: string, runClass: 'formal' | 'exploratory'): Run {
return {
@@ -79,6 +129,15 @@ export const cases: Record<string, () => JSX.Element> = {
<Card name="inquiry-approval-done"
approval={approvalOf(fx.approval_id, RUNS.approve, 'exploratory', reports.approve, 'approved')} />
),
'inquiry-approval-nodoc': () => (
<Card name="inquiry-approval-nodoc" approval={approvalOf(fx.approval_id, RUNS.approve, 'exploratory',
{ ...reports.approve, doc_artifact: undefined } as ApprovalReport)} />
),
'inquiry-approval-twice': () => (
<TwoPlaces approval={approvalOf(fx.approval_id, RUNS.approve, 'exploratory', reports.approve)} />
),
'inquiry-approval-steps': () => <Steps name="inquiry-approval-steps" keyed />,
'inquiry-approval-steps-same': () => <Steps name="inquiry-approval-steps-same" keyed={false} />,
'inquiry-run-explore': () => <RunEvidence name="inquiry-run-explore" run={runOf(RUNS.explore, 'exploratory')} />,
'inquiry-run-formal': () => <RunEvidence name="inquiry-run-formal" run={runOf(RUNS.sealed, 'formal')} />,
}
+28 -4
View File
@@ -3309,6 +3309,8 @@ export const INQUIRY_TEXT = {
// ---- 报告上的入口(EvidenceDoc):句末的「质询」、划选原文时浮出的「质询」 ----
ask: '质询',
askUnit: (text: string) => `质询这句:${text}`,
/** 句末「质询」图标的悬停说明 */
askTitle: '质询这一句',
askSelection: '就选中的原文质询',
/** 读屏摘要里接在按键说明后面 */
keysHint: '每句结论后有「质询」按钮:方向键走到这一句,再按 Tab 到达;没有可点内容的句子,方向键直接走到它的「质询」',
@@ -3316,11 +3318,18 @@ export const INQUIRY_TEXT = {
// ---- 问答串(ReportInquiry) ----
headCount: (n: number) => `${formatNumber(n)} 次`,
askReport: '就整份报告提问',
intro: '展开逐段证据后,悬停一句结论或划选一段原文,点「质询」即可就该处提问',
introRun: '悬停一句结论或划选一段原文,点「质询」即可就该处提问',
intro: '展开逐段证据后,点句末的「质询」图标或划选一段原文,即可就该处提问',
introRun: '点句末的「质询」图标或划选一段原文,即可就该处提问',
/** 报告没有逐段证据(没有报告文档):句子、原文上都没有入口 */
introNoDoc: '这份报告没有逐段证据,可以就整份报告提问',
/** 设置里关了审批质询:不给入口,已有的问答照列 */
disabled: '审批质询已在设置中关闭',
wholeReport: '整份报告',
/** 报告文档取不到,写不出问的是哪一句 */
unitMissing: '报告中的一句',
/** 之前几轮审批里问的、报告后来改写过(句子编号对不上),或者对不上是哪一稿:单独一组 */
otherDoc: '报告其他版本上的质询',
otherDocHint: '提问时的报告与当前显示的版本不同,或无法确认是否相同,所以不挂到当前报告的句子下面',
loadFailed: '无法读取之前的质询记录',
retry: '重试',
q: '问',
@@ -3330,8 +3339,18 @@ export const INQUIRY_TEXT = {
quoteLine: (quote: string) => `原文「${quote}」`,
notRecorded: '这次问答没有写入运行记录',
notRecordedHint: '它只显示在当前页面,刷新后不再显示,也不会随运行封存',
/** 屏幕阅读器:回答回来了 */
arrived: '已收到回答',
/** 屏幕阅读器:回答回来了(带上问题的开头,连着几次回答各报一遍) */
arrived: (question: string) => {
const chars = [...question.trim()]
return `已收到回答:${chars.length > 20 ? `${chars.slice(0, 20).join('')}…` : chars.join('')}`
},
/** 同一张审批卡同时挂在两处(画布的审批浮层和右栏):提问框只在打开它的那一处 */
elsewhere: '提问框已在另一处打开',
moveHere: '在这里继续',
/** 回答期间另开了提问框,正在回答的那一条却没问成:原来的问题和原因留在它那一句下面 */
askFailed: (message: string) => `没有问成:${message}`,
reask: '放回提问框',
dismiss: '忽略',
// ---- 提问框 ----
target: { unit: '就这句提问', quote: '就选中的原文提问', report: '就整份报告提问' } as Record<'unit' | 'quote' | 'report', string>,
@@ -3362,9 +3381,14 @@ export const INQUIRY_TEXT = {
/** 回答里有出处的数字:读屏听到「数字,出处:查询 Q1 · 第 1 行 · amount」,点一下展开那次查询的结果 */
cited: (text: string, source: string) => `${text},出处:${source},查看这次查询的结果`,
citedReport: (text: string, source: string) => `${text},出处:${source},在报告中查看证据`,
/** 问的是报告的另一稿:引用的是那一稿里的出处,不在当前这一稿里找 */
citedOther: (text: string, source: string) => `${text},出处:${source}(提问时那一版报告中的出处)`,
/** 引用的是报告已有的出处,报告里却找不到引用它的地方:照回答文档记下的出处说一句 */
reportSource: (source: string) => `出处:${source}(报告已有的出处,报告中没有引用它的地方)`,
reportSourceOther: (source: string) => `出处:${source}(提问时那一版报告中的出处)`,
queriesHead: (n: number) => `核查模型执行的查询(${formatNumber(n)} 次)`,
/** 查询列表收着时也写:截断直接关系到回答靠不靠得住 */
queriesTruncated: (n: number) => `其中 ${formatNumber(n)} 次结果已截断`,
noQueries: '核查模型没有执行查询',
rows: (n: number) => `${formatNumber(n)} 行`,
truncated: '结果已截断,数据库中还有更多数据',
+8 -7
View File
@@ -16,7 +16,7 @@ import { EVIDENCE_AUDIT_TEXT as T, UPGRADE_TEXT } from '../../lib/terms'
import { IssuanceBanner } from '../../run/AssistantStream'
import { EvidenceDoc, type EvidenceDocHandle } from '../../run/EvidenceDoc'
import { EvidenceGuessView } from '../../run/EvidenceGuess'
import { NO_APPROVAL, ReportInquiry, inquiryOpen, locateIn } from '../../run/Inquiry'
import { ReportInquiry, inquiryOpen, locateIn, useInquiryAccess } from '../../run/Inquiry'
import { Markdown, inlineStyles } from '../../run/Markdown'
import { useEvidence } from '../../store/evidence'
import { useCatalog } from '../../store/catalog'
@@ -125,8 +125,10 @@ export function EvidencePane({ run, output, labelOf, refreshKey }: {
const mode = graph?.mode ?? audit.data?.mode
const seal = audit.data?.seal ?? graph?.seal
// 审批质询:封存了的探索运行可以接着就报告提问(追加在封存之后);正式运行封存后不再开放
// 审批质询:封存了的探索运行可以接着就报告提问(追加在封存之后);正式运行封存后不再开放。问答串每次运行都挂(只读):
// 审批时问过什么、怎么答的、查了什么随运行封存,审批处理掉以后也得在报告旁边看得到
const asking = inquiryOpen(run)
const { access: inquiry, disabled: inquiryOff } = useInquiryAccess(asking, null)
if (!graph && graphSlot?.status !== 'error') {
return (
@@ -171,11 +173,10 @@ export function EvidencePane({ run, output, labelOf, refreshKey }: {
runId={run.id} runClass={run.run_class} label={r.fields?.join('、') || r.node_id} tally
tier={typeof output?._issuance?.tier === 'string' ? output._issuance.tier : undefined}
panel={wide ? 'dock' : 'auto'} dock={dock} onView={(o) => onView(r.node_id!, o)}
inquiry={asking ? NO_APPROVAL : null} />
{asking && (
<ReportInquiry runId={run.id} report={r.node_id!} docArtifact={r.doc_artifact} access={NO_APPROVAL} postSeal
onLocate={(seg) => locateIn(handles.current.get(r.node_id!), doc, seg)} />
)}
inquiry={inquiry} />
<ReportInquiry runId={run.id} report={r.node_id!} docArtifact={r.doc_artifact} access={inquiry}
disabled={inquiryOff} postSeal={asking}
onLocate={(seg) => locateIn(handles.current.get(r.node_id!), doc, seg)} />
</>
) : slot?.status === 'error' ? (
<p className="text-2xs" style={{ color: 'var(--st-failed)' }}>{T.docGone(labelOf(r.node_id) ?? r.node_id ?? '')}</p>
+11
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ApiError, api, streamRun } from '../../api/client'
import { useCatalog, useOnReconnect } from '../../store/catalog'
import { useInquiryRecorded } from '../../run/Inquiry'
import { emptyTrace, finalizeTrace, foldEvent, type Trace } from '../../run/trace'
import type { Approval, GraphSpec, Run, RunEvent } from '../../types'
import { isLiveRun } from './model'
@@ -202,6 +203,16 @@ export function useRunDetail(runId: string, onChange: (run: Run) => void): RunDe
}
}, [streaming, runId, flush, resync, refreshPending, refreshApprovals])
// 这一页里问的审批质询写进了运行记录(approval.inquiry):停在审批上、已经结束的运行这里不接流,补拉一次事件,
// 时间线才出现「审批人质询」那一行。在接流的照常从流里收到
const inquired = useInquiryRecorded(runId)
const inquiredSeen = useRef(inquired)
useEffect(() => {
if (inquiredSeen.current === inquired) return
inquiredSeen.current = inquired
if (!streaming) void reload()
}, [inquired, streaming, reload])
const follow = useCallback((next?: Run | null) => {
if (next) adopt(next)
setStreaming(true)
+19 -12
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useId, useMemo, useRef, useState, type Ref } from 'react'
import { useCallback, useEffect, useId, useRef, useState, type Ref } from 'react'
import { ChevronRight, ShieldCheck } from 'lucide-react'
import { EVIDENCE_STATE, claimCountsOf, claimProblems, statsTally } from '../lib/evidence'
import { formatNumber } from '../lib/format'
@@ -6,7 +6,7 @@ import { APPROVAL_TEXT, EVIDENCE_TEXT, JUDGE_TEXT, evidenceTally, runClassLabel
import { useEvidence } from '../store/evidence'
import type { ApprovalReport, EvidenceSegment } from '../types'
import { EvidenceDoc, type EvidenceDocHandle } from './EvidenceDoc'
import { ReportInquiry, reportSegmentFor, type InquiryAccess } from './Inquiry'
import { ReportInquiry, reportSegmentFor, useInquiryAccess, type InquiryAccess } from './Inquiry'
type RunClass = 'formal' | 'exploratory' | null | undefined
@@ -54,27 +54,33 @@ function claimsLine(report: ApprovalReport, runClass: RunClass): { text: string;
*
* 真实运行里报告有一句结论和证据相矛盾,审批人批准时完全不知道:卡上只有正文
*/
export function ApprovalReports({ reports, runId, runClass, approvalId }: {
export function ApprovalReports({ reports, runId, runClass, approvalId, askable = false }: {
reports: ApprovalReport[]
runId: string
runClass: RunClass
/**
* 运行停在的这张审批(待处理的才给)。有它,审批人可以就报告提出质询:报告上的句子、划选的原文挂「质询」,
* 核对概况下面是问答串和提问框(run/Inquiry.tsx)
*/
/** 这张审批卡的审批(处理过的也给):问答串据此认哪几条问的就是卡上这一稿报告 */
approvalId?: string
/**
* 审批待处理:审批人可以就报告提出质询——报告上的句子、划选的原文挂「质询」,核对概况下面是问答串和提问框
* (run/Inquiry.tsx)。设置里关了审批质询时照样不给
*/
askable?: boolean
}) {
const access = useMemo<InquiryAccess | null>(() => (approvalId ? { approvalId } : null), [approvalId])
const { access, disabled } = useInquiryAccess(askable && !!approvalId, approvalId ?? null)
if (!reports.length) return null
return (
<>
{reports.map((r) => <ReportCheck key={r.node_id} report={r} runId={runId} runClass={runClass} access={access} />)}
{reports.map((r) => (
<ReportCheck key={r.node_id} report={r} runId={runId} runClass={runClass} access={access} disabled={disabled}
approvalId={approvalId ?? null} />
))}
</>
)
}
function ReportCheck({ report, runId, runClass, access }: {
report: ApprovalReport; runId: string; runClass: RunClass; access: InquiryAccess | null
function ReportCheck({ report, runId, runClass, access, disabled, approvalId }: {
report: ApprovalReport; runId: string; runClass: RunClass; access: InquiryAccess | null; disabled: boolean
approvalId: string | null
}) {
const [open, setOpen] = useState(false)
// 回答里引用了报告已有的出处:在逐段证据里打开那一处的证据面板。逐段证据收着的先展开,挂上之后再打开
@@ -137,7 +143,8 @@ function ReportCheck({ report, runId, runClass, access }: {
</>
)}
{/* 审批质询:问答串(进入审批卡时按列表接口恢复)、提问框和「就整份报告提问」 */}
<ReportInquiry runId={runId} report={report.node_id} docArtifact={report.doc_artifact} access={access} onLocate={locate} />
<ReportInquiry runId={runId} report={report.node_id} docArtifact={report.doc_artifact} access={access} disabled={disabled}
cardId={approvalId} onLocate={locate} />
</div>
)
}
+17 -13
View File
@@ -62,9 +62,12 @@ const unitOfKey = (key: string) => (key.startsWith('claim:') ? key.slice(6) : nu
const askKey = (unit: string) => `ask:${unit}`
const unitOfAsk = (key: string) => (key.startsWith('ask:') ? key.slice(4) : null)
/** 哪些句子挂「质询」:结论句(老文档没有 kind 的按有字的句子),标题、代码块里的不挂 */
/**
* 哪些句子挂「质询」:结论句(老文档没有 kind 的按有字的句子),标题、代码块里的不挂。表格里的格子也不挂:每格一个
* 图标会把列撑宽、满表都是图标;问表里的数可以划选那一格,或者就整份报告提问
*/
const askable = (unit: EvidenceUnit, block: EvidenceBlock) =>
(unit.kind ? unit.kind === 'claim' : !!unitText(unit).trim()) && !['heading', 'code', 'hr'].includes(block.type)
(unit.kind ? unit.kind === 'claim' : !!unitText(unit).trim()) && !['heading', 'code', 'hr', 'table'].includes(block.type)
interface Badge { unit: EvidenceUnit; block: number; verdict: EvidenceVerdict; state: EvidenceStateCode }
@@ -946,7 +949,9 @@ function UnitView({ unit, doc, current, active, panelId, code, tag, badge, ask }
</>
)
if (!ask) return body
// 「质询」跟在句末:平时不占地方(宽度为 0、透明),悬停这一句、焦点落在这一句上时出现;触屏没有悬停,一直显示。
// 「质询」跟在句末,是一个固定宽度的小图标:平时淡淡的,悬停这一句、焦点落在这一句上时显出来(强调色、描边)。
// 显没显出来只换颜色,宽度一直是那么宽——悬停时撑开的话,后面的字整体右移,鼠标底下的数字跟着挪走,点不到。
// 触屏没有悬停,一直显出来,并写上「质询」两个字。
// 跟随的那种只在焦点在这一句上时进 Tab 顺序,整份报告不因此多出一串 Tab 位
const here = current != null && (current === key || current === askKey(unit.id)
|| (unit.segments ?? []).some((sg) => sg.id === current))
@@ -954,16 +959,15 @@ function UnitView({ unit, doc, current, active, panelId, code, tag, badge, ask }
<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())}
className={'group/ask inline-flex w-0 select-none overflow-hidden align-middle opacity-0 outline-none focus:w-auto focus:opacity-100 '
+ 'group-hover/evu:w-auto group-hover/evu:opacity-100 group-focus-within/evu:w-auto group-focus-within/evu:opacity-100 '
+ 'pointer-coarse:w-auto pointer-coarse:opacity-100'}>
<span aria-hidden="true"
className={'my-px ml-1 inline-flex items-center gap-0.5 whitespace-nowrap rounded border px-1 text-[10px] font-medium leading-[1.4] '
+ 'text-[color:var(--accent)] hover:bg-hover group-focus-visible/ask:outline group-focus-visible/ask:outline-2 '
+ 'group-focus-visible/ask:outline-offset-1 group-focus-visible/ask:outline-[color:var(--accent)]'}>
<MessageCircleQuestion size={10} aria-hidden /> {INQUIRY_TEXT.ask}
</span>
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-faint opacity-60 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>
)
+455 -99
View File
@@ -9,8 +9,9 @@ import { Spinner, isComposing, useTicker } from '../components/ui'
import { EVIDENCE_STATE, segmentLabel, segmentState, sourceOf, unitText, type EvidenceStateCode } from '../lib/evidence'
import { humanizeError } from '../lib/errors'
import { formatCost, formatDateTime, formatTime, parseServerTime } from '../lib/format'
import { EVIDENCE_STATE_LABEL, EVIDENCE_TEXT, INQUIRY_TEXT } from '../lib/terms'
import { COST_TEXT, EVIDENCE_STATE_LABEL, EVIDENCE_TEXT, INQUIRY_TEXT, runCostText } from '../lib/terms'
import { useEvidence } from '../store/evidence'
import { useStudio } from '../store/studio'
import type {
EvidenceBlock, EvidenceDocData, EvidenceSegment, EvidenceUnit, Inquiry, InquiryQuery, InquiryRequest, InquirySnapshot, Run,
} from '../types'
@@ -31,20 +32,25 @@ import { BlockView, CopyChip, Markdown, StyledSlice, inlineStyles, numericColumn
*
* 一次运行同一时刻只有一条在回答(服务端回 409 inquiry_busy):请求挂在 store 上,不随组件卸载作废,回答回来时
* 卡片收起了也照样记进问答串。没写进运行记录的(recorded 为假)醒目地说,它只留在这一页里。
*
* 同一份报告可能同时挂在两处(编排页里画布的审批浮层和右栏是同一张卡),也可能先后挂上几张审批(两级审批审同一份
* 报告、驳回改写后再审):提问框只画在打开它的那一处(InquiryAccess.host),只认打开它时的那张审批;问答串按每条
* 问的是哪一稿报告分开——不是眼前这一稿的不按句子编号往句子下面挂(编号是位置序号,改写之后对不上)
*/
/** 问题、原文的长度上限(同服务端,按字数) */
export const QUESTION_MAX = 1000
export const QUOTE_MAX = 500
/** 页面传给报告文档、问答串的「能不能问」:给了就是能问;approvalId 是运行停在的那张审批(封存后的探索运行为 null) */
/**
* 页面传给报告文档、问答串的「能不能问」:给了就是能问。approvalId 是运行停在的那张审批(封存后的探索运行为
* null);host 标明是哪一处(useInquiryAccess 生成),同一份报告同时挂在两处时提问框只画在打开它的那一处
*/
export interface InquiryAccess {
approvalId: string | null
host: string
}
/** 封存了的探索运行:能问,不带审批(同一个对象,报告文档不因此重画) */
export const NO_APPROVAL: InquiryAccess = Object.freeze({ approvalId: null })
/** 要问的是哪儿:一句(unit)、划选的原文(quote,同时带上它起头的那一句),或者整份报告(两个都是 null) */
export interface InquiryTarget {
unit: string | null
@@ -128,6 +134,43 @@ function problemOf(e: unknown): Problem {
return { message: humanizeError(e).title || INQUIRY_TEXT.submitFailed, code: 'client' }
}
// ---------------------------------------------------------------- 设置里开没开
/**
* 设置里的审批质询开没开(verify.inquiry.enabled)。关了就不给入口——写完问题才收到 409 inquiry_disabled 太晚了。
* 取不到(老服务端、断网)按开着算:服务端关了照样会拒绝。各处共用一次请求,半分钟内不重取
*/
let enabledValue: boolean | null = null
let enabledAt = 0
let enabledPending: Promise<boolean | null> | null = null
const ENABLED_TTL = 30_000
function fetchEnabled(): Promise<boolean | null> {
if (enabledPending) return enabledPending
if (enabledAt && Date.now() - enabledAt < ENABLED_TTL) return Promise.resolve(enabledValue)
enabledPending = api.settings.get().then(
(s) => {
const part = s?.verify?.inquiry
enabledValue = !(part && typeof part === 'object' && part.enabled === false)
enabledAt = Date.now()
return enabledValue
},
() => enabledValue,
).finally(() => { enabledPending = null })
return enabledPending
}
function useInquiryEnabled(active: boolean): boolean | null {
const [value, setValue] = useState<boolean | null>(enabledValue)
useEffect(() => {
if (!active) return
let alive = true
void fetchEnabled().then((v) => { if (alive) setValue(v) })
return () => { alive = false }
}, [active])
return value
}
// ---------------------------------------------------------------- store
interface Problem { message: string; code: string; field?: string }
@@ -141,6 +184,16 @@ export interface InquiryEntry {
note: string | null
}
/** 回答期间另开了提问框、正在回答的那一条却没问成:问题和原因挂在它那一句下面,不覆盖新开的提问框 */
interface Failure {
target: InquiryTarget
question: string
error: Problem
approvalId: string | null
/** 在哪一处问的:只有那一处用 role=alert 报出来,另一处同样显示、不重复播报 */
host: string
}
interface Thread {
status: 'loading' | 'ok' | 'error'
/** 服务端列出来的 */
@@ -148,14 +201,24 @@ interface Thread {
/** 这一页里问过的(列表重新取回来之后,写进运行记录的按序号去重,没写进的一直留在这里) */
local: InquiryEntry[]
error?: Problem
failed?: Failure
}
interface Draft {
target: InquiryTarget
/**
* 打开它时停在的那张审批(封存了的探索运行为 null)。提问框只在同一张审批的地方出现:换了审批(同一份报告的下一级
* 审批、驳回改写后再审),这一份就不再显示,下次点「质询」从头写
*/
approvalId: string | null
/**
* 打开它的那一处(InquiryAccess.host):同一份报告同时挂在两处时,提问框只画在这里,焦点也只进这里。那一处卸掉了
* (浮层关了)为 null,由还挂着的、同一张审批的另一处接过去
*/
host: string | null
question: string
error: Problem | null
/** 每次点「质询」加一:提问框据此把焦点送进输入框,换了一句也一样 */
/** 每次点「质询」都换一个(全局递增,关掉再开也不重复):提问框据此把焦点送进输入框,换了一句也一样 */
nonce: number
}
@@ -164,6 +227,16 @@ interface Pending {
target: InquiryTarget
question: string
startedAt: number
/** 在哪一处问的:焦点交接、「已收到回答」的播报只在这一处 */
host: string
}
/** 一次运行里每张审批审的是哪一稿报告:审批 id → { 报告节点: 文档工件 } */
interface ApprovalDocs {
status: 'loading' | 'ok' | 'error'
map: Record<string, Record<string, string>>
/** 为哪几张审批取过:取回来列表里也没有的(审批记录没了)不再为它重取 */
asked: string[]
}
interface InquiryState {
@@ -172,30 +245,53 @@ interface InquiryState {
drafts: Record<string, Draft | undefined>
/** 正在回答的,按运行:一次运行同一时刻只有一条 */
pending: Record<string, Pending | undefined>
/** 按运行:问答串据此分辨之前几轮审批里问的是不是眼前这一稿 */
approvalDocs: Record<string, ApprovalDocs | undefined>
/** 这一页里写进了运行记录的问答各几条(按运行):运行详情据此补拉事件,时间线才出现「审批人质询」 */
recorded: Record<string, number>
load: (runId: string, report: string) => Promise<void>
compose: (runId: string, report: string, target: InquiryTarget, approvalId: string | null) => void
loadApprovalDocs: (runId: string, ids: string[]) => Promise<void>
compose: (runId: string, report: string, target: InquiryTarget, access: InquiryAccess) => void
edit: (key: string, question: string) => void
close: (key: string) => void
submit: (runId: string, report: string) => Promise<void>
/** 接过没有主人的提问框(打开它的那一处卸掉了) */
adopt: (key: string, access: InquiryAccess) => void
/** 这一处卸掉了:它开着的提问框交出去 */
release: (key: string, host: string) => void
/** 提问框在另一处开着,审批人要在这里接着写 */
move: (key: string, access: InquiryAccess) => void
submit: (runId: string, report: string, access: InquiryAccess) => Promise<void>
/** 没问成的那一条重新放进提问框 */
reask: (runId: string, report: string, access: InquiryAccess) => void
dismissFailed: (key: string) => void
}
export const inquiryKey = (runId: string, report: string) => `${runId}@${report}`
/** 打开提问框的那个元素(句末的「质询」、划选浮出的按钮已经没了时退回整份报告那个):Esc、取消之后焦点回到这里 */
const openers = new Map<string, HTMLElement>()
/** 挂着的问答串(`报告键|那一处`):没问成、提问框要重新打开时,打开它的那一处已经卸掉了就不认它 */
const liveHosts = new Set<string>()
/** 提问框已经把焦点送进去过的 nonce:别处接过提问框(浮层关了、右栏接着显示)不算点了「质询」,不抢焦点 */
const focusedNonce = new Map<string, number>()
let nonceSeq = 0
let localSeq = 0
const emptyThread = (): Thread => ({ status: 'ok', listed: [], local: [] })
export const useInquiry = create<InquiryState>((set, get) => ({
threads: {},
drafts: {},
pending: {},
approvalDocs: {},
recorded: {},
load: async (runId, report) => {
const key = inquiryKey(runId, report)
if (get().threads[key]?.status === 'loading') return
set((s) => {
const cur = s.threads[key]
return { threads: { ...s.threads, [key]: { listed: cur?.listed ?? [], local: cur?.local ?? [], status: 'loading' } } }
return { threads: { ...s.threads, [key]: { ...cur, listed: cur?.listed ?? [], local: cur?.local ?? [], status: 'loading' } } }
})
try {
const res = await api.inquiry.list(runId, { report })
@@ -211,13 +307,39 @@ export const useInquiry = create<InquiryState>((set, get) => ({
}
},
compose: (runId, report, target, approvalId) => {
loadApprovalDocs: async (runId, ids) => {
if (get().approvalDocs[runId]?.status === 'loading') return
set((s) => {
const cur = s.approvalDocs[runId]
return { approvalDocs: { ...s.approvalDocs, [runId]: {
status: 'loading', map: cur?.map ?? {}, asked: [...new Set([...(cur?.asked ?? []), ...ids])],
} } }
})
try {
const list = await api.approvals.list({ run_id: runId, status: 'all', limit: 500 })
const map: Record<string, Record<string, string>> = {}
for (const a of Array.isArray(list) ? list : []) {
if (!a || typeof a.id !== 'string' || a.run_id !== runId) continue
const docs: Record<string, string> = {}
for (const r of Array.isArray(a.payload?.reports) ? a.payload.reports : []) {
if (r && typeof r.node_id === 'string' && typeof r.doc_artifact === 'string' && r.doc_artifact) docs[r.node_id] = r.doc_artifact
}
map[a.id] = docs
}
set((s) => ({ approvalDocs: { ...s.approvalDocs, [runId]: { ...s.approvalDocs[runId]!, status: 'ok', map } } }))
} catch {
set((s) => ({ approvalDocs: { ...s.approvalDocs, [runId]: { ...s.approvalDocs[runId]!, status: 'error' } } }))
}
},
compose: (runId, report, target, access) => {
const key = inquiryKey(runId, report)
set((s) => {
const cur = s.drafts[key]
// 换了一句:问题留着(多半是同一个问题挪个地方问),上一次的报错不留
// 换了一句:问题留着(多半是同一个问题挪个地方问),上一次的报错不留。换了审批的从头写:那一份是就上一张审批问的
const question = cur && cur.approvalId === access.approvalId ? cur.question : ''
return { drafts: { ...s.drafts, [key]: {
target, approvalId, question: cur?.question ?? '', error: null, nonce: (cur?.nonce ?? 0) + 1,
target, approvalId: access.approvalId, host: access.host, question, error: null, nonce: ++nonceSeq,
} } }
})
},
@@ -229,7 +351,24 @@ export const useInquiry = create<InquiryState>((set, get) => ({
close: (key) => set((s) => ({ drafts: { ...s.drafts, [key]: undefined } })),
submit: async (runId, report) => {
adopt: (key, access) => set((s) => {
const d = s.drafts[key]
return d && d.approvalId === access.approvalId && d.host == null
? { drafts: { ...s.drafts, [key]: { ...d, host: access.host } } } : s
}),
release: (key, host) => set((s) => {
const d = s.drafts[key]
return d?.host === host ? { drafts: { ...s.drafts, [key]: { ...d, host: null } } } : s
}),
move: (key, access) => set((s) => {
const d = s.drafts[key]
return d && d.approvalId === access.approvalId
? { drafts: { ...s.drafts, [key]: { ...d, host: access.host, nonce: ++nonceSeq } } } : s
}),
submit: async (runId, report, access) => {
const key = inquiryKey(runId, report)
const draft = get().drafts[key]
if (!draft) return
@@ -239,39 +378,83 @@ export const useInquiry = create<InquiryState>((set, get) => ({
if ([...question].length > QUESTION_MAX) return fail({ message: INQUIRY_TEXT.tooLong(QUESTION_MAX), code: 'local', field: 'question' })
if (get().pending[runId]) return fail({ message: INQUIRY_TEXT.busyOther, code: 'local' })
const quote = draft.target.quote ? [...draft.target.quote].slice(0, QUOTE_MAX).join('') : ''
// 约定:unit 可为 null;quote 可选,没划选就不带;approval_id 只在运行停在审批上时带
// 约定:unit 可为 null;quote 可选,没划选就不带;approval_id 只在运行停在审批上时带。带的是页面此刻给的那张审批,
// 不是草稿里记着的:提问框只在同一张审批的地方出现,两者本来一致,换了审批的草稿根本画不出来
const body: InquiryRequest = {
report, unit: draft.target.unit, ...(quote ? { quote } : {}), question,
...(draft.approvalId ? { approval_id: draft.approvalId } : {}),
...(access.approvalId ? { approval_id: access.approvalId } : {}),
}
set((s) => ({
drafts: { ...s.drafts, [key]: undefined },
pending: { ...s.pending, [runId]: { key, target: draft.target, question, startedAt: Date.now() } },
pending: { ...s.pending, [runId]: { key, target: draft.target, question, startedAt: Date.now(), host: access.host } },
}))
try {
const res = await api.inquiry.ask(runId, body)
const inquiry = asInquiry(res?.inquiry) ?? asInquiry({ ...body, answer: {} })!
const got = asInquiry(res?.inquiry) ?? asInquiry({ ...body, answer: {} })!
// 服务端没回 approval_id 的照请求补上:问答串按它分辨这一条问的是不是眼前这一稿报告
const inquiry: Inquiry = { ...got, report: got.report || report, approval_id: got.approval_id ?? body.approval_id ?? null }
const entry: InquiryEntry = {
id: inquiry.seq != null ? `s${inquiry.seq}` : `l${++localSeq}`, inquiry: { ...inquiry, report: inquiry.report || report },
id: inquiry.seq != null ? `s${inquiry.seq}` : `l${++localSeq}`, inquiry,
recorded: res?.recorded !== false, note: str(res?.note) || null,
}
set((s) => {
const cur = s.threads[key] ?? { status: 'ok' as const, listed: [], local: [] }
const cur = s.threads[key] ?? emptyThread()
return {
threads: { ...s.threads, [key]: { ...cur, local: [...cur.local, entry] } },
pending: { ...s.pending, [runId]: undefined },
...(entry.recorded ? { recorded: { ...s.recorded, [runId]: (s.recorded[runId] ?? 0) + 1 } } : {}),
}
})
if (entry.recorded) followRun(runId)
} catch (e) {
// 没问成(409 / 422 / 连不上):提问框带着原来的问题重新打开,照服务端的说明显示原因
set((s) => ({
pending: { ...s.pending, [runId]: undefined },
drafts: { ...s.drafts, [key]: { ...draft, question: draft.question, error: problemOf(e), nonce: draft.nonce + 1 } },
}))
const error = problemOf(e)
set((s) => {
const pending = { ...s.pending, [runId]: undefined }
// 回答期间另开了提问框(换了一句、写着下一个问题):那一份原样留着,没问成的这一条连同原因挂在它那一句下面
if (s.drafts[key]) {
const cur = s.threads[key] ?? emptyThread()
return { pending, threads: { ...s.threads, [key]: { ...cur, failed: {
target: draft.target, question: draft.question, error, approvalId: access.approvalId, host: access.host,
} } } }
}
// 没另开:提问框带着原来的问题重新打开,照服务端的说明显示原因。问的那一处已经卸掉了(浮层关了)就交出去,
// 由还挂着的、同一张审批的那一处接过去
return { pending, drafts: { ...s.drafts, [key]: {
...draft, approvalId: access.approvalId, host: liveHosts.has(`${key}|${access.host}`) ? access.host : null,
error, nonce: ++nonceSeq,
} } }
})
}
},
reask: (runId, report, access) => set((s) => {
const key = inquiryKey(runId, report)
const f = s.threads[key]?.failed
if (!f) return s
return {
drafts: { ...s.drafts, [key]: {
target: f.target, approvalId: access.approvalId, host: access.host, question: f.question, error: null, nonce: ++nonceSeq,
} },
threads: { ...s.threads, [key]: { ...s.threads[key]!, failed: undefined } },
}
}),
dismissFailed: (key) => set((s) => (s.threads[key]?.failed
? { threads: { ...s.threads, [key]: { ...s.threads[key]!, failed: undefined } } } : s)),
}))
/**
* 写进了运行记录的问答(approval.inquiry):画布正显示这次运行、却没在接流(停在审批上、已经结束时,流在补完历史
* 之后就收了尾)时,按手上已有的最大序号补接一次,时间线才出现「审批人质询」这一行。运行详情读 useInquiryRecorded
*/
function followRun(runId: string) {
const studio = useStudio.getState()
if (studio.run?.id === runId && !studio.streaming) void studio.attachRun(runId).catch(() => undefined)
}
/** 这一页里有几条问答写进了这次运行的记录:变了就补拉事件(运行详情的时间线) */
export const useInquiryRecorded = (runId: string): number => useInquiry((s) => s.recorded[runId] ?? 0)
/**
* 句末的「质询」、划选浮出的「质询」点下去:打开这份报告的提问框。opener 是点下去的那个元素,Esc、取消之后焦点回到它
*/
@@ -279,13 +462,25 @@ export function askAbout(runId: string, report: string, access: InquiryAccess, t
const key = inquiryKey(runId, report)
if (opener) openers.set(key, opener)
else openers.delete(key)
useInquiry.getState().compose(runId, report, target, access.approvalId)
useInquiry.getState().compose(runId, report, target, access)
}
/** 这次运行是不是有一条质询正在回答:审批卡据此暂停批准 / 驳回(服务端这时也回 409) */
export const useInquiryBusy = (runId: string | null | undefined): boolean =>
useInquiry((s) => !!runId && !!s.pending[runId])
/**
* 页面给报告文档和问答串的「能不能问」。open 是按运行状态能不能问(审批卡:审批待处理;运行详情:封存了的探索运行),
* 设置里关了审批质询时 access 为 null、disabled 为真。host 每一处各一个:同一张审批卡同时挂在两处时靠它分开
*/
export function useInquiryAccess(open: boolean, approvalId: string | null): { access: InquiryAccess | null; disabled: boolean } {
const host = useId()
const enabled = useInquiryEnabled(open)
const access = useMemo<InquiryAccess | null>(() => (open && enabled !== false ? { approvalId, host } : null),
[open, enabled, approvalId, host])
return { access, disabled: open && enabled === false }
}
/** 问答串:服务端列出来的,加上这一页里问过、列表里还没有的(没写进运行记录的一直只在这里),按提问时间排 */
function entriesOf(thread: Thread | undefined): InquiryEntry[] {
if (!thread) return []
@@ -306,6 +501,9 @@ function entriesOf(thread: Thread | undefined): InquiryEntry[] {
const groupKey = (t: { unit: string | null; quote: string | null }) =>
t.unit ? `u:${t.unit}` : t.quote ? `q:${t.quote}` : 'report'
/** 问的不是眼前这一稿报告的那几条:单独一组,排在最后 */
const OTHER = 'other'
interface Group {
key: string
unit: string | null
@@ -325,30 +523,59 @@ const FOCUS_RING = 'rounded-sm focus:outline-none focus-visible:outline focus-vi
* access 为空时只读:只列已有的问答,不给提问(没有问答时什么都不画)。onLocate:回答里引用了报告已有出处的数字,
* 交给页面在报告里打开那一处的证据面板,找不到时返回 false,这里照文档记下的出处说一句
*/
export function ReportInquiry({ runId, report, docArtifact, access, onLocate, postSeal = false }: {
export function ReportInquiry({ runId, report, docArtifact, access, cardId = null, disabled = false, onLocate, postSeal = false }: {
runId: string
report: string
/** 报告文档的工件 id:问答串的句子取原文 */
docArtifact?: string | null
access: InquiryAccess | null
/**
* 页面上这一稿报告是哪张审批审的(审批卡:这张卡的审批 id,处理过的卡也给)。运行详情不给:显示的是封存的那一稿,
* 封存之后问的(approval_id 为空)就是问它。别的审批里问的,按那张审批审的文档对上了才挂到句子下面
*/
cardId?: string | null
/** 设置里关了审批质询:不给入口,已有的问答照列,写明关了 */
disabled?: boolean
onLocate?: (seg: EvidenceSegment, doc: EvidenceDocData) => boolean
/** 运行已封存(运行详情):提问框写明问答追加在封存之后 */
postSeal?: boolean
}) {
const key = inquiryKey(runId, report)
const host = access?.host ?? null
const thread = useInquiry((s) => s.threads[key])
const draft = useInquiry((s) => s.drafts[key])
const draftAny = useInquiry((s) => s.drafts[key])
const pending = useInquiry((s) => s.pending[runId])
const load = useInquiry((s) => s.load)
const adopt = useInquiry((s) => s.adopt)
const release = useInquiry((s) => s.release)
const move = useInquiry((s) => s.move)
const reask = useInquiry((s) => s.reask)
const dismissFailed = useInquiry((s) => s.dismissFailed)
useEffect(() => { void load(runId, report) }, [runId, report, load])
const entries = useMemo(() => entriesOf(thread), [thread])
const mine = pending?.key === key ? pending : undefined
// 提问框只认打开它时的那张审批:换了审批(上一张批掉了、驳回改写后再审)就不在这里出现;同一张审批的,只画在打开它
// 的那一处,另一处写一句、给个「在这里继续」
const draft = access && draftAny && draftAny.approvalId === access.approvalId ? draftAny : undefined
const ownDraft = draft && draft.host === host ? draft : undefined
const otherDraft = draft && draft.host && draft.host !== host ? draft : undefined
useEffect(() => {
if (!host) return
liveHosts.add(`${key}|${host}`)
return () => {
liveHosts.delete(`${key}|${host}`)
release(key, host)
}
}, [key, host, release])
useEffect(() => { if (access && draft && draft.host == null) adopt(key, access) }, [access, draft, key, adopt])
const failed = access && thread?.failed && thread.failed.approvalId === access.approvalId ? thread.failed : undefined
// 句子的原文从报告文档取(审批卡上逐段证据没展开时也要取:问答串得写出问的是哪一句;回答引用了报告已有的出处时,
// 也要靠它在报告里找到那一处)。文档按工件 id 缓存,同一份只取一次
const docSlot = useEvidence((s) => (docArtifact ? s.docs[docArtifact] : undefined))
const loadDoc = useEvidence((s) => s.loadDoc)
const needDoc = !!docArtifact && (entries.length > 0 || !!draft || !!mine)
const needDoc = !!docArtifact && (entries.length > 0 || !!draft || !!mine || !!failed)
useEffect(() => { if (needDoc && docArtifact) void loadDoc(docArtifact) }, [needDoc, docArtifact, loadDoc])
const units = useMemo(() => {
const map = new Map<string, string>()
@@ -357,6 +584,23 @@ export function ReportInquiry({ runId, report, docArtifact, access, onLocate, po
return map
}, [docSlot])
// 每条问的是哪一稿:和页面上这一稿同一张审批的(运行详情:封存之后问的)直接认;别的审批里问的,按那张审批审的
// 文档对一下(审批列表里带着),对上了才按句子编号挂——编号是位置序号,驳回改写之后同一个编号是另一句话
const docsSlot = useInquiry((s) => s.approvalDocs[runId])
const loadApprovalDocs = useInquiry((s) => s.loadApprovalDocs)
const foreign = useMemo(() => [...new Set(entries.map((e) => e.inquiry.approval_id)
.filter((a): a is string => !!a && a !== cardId))].sort(), [entries, cardId])
// 还没为它取过审批列表的(列表里取回来也没有的不再重取:审批记录没了,再取也一样)
const unknown = foreign.filter((a) => !(docsSlot && (a in docsSlot.map || docsSlot.asked.includes(a)))).join(',')
useEffect(() => { if (unknown && docArtifact) void loadApprovalDocs(runId, unknown.split(',')) }, [unknown, docArtifact, runId, loadApprovalDocs])
// 对不上之前先不画别的审批里问的那几条,免得先挂在一处、取回来再挪
const settled = !foreign.length || !docArtifact || (!unknown && docsSlot?.status !== 'loading')
const sameDoc = useCallback((x: Inquiry) => {
const a = x.approval_id ?? null
if (a === (cardId ?? null)) return true
return !!a && !!docArtifact && docsSlot?.map[a]?.[report] === docArtifact
}, [cardId, docArtifact, docsSlot, report])
const groups = useMemo(() => {
const out = new Map<string, Group>()
const add = (unit: string | null, quote: string | null) => {
@@ -364,39 +608,51 @@ export function ReportInquiry({ runId, report, docArtifact, access, onLocate, po
if (!out.has(k)) out.set(k, { key: k, unit, quote, entries: [] })
return out.get(k)!
}
for (const e of entries) add(e.inquiry.unit, e.inquiry.quote).entries.push(e)
// 正在回答的、开着的提问框挂在它们问的那一句下面:那句还没有问答时另起一组,排在最后
const others: InquiryEntry[] = []
for (const e of entries) {
if (sameDoc(e.inquiry)) add(e.inquiry.unit, e.inquiry.quote).entries.push(e)
else if (settled) others.push(e)
}
// 正在回答的、没问成的、开着的提问框挂在它们问的那一句下面(按先后):那句还没有问答时另起一组
if (mine) add(mine.target.unit, mine.target.quote)
if (failed) add(failed.target.unit, failed.target.quote)
if (draft) add(draft.target.unit, draft.target.quote)
return [...out.values()]
}, [entries, mine, draft])
const list = [...out.values()]
if (others.length) list.push({ key: OTHER, unit: null, quote: null, entries: others })
return list
}, [entries, sameDoc, settled, mine, draft, failed])
// 提交之后提问框拿掉了,焦点接到「正在回答」那一条上;回答回来那一条换掉了,再接到回答上。焦点已经去了别处的不抢;
// 没问成(提问框带着报错重新打开)时焦点由提问框自己接
// 没问成(提问框带着报错重新打开)时焦点由提问框自己接。都只在问的那一处:同一张卡挂在两处时另一处不动
const pendingRef = useRef<HTMLElement | null>(null)
const lastRef = useRef<HTMLElement | null>(null)
const count = entries.length
const [heard, setHeard] = useState('')
const asking = useRef<{ count: number; held: boolean } | null>(null)
const asking = useRef<{ count: number; held: boolean; ours: boolean; question: string } | null>(null)
useLayoutEffect(() => {
if (mine) {
if (!asking.current) {
const held = focusLost()
const ours = !!host && mine.host === host
const held = ours && focusLost()
if (held) pendingRef.current?.focus({ preventScroll: true })
asking.current = { count, held }
asking.current = { count, held, ours, question: mine.question }
}
return
}
const was = asking.current
asking.current = null
if (!was || count <= was.count) return
setHeard(INQUIRY_TEXT.arrived)
if (!was?.ours || count <= was.count) return
// 先清空再写:连着两次回答说的是同一句时,读屏也得再报一遍
setHeard('')
requestAnimationFrame(() => setHeard(INQUIRY_TEXT.arrived(was.question)))
if (was.held && focusLost()) lastRef.current?.focus({ preventScroll: true })
}, [mine, count])
}, [mine, count, host])
const reportBtn = useRef<HTMLButtonElement>(null)
if (!access && !entries.length && thread?.status !== 'error') return null
const latest = entries[entries.length - 1]?.id
// 列表还没取回来时不说「还没有问答、可以这样问」:取回来有问答,这一句又得撤掉
const fresh = !count && !mine && !draft && !failed && thread?.status === 'ok'
return (
<section className="mt-2 min-w-0 border-t pt-2" data-inquiry={report} aria-label={INQUIRY_TEXT.title}>
@@ -406,16 +662,18 @@ export function ReportInquiry({ runId, report, docArtifact, access, onLocate, po
{count > 0 && <span className="tnum font-normal" data-inquiry-count={count}>· {INQUIRY_TEXT.headCount(count)}</span>}
</span>
<span className="flex-1" />
{access && (
{access ? (
<button ref={reportBtn} type="button" className="btn btn-xs" data-inquiry-ask-report=""
onClick={(e) => askAbout(runId, report, access, { unit: null, quote: null, text: '' }, e.currentTarget)}>
<MessageCircleQuestion size={11} aria-hidden /> {INQUIRY_TEXT.askReport}
</button>
) : disabled && (
<span className="text-2xs text-faint" data-inquiry-disabled="">{INQUIRY_TEXT.disabled}</span>
)}
</div>
{access && !count && !mine && !draft && (
{access && fresh && (
<p className="mt-1 text-2xs leading-relaxed text-dim" data-inquiry-intro="">
{postSeal ? INQUIRY_TEXT.introRun : INQUIRY_TEXT.intro}
{!docArtifact ? INQUIRY_TEXT.introNoDoc : postSeal ? INQUIRY_TEXT.introRun : INQUIRY_TEXT.intro}
</p>
)}
{thread?.status === 'error' && (
@@ -424,23 +682,42 @@ export function ReportInquiry({ runId, report, docArtifact, access, onLocate, po
<button type="button" className="btn btn-xs" onClick={() => void load(runId, report)}>{INQUIRY_TEXT.retry}</button>
</div>
)}
<div className="sr-only" aria-live="polite" aria-atomic="true">{heard}</div>
<div className="sr-only" aria-live="polite" aria-atomic="true" data-inquiry-live="">{heard}</div>
{groups.length > 0 && (
<div className="mt-1.5 space-y-2">
{groups.map((g) => (
<div key={g.key} className="min-w-0" data-inquiry-group={g.unit ?? (g.quote ? 'quote' : 'report')}>
<div key={g.key} className="min-w-0"
data-inquiry-group={g.key === OTHER ? OTHER : g.unit ?? (g.quote ? 'quote' : 'report')}>
<GroupHead group={g} units={units} />
<div className="mt-1 space-y-2 border-l pl-2">
{g.entries.map((e) => (
<EntryView key={e.id} runId={runId} entry={e} groupQuote={g.unit ? null : g.quote} onLocate={onLocate}
<EntryView key={e.id} runId={runId} entry={e} groupQuote={g.unit ? null : g.quote}
otherDoc={g.key === OTHER} onLocate={g.key === OTHER ? undefined : onLocate}
focusRef={e.id === latest ? (el) => { lastRef.current = el } : undefined} />
))}
{mine && groupKey(mine.target) === g.key && (
<PendingView pending={mine} focusRef={(el) => { pendingRef.current = el }} />
)}
{draft && groupKey(draft.target) === g.key && (
<Composer runId={runId} report={report} draft={draft} busy={!!pending} postSeal={postSeal}
fallback={reportBtn} />
{g.key !== OTHER && (
<>
{mine && groupKey(mine.target) === g.key && (
<PendingView pending={mine} focusRef={(el) => { pendingRef.current = el }} />
)}
{failed && groupKey(failed.target) === g.key && (
<FailedView failed={failed} alert={failed.host === host} canReask={!draft}
onReask={() => { openers.delete(key); reask(runId, report, access!) }}
onDismiss={() => dismissFailed(key)} />
)}
{ownDraft && access && groupKey(ownDraft.target) === g.key && (
<Composer runId={runId} report={report} access={access} draft={ownDraft} busy={!!pending} postSeal={postSeal}
fallback={reportBtn} />
)}
{otherDraft && access && groupKey(otherDraft.target) === g.key && (
<p className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-2xs text-dim" data-inquiry-draft-elsewhere="">
<span>{INQUIRY_TEXT.elsewhere}</span>
<button type="button" className="btn btn-xs" onClick={() => { openers.delete(key); move(key, access) }}>
{INQUIRY_TEXT.moveHere}
</button>
</p>
)}
</>
)}
</div>
</div>
@@ -451,8 +728,19 @@ export function ReportInquiry({ runId, report, docArtifact, access, onLocate, po
)
}
/** 一组的抬头:问的是哪一句(原文从报告文档取,取不到写「报告中的一句」)、哪段原文,或者整份报告 */
/**
* 一组的抬头:问的是哪一句(原文从报告文档取,取不到写「报告中的一句」)、哪段原文,或者整份报告。问的不是眼前这一稿的
* 那一组只写明这一点:句子编号对不上,不去取原文
*/
function GroupHead({ group, units }: { group: Group; units: Map<string, string> }) {
if (group.key === OTHER) {
return (
<div className="min-w-0 text-2xs leading-relaxed" data-inquiry-about="">
<span className="font-medium text-dim" data-inquiry-about-text="">{INQUIRY_TEXT.otherDoc}</span>
<span className="block text-faint">{INQUIRY_TEXT.otherDocHint}</span>
</div>
)
}
const text = group.unit ? units.get(group.unit) || group.entries.find((e) => e.inquiry.quote)?.inquiry.quote || ''
: group.quote ?? ''
return (
@@ -473,12 +761,26 @@ function Tag({ children }: { children: ReactNode }) {
return <span className="chip shrink-0 px-1.5 py-0 font-medium leading-relaxed" aria-hidden>{children}</span>
}
/** 回答的花费:调用过模型才写;模型不在价格目录里的估不出金额,不写「$0」(同运行的金额,见 runCostText) */
function costOf(usage: Inquiry['usage']): { text: string; title?: string } | null {
const calls = typeof usage?.calls === 'number' ? usage.calls : 0
const unpriced = typeof usage?.unpriced_calls === 'number' ? usage.unpriced_calls : 0
const usd = typeof usage?.cost_usd === 'number' ? usage.cost_usd : null
// 一次模型调用都没有(没配好模型、当日金额已到上限)就不写花费,写「$0」像是调用过
if (!calls && !unpriced && !(usd != null && usd > 0)) return null
const text = runCostText(usd, unpriced)
if (!text) return null
return { text: text === COST_TEXT.unpriced ? text : INQUIRY_TEXT.cost(text), title: unpriced > 0 ? COST_TEXT.unpricedTitle(unpriced) : undefined }
}
/** 一问一答 */
function EntryView({ runId, entry, groupQuote, onLocate, focusRef }: {
function EntryView({ runId, entry, groupQuote, otherDoc, onLocate, focusRef }: {
runId: string
entry: InquiryEntry
/** 组抬头已经写着这段原文:问题下面不再写一遍 */
groupQuote: string | null
/** 问的不是眼前这一稿报告:回答引用报告已有的出处时,不在眼前这一稿里找 */
otherDoc: boolean
onLocate?: (seg: EvidenceSegment, doc: EvidenceDocData) => boolean
focusRef?: (el: HTMLElement | null) => void
}) {
@@ -518,9 +820,7 @@ function EntryView({ runId, entry, groupQuote, onLocate, focusRef }: {
const answer = inquiry.answer
const limit = answer.limit ? INQUIRY_TEXT.limit[answer.limit] ?? INQUIRY_TEXT.limitOther : ''
const quote = inquiry.quote && inquiry.quote !== groupQuote ? inquiry.quote : null
// 一次模型调用都没有(没配好模型、当日金额已到上限)就不写花费,写「$0」像是调用过
const calls = typeof inquiry.usage?.calls === 'number' ? inquiry.usage.calls : null
const cost = typeof inquiry.usage?.cost_usd === 'number' && (inquiry.usage.cost_usd > 0 || !!calls) ? inquiry.usage.cost_usd : null
const cost = costOf(inquiry.usage)
const answered = !!answer.text || !!answer.doc_artifact
return (
<article ref={focusRef} tabIndex={-1} className={`min-w-0 space-y-1 text-2xs leading-relaxed ${FOCUS_RING}`}
@@ -553,9 +853,11 @@ function EntryView({ runId, entry, groupQuote, onLocate, focusRef }: {
<div className="flex min-w-0 items-start gap-1.5">
<Tag>{INQUIRY_TEXT.a}</Tag>
<div className="min-w-0 flex-1 space-y-1">
<AnswerBody inquiry={inquiry} onCite={onCite} />
<AnswerBody inquiry={inquiry} otherDoc={otherDoc} onCite={onCite} />
{elsewhere != null && (
<p className="text-dim" data-inquiry-elsewhere="">{INQUIRY_TEXT.reportSource(elsewhere)}</p>
<p className="text-dim" data-inquiry-elsewhere="">
{otherDoc ? INQUIRY_TEXT.reportSourceOther(elsewhere) : INQUIRY_TEXT.reportSource(elsewhere)}
</p>
)}
{answer.ok && limit && (
<p style={{ color: 'var(--st-waiting)' }} data-inquiry-limit={answer.limit}>{INQUIRY_TEXT.answerIncomplete(limit)}</p>
@@ -578,14 +880,17 @@ function EntryView({ runId, entry, groupQuote, onLocate, focusRef }: {
{answered && (
<p className="text-faint" data-inquiry-judged="">
{INQUIRY_TEXT.answeredBy(inquiry.model)}
{cost != null && <span className="tnum"> · {INQUIRY_TEXT.cost(formatCost(cost))}</span>}
{cost && <span className="tnum" title={cost.title} data-inquiry-cost=""> · {cost.text}</span>}
</p>
)}
</article>
)
}
/** 正在回答的那一条:问题、转圈和等了多久。回答期间审批卡的批准 / 驳回暂停(审批卡读 useInquiryBusy) */
/**
* 正在回答的那一条:问题、转圈和等了多久。回答期间审批卡的批准 / 驳回暂停(审批卡读 useInquiryBusy)。
* 播报区只放不变的那句:等了几秒每秒一变,放在里面读屏会每秒重读一遍
*/
function PendingView({ pending, focusRef }: { pending: Pending; focusRef: (el: HTMLElement | null) => void }) {
const now = useTicker(1000)
const waited = Math.max(0, Math.floor((now - pending.startedAt) / 1000))
@@ -596,22 +901,50 @@ function PendingView({ pending, focusRef }: { pending: Pending; focusRef: (el: H
<Tag>{INQUIRY_TEXT.q}</Tag>
<p className="min-w-0 flex-1 whitespace-pre-wrap font-medium text-fg [overflow-wrap:anywhere]">{pending.question}</p>
</div>
<p className="flex items-center gap-1.5 text-dim" role="status">
<p className="flex flex-wrap items-center gap-x-1.5 text-dim">
<Spinner size={11} />
<span>{INQUIRY_TEXT.pending}</span>
{waited >= 3 && <span className="tnum text-faint">· {INQUIRY_TEXT.waited(waited)}</span>}
<span role="status">{INQUIRY_TEXT.pending}</span>
{waited >= 3 && <span className="tnum text-faint" aria-hidden="true" data-inquiry-waited="">· {INQUIRY_TEXT.waited(waited)}</span>}
</p>
</div>
)
}
/**
* 回答期间另开了提问框、正在回答的那一条却没问成:问题和原因照原样留着(提问框里新写的不动)。提问框关着时可以把它
* 放回提问框重问
*/
function FailedView({ failed, alert, canReask, onReask, onDismiss }: {
failed: Failure; alert: boolean; canReask: boolean; onReask: () => void; onDismiss: () => void
}) {
return (
<div className="min-w-0 space-y-1 text-2xs leading-relaxed" data-inquiry-failed-ask="">
<div className="flex min-w-0 items-start gap-1.5">
<Tag>{INQUIRY_TEXT.q}</Tag>
<p className="min-w-0 flex-1 whitespace-pre-wrap font-medium text-fg [overflow-wrap:anywhere]" data-inquiry-question="">
{failed.question}
</p>
</div>
<p role={alert ? 'alert' : undefined} className="[overflow-wrap:anywhere]" style={{ color: 'var(--st-failed)' }}
data-inquiry-error={failed.error.code}>
{INQUIRY_TEXT.askFailed(failed.error.message)}
</p>
<div className="flex flex-wrap items-center gap-2">
{canReask && <button type="button" className="btn btn-xs" onClick={onReask} data-inquiry-reask="">{INQUIRY_TEXT.reask}</button>}
<button type="button" className="btn btn-xs btn-ghost" onClick={onDismiss}>{INQUIRY_TEXT.dismiss}</button>
</div>
</div>
)
}
/**
* 提问框:问的是哪一句(或哪段原文、整份报告)、问题、提交。Esc 关掉、焦点回到点开它的那个「质询」;Ctrl / ⌘ + 回车提交。
* 字数超了、空着不让提交;这次运行另有一条在回答时也不让(服务端同样会拒绝)
*/
function Composer({ runId, report, draft, busy, postSeal, fallback }: {
function Composer({ runId, report, access, draft, busy, postSeal, fallback }: {
runId: string
report: string
access: InquiryAccess
draft: Draft
busy: boolean
postSeal: boolean
@@ -624,11 +957,14 @@ function Composer({ runId, report, draft, busy, postSeal, fallback }: {
const id = useId()
const area = useRef<HTMLTextAreaElement>(null)
const form = useRef<HTMLFormElement>(null)
// 每点一次「质询」(换了一句也算)都把焦点送进输入框,并把提问框滚进视野
// 每点一次「质询」(换了一句也算)都把焦点送进输入框,并把提问框滚进视野。只认点出来的:浮层关了、提问框挪到右栏
// 接着显示时不抢焦点
useEffect(() => {
if (focusedNonce.get(key) === draft.nonce) return
focusedNonce.set(key, draft.nonce)
area.current?.focus({ preventScroll: true })
form.current?.scrollIntoView({ block: 'nearest' })
}, [draft.nonce])
}, [key, draft.nonce])
const chars = [...draft.question].length
const over = chars > QUESTION_MAX
const empty = !draft.question.trim()
@@ -640,10 +976,14 @@ function Composer({ runId, report, draft, busy, postSeal, fallback }: {
const back = openers.get(key)
openers.delete(key)
requestAnimationFrame(() => {
if (back?.isConnected) back.focus({ preventScroll: true })
else fallback.current?.focus({ preventScroll: true })
const el = back?.isConnected ? back : fallback.current
if (!el) return
// 焦点回去的地方可能已经滚出视野(提问框滚进来时把它挤上去了,或者在限高的报告框里):滚回来,键盘用户看得见
el.focus({ preventScroll: true })
el.scrollIntoView({ block: 'nearest', inline: 'nearest' })
})
}
const send = () => { if (!empty && !over && !busy) void submit(runId, report, access) }
const onKeyDown = (e: ReactKeyboardEvent) => {
if (isComposing(e)) return
if (e.key === 'Escape') {
@@ -653,13 +993,13 @@ function Composer({ runId, report, draft, busy, postSeal, fallback }: {
dismiss()
} else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
if (!empty && !over && !busy) void submit(runId, report)
send()
}
}
const problem = draft.error
return (
<form ref={form} className="min-w-0 space-y-1.5 rounded border bg-panel p-2" data-inquiry-composer={target}
onKeyDown={onKeyDown} onSubmit={(e) => { e.preventDefault(); if (!empty && !over && !busy) void submit(runId, report) }}>
onKeyDown={onKeyDown} onSubmit={(e) => { e.preventDefault(); send() }}>
<div className="text-2xs font-medium text-faint" id={`${id}-target`}>{INQUIRY_TEXT.target[target]}</div>
{shown && (
<blockquote className="line-clamp-3 border-l-2 pl-2 text-2xs leading-relaxed text-dim [overflow-wrap:anywhere]"
@@ -711,7 +1051,7 @@ type Cite =
* 回答正文。有回答文档时按文档画:数字的出处照报告里的样子(有出处的实线、裸数字点状线加「无证据」);文档取到之前、
* 取不到时按渲染后的正文显示,数字照写,只是不标出处
*/
function AnswerBody({ inquiry, onCite }: { inquiry: Inquiry; onCite: (c: Cite) => void }) {
function AnswerBody({ inquiry, otherDoc, onCite }: { inquiry: Inquiry; otherDoc: boolean; onCite: (c: Cite) => void }) {
const artifact = inquiry.answer.doc_artifact ?? ''
const slot = useEvidence((s) => (artifact ? s.docs[artifact] : undefined))
const loadDoc = useEvidence((s) => s.loadDoc)
@@ -724,7 +1064,7 @@ function AnswerBody({ inquiry, onCite }: { inquiry: Inquiry; onCite: (c: Cite) =
return map
}, [inquiry.queries])
if (doc && (doc.blocks ?? []).length) {
return <AnswerDoc doc={doc} own={own} onCite={onCite} />
return <AnswerDoc doc={doc} own={own} otherDoc={otherDoc} onCite={onCite} />
}
if (inquiry.answer.text) {
return <div className="min-w-0 text-[11.5px]" data-inquiry-answer-text=""><Markdown text={inquiry.answer.text} dense /></div>
@@ -737,22 +1077,21 @@ function AnswerBody({ inquiry, onCite }: { inquiry: Inquiry; onCite: (c: Cite) =
/** 列表项从哪一个片段开始:同 EvidenceDoc(行首的「- 」「1. 」是结构片段) */
const ITEM_START = /(^|\n)[ \t]*([-*+]|\d+[.)])[ \t]+$/
function AnswerDoc({ doc, own, onCite }: {
doc: EvidenceDocData; own: Map<string, InquiryQuery>; onCite: (c: Cite) => void
}) {
interface AnswerCtx { doc: EvidenceDocData; own: Map<string, InquiryQuery>; otherDoc: boolean; onCite: (c: Cite) => void }
function AnswerDoc({ doc, own, otherDoc, onCite }: AnswerCtx) {
const ctx = useMemo(() => ({ doc, own, otherDoc, onCite }), [doc, own, otherDoc, onCite])
return (
<div className="min-w-0 space-y-1.5 text-[11.5px] leading-relaxed" data-inquiry-answer-doc="">
{doc.blocks.map((block, b) => <AnswerBlock key={block.id ?? b} block={block} doc={doc} own={own} onCite={onCite} />)}
{doc.blocks.map((block, b) => <AnswerBlock key={block.id ?? b} block={block} ctx={ctx} />)}
</div>
)
}
/** 一块:外观借 Markdown 的 BlockView,和报告文档、普通答案长得一样(块的分法同 EvidenceDoc 的 DocBlock) */
function AnswerBlock({ block, doc, own, onCite }: {
block: EvidenceBlock; doc: EvidenceDocData; own: Map<string, InquiryQuery>; onCite: (c: Cite) => void
}) {
function AnswerBlock({ block, ctx }: { block: EvidenceBlock; ctx: AnswerCtx }) {
const units = block.units ?? []
const unit = (u: EvidenceUnit, code = false) => <AnswerUnit key={u.id} unit={u} doc={doc} own={own} code={code} onCite={onCite} />
const unit = (u: EvidenceUnit, code = false) => <AnswerUnit key={u.id} unit={u} ctx={ctx} code={code} />
switch (block.type) {
case 'heading':
return <BlockView dense shape={{ kind: 'h', level: block.level ?? 3, body: units.map((u) => unit(u)) }} />
@@ -812,6 +1151,9 @@ const evStyle = (state: EvidenceStateCode) => ({
'--ev-line': EVIDENCE_STATE[state].decoration, '--ev-soft': EVIDENCE_STATE[state].soft,
}) as CSSProperties
/** 句末挂小标签的几种(同报告文档):无证据、疑似不存在的名称 */
const TAGGED = new Set<EvidenceStateCode>(['none', 'suspect'])
/**
* 回答里有出处的一个数字指向哪:引用的是质询自己某次查询里的一格(目录条目的工件就是那次查询的快照,工件缺的按别名
* 认),还是报告已有的出处(口径卡上的指标、报告的查询……)
@@ -833,12 +1175,12 @@ function citeOf(seg: EvidenceSegment, doc: EvidenceDocData, own: Map<string, Inq
/**
* 一句话:文字按行内 Markdown 渲染(整句算一次样式,同 EvidenceDoc 的 UnitView)。有出处的数字是按钮:质询自己查询里的
* 后面挂查询编号,点一下展开那次查询的结果、标出那一格;报告已有的出处点一下在报告里打开它的证据。裸数字、疑似不存在的
* 名称画点状线,句末挂「无证据」「疑似不存在的名称」
* 后面挂查询编号,点一下展开那次查询的结果、标出那一格;报告已有的出处点一下在报告里打开它的证据。别的有状态的片段照
* 报告里的线型画(裸数字、疑似不存在、核对不了的名称、猜测的来源是点状线,句末挂「无证据」「疑似不存在的名称」),
* 不能和核对过的字长得一样
*/
function AnswerUnit({ unit, doc, own, code, onCite }: {
unit: EvidenceUnit; doc: EvidenceDocData; own: Map<string, InquiryQuery>; code: boolean; onCite: (c: Cite) => void
}) {
function AnswerUnit({ unit, ctx, code }: { unit: EvidenceUnit; ctx: AnswerCtx; code: boolean }) {
const { doc, own, otherDoc, onCite } = ctx
const segs = (unit.segments ?? []).filter((s) => s.kind !== 'structural')
const whole = segs.map((s) => s.text).join('')
const styles = useMemo(() => (code ? null : inlineStyles(whole)), [whole, code])
@@ -855,7 +1197,8 @@ function AnswerUnit({ unit, doc, own, code, onCite }: {
return (
<button key={s.id} type="button" className="ev-seg" data-ev-state="deterministic" data-ev-line="solid"
data-inquiry-cite={cite.kind === 'query' ? cite.alias : 'report'} style={evStyle('deterministic')} title={source}
aria-label={cite.kind === 'query' ? INQUIRY_TEXT.cited(s.text, source) : INQUIRY_TEXT.citedReport(s.text, source)}
aria-label={cite.kind === 'query' ? INQUIRY_TEXT.cited(s.text, source)
: otherDoc ? INQUIRY_TEXT.citedOther(s.text, source) : INQUIRY_TEXT.citedReport(s.text, source)}
onClick={() => onCite(cite)}>
{body}
{cite.kind === 'query' && (
@@ -865,12 +1208,14 @@ function AnswerUnit({ unit, doc, own, code, onCite }: {
</button>
)
}
if (state === 'none' || state === 'suspect') {
alerts.add(state)
const meta = state ? EVIDENCE_STATE[state] : null
if (state && meta && (meta.line === 'solid' || meta.line === 'dotted')) {
if (TAGGED.has(state)) alerts.add(state)
return (
<span key={s.id} className="ev-mark" data-ev-state={state} data-ev-line="dotted" style={evStyle(state)}
title={segmentLabel(s, doc)}>
{body}<span className="sr-only">({EVIDENCE_STATE_LABEL[state]})</span>
<span key={s.id} className="ev-mark" data-ev-state={state} data-ev-line={meta.line} title={segmentLabel(s, doc)}
style={{ ...evStyle(state), ...(meta.line === 'solid' ? { textDecorationStyle: 'solid' } : {}) }}>
{s.strong ? <strong className="font-semibold">{body}</strong> : body}
{state !== 'deterministic' && <span className="sr-only">({EVIDENCE_STATE_LABEL[state]})</span>}
</span>
)
}
@@ -881,7 +1226,7 @@ function AnswerUnit({ unit, doc, own, code, onCite }: {
{nodes}
{!code && [...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}{EVIDENCE_STATE_LABEL[state]}
{EVIDENCE_STATE[state].glyph}{state === 'suspect' ? EVIDENCE_TEXT.suspectTag : EVIDENCE_STATE_LABEL[state]}
</span>
))}
</>
@@ -890,7 +1235,10 @@ function AnswerUnit({ unit, doc, own, code, onCite }: {
// ---------------------------------------------------------------- 查询
/** 核查模型执行过的查询:SQL、行数、截断;每次可以展开结果(按质询工件接口取快照,遮罩列已遮掉) */
/**
* 核查模型执行过的查询:SQL、行数、截断;每次可以展开结果(按质询工件接口取快照,遮罩列已遮掉)。列表收着时也写明有几次
* 结果被截断——截断直接关系到回答靠不靠得住,不能要展开才看得到
*/
function QueryList({ runId, baseId, queries, shown, open, onOpen, onToggle }: {
runId: string
baseId: string
@@ -902,14 +1250,22 @@ function QueryList({ runId, baseId, queries, shown, open, onOpen, onToggle }: {
}) {
const listId = useId()
if (!queries.length) return <p className="text-faint" data-inquiry-queries="0">{INQUIRY_TEXT.noQueries}</p>
const cut = queries.filter((q) => q.truncated).length
return (
<section className="min-w-0" data-inquiry-queries={queries.length}>
<button type="button" className="flex items-center gap-1 rounded text-faint hover:text-fg focus-visible:outline focus-visible:outline-2 focus-visible:outline-[color:var(--accent)]"
aria-expanded={open} aria-controls={open ? listId : undefined} onClick={() => onOpen(!open)}
data-inquiry-queries-toggle="">
<ChevronRight size={11} aria-hidden style={{ transform: open ? 'rotate(90deg)' : 'none', transition: 'transform .15s' }} />
{INQUIRY_TEXT.queriesHead(queries.length)}
</button>
<div className="flex min-w-0 flex-wrap items-center gap-x-1.5">
<button type="button" className="flex items-center gap-1 rounded text-faint hover:text-fg focus-visible:outline focus-visible:outline-2 focus-visible:outline-[color:var(--accent)]"
aria-expanded={open} aria-controls={open ? listId : undefined} onClick={() => onOpen(!open)}
data-inquiry-queries-toggle="">
<ChevronRight size={11} aria-hidden style={{ transform: open ? 'rotate(90deg)' : 'none', transition: 'transform .15s' }} />
{INQUIRY_TEXT.queriesHead(queries.length)}
</button>
{cut > 0 && (
<span className="tnum" style={{ color: 'var(--st-waiting)' }} data-inquiry-queries-truncated={cut}>
· {INQUIRY_TEXT.queriesTruncated(cut)}
</span>
)}
</div>
{open && (
<div id={listId} className="mt-1 space-y-1.5">
{queries.map((q, i) => (
+7 -5
View File
@@ -5,7 +5,7 @@ import { api } from '../api/client'
import { useStudio } from '../store/studio'
import { useCatalog } from '../store/catalog'
import { NODE_DEFS } from '../canvas/nodeDefs'
import { StatusDot, useToast } from '../components/ui'
import { Spinner, StatusDot, useToast } from '../components/ui'
import { useLocalActor } from '../lib/actor'
import { formatDateTime, formatSpan, formatTime, parseServerTime } from '../lib/format'
import { INQUIRY_TEXT, runClassLabel, runName, TOOL_TRUST_TEXT } from '../lib/terms'
@@ -156,7 +156,7 @@ export function ApprovalCard({ approval, onResolved, showWorkflow = true }: {
{dualCheck && <DualCheckApproval approval={approval} onResolved={onResolved} />}
<ApprovalReports reports={reports} runId={approval.run_id} runClass={approval.run_class}
approvalId={approval.status === 'pending' ? approval.id : undefined} />
approvalId={approval.id} askable={approval.status === 'pending'} />
<ChallengeSummary reports={reports} runId={approval.run_id} />
{payload.tool && (
@@ -212,9 +212,11 @@ export function ApprovalCard({ approval, onResolved, showWorkflow = true }: {
</button>
</div>
{inquiring && (
<div className="mt-1.5 text-2xs font-medium leading-relaxed" role="status" data-approval-inquiry-busy=""
style={{ color: 'var(--st-waiting)' }}>
{INQUIRY_TEXT.decideBlocked}
// 字用正文色:审批卡铺着一层提醒色的底,提醒色的字在浅色主题下对比度不够;提醒色交给转圈的图标
<div className="mt-1.5 flex items-start gap-1 text-2xs font-medium leading-relaxed text-fg" role="status"
data-approval-inquiry-busy="">
<span className="mt-[3px] shrink-0" style={{ color: 'var(--st-waiting)' }}><Spinner size={11} /></span>
<span>{INQUIRY_TEXT.decideBlocked}</span>
</div>
)}
{/* 批了会怎样,常显而不是藏在 hover 里:审批人得在按下去之前知道 */}
+1 -1
View File
@@ -98,7 +98,7 @@ const ESTIMATE = {
stream: 173, chat: 160, studio: 149, manage: 145, runs: 112, publish: 92, shell: 87,
evidence: 91, versions: 60, catalog: 45, 'ui-kit': 70, ui: 61, guards: 8, 'canvas-layout': 2, copy: 2, tokens: 1, trace: 1, decode: 1,
// 占位,换成真检查时按实测改
schedules: 60, compare: 45, verify: 71, inquiry: 22, bundle: 16, whatif: 33,
schedules: 60, compare: 45, verify: 71, inquiry: 60, bundle: 16, whatif: 33,
}
// 最慢的两项按段拆成几份。用的是它们自己的段过滤:最后一份用 *_SKIP 兜住「其余全部」,以后新加的段
// 不用改这里也跑得到(拆出去的那几份要是一段都没对上,那一份会报「一项都没跑」)。
+558 -20
View File
@@ -16,13 +16,14 @@
//
// node scripts/check-inquiry.mjs
// 对别的实例跑时带上地址:AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-inquiry.mjs
// INQUIRY_SHOTS=<目录> 时截几张图,供人眼复核。
// INQUIRY_SHOTS=<目录> 时截几张图,供人眼复核。INQUIRY_ONLY=<字> 只跑名字里带这几个字的段(改代码时用)。
import { mkdirSync, readFileSync } from 'node:fs'
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
const CHROME = process.env.CHROME_PATH ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
const SHOTS = process.env.INQUIRY_SHOTS ?? ''
const ONLY = process.env.INQUIRY_ONLY ?? ''
const root = new URL('..', import.meta.url).pathname
const fx = JSON.parse(readFileSync(`${root}frontend/src/dev/fixtures/inquiry.json`, 'utf8'))
const RUNS = fx.runs
@@ -44,6 +45,7 @@ const check = (name, cond, detail = '') => {
if (!cond) failed++
}
async function section(name, fn) {
if (ONLY && !name.includes(ONLY)) return
console.log(`\n=== ${name} ===`)
try {
await fn()
@@ -63,18 +65,26 @@ const answerFor = (body, extra = {}) => ({
/**
* 开一个预览用例。列表按运行答夹具里的问答(state.lists 可改),提问按 state.reply 答({status, json} 或按请求体算的函数;
* state.gate 是个没兑现的 Promise 时先挂着,测「回答期间」)。记下列表、提问、取快照、取工件的请求和拦下的写请求
* state.gate 是个没兑现的 Promise 时先挂着,测「回答期间」;state.listGate 同样挂着列表)。审批列表按 state.approvals
* (运行 → 审批数组)答,设置按 state.settings 答;state.docs、state.snaps 盖过夹具里的文档、快照。setup 在打开页面之前
* 改 state(进入时就要取的列表、设置得先备好);ws 接管运行的事件流。记下列表、提问、取快照、取工件、取审批列表的请求和拦下的写请求
*/
async function open(syn, { width = 1100, height = 900, theme = 'dark' } = {}) {
async function open(syn, { width = 1100, height = 900, theme = 'dark', setup, ws, waitUntil = 'networkidle' } = {}) {
const ctx = await browser.newContext({ viewport: { width, height }, colorScheme: theme, timezoneId: 'Asia/Shanghai' })
ctx.setDefaultTimeout(6000)
const page = await ctx.newPage()
const hits = { lists: [], asks: [], snapshots: [], artifacts: [], segments: [], writes: [], other: [] }
const hits = { lists: [], asks: [], snapshots: [], artifacts: [], segments: [], approvals: [], writes: [], other: [] }
const state = {
lists: { [RUNS.approve]: fx.items, [RUNS.formal]: fx.items, [RUNS.explore]: fx.explore_items },
reply: (body) => ({ status: 200, json: answerFor(body) }),
gate: null,
listGate: null,
approvals: {},
settings: {},
docs: {},
snaps: {},
}
setup?.(state)
const errors = []
page.on('pageerror', (e) => errors.push(e.message))
page.on('dialog', (d) => { errors.push(`原生对话框:${d.message()}`); void d.dismiss() })
@@ -98,12 +108,21 @@ async function open(syn, { width = 1100, height = 900, theme = 'dark' } = {}) {
if (inq) {
const run = decodeURIComponent(inq[1])
hits.lists.push({ run, report: url.searchParams.get('report'), unit: url.searchParams.get('unit') })
if (state.listGate) await state.listGate
return route.fulfill({ json: { items: state.lists[run] ?? [] } })
}
if (path === '/api/approvals') {
const run = url.searchParams.get('run_id')
hits.approvals.push({ run, status: url.searchParams.get('status') })
const list = state.approvals[run]
return list ? route.fulfill({ json: list }) : route.fulfill({ status: 404, json: { detail: '检查脚本没有伪造这个接口' } })
}
if (path === '/api/settings') return route.fulfill({ json: state.settings })
const snap = path.match(/^\/api\/runs\/([^/]+)\/inquiries\/artifacts\/([^/]+)$/)
if (snap) {
const id = decodeURIComponent(snap[2])
hits.snapshots.push(id)
if (state.snaps[id]) return route.fulfill({ json: state.snaps[id] })
const body = fx.snapshots[id]
if (!body) return route.fulfill({ status: 404, json: { detail: '不是这次运行的质询引用过的工件' } })
// 约定没定死外面包不包一层:一份包在 {id, content} 里,其余是快照本身,两种界面都得认
@@ -113,7 +132,8 @@ async function open(syn, { width = 1100, height = 900, theme = 'dark' } = {}) {
if (art) {
const id = decodeURIComponent(art[1])
hits.artifacts.push(id)
return fx.docs[id] ? route.fulfill({ json: { id, content: fx.docs[id] } })
const doc = state.docs[id] ?? fx.docs[id]
return doc ? route.fulfill({ json: { id, content: doc } })
: route.fulfill({ status: 404, json: { detail: '工件不存在,可能已被删除' } })
}
const seg = path.match(/^\/api\/runs\/([^/]+)\/evidence\/segments\/([^/]+)$/)
@@ -138,7 +158,9 @@ async function open(syn, { width = 1100, height = 900, theme = 'dark' } = {}) {
hits.other.push(path)
return route.fulfill({ status: 404, json: { detail: '检查脚本没有伪造这个接口' } })
})
await page.goto(`${WEB}/preview.html?syn=${syn}&theme=${theme}`, { waitUntil: 'networkidle' })
// 事件流:Playwright 靠注入页面的脚本接管 WebSocket,得在打开页面之前挂上
if (ws) await page.routeWebSocket(/\/api\/runs\/[^/]+\/stream/, ws)
await page.goto(`${WEB}/preview.html?syn=${syn}&theme=${theme}`, { waitUntil })
return { ctx, page, hits, state, errors }
}
@@ -170,17 +192,59 @@ async function expand(page) {
await page.locator('[data-approval-evidence]').click()
await page.locator('[data-approval-evidence-doc] [data-evidence-doc] [data-block]').first().waitFor()
}
/** 像人一样点某一句的「质询」:先把鼠标移到这一句的字上(「质询」这才出现),再点它 */
async function clickAsk(page, unit) {
// 句子会折行:包住它的框的左上角可能落在上一句上,鼠标要放到这一句第一行的字上
const at = await page.locator(`[data-ev-unit="${unit}"]`).evaluate((el) => {
/** 鼠标放到某一句第一行的字上(句子会折行:包住它的框的左上角可能落在上一句上) */
async function hoverUnit(page, unit, scope = page) {
const at = await scope.locator(`[data-ev-unit="${unit}"]`).evaluate((el) => {
el.scrollIntoView({ block: 'center' })
const r = el.getClientRects()[0]
return { x: r.left + Math.min(8, r.width / 2), y: r.top + r.height / 2 }
})
await page.mouse.move(at.x, at.y)
await askBtn(page, unit).click()
}
/** 像人一样点某一句的「质询」:先把鼠标移到这一句的字上(「质询」这才显出来),再点它。scope:同一页有两份报告时限定在哪一份 */
async function clickAsk(page, unit, scope = page) {
await hoverUnit(page, unit, scope)
await scope.locator(`[data-inquiry-ask="${unit}"]`).click()
}
/** 一次审批(审批列表接口的一行):审的是哪一稿报告 */
const approvalRow = (id, run, doc) => ({ id, run_id: run, node_id: 'review', mode: 'approve', status: 'approved', response: {},
payload: { kind: 'human_node', reports: [{ node_id: 'write', doc_artifact: doc }] }, created_at: '2026-10-05T06:00:00' })
const clone = (x) => JSON.parse(JSON.stringify(x))
/** 问答串的分组:每组是哪一句、抬头写的什么、有哪几条 */
const groupsOf = (page, scope = '[data-inquiry="write"]') => page.locator(`${scope} [data-inquiry-group]`).evaluateAll((els) => els.map((el) => ({
unit: el.getAttribute('data-inquiry-group'), about: el.querySelector('[data-inquiry-about]')?.textContent?.trim() ?? '',
entries: [...el.querySelectorAll('[data-inquiry-entry]')].map((x) => x.getAttribute('data-inquiry-entry')),
})))
/** 报告正文里每个片段、每句话、每个「质询」的位置(第一个框),悬停前后对比用 */
const layout = (page, root = '[data-evidence-doc]') => page.evaluate((r) => {
const out = []
for (const el of document.querySelector(r)?.querySelectorAll('[data-seg], [data-ev-unit], [data-inquiry-ask], td, th') ?? []) {
const box = el.getClientRects()[0] ?? el.getBoundingClientRect()
const key = el.getAttribute('data-seg') ?? (el.hasAttribute('data-inquiry-ask') ? `ask:${el.getAttribute('data-inquiry-ask')}`
: el.getAttribute('data-ev-unit') ?? `${el.tagName}${out.length}`)
out.push([key, { x: box.left, y: box.top, w: box.width, h: box.height }])
}
return out
}, root).then((list) => new Map(list))
/** 两次位置里挪了的(超过半个像素) */
const moved = (a, b) => [...a].filter(([k, v]) => {
const w = b.get(k)
return !w || Math.abs(w.x - v.x) > 0.5 || Math.abs(w.y - v.y) > 0.5 || Math.abs(w.w - v.w) > 0.5
}).map(([k, v]) => `${k} ${Math.round(v.x)},${Math.round(v.y)}→${Math.round(b.get(k)?.x)},${Math.round(b.get(k)?.y)}`)
/** 焦点所在的元素在不在视口里、在不在会裁切它的祖先(限高滚动的报告框)的可见范围里 */
const focusShown = (page) => page.evaluate(() => {
const el = document.activeElement
if (!el || el === document.body) return { ok: false, why: 'body' }
const r = el.getBoundingClientRect()
if (r.bottom <= 0 || r.top >= innerHeight || r.width === 0) return { ok: false, why: `视口外 ${Math.round(r.top)}` }
for (let p = el.parentElement; p; p = p.parentElement) {
const s = getComputedStyle(p)
if (s.overflowY === 'visible' && s.overflowX === 'visible') continue
const b = p.getBoundingClientRect()
if (r.bottom <= b.top || r.top >= b.bottom) return { ok: false, why: `被 ${p.tagName} 裁掉` }
}
return { ok: true, ask: el.getAttribute('data-inquiry-ask') }
})
/** 等着某个条件成立(最多 ms 毫秒) */
async function until(fn, ms = 3000) {
const end = Date.now() + ms
@@ -330,10 +394,22 @@ await section('入口:悬停、键盘聚焦一句出现「质询」,Tab 到
await expand(page)
const units = await page.locator('[data-inquiry-ask]').evaluateAll((els) => els.map((el) => el.getAttribute('data-inquiry-ask')))
check('每句结论后各一个「质询」,标题不挂', JSON.stringify(units) === JSON.stringify(['u1', 'u2', 'u3', 'u4', 'u5', 'u6', 'u7']), JSON.stringify(units))
check('平时都看不见、不占地方', !(await seen(askBtn(page, 'u2'))) && (await askBtn(page, 'u2').boundingBox())?.width < 1)
await page.locator('[data-ev-unit="u2"] [data-seg="s9"]').hover()
const idle = await askBtn(page, 'u2').evaluate((el) => ({ w: el.getBoundingClientRect().width, o: Number(getComputedStyle(el).opacity) }))
check('平时是句末淡淡的一个小图标(不抢眼,宽度固定)', !(await seen(askBtn(page, 'u2'))) && idle.o < 0.75 && idle.w > 8 && idle.w < 24,
JSON.stringify(idle))
const before = await layout(page)
// 悬停第一句(9 月销售额……),它的「质询」显出来;同一段里后面那一句(退款金额 21,500元……)不能被挤开
await page.locator('[data-ev-unit="u1"] [data-seg="s4"]').hover()
await page.waitForTimeout(100)
check('悬停一句:这一句的「质询」出现,别的句子的不出现', await seen(askBtn(page, 'u2')) && !(await seen(askBtn(page, 'u1'))))
const mid = await layout(page)
check('悬停前后正文一个字都不挪(「质询」显出来只换颜色,不撑开)', moved(before, mid).length === 0, moved(before, mid).slice(0, 3).join(';'))
// 悬停第一句时看到的后一句的数字,把鼠标移过去:第一句的「质询」收起,数字还在原处,鼠标底下就是它
const s9 = mid.get('s9')
await page.mouse.move(s9.x + 4, s9.y + s9.h / 2)
await page.waitForTimeout(100)
check('移到悬停时看到的后一句的数字上:鼠标底下就是它', await page.evaluate(([x, y]) => document.elementFromPoint(x, y)?.closest('[data-seg]')?.getAttribute('data-seg'),
[s9.x + 4, s9.y + s9.h / 2]) === 's9')
check('悬停一句:这一句的「质询」显出来,别的句子的不显', await seen(askBtn(page, 'u2')) && !(await seen(askBtn(page, 'u1'))))
check('读屏听得到问的是哪一句', (await askBtn(page, 'u2').getAttribute('aria-label')) === '质询这句:退款金额 21,500元,退款率 1.89%。')
await page.mouse.move(5, 5)
await page.waitForTimeout(100)
@@ -702,11 +778,25 @@ await section('运行详情「证据」页签:封存了的探索运行有入
await ctx.close()
}
{
const { ctx, page, hits, errors } = await open('inquiry-run-formal')
// 审批时问过的随运行封存:审批处理掉以后,在运行详情的报告下面只读列出(查过的 SQL、行数、快照都在)
const { ctx, page, hits, errors } = await open('inquiry-run-formal', { setup: (s) => {
s.lists[RUNS.sealed] = fx.items.map((x) => ({ ...x, approval_id: fx.formal_approval_id }))
s.approvals[RUNS.sealed] = [approvalRow(fx.formal_approval_id, RUNS.sealed, fx.reports.sealed.doc_artifact)]
} })
await page.locator('[data-evidence-report="write"] [data-ev-unit], [data-evidence-report="write"] [data-seg="s4"]').first().waitFor()
await page.waitForTimeout(300)
check('正式运行封存后:报告上没有「质询」,也没有问答串和提问入口',
await page.locator('[data-inquiry-ask], [data-inquiry], [data-inquiry-ask-report]').count() === 0)
await page.locator('[data-inquiry="write"] [data-inquiry-entry="31"]').waitFor()
await page.waitForTimeout(200)
check('正式运行封存后:报告上没有「质询」,也没有提问入口、提问框',
await page.locator('[data-inquiry-ask], [data-inquiry-ask-report], [data-inquiry-composer], [data-inquiry-intro]').count() === 0)
const groups = await groupsOf(page)
check('正式运行封存后:审批时问过的照列,按句子挂在报告下面(审的就是封存的这一稿)',
JSON.stringify(groups.map((g) => [g.unit, g.entries])) === JSON.stringify([['u2', ['31']], ['u3', ['34']], ['u4', ['36']]]),
JSON.stringify(groups.map((g) => [g.unit, g.entries])))
const e2 = entry(page, 34)
await e2.locator('[data-inquiry-queries-toggle]').click()
const q2 = e2.locator('[data-inquiry-query="Q2"]')
check('正式运行封存后:查过的 SQL、行数照样看得到', (await text(q2.locator('[data-inquiry-sql]'))).startsWith('SELECT member_id')
&& (await text(q2.locator('[data-inquiry-rows]'))) === '50 行')
await page.evaluate(() => {
const el = [...document.querySelectorAll('[data-evidence-report="write"] [data-block]')].find((b) => b.textContent.includes('枫岭区'))
const range = document.createRange()
@@ -716,10 +806,442 @@ await section('运行详情「证据」页签:封存了的探索运行有入
})
await page.waitForTimeout(200)
check('正式运行封存后:划选也不浮出「质询」', await page.locator('[data-inquiry-selection]').count() === 0)
check('正式运行封存后:不取问答列表', hits.lists.length === 0, JSON.stringify(hits.lists))
check('正式运行封存后:按运行和报告取问答列表,没有发出提问', hits.lists.length === 1 && hits.lists[0].run === RUNS.sealed && hits.asks.length === 0,
JSON.stringify(hits.lists))
check('正式运行:页面没有报错', errors.length === 0, errors.join(';'))
await ctx.close()
}
{
// 没有任何问答的正式运行:什么都不画
const { ctx, page, errors } = await open('inquiry-run-formal')
await page.locator('[data-evidence-report="write"] [data-seg="s4"]').first().waitFor()
await page.waitForTimeout(300)
check('正式运行封存后、没有问答:不画问答串', await page.locator('[data-inquiry], [data-inquiry-ask]').count() === 0)
check('没有问答的正式运行:页面没有报错', errors.length === 0, errors.join(';'))
await ctx.close()
}
})
await section('有表格的报告:悬停句子、表格的格子都不挪动排版;表格的格子不挂「质询」', async () => {
// 报告文档换成带表格的那份(run/__tests__/evidence-doc.json):表里带出处的格子也是结论句
const ev = JSON.parse(readFileSync(`${root}frontend/src/run/__tests__/evidence-doc.json`, 'utf8'))
const { ctx, page, errors } = await open('inquiry-approval', { setup: (s) => {
s.docs[fx.reports.approve.doc_artifact] = { ...ev.doc, node_id: 'write' }
} })
await expand(page)
const table = page.locator('[data-approval-evidence-doc] table').first()
await table.waitFor()
check('表格的格子不挂「质询」', await table.locator('[data-inquiry-ask]').count() === 0
&& await table.locator('[data-ev-unit]').count() === 0)
check('表格外的结论句照常挂', await page.locator('[data-approval-evidence-doc] [data-inquiry-ask]').count() >= 5)
const before = await layout(page)
const cell = table.locator('td [data-seg]').first()
await cell.hover()
await page.waitForTimeout(100)
const mid = await layout(page)
check('悬停表格里有出处的格子:列宽、正文都不变', moved(before, mid).length === 0, moved(before, mid).slice(0, 3).join(';'))
const units = await page.locator('[data-approval-evidence-doc] [data-ev-unit]').evaluateAll((els) => els.map((el) => el.getAttribute('data-ev-unit')))
const shifted = []
for (const u of units) {
await hoverUnit(page, u)
await page.waitForTimeout(60)
const now = await layout(page)
// 悬停时页面为了把那一句放到中间滚动过:比相对位置,不比绝对位置
const base = before.get(u)
const dy = now.get(u).y - base.y
const shift = [...before].filter(([k, v]) => { const w = now.get(k); return !w || Math.abs(w.x - v.x) > 0.5 || Math.abs(w.y - dy - v.y) > 0.5 || Math.abs(w.w - v.w) > 0.5 })
if (shift.length) shifted.push(`${u}:${shift.slice(0, 2).map(([k]) => k).join('、')}`)
}
check('逐句悬停:正文、表格一处都不挪', shifted.length === 0, shifted.slice(0, 3).join(';'))
check('页面没有报错', errors.length === 0, errors.join(';'))
await ctx.close()
})
await section('Esc、「取消」关掉提问框:焦点回到那一句的「质询」,并滚回视野', async () => {
for (const [width, height] of [[390, 844], [1100, 700]]) {
const { ctx, page, errors } = await open('inquiry-approval', { width, height })
await expand(page)
await page.locator('[data-seg="s4"]').focus()
await page.keyboard.press('Tab')
await page.keyboard.press('Enter')
await composer(page).waitFor()
await page.waitForTimeout(200)
await page.keyboard.press('Escape')
check(`${width}px:Esc 后焦点回到 u1 的「质询」`, await until(async () => (await active(page))?.ask === 'u1'), JSON.stringify(await active(page)))
await page.waitForTimeout(150)
const shown = await focusShown(page)
check(`${width}px:……它在视野里(页面、报告框都滚到它),键盘用户看得见焦点`, shown.ok, JSON.stringify(shown))
await clickAsk(page, 'u4')
await composer(page).waitFor()
await page.waitForTimeout(200)
await composer(page).locator('button', { hasText: '取消' }).click()
check(`${width}px:点「取消」后焦点回到 u4 的「质询」`, await until(async () => (await active(page))?.ask === 'u4'), JSON.stringify(await active(page)))
await page.waitForTimeout(150)
check(`${width}px:……同样在视野里`, (await focusShown(page)).ok, JSON.stringify(await focusShown(page)))
check(`${width}px:页面没有报错`, errors.length === 0, errors.join(';'))
await ctx.close()
}
})
await section('同一张审批卡同时挂在两处(画布的审批浮层 + 右栏):提问框只在点的那一处,焦点不跳走', async () => {
const { ctx, page, hits, state, errors } = await open('inquiry-approval-twice', { width: 1400, height: 900 })
const pop = page.locator('[data-fixture-pop]')
const panel = page.locator('[data-fixture-panel]')
await pop.locator('[data-inquiry-entry="31"]').waitFor()
await panel.locator('[data-inquiry-entry="31"]').waitFor()
const where = () => page.evaluate(() => {
const a = document.activeElement
return { pop: !!a?.closest('[data-fixture-pop]'), panel: !!a?.closest('[data-fixture-panel]'), input: !!a?.hasAttribute('data-inquiry-input') }
})
await pop.locator('[data-inquiry-ask-report]').click()
await pop.locator('[data-inquiry-composer]').waitFor()
await page.waitForTimeout(200)
check('在浮层里点「就整份报告提问」:只有浮层里出提问框', await page.locator('[data-inquiry-composer]').count() === 1
&& await pop.locator('[data-inquiry-composer]').count() === 1)
check('焦点在浮层的输入框里,没有跳到右栏', JSON.stringify(await where()) === JSON.stringify({ pop: true, panel: false, input: true }),
JSON.stringify(await where()))
check('右栏同一组写明提问框在另一处打开', (await text(panel.locator('[data-inquiry-group="report"] [data-inquiry-draft-elsewhere]'))).startsWith('提问框已在另一处打开'))
await page.keyboard.type('浮层里写的问题')
check('敲的字进浮层的输入框,右栏没有第二个输入框', (await pop.locator('[data-inquiry-input]').inputValue()) === '浮层里写的问题'
&& await panel.locator('[data-inquiry-input]').count() === 0)
// 句子上的「质询」:两处都展开逐段证据,在浮层里点
await pop.locator('[data-approval-evidence]').click()
await pop.locator('[data-approval-evidence-doc] [data-block]').first().waitFor()
await panel.locator('[data-approval-evidence]').click()
await panel.locator('[data-approval-evidence-doc] [data-block]').first().waitFor()
await clickAsk(page, 'u3', pop)
await page.waitForTimeout(200)
check('在浮层里点一句的「质询」:提问框换到那一句,仍只在浮层里,问题留着', await page.locator('[data-inquiry-composer]').count() === 1
&& (await pop.locator('[data-inquiry-composer]').getAttribute('data-inquiry-composer')) === 'unit'
&& (await pop.locator('[data-inquiry-input]').inputValue()) === '浮层里写的问题')
check('……焦点仍在浮层里', JSON.stringify(await where()) === JSON.stringify({ pop: true, panel: false, input: true }), JSON.stringify(await where()))
await page.keyboard.press('Escape')
check('Esc:焦点回到浮层里那一句的「质询」', await until(async () => (await where()).pop && (await active(page))?.ask === 'u3'),
JSON.stringify(await where()))
// 浮层关掉:右栏接过提问框,但不抢焦点
await clickAsk(page, 'u3', pop)
await pop.locator('[data-inquiry-input]').fill('关浮层之前写的问题')
await page.locator('[data-fixture-toggle]').click()
await panel.locator('[data-inquiry-composer]').waitFor()
await page.waitForTimeout(200)
check('浮层关了:右栏接过提问框,问题还在', await page.locator('[data-inquiry-composer]').count() === 1
&& (await panel.locator('[data-inquiry-input]').inputValue()) === '关浮层之前写的问题')
check('……接过来时不抢焦点(焦点还在刚点的按钮上)', await page.evaluate(() => document.activeElement?.hasAttribute('data-fixture-toggle')))
// 再打开浮层:提问框留在右栏,浮层里可以「在这里继续」
await page.locator('[data-fixture-toggle]').click()
await pop.locator('[data-inquiry-draft-elsewhere]').waitFor()
check('再打开浮层:提问框仍在右栏,浮层写明在另一处打开', await panel.locator('[data-inquiry-composer]').count() === 1
&& await pop.locator('[data-inquiry-composer]').count() === 0)
await pop.locator('[data-inquiry-draft-elsewhere] button').click()
await pop.locator('[data-inquiry-composer]').waitFor()
await page.waitForTimeout(150)
check('点「在这里继续」:提问框挪到浮层,焦点跟过去,问题还在', await panel.locator('[data-inquiry-composer]').count() === 0
&& (await pop.locator('[data-inquiry-input]').inputValue()) === '关浮层之前写的问题'
&& JSON.stringify(await where()) === JSON.stringify({ pop: true, panel: false, input: true }))
// 在浮层里提交:回答期间、回答回来时焦点都只在浮层里交接
let release
state.gate = new Promise((r) => { release = r })
await pop.locator('[data-inquiry-submit]').click()
await pop.locator('[data-inquiry-pending]').waitFor()
await page.waitForTimeout(150)
check('回答期间:两处都显示正在回答,焦点接到浮层里那一条', await page.locator('[data-inquiry-pending]').count() === 2
&& await page.evaluate(() => !!document.activeElement?.closest('[data-fixture-pop] [data-inquiry-pending]')))
check('回答期间:两处的批准都不可用', await page.locator('[data-approval]').getByRole('button', { name: '批准', exact: true }).evaluateAll((els) => els.length === 2 && els.every((el) => el.disabled)))
release()
await pop.locator('[data-inquiry-entry="40"]').waitFor()
await panel.locator('[data-inquiry-entry="40"]').waitFor()
await page.waitForTimeout(200)
check('回答回来:两处都挂上,焦点接到浮层里的回答', await page.evaluate(() => !!document.activeElement?.closest('[data-fixture-pop] [data-inquiry-entry="40"]')))
const heard = await page.evaluate(() => [...document.querySelectorAll('[data-inquiry-live]')].map((el) => [!!el.closest('[data-fixture-pop]'), el.textContent]))
check('「已收到回答」只在问的那一处播报', heard.filter(([, t]) => t).length === 1 && heard.find(([, t]) => t)?.[0] === true, JSON.stringify(heard))
check('只发出一次提问', hits.asks.length === 1 && hits.asks[0].body.approval_id === fx.approval_id)
check('页面没有报错', errors.length === 0, errors.join(';'))
await ctx.close()
})
await section('同一份报告先后两张审批:换了审批,上一张的提问框不跟过来,提问带新的那张', async () => {
for (const syn of ['inquiry-approval-steps', 'inquiry-approval-steps-same']) {
const { ctx, page, hits, errors } = await open(syn, { setup: (s) => {
s.approvals[RUNS.approve] = [approvalRow(fx.approval_id, RUNS.approve, fx.reports.approve.doc_artifact),
approvalRow('ap-inq-3', RUNS.approve, fx.reports.approve.doc_artifact)]
} })
await entry(page, 31).waitFor()
await page.locator('[data-inquiry-ask-report]').click()
await page.locator('[data-inquiry-input]').fill('上一张审批上没发出去的问题')
await page.locator('[data-fixture-next]').click()
await page.locator('[data-approval="ap-inq-3"]').waitFor()
await entry(page, 31).waitFor()
await page.waitForTimeout(200)
check(`${syn}:换到下一张审批,上一张的提问框不跟过来`, await composer(page).count() === 0)
check(`${syn}:也没抢焦点`, !(await active(page))?.input)
check(`${syn}:上一级审批问过的(同一份报告)照样挂在句子下面`, JSON.stringify((await groupsOf(page)).map((g) => [g.unit, g.entries]))
=== JSON.stringify([['u2', ['31']], ['u3', ['34']], ['u4', ['36']]]), JSON.stringify((await groupsOf(page)).map((g) => [g.unit, g.entries])))
await page.locator('[data-inquiry-ask-report]').click()
await composer(page).waitFor()
check(`${syn}:在新的审批上点提问:从头写`, (await page.locator('[data-inquiry-input]').inputValue()) === '')
await page.locator('[data-inquiry-input]').fill('总监的问题')
await page.locator('[data-inquiry-submit]').click()
await entry(page, 40).waitFor()
check(`${syn}:请求体带的是新的那张审批`, hits.asks.length === 1 && hits.asks[0].body.approval_id === 'ap-inq-3',
JSON.stringify(hits.asks.map((a) => a.body)))
check(`${syn}:回答挂在句子下面(问的就是这一稿)`, await group(page, 'report').locator('[data-inquiry-entry="40"]').count() === 1)
check(`${syn}:页面没有报错`, errors.length === 0, errors.join(';'))
await ctx.close()
}
})
await section('之前几轮审批里问的:报告改写过的不按句子编号挂,单独一组', async () => {
const prev = { ...clone(fx.items[0]), seq: 12, approval_id: 'ap-inq-0', question: '上一轮:退款率 3.5% 是怎么算的?', at: '2026-10-05T05:40:00Z' }
const level = { ...clone(fx.items[1]), seq: 20, approval_id: 'ap-inq-9', question: '主管那一级问的:新客怎么界定?', at: '2026-10-05T05:50:00Z' }
const lost = { ...clone(fx.items[1]), seq: 22, approval_id: 'ap-gone', question: '审批列表里找不到这张审批', at: '2026-10-05T05:55:00Z' }
const { ctx, page, hits, errors } = await open('inquiry-approval', { setup: (s) => {
s.lists[RUNS.approve] = [prev, level, lost, ...fx.items]
s.approvals[RUNS.approve] = [approvalRow(fx.approval_id, RUNS.approve, fx.reports.approve.doc_artifact),
approvalRow('ap-inq-9', RUNS.approve, fx.reports.approve.doc_artifact), approvalRow('ap-inq-0', RUNS.approve, '0'.repeat(64))]
} })
await entry(page, 12).waitFor()
await entry(page, 36).waitFor()
const groups = await groupsOf(page)
// 组按各自最早的那一条排:主管那一级问新客的那条最早
check('同一稿报告上问的照常按句子挂(另一级审批审的是同一份文档的也挂上)',
JSON.stringify(groups.slice(0, 3).map((g) => [g.unit, g.entries])) === JSON.stringify([['u3', ['20', '34']], ['u2', ['31']], ['u4', ['36']]]),
JSON.stringify(groups.map((g) => [g.unit, g.entries])))
const other = groups.at(-1)
check('报告改写前问的、对不上是哪一稿的:单独一组,排在最后', other?.unit === 'other' && JSON.stringify(other.entries) === JSON.stringify(['12', '22']),
JSON.stringify(other))
check('那一组写明是报告其他版本上的质询,不拿当前报告同编号的句子当抬头', other?.about.startsWith('报告其他版本上的质询')
&& !other.about.includes('退款金额'), other?.about)
check('次数照算全部', (await text(page.locator('[data-inquiry-count]'))) === '· 6 次')
check('按运行取一次审批列表(全部状态)', hits.approvals.length === 1 && hits.approvals[0].run === RUNS.approve && hits.approvals[0].status === 'all',
JSON.stringify(hits.approvals))
// 那一组里的回答引用了报告已有的出处:不在当前这一稿里找
const e = entry(page, 12)
await e.locator('[data-inquiry-answer-doc]').waitFor()
check('报告其他版本上的回答:引用报告出处的数,读屏说是提问时那一版里的', ((await e.locator('[data-inquiry-cite="report"]').getAttribute('aria-label')) ?? '')
.includes('提问时那一版报告中的出处'))
await e.locator('[data-inquiry-cite="report"]').click()
await page.waitForTimeout(200)
check('……点了不去当前报告里打开证据,照文档记下的出处说一句', await page.locator('[data-approval-evidence-doc]').count() === 0
&& (await text(e.locator('[data-inquiry-elsewhere]'))).includes('提问时那一版报告中的出处'))
check('页面没有报错', errors.length === 0, errors.join(';'))
await ctx.close()
{
// 审批列表取不到:别的审批里问的一律单独一组(宁可不挂,不挂错)
const { ctx, page, errors } = await open('inquiry-approval', { setup: (s) => { s.lists[RUNS.approve] = [prev, level, ...fx.items] } })
await entry(page, 12).waitFor()
const g = await groupsOf(page)
check('审批列表取不到:别的审批里问的都单独一组', JSON.stringify(g.at(-1)) !== undefined && g.at(-1).unit === 'other'
&& JSON.stringify(g.at(-1).entries) === JSON.stringify(['12', '20']), JSON.stringify(g.map((x) => [x.unit, x.entries])))
check('审批列表取不到:页面没有报错', errors.length === 0, errors.join(';'))
await ctx.close()
}
{
// 运行详情:显示的是封存的那一稿。封存之后问的、审批时审的就是这一稿的挂句子下面,更早那一稿上问的单独一组
const early = { ...clone(fx.items[0]), seq: 14, approval_id: 'ap-early', question: '改写前:退款率是不是算错了?', at: '2026-10-05T05:10:00Z' }
const last = { ...clone(fx.items[1]), seq: 30, approval_id: 'ap-last', question: '最后一轮审批:新客口径?', at: '2026-10-05T06:10:00Z' }
const { ctx, page, errors } = await open('inquiry-run-explore', { setup: (s) => {
s.lists[RUNS.explore] = [early, last, ...fx.explore_items]
s.approvals[RUNS.explore] = [approvalRow('ap-early', RUNS.explore, '1'.repeat(64)),
approvalRow('ap-last', RUNS.explore, fx.reports.explore.doc_artifact)]
} })
await page.locator('[data-inquiry="write"] [data-inquiry-entry="14"]').waitFor()
const g = await groupsOf(page)
check('运行详情:封存之后问的、最后一轮审批时问的挂句子下面,改写前那一稿上问的单独一组',
JSON.stringify(g.map((x) => [x.unit, x.entries])) === JSON.stringify([['u3', ['30']], ['u2', ['58']], ['other', ['14']]]),
JSON.stringify(g.map((x) => [x.unit, x.entries])))
check('运行详情:页面没有报错', errors.length === 0, errors.join(';'))
await ctx.close()
}
})
await section('回答期间另开了提问框,正在回答的那一条没问成:新写的不被覆盖', async () => {
const { ctx, page, state, errors } = await open('inquiry-approval')
await expand(page)
let release
state.gate = new Promise((r) => { release = r })
state.reply = { status: 500, json: { detail: '核查模型调用超时,请稍后重试' } }
await page.locator('[data-seg="s4"]').focus()
await page.keyboard.press('Tab')
await page.keyboard.press('Enter')
await page.locator('[data-inquiry-input]').fill('第一个问题:销售额含不含线上?')
await page.locator('[data-inquiry-submit]').click()
await page.locator('[data-inquiry-pending]').waitFor()
await page.waitForTimeout(3300)
const status = await page.locator('[data-inquiry-pending] [role="status"]').evaluateAll((els) => els.map((el) => el.textContent.trim()))
check('等了几秒:播报区只有不变的那句,「已等待 N 秒」在播报区外、读屏不读', JSON.stringify(status) === JSON.stringify(['正在查询与回答,通常需要几十秒'])
&& (await page.locator('[data-inquiry-waited]').getAttribute('aria-hidden')) === 'true'
&& (await text(page.locator('[data-inquiry-waited]'))).startsWith('· 已等待'), JSON.stringify(status))
await page.locator('[data-inquiry-ask-report]').click()
await page.locator('[data-inquiry-input]').fill('第二个问题:数据截止到哪天?')
release()
await page.locator('[data-inquiry-failed-ask]').waitFor()
await page.waitForTimeout(150)
check('提问框原样留着:还是整份报告、还是新写的问题', await composer(page).count() === 1
&& (await composer(page).getAttribute('data-inquiry-composer')) === 'report'
&& (await page.locator('[data-inquiry-input]').inputValue()) === '第二个问题:数据截止到哪天?')
const failedAsk = group(page, 'u1').locator('[data-inquiry-failed-ask]')
check('没问成的那一条挂在它那一句下面:问题和服务端的说明都在', (await text(failedAsk.locator('[data-inquiry-question]'))) === '第一个问题:销售额含不含线上?'
&& (await text(failedAsk.locator('[data-inquiry-error]'))) === '没有问成:核查模型调用超时,请稍后重试', await text(failedAsk))
check('……原因用 role=alert 报出', (await failedAsk.locator('[data-inquiry-error]').getAttribute('role')) === 'alert')
check('提问框开着时不给「放回提问框」(免得盖掉新写的)', await failedAsk.locator('[data-inquiry-reask]').count() === 0)
await composer(page).locator('button', { hasText: '取消' }).click()
await failedAsk.locator('[data-inquiry-reask]').click()
await composer(page).waitFor()
check('放回提问框:就那一句、原来的问题,没问成的那一条拿掉', (await composer(page).getAttribute('data-inquiry-composer')) === 'unit'
&& (await page.locator('[data-inquiry-input]').inputValue()) === '第一个问题:销售额含不含线上?'
&& await group(page, 'u1').locator('[data-inquiry-composer]').count() === 1 && await page.locator('[data-inquiry-failed-ask]').count() === 0)
check('批准、驳回照常可用', !(await approve(page).isDisabled()))
check('页面没有报错', errors.length === 0, errors.join(';'))
await ctx.close()
})
await section('回答回来时读屏播报,连着两次都报', async () => {
const { ctx, page, state, errors } = await open('inquiry-approval')
await entry(page, 31).waitFor()
await page.evaluate(() => {
const live = document.querySelector('[data-inquiry="write"] [data-inquiry-live]')
window.__heard = []
new MutationObserver(() => { const t = live.textContent; if (t) window.__heard.push(t) })
.observe(live, { childList: true, characterData: true, subtree: true })
})
let seq = 100
state.reply = (body) => ({ status: 200, json: answerFor(body, { inquiry: { ...answerFor(body).inquiry, seq: seq++ } }) })
for (const n of [100, 101]) {
await page.locator('[data-inquiry-ask-report]').click()
await page.locator('[data-inquiry-input]').fill('数据截止到哪一天?')
await page.locator('[data-inquiry-submit]').click()
await entry(page, n).waitFor()
await page.waitForTimeout(200)
}
const heard = await page.evaluate(() => window.__heard)
check('同一个问题问两遍,两次回答各报一遍「已收到回答」', heard.length === 2 && heard.every((t) => t === '已收到回答:数据截止到哪一天?'),
JSON.stringify(heard))
check('页面没有报错', errors.length === 0, errors.join(';'))
await ctx.close()
})
await section('花费估不出不写 $0;查询列表收着也写截断;核对不了、猜测来源的字照报告的样子画', async () => {
const items = clone(fx.items)
items[0].usage = { input_tokens: 5210, output_tokens: 640, cost_usd: 0, calls: 3, unpriced_calls: 3 }
items[1].usage = { input_tokens: 5210, output_tokens: 640, cost_usd: 0.002, calls: 3, unpriced_calls: 1 }
const docId = items[0].answer.doc_artifact
const doc = clone(fx.docs[docId])
// 回答里再加一句:一个核对不了的表名、一个按数值猜测来源的数
doc.blocks.push({ id: 'b9', type: 'paragraph', units: [{ id: 'u9', kind: 'claim', segments: [
{ id: 's90', kind: 'structural', text: '\n\n', state: 'neutral' },
{ id: 's91', kind: 'text', text: '退款明细在 ', state: 'neutral' },
{ id: 's92', kind: 'entity', text: 'refund_items', state: 'none', issue: 'unverified_entity' },
{ id: 's93', kind: 'text', text: ' 表中,约占 ', state: 'neutral' },
{ id: 's94', kind: 'number', text: '96%', state: 'candidate' },
{ id: 's95', kind: 'text', text: '。', state: 'neutral' },
] }] })
const { ctx, page, errors } = await open('inquiry-approval', { setup: (s) => { s.lists[RUNS.approve] = items; s.docs[docId] = doc } })
const e1 = entry(page, 31)
await e1.locator('[data-inquiry-answer-doc]').waitFor()
const c1 = e1.locator('[data-inquiry-cost]')
check('模型都不在价格目录中:写「金额无法估算」,不写 $0', (await text(c1)) === '· 金额无法估算'
&& ((await c1.getAttribute('title')) ?? '').startsWith('3 次调用的模型不在价格目录中'), await text(e1.locator('[data-inquiry-judged]')))
check('部分调用估不出金额:写「≥」', (await text(entry(page, 34).locator('[data-inquiry-cost]'))) === '· 花费 ≥ $0.002',
await text(entry(page, 34).locator('[data-inquiry-judged]')))
const e2 = entry(page, 34)
check('查询列表收着时也写明有几次结果被截断', (await e2.locator('[data-inquiry-queries-toggle]').getAttribute('aria-expanded')) === 'false'
&& (await text(e2.locator('[data-inquiry-queries-truncated]'))) === '· 其中 1 次结果已截断'
&& await e1.locator('[data-inquiry-queries-truncated]').count() === 0)
const marks = await e1.locator('.ev-mark').evaluateAll((els) => els.map((el) => [el.getAttribute('data-ev-state'), el.getAttribute('data-ev-line'),
getComputedStyle(el).textDecorationStyle, el.textContent]))
check('核对不了的名称、猜测的来源:点状线,读屏听得到是什么状态', JSON.stringify(marks.map((m) => m.slice(0, 3))) === JSON.stringify([
['unverified', 'dotted', 'dotted'], ['candidate', 'dotted', 'dotted']]) && marks[0][3].includes('(') && marks[1][3].includes('('),
JSON.stringify(marks))
check('页面没有报错', errors.length === 0, errors.join(';'))
await ctx.close()
})
await section('没有逐段证据的报告:只说可以就整份报告提问;列表取回来之前不说「还没有问答」', async () => {
let release
const { ctx, page, errors } = await open('inquiry-approval-nodoc', { waitUntil: 'load', setup: (s) => {
s.lists[RUNS.approve] = []
s.listGate = new Promise((r) => { release = r })
} })
await page.locator('[data-inquiry="write"]').waitFor()
await page.waitForTimeout(300)
check('列表还在取:不说「还没有问答、可以这样问」', await page.locator('[data-inquiry-intro]').count() === 0)
release()
await page.locator('[data-inquiry-intro]').waitFor()
check('没有逐段证据:没有「展开逐段证据」,提示只说就整份报告提问', await page.locator('[data-approval-evidence]').count() === 0
&& (await text(page.locator('[data-inquiry-intro]'))) === '这份报告没有逐段证据,可以就整份报告提问')
check('页面没有报错', errors.length === 0, errors.join(';'))
await ctx.close()
})
await section('设置里关了审批质询:不给入口,写明已关闭,之前的问答照列', async () => {
const { ctx, page, hits, errors } = await open('inquiry-approval', { setup: (s) => { s.settings = { verify: { inquiry: { enabled: false } } } } })
await entry(page, 31).waitFor()
await page.waitForTimeout(200)
check('关了:没有「就整份报告提问」,写明已在设置中关闭', await page.locator('[data-inquiry-ask-report]').count() === 0
&& (await text(page.locator('[data-inquiry-disabled]'))) === '审批质询已在设置中关闭')
check('关了:之前的问答照列', await page.locator('[data-inquiry-entry]').count() === 3)
await expand(page)
check('关了:报告上没有「质询」', await page.locator('[data-inquiry-ask]').count() === 0)
await select(page, 'u4', '枫岭区销售额最高')
await page.waitForTimeout(200)
check('关了:划选也不浮出「质询」', await page.locator('[data-inquiry-selection]').count() === 0)
check('关了:没有发出提问', hits.asks.length === 0)
check('页面没有报错', errors.length === 0, errors.join(';'))
await ctx.close()
})
await section('遮罩:接口给了原值、只在快照里记着遮罩列时,表格、悬停说明、导出的 CSV 都不出原值', async () => {
const raw = clone(fx.snapshots[MEMBERS])
raw.rows = raw.rows.map((r, i) => [r[0], `139-0000-${1000 + i}`, r[2], r[3]])
delete raw.masked
raw.mask_columns = ['phone']
const { ctx, page, errors } = await open('inquiry-approval', { setup: (s) => { s.snaps[MEMBERS] = { id: MEMBERS, content: raw } } })
const e2 = entry(page, 34)
await e2.locator('[data-inquiry-answer-doc]').waitFor()
await e2.locator('[data-inquiry-cite="Q2"]').click()
const q2 = e2.locator('[data-inquiry-query="Q2"]')
await q2.locator('[data-inquiry-snapshot="ok"]').waitFor()
const leak = await q2.evaluate((el) => [el.innerText, ...[...el.querySelectorAll('[title]')].map((x) => x.getAttribute('title'))]
.filter((t) => t && t.includes('139-0000')).length)
check('表格里、悬停说明里都没有原值', leak === 0 && await q2.locator('td[data-masked]').count() === 50, `${leak} 处`)
const [download] = await Promise.all([page.waitForEvent('download'), q2.getByRole('button', { name: 'CSV' }).click()])
const csv = readFileSync(await download.path(), 'utf8')
check('导出的 CSV 也没有原值,写「已遮罩」', !csv.includes('139-0000') && csv.includes('已遮罩'), csv.split('\n').slice(0, 2).join(' | '))
check('页面没有报错', errors.length === 0, errors.join(';'))
await ctx.close()
})
await section('写进运行记录的问答:画布正显示这次运行、没在接流时补接一次,时间线收到那一条', async () => {
const urls = []
const { ctx, page, state, errors } = await open('inquiry-approval', { ws: (ws) => {
urls.push(ws.url())
ws.send(JSON.stringify({ seq: 40, type: 'approval.inquiry', node_id: null, ts: 1759646000, data: {
question: '这份报告的数据截止到哪一天?', quote: null, report: 'write', unit: null, approval_id: fx.approval_id,
answer_text: '截止到 9 月 30 日。', answer_doc: null, ok: true, limit: null, error: null, inquiry_artifacts: [], queries: [] } }))
ws.send(JSON.stringify({ seq: 0, type: 'stream.end', node_id: null, ts: 0, data: { status: 'interrupted' } }))
} })
await entry(page, 31).waitFor()
// 画布正显示这次运行:停在审批上,流已经收尾(streaming 为假),手上最大的序号是 39
await page.evaluate(([run]) => window.__studio.setState({ run: { id: run, status: 'interrupted', run_class: 'exploratory' },
streaming: false, unsubscribe: null, events: [{ seq: 39, type: 'human.requested', node_id: 'review', ts: 1759645000, data: {} }] }), [RUNS.approve])
await page.locator('[data-inquiry-ask-report]').click()
await page.locator('[data-inquiry-input]').fill('这份报告的数据截止到哪一天?')
await page.locator('[data-inquiry-submit]').click()
await entry(page, 40).waitFor()
check('补接事件流:从手上最大的序号之后接', await until(async () => urls.length === 1) && urls[0].includes('after=39'), urls.join('、'))
check('那一条质询事件进了画布的事件(时间线据此出「审批人质询」)', await until(() => page.evaluate(() =>
window.__studio.getState().events.some((e) => e.type === 'approval.inquiry' && e.seq === 40))))
// 没写进运行记录的不补接
state.reply = (body) => ({ status: 200, json: answerFor(body, { inquiry: { ...answerFor(body).inquiry, seq: null }, recorded: false,
note: '运行已不在等待审批' }) })
await page.locator('[data-inquiry-ask-report]').click()
await page.locator('[data-inquiry-input]').fill('再问一句')
await page.locator('[data-inquiry-submit]').click()
await page.locator('[data-inquiry-recorded="false"]').waitFor()
await page.waitForTimeout(300)
check('没写进运行记录的不补接', urls.length === 1, urls.join('、'))
check('页面没有报错', errors.length === 0, errors.join(';'))
await ctx.close()
})
/** 页面和某个容器都没有横向滚动(表格、SQL 框自己的滚动条不算) */
@@ -783,11 +1305,14 @@ await section('窄屏与深浅色', async () => {
await composer(page).waitFor()
const o = await overflow(page, '[data-approval]')
check(`390px(${theme}):展开逐段证据、查询结果、提问框也没有横向溢出`, o.page <= 0 && o.box <= 1, JSON.stringify(o))
// 「质询」量的是悬停这一句时显出来的样子(平时是淡淡的提示,悬停、焦点在这一句上时才是要读清的那个按钮)
await hoverUnit(page, 'u1')
await page.waitForTimeout(100)
const ratios = {
unrecorded: await ratioOf(page, '[data-inquiry-unrecorded]'),
limit: await ratioOf(page, '[data-inquiry-limit]'),
failed: await ratioOf(page, '[data-inquiry-failed]'),
ask: await ratioOf(page, '[data-inquiry-ask="u1"] > span'),
ask: await ratioOf(page, '[data-inquiry-ask="u1"]'),
judged: await ratioOf(page, '[data-inquiry-judged]'),
meta: await ratioOf(page, '[data-inquiry-meta]'),
}
@@ -805,6 +1330,19 @@ await section('窄屏与深浅色', async () => {
check(`390px(${theme}):页面没有报错`, errors.length === 0, errors.join(';'))
await ctx.close()
}
for (const theme of ['dark', 'light']) {
// 回答期间批准 / 驳回下面那句原因:审批卡铺着一层提醒色的底,提醒色的字在浅色主题下不够深
const { ctx, page, state, errors } = await open('inquiry-approval', { theme })
state.gate = new Promise(() => {})
await page.locator('[data-inquiry-ask-report]').click()
await page.locator('[data-inquiry-input]').fill('回答期间的说明看得清吗')
await page.locator('[data-inquiry-submit]').click()
await page.locator('[data-approval-inquiry-busy]').waitFor()
const r = await ratioOf(page, '[data-approval-inquiry-busy]')
check(`${theme}:回答期间批准 / 驳回不可用的原因,对比度 ≥ 4.5`, r >= 4.5, r.toFixed(2))
check(`${theme}:页面没有报错`, errors.length === 0, errors.join(';'))
await ctx.close()
}
{
const { ctx, page, errors } = await open('inquiry-approval-dense', { width: 1100, height: 900 })
await page.locator('[data-inquiry-entry="34"] [data-inquiry-answer-doc]').waitFor()