diff --git a/frontend/src/api/bundle.ts b/frontend/src/api/bundle.ts index 0849c1fe..07e5b893 100644 --- a/frontend/src/api/bundle.ts +++ b/frontend/src/api/bundle.ts @@ -1,7 +1,45 @@ /** - * 可验证报告的接口(/api/runs/{run_id}/…)。现在是空的,可验证报告的工作包往里加,挂在 api.bundle 上。 + * 可验证报告的接口(/api/runs/{run_id}/…),挂在 api.bundle 上。 * * 请求函数(get / post / del……)和 ApiError 从 ./http 导入,不要从 ./client 导入:client.ts 要导入 * 这个文件把它挂到 api 上,反过来导入就成了循环(见 http.ts 开头的说明)。 */ -export const bundle = {} +import { ApiError, BASE, NETWORK_MESSAGE, actorHeader, failure, report } from './http' + +/** + * Content-Disposition 里的文件名:先看 RFC 5987 的 filename*(中文名写在这里),没有或解不开再看 filename。 + * 服务端给的 filename 是纯 ASCII 的退路(agentlab-<运行号>-bundle.html),只在老客户端那边派上用场 + */ +export function dispositionName(header: string | null): string | null { + if (!header) return null + const star = /filename\*\s*=\s*UTF-8''([^;]+)/i.exec(header)?.[1] + if (star) { + try { + return decodeURIComponent(star.trim().replace(/^"|"$/g, '')) + } catch { + // 百分号编码坏了:退回 filename + } + } + return /filename\s*=\s*"?([^";]+)"?/i.exec(header)?.[1]?.trim() || null +} + +export const bundle = { + /** + * 下载可验证报告(GET /runs/{id}/bundle.html,附件):回来的是文件本身和服务端给的文件名,没给就是 null, + * 由调用方按运行名补。运行未封存、封存核对不一致时服务端回 409,ApiError 的 message 是给人看的原因 + */ + download: async (runId: string): Promise<{ blob: Blob; name: string | null }> => { + const path = `/runs/${encodeURIComponent(runId)}/bundle.html` + let res: Response + try { + res = await fetch(BASE + path, { headers: actorHeader() }) + } catch (e: any) { + const err = new ApiError(0, NETWORK_MESSAGE, { kind: 'network', raw: `${e?.name ?? 'Error'}: ${e?.message ?? e}` }) + report(false, err) + throw err + } + if (!res.ok) throw failure(res.status, res.statusText, await res.text().catch(() => ''), path) + report(true) + return { blob: await res.blob(), name: dispositionName(res.headers.get('Content-Disposition')) } + }, +} diff --git a/frontend/src/dev/fixtures/bundle.tsx b/frontend/src/dev/fixtures/bundle.tsx new file mode 100644 index 00000000..c6d63ac0 --- /dev/null +++ b/frontend/src/dev/fixtures/bundle.tsx @@ -0,0 +1,53 @@ +import type { JSX } from 'react' +import { ProvenanceBar } from '../../pages/runs/Telemetry' +import type { RunPhase } from '../../run/trace' +import type { Run } from '../../types' + +/** + * 可验证报告的预览用例:记录页封存凭证条上的「导出可验证报告」。scripts/check-bundle.mjs 按 ?syn=<用例名> + * 打开,用 page.route 假装 /api/runs/{id}/bundle.html,不碰真实数据。 + * + * - bundle-provenance-sealed:已封存,可以导出 + * - bundle-provenance-running / -waiting:还在运行、等待审批,按钮不可用并说明原因 + * - bundle-provenance-unsealed:结束了却没有封存清单(加封存之前的老运行) + * - bundle-provenance-again:失败后接着跑,留着上一次的封存,正在运行:同样不可用(跑完会换掉清单哈希) + */ +const SEALED: Run = { + id: 'f4b0f4b0f4b0f4b0f4b0f4b0f4b0f4b0', + workflow_id: 'wf-bundle-sample', + workflow_name: '门店周报', + status: 'succeeded', + input: {}, + output: {}, + error: null, + usage: {}, + run_class: 'formal', + version: 3, + version_hash: 'a1'.repeat(32), + manifest_hash: 'c3b5'.repeat(16), + manifest_seq: 59, + started_by: '值班分析员', + created_at: '2026-09-07T01:00:00Z', + started_at: '2026-09-07T01:00:01Z', + finished_at: '2026-09-07T01:02:03Z', +} + +const unsealed = (status: Run['status']): Run => ({ ...SEALED, status, manifest_hash: null, manifest_seq: null, finished_at: null }) + +function Frame({ run, phase }: { run: Run; phase: RunPhase }) { + return ( +
+
+ +
+
+ ) +} + +export const cases: Record JSX.Element> = { + 'bundle-provenance-sealed': () => , + 'bundle-provenance-running': () => , + 'bundle-provenance-waiting': () => , + 'bundle-provenance-unsealed': () => , + 'bundle-provenance-again': () => , +} diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 57e85bf3..a5d969e0 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -2748,9 +2748,21 @@ export const RUN_COMPARE_TEXT = { pendingHint: '运行对比尚未开放,完成后可从记录页选择两次运行进行对照', } -/** 可验证报告:导出可独立核对的报告 */ +/** + * 可验证报告:把一次已封存运行的报告导出成一个 HTML 文件,收件人不连服务也能在浏览器里核对。 + * 入口在记录页的封存凭证条上;页面本身的文案在服务端 bundle/verifier.js 里(它是离开系统独立打开的文件) + */ export const BUNDLE_TEXT = { title: '可验证报告', + export: '导出可验证报告', + hint: '导出为单个 HTML 文件:收件人不连服务,也能在浏览器中核对数字的出处和运行记录', + /** 不能导出时按钮旁说明的原因:和凭证条左边「未封存 · …」的说法对得上 */ + blockedRunning: '运行结束并封存后才能导出', + blockedWaiting: '等待审批中,运行结束并封存后才能导出', + blockedUnsealed: '这次运行没有封存清单,无法导出', + exported: (name: string) => `已导出「${name}」`, + /** 服务端没给文件名时按运行名补,规则同服务端的附件名 */ + fileName: (name: string) => `${name}-可验证报告.html`, } /** 审批质询:审批人在批准前就待审的报告提出质询 */ diff --git a/frontend/src/pages/runs/Telemetry.tsx b/frontend/src/pages/runs/Telemetry.tsx index 01435e46..474074f5 100644 --- a/frontend/src/pages/runs/Telemetry.tsx +++ b/frontend/src/pages/runs/Telemetry.tsx @@ -1,8 +1,8 @@ -import { useState, type ReactNode } from 'react' -import { ChevronDown, Shield, ShieldAlert, ShieldCheck } from 'lucide-react' +import { useId, useState, type ReactNode } from 'react' +import { ChevronDown, Download, Shield, ShieldAlert, ShieldCheck } from 'lucide-react' import clsx from 'clsx' import { api } from '../../api/client' -import { StatusPill, toast, useTicker } from '../../components/ui' +import { Spinner, StatusPill, toast, useTicker } from '../../components/ui' import { NONE, formatClock, formatCost, formatDateTime, formatNumber, formatSpan, formatTime, formatTokens, shortId, } from '../../lib/format' @@ -10,7 +10,7 @@ import { type StatusCode } from '../../lib/status' import { isExecuting, liveAt, project, type Projection, type RunPhase, type Trace } from '../../run/trace' import { useRunClock } from '../../run/useRunClock' import type { Approval, Run } from '../../types' -import { COST_TEXT, UNSAVED_HINT, isUnsaved, runCostText, runName } from '../../lib/terms' +import { BUNDLE_TEXT, COST_TEXT, UNSAVED_HINT, isUnsaved, runCostText, runName } from '../../lib/terms' import { LONG_WAIT_MS, ageMs, cancelReason, runScope, runTiming, type RunTiming, } from './model' @@ -257,6 +257,8 @@ export function ProvenanceBar({ run, eventCount, phase }: { run: Run; eventCount const [verifying, setVerifying] = useState(false) const [fresh, setFresh] = useState(false) const [open, setOpen] = useState(false) + const [exporting, setExporting] = useState(false) + const blockedId = useId() const sealed = !!run.manifest_hash const doVerify = async () => { @@ -281,6 +283,28 @@ export function ProvenanceBar({ run, eventCount, phase }: { run: Run; eventCount ? '等待审批中,运行结束后封存' : '本次运行没有封存清单' + // 可验证报告只给结束了、封存了的运行:没封存的事件还会变;失败后接着跑的运行留着上一次的封存,跑完会换掉清单 + // 哈希,导出去的文件就和系统对不上了(规则同服务端 api/bundle.py)。封存核对不一致由服务端回 409,提示里照实说 + const finished = phase === 'succeeded' || phase === 'failed' || phase === 'cancelled' + const exportBlocked = sealed && finished ? null + : phase === 'waiting' ? BUNDLE_TEXT.blockedWaiting + : !finished ? BUNDLE_TEXT.blockedRunning + : BUNDLE_TEXT.blockedUnsealed + + const doExport = async () => { + setExporting(true) + try { + const { blob, name } = await api.bundle.download(run.id) + const file = name ?? BUNDLE_TEXT.fileName(runName(run)) + saveFile(file, blob) + toast.ok(BUNDLE_TEXT.exported(file)) + } catch (e) { + toast.error(e, { key: `bundle:${run.id}` }) + } finally { + setExporting(false) + } + } + // 扫描线的时长按事件条数估:重算的就是这些事件 const scanMs = Math.min(2400, Math.max(600, eventCount * 0.8)) @@ -321,6 +345,15 @@ export function ProvenanceBar({ run, eventCount, phase }: { run: Run; eventCount {verify ? '重新核对' : '核对'} )} + {/* 禁用的按钮收不到指针事件,悬停提示挂在外面那层上;读屏经 aria-describedby 读到原因 */} + + + + {exportBlocked && {exportBlocked}}