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}}