From eb7151392469d841f5cc019fa93e5190edcd5825 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 09:57:34 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=A1=E6=89=B9=E8=B4=A8=E8=AF=A2=EF=BC=88?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=EF=BC=89=E5=A4=8D=E6=A0=B8=E6=95=B4=E6=94=B9?= =?UTF-8?q?=EF=BC=88=E7=AC=AC=201=20=E8=BD=AE=EF=BC=89=EF=BC=9A=E5=90=8C?= =?UTF-8?q?=E4=B8=80=E5=BC=A0=E5=8D=A1=E6=8C=82=E4=B8=A4=E5=A4=84=E3=80=81?= =?UTF-8?q?=E6=8D=A2=E4=BA=86=E5=AE=A1=E6=89=B9=E3=80=81=E6=8A=A5=E5=91=8A?= =?UTF-8?q?=E6=94=B9=E5=86=99=E5=89=8D=E7=9A=84=E9=97=AE=E7=AD=94=E5=90=84?= =?UTF-8?q?=E5=BD=92=E5=85=B6=E4=BD=8D=EF=BC=8C=E5=8F=A5=E6=9C=AB=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=E4=B8=8D=E5=86=8D=E6=8C=A4=E5=8A=A8=E6=AD=A3=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 提问框记下打开它的那一处(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 --- frontend/src/dev/fixtures/inquiry.tsx | 71 ++- frontend/src/lib/terms.ts | 32 +- frontend/src/pages/runs/EvidencePane.tsx | 15 +- frontend/src/pages/runs/useRunDetail.ts | 11 + frontend/src/run/ApprovalReport.tsx | 31 +- frontend/src/run/EvidenceDoc.tsx | 30 +- frontend/src/run/Inquiry.tsx | 554 ++++++++++++++++++---- frontend/src/run/RunPanel.tsx | 12 +- scripts/check-all.mjs | 2 +- scripts/check-inquiry.mjs | 578 ++++++++++++++++++++++- 10 files changed, 1169 insertions(+), 167 deletions(-) diff --git a/frontend/src/dev/fixtures/inquiry.tsx b/frontend/src/dev/fixtures/inquiry.tsx index 3ad1ac5c..88e69b3d 100644 --- a/frontend/src/dev/fixtures/inquiry.tsx +++ b/frontend/src/dev/fixtures/inquiry.tsx @@ -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 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 ( +
+ {pop && createPortal( +
+ +
, + document.body, + )} +
+ +
+ +
+ ) +} + +/** 同一份报告先后两张审批:「处理掉这张」之后换下一张(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 ( +
+ +
+ {keyed + ? undefined} /> + : undefined} />} +
+
+ ) +} + /** 运行详情:一次已经结束、封存了的运行 */ function runOf(id: string, runClass: 'formal' | 'exploratory'): Run { return { @@ -79,6 +129,15 @@ export const cases: Record JSX.Element> = { ), + 'inquiry-approval-nodoc': () => ( + + ), + 'inquiry-approval-twice': () => ( + + ), + 'inquiry-approval-steps': () => , + 'inquiry-approval-steps-same': () => , 'inquiry-run-explore': () => , 'inquiry-run-formal': () => , } diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 4f772874..c051db04 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -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: '结果已截断,数据库中还有更多数据', diff --git a/frontend/src/pages/runs/EvidencePane.tsx b/frontend/src/pages/runs/EvidencePane.tsx index ba97238d..3302d8d9 100644 --- a/frontend/src/pages/runs/EvidencePane.tsx +++ b/frontend/src/pages/runs/EvidencePane.tsx @@ -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 && ( - locateIn(handles.current.get(r.node_id!), doc, seg)} /> - )} + inquiry={inquiry} /> + locateIn(handles.current.get(r.node_id!), doc, seg)} /> ) : slot?.status === 'error' ? (

{T.docGone(labelOf(r.node_id) ?? r.node_id ?? '')}

diff --git a/frontend/src/pages/runs/useRunDetail.ts b/frontend/src/pages/runs/useRunDetail.ts index 95800ef8..961143d1 100644 --- a/frontend/src/pages/runs/useRunDetail.ts +++ b/frontend/src/pages/runs/useRunDetail.ts @@ -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) diff --git a/frontend/src/run/ApprovalReport.tsx b/frontend/src/run/ApprovalReport.tsx index e95b71f9..31852da4 100644 --- a/frontend/src/run/ApprovalReport.tsx +++ b/frontend/src/run/ApprovalReport.tsx @@ -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(() => (approvalId ? { approvalId } : null), [approvalId]) + const { access, disabled } = useInquiryAccess(askable && !!approvalId, approvalId ?? null) if (!reports.length) return null return ( <> - {reports.map((r) => )} + {reports.map((r) => ( + + ))} ) } -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 }: { )} {/* 审批质询:问答串(进入审批卡时按列表接口恢复)、提问框和「就整份报告提问」 */} - + ) } diff --git a/frontend/src/run/EvidenceDoc.tsx b/frontend/src/run/EvidenceDoc.tsx index b5538ac2..e086f702 100644 --- a/frontend/src/run/EvidenceDoc.tsx +++ b/frontend/src/run/EvidenceDoc.tsx @@ -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 } {body} ) diff --git a/frontend/src/run/Inquiry.tsx b/frontend/src/run/Inquiry.tsx index dd146636..90f4ff41 100644 --- a/frontend/src/run/Inquiry.tsx +++ b/frontend/src/run/Inquiry.tsx @@ -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 | null = null +const ENABLED_TTL = 30_000 + +function fetchEnabled(): Promise { + 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(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> + /** 为哪几张审批取过:取回来列表里也没有的(审批记录没了)不再为它重取 */ + asked: string[] } interface InquiryState { @@ -172,30 +245,53 @@ interface InquiryState { drafts: Record /** 正在回答的,按运行:一次运行同一时刻只有一条 */ pending: Record + /** 按运行:问答串据此分辨之前几轮审批里问的是不是眼前这一稿 */ + approvalDocs: Record + /** 这一页里写进了运行记录的问答各几条(按运行):运行详情据此补拉事件,时间线才出现「审批人质询」 */ + recorded: Record load: (runId: string, report: string) => Promise - compose: (runId: string, report: string, target: InquiryTarget, approvalId: string | null) => void + loadApprovalDocs: (runId: string, ids: string[]) => Promise + 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 + /** 接过没有主人的提问框(打开它的那一处卸掉了) */ + 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 + /** 没问成的那一条重新放进提问框 */ + 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() +/** 挂着的问答串(`报告键|那一处`):没问成、提问框要重新打开时,打开它的那一处已经卸掉了就不认它 */ +const liveHosts = new Set() +/** 提问框已经把焦点送进去过的 nonce:别处接过提问框(浮层关了、右栏接着显示)不算点了「质询」,不抢焦点 */ +const focusedNonce = new Map() +let nonceSeq = 0 let localSeq = 0 +const emptyThread = (): Thread => ({ status: 'ok', listed: [], local: [] }) + export const useInquiry = create((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((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> = {} + for (const a of Array.isArray(list) ? list : []) { + if (!a || typeof a.id !== 'string' || a.run_id !== runId) continue + const docs: Record = {} + 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((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((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(() => (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() @@ -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() 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(null) const lastRef = useRef(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(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 (
@@ -406,16 +662,18 @@ export function ReportInquiry({ runId, report, docArtifact, access, onLocate, po {count > 0 && · {INQUIRY_TEXT.headCount(count)}} - {access && ( + {access ? ( + ) : disabled && ( + {INQUIRY_TEXT.disabled} )} - {access && !count && !mine && !draft && ( + {access && fresh && (

- {postSeal ? INQUIRY_TEXT.introRun : INQUIRY_TEXT.intro} + {!docArtifact ? INQUIRY_TEXT.introNoDoc : postSeal ? INQUIRY_TEXT.introRun : INQUIRY_TEXT.intro}

)} {thread?.status === 'error' && ( @@ -424,23 +682,42 @@ export function ReportInquiry({ runId, report, docArtifact, access, onLocate, po )} -
{heard}
+
{heard}
{groups.length > 0 && (
{groups.map((g) => ( -
+
{g.entries.map((e) => ( - { lastRef.current = el } : undefined} /> ))} - {mine && groupKey(mine.target) === g.key && ( - { pendingRef.current = el }} /> - )} - {draft && groupKey(draft.target) === g.key && ( - + {g.key !== OTHER && ( + <> + {mine && groupKey(mine.target) === g.key && ( + { pendingRef.current = el }} /> + )} + {failed && groupKey(failed.target) === g.key && ( + { openers.delete(key); reask(runId, report, access!) }} + onDismiss={() => dismissFailed(key)} /> + )} + {ownDraft && access && groupKey(ownDraft.target) === g.key && ( + + )} + {otherDraft && access && groupKey(otherDraft.target) === g.key && ( +

+ {INQUIRY_TEXT.elsewhere} + +

+ )} + )}
@@ -451,8 +728,19 @@ export function ReportInquiry({ runId, report, docArtifact, access, onLocate, po ) } -/** 一组的抬头:问的是哪一句(原文从报告文档取,取不到写「报告中的一句」)、哪段原文,或者整份报告 */ +/** + * 一组的抬头:问的是哪一句(原文从报告文档取,取不到写「报告中的一句」)、哪段原文,或者整份报告。问的不是眼前这一稿的 + * 那一组只写明这一点:句子编号对不上,不去取原文 + */ function GroupHead({ group, units }: { group: Group; units: Map }) { + if (group.key === OTHER) { + return ( +
+ {INQUIRY_TEXT.otherDoc} + {INQUIRY_TEXT.otherDocHint} +
+ ) + } 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 {children} } +/** 回答的花费:调用过模型才写;模型不在价格目录里的估不出金额,不写「$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 (
{INQUIRY_TEXT.a}
- + {elsewhere != null && ( -

{INQUIRY_TEXT.reportSource(elsewhere)}

+

+ {otherDoc ? INQUIRY_TEXT.reportSourceOther(elsewhere) : INQUIRY_TEXT.reportSource(elsewhere)} +

)} {answer.ok && limit && (

{INQUIRY_TEXT.answerIncomplete(limit)}

@@ -578,14 +880,17 @@ function EntryView({ runId, entry, groupQuote, onLocate, focusRef }: { {answered && (

{INQUIRY_TEXT.answeredBy(inquiry.model)} - {cost != null && · {INQUIRY_TEXT.cost(formatCost(cost))}} + {cost && · {cost.text}}

)}
) } -/** 正在回答的那一条:问题、转圈和等了多久。回答期间审批卡的批准 / 驳回暂停(审批卡读 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 {INQUIRY_TEXT.q}

{pending.question}

-

+

- {INQUIRY_TEXT.pending} - {waited >= 3 && · {INQUIRY_TEXT.waited(waited)}} + {INQUIRY_TEXT.pending} + {waited >= 3 && }

) } +/** + * 回答期间另开了提问框、正在回答的那一条却没问成:问题和原因照原样留着(提问框里新写的不动)。提问框关着时可以把它 + * 放回提问框重问 + */ +function FailedView({ failed, alert, canReask, onReask, onDismiss }: { + failed: Failure; alert: boolean; canReask: boolean; onReask: () => void; onDismiss: () => void +}) { + return ( +
+
+ {INQUIRY_TEXT.q} +

+ {failed.question} +

+
+

+ {INQUIRY_TEXT.askFailed(failed.error.message)} +

+
+ {canReask && } + +
+
+ ) +} + /** * 提问框:问的是哪一句(或哪段原文、整份报告)、问题、提交。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(null) const form = useRef(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 (
{ e.preventDefault(); if (!empty && !over && !busy) void submit(runId, report) }}> + onKeyDown={onKeyDown} onSubmit={(e) => { e.preventDefault(); send() }}>
{INQUIRY_TEXT.target[target]}
{shown && (
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 + return } if (inquiry.answer.text) { return
@@ -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; onCite: (c: Cite) => void -}) { +interface AnswerCtx { doc: EvidenceDocData; own: Map; 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 (
- {doc.blocks.map((block, b) => )} + {doc.blocks.map((block, b) => )}
) } /** 一块:外观借 Markdown 的 BlockView,和报告文档、普通答案长得一样(块的分法同 EvidenceDoc 的 DocBlock) */ -function AnswerBlock({ block, doc, own, onCite }: { - block: EvidenceBlock; doc: EvidenceDocData; own: Map; onCite: (c: Cite) => void -}) { +function AnswerBlock({ block, ctx }: { block: EvidenceBlock; ctx: AnswerCtx }) { const units = block.units ?? [] - const unit = (u: EvidenceUnit, code = false) => + const unit = (u: EvidenceUnit, code = false) => switch (block.type) { case 'heading': return 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(['none', 'suspect']) + /** * 回答里有出处的一个数字指向哪:引用的是质询自己某次查询里的一格(目录条目的工件就是那次查询的快照,工件缺的按别名 * 认),还是报告已有的出处(口径卡上的指标、报告的查询……) @@ -833,12 +1175,12 @@ function citeOf(seg: EvidenceSegment, doc: EvidenceDocData, own: Map; 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 ( ) } - 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 ( - - {body}({EVIDENCE_STATE_LABEL[state]}) + + {s.strong ? {body} : body} + {state !== 'deterministic' && ({EVIDENCE_STATE_LABEL[state]})} ) } @@ -881,7 +1226,7 @@ function AnswerUnit({ unit, doc, own, code, onCite }: { {nodes} {!code && [...alerts].map((state) => ( ))} @@ -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

{INQUIRY_TEXT.noQueries}

+ const cut = queries.filter((q) => q.truncated).length return (
- +
+ + {cut > 0 && ( + + · {INQUIRY_TEXT.queriesTruncated(cut)} + + )} +
{open && (
{queries.map((q, i) => ( diff --git a/frontend/src/run/RunPanel.tsx b/frontend/src/run/RunPanel.tsx index ed5ef067..67239c38 100644 --- a/frontend/src/run/RunPanel.tsx +++ b/frontend/src/run/RunPanel.tsx @@ -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 && } + approvalId={approval.id} askable={approval.status === 'pending'} /> {payload.tool && ( @@ -212,9 +212,11 @@ export function ApprovalCard({ approval, onResolved, showWorkflow = true }: {
{inquiring && ( -
- {INQUIRY_TEXT.decideBlocked} + // 字用正文色:审批卡铺着一层提醒色的底,提醒色的字在浅色主题下对比度不够;提醒色交给转圈的图标 +
+ + {INQUIRY_TEXT.decideBlocked}
)} {/* 批了会怎样,常显而不是藏在 hover 里:审批人得在按下去之前知道 */} diff --git a/scripts/check-all.mjs b/scripts/check-all.mjs index e9b3b25a..f3bb5075 100644 --- a/scripts/check-all.mjs +++ b/scripts/check-all.mjs @@ -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 兜住「其余全部」,以后新加的段 // 不用改这里也跑得到(拆出去的那几份要是一段都没对上,那一份会报「一项都没跑」)。 diff --git a/scripts/check-inquiry.mjs b/scripts/check-inquiry.mjs index b70d8380..94f3ba91 100644 --- a/scripts/check-inquiry.mjs +++ b/scripts/check-inquiry.mjs @@ -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()