feat(desktop,gateway): 报告归口运行页 —— 能找回、能复盘、能导出

承接上一个提交(报告终于落库进运行历史),把「所有执行归口运行页」做完整:
只能看不能存,不算归口。

- 运行页的输出面板:报告类运行显示「报告正文」并在右上角出 Word/Markdown
  导出。报告页那两个导出按钮依赖它自己一切页面就没的本地状态,所以从历史
  里找回来的报告,只能在运行页导。
- 运行历史列表显示报告主题。此前只有 report_<hex>,谁也认不出是哪份报告。
  主题从 graph->>'topic' 取(报告的 graph 就是占位 DSL {"topic":"…"},普通
  任务 DSL 没有顶层 topic → 空串,不会误伤)。
- isReportRun() 按 task_id 的 report_ 前缀判定 —— 这是既有契约,导出接口
  /reports/:id/export 本来就按同一个 id 寻址。+4 单测(含"不能只看是否包含
  report"的误判防线)。
- reportFilename 从 ReportView 提到 lib:运行页也要用,不能私藏在一个 view 里。

live 验证(真账号,桌面端实机):运行历史首条显示「Redis 缓存穿透的三种解法」
→ 点开复盘 8 节点 + 全文 → 点 Word → **原生另存为对话框弹出**(文件名预填
主题)→ 保存 → 落盘 5KB,file 认 Microsoft Word 2007+,解 zip 得
word/document.xml,正文 2446 字。

**顺带把 wails3 迁移最后一个盲区验掉了**:v3 把 runtime.SaveFileDialog 重写成
application.Get().Dialog.SaveFile().SetFilename().AddFilter()
.PromptForSingleSelection(),此前从没被点过一次,记忆里只敢写"理应工作"。
现在实测通了。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-16 13:20:57 +08:00
parent fd268664c8
commit fd99ba6226
6 changed files with 78 additions and 15 deletions
+7
View File
@@ -593,6 +593,12 @@ export function reportDownloadUrl(taskId: string): string {
}
// reportExportUrl: 按需导出报告地址(format=docx|md;后端现渲染)。PDF 由前端打印预览生成。
// 安全文件名:去掉路径不安全字符,限长 + 指定后缀。主题为空则退回 task_id。
export function reportFilename(topic: string, id: string, ext: string): string {
const base = topic.replace(/[\\/:*?"<>|]/g, "").trim().slice(0, 40) || id;
return `${base}.${ext}`;
}
export function reportExportUrl(taskId: string, format: "docx" | "md"): string {
return `${GATEWAY}/api/v1/reports/${taskId}/export?format=${format}`;
}
@@ -674,6 +680,7 @@ export interface RunSummary {
at: string;
eval_level: string;
eval_overall: number;
topic: string; // 报告类运行的主题;普通任务为空串
}
export async function listRuns(limit = 30): Promise<RunSummary[]> {
+17 -1
View File
@@ -1,6 +1,6 @@
import { describe, it, expect } from "vitest";
import type { ExecEvent } from "./api";
import { deriveNodes, pendingApproval, isMultiAgent, deriveTeam, teamNow } from "./run";
import { deriveNodes, pendingApproval, isMultiAgent, deriveTeam, teamNow, isReportRun } from "./run";
let seq = 0;
function ev(node: string, phase: string, extra: Partial<ExecEvent> = {}): ExecEvent {
@@ -218,3 +218,19 @@ describe("teamNow(并发时间轴的右端)", () => {
expect(t.t1 - t.t0).toBe(2000);
});
});
describe("isReportRun(运行是不是报告生成)", () => {
it("report_ 前缀 → true", () => {
expect(isReportRun("report_5dae9155af5cb500")).toBe(true);
});
it("普通任务 → false", () => {
expect(isReportRun("task_e984b1f3e38a35c7")).toBe(false);
});
it("空/未定义 → false(当前无选中运行时不该冒出导出按钮)", () => {
expect(isReportRun(undefined)).toBe(false);
expect(isReportRun("")).toBe(false);
});
it("不能只看是否包含 report(避免误判)", () => {
expect(isReportRun("task_report_like")).toBe(false);
});
});
+7
View File
@@ -146,6 +146,13 @@ function splitBrief(detail?: string): { brief?: string; output?: string } {
return { output: detail };
}
// isReportRun 判定一次运行是不是「报告生成」。
// 靠 task_id 前缀:后端 newReportID() 出 "report_<hex>"、普通任务是 "task_<hex>"
// 而报告导出接口 /reports/:id/export 本来就按同一个 id 寻址——这个前缀是既有契约,不是我临时约的。
export function isReportRun(taskId: string | undefined): boolean {
return !!taskId && taskId.startsWith("report_");
}
// teamNow 决定并发时间轴的右端。直播时是此刻——未收口的工位应持续生长;
// 复盘时必须是最后一个事件的时间戳:回放一条卡在 running 的历史任务(崩溃/超时留下的),
// 未收口工位若量到 Date.now(),时间轴会一路拉到今天,跨度直接爆表。
@@ -1,17 +1,11 @@
import { useRef, useState } from "react";
import { Play, FileText, FileType2, Printer, FileCode } from "lucide-react";
import { generateReport, streamTokens, streamExec, reportExportUrl, type Identity, type ExecEvent } from "../lib/api";
import { generateReport, streamTokens, streamExec, reportExportUrl, reportFilename, type Identity, type ExecEvent } from "../lib/api";
import { saveReportAs, printReportHtml, notify } from "../lib/desktop";
import { ExecTrace } from "../components/ExecTrace";
import { Markdown } from "../components/Markdown";
import { Button, Input, Field, Panel, Dot, EmptyState, useToast } from "../ui";
// 安全文件名:去掉路径不安全字符,限长 + 指定后缀。
function reportFilename(topic: string, id: string, ext: string): string {
const base = topic.replace(/[\\/:*?"<>|]/g, "").trim().slice(0, 40) || id;
return `${base}.${ext}`;
}
type Phase = "idle" | "running" | "done" | "error";
// 报告生成:输入主题(+可选知识库) → 触发后端专用编排
@@ -1,14 +1,15 @@
import { useCallback, useEffect, useState } from "react";
import { Activity, FileText, History, Users, Wrench } from "lucide-react";
import { Activity, FileText, FileType2, FileCode, History, Users, Wrench } from "lucide-react";
import { ExecTrace } from "../components/ExecTrace";
import { TeamView } from "../components/TeamView";
import { OfficeView } from "../components/OfficeView";
import { Markdown } from "../components/Markdown";
import { ChartView } from "../components/ChartView";
import { extractChartBlocks, hasChart } from "../lib/chartspec";
import { deriveNodes, isMultiAgent, emptyRun, type RunState } from "../lib/run";
import { listRuns, taskEval, runReplay, type RunSummary, type EvalResult } from "../lib/api";
import { Tabs, Panel, Dot, Badge, EmptyState, cn, type TabDef } from "../ui";
import { deriveNodes, isMultiAgent, isReportRun, emptyRun, type RunState } from "../lib/run";
import { listRuns, taskEval, runReplay, reportExportUrl, reportFilename, type RunSummary, type EvalResult } from "../lib/api";
import { Tabs, Panel, Dot, Badge, Button, EmptyState, useToast, cn, type TabDef } from "../ui";
import { saveReportAs } from "../lib/desktop";
type DetailTab = "trace" | "team" | "tools" | "eval";
@@ -29,6 +30,7 @@ function relTime(iso: string): string {
// 运行 · 观测:左=运行历史,中=tab 切换(轨迹/工具/评测)+状态,右=模型输出(Markdown)。
// 选中历史运行从 Redis 回放;「当前运行」置顶沿用实时订阅。
export function RunsView({ run }: { run: RunState }) {
const toast = useToast();
const [runs, setRuns] = useState<RunSummary[]>([]);
const [sel, setSel] = useState<string | null>(null); // null = 当前实时运行
const [replay, setReplay] = useState<RunState>(emptyRun);
@@ -68,6 +70,19 @@ export function RunsView({ run }: { run: RunState }) {
const liveActive = run.taskId && run.phase !== "idle";
const cur = sel ? replay : run;
// 报告类运行在这里也能导出:所有执行归口运行页,只能看不能存就不算归口。
// 报告页的导出按钮依赖它自己那份一切页面就没的本地状态,找回来的报告只能在这儿导。
const curId = sel ?? run.taskId ?? "";
const isReport = isReportRun(curId);
const curTopic = runs.find((r) => r.task_id === curId)?.topic ?? "";
const exportReport = async (format: "docx" | "md") => {
try {
const p = await saveReportAs(reportExportUrl(curId, format), reportFilename(curTopic, curId, format));
if (p) toast.push("success", "已保存到 " + p);
} catch (e) {
toast.push("error", (e as Error).message);
}
};
const nodes = deriveNodes(cur.exec);
// 工具调用面板纳入专家派发:MCP 工具(kind=tool)与多智能体协调里的子智能体派发(kind=agent)都是「调用」。
const calls = nodes.filter((n) => n.kind === "tool" || n.kind === "agent");
@@ -119,7 +134,12 @@ export function RunsView({ run }: { run: RunState }) {
className={cn("flex w-full items-center gap-2 rounded-md px-2 py-2 text-left", sel === r.task_id ? "bg-ink-800 ring-1 ring-brand/40" : "hover:bg-ink-850")}>
<Dot tone={STATUS_DOT[r.status] ?? "neutral"} />
<span className="min-w-0 flex-1">
<span className="block truncate font-mono text-[11px] text-slate-300">{r.task_id}</span>
{r.topic ? (
// 报告类运行显示主题——列表里挂个 report_<hex> 谁也认不出是哪份报告。
<span className="block truncate text-[11px] text-slate-300">{r.topic}</span>
) : (
<span className="block truncate font-mono text-[11px] text-slate-300">{r.task_id}</span>
)}
<span className="block text-[10px] text-slate-600">{r.status} · {relTime(r.at)}</span>
</span>
{r.eval_level && <Badge tone={LEVEL_TONE[r.eval_level] ?? "neutral"}>{r.eval_overall.toFixed(2)}</Badge>}
@@ -167,7 +187,22 @@ export function RunsView({ run }: { run: RunState }) {
</div>
{/* 右:模型输出(Markdown,固定面板,与之前一致) */}
<Panel title="模型输出" icon={FileText}>
<Panel
title={isReport ? "报告正文" : "模型输出"}
icon={FileText}
actions={
isReport && cur.output ? (
<div className="flex items-center gap-1.5">
<Button icon={FileType2} onClick={() => exportReport("docx")}>
Word
</Button>
<Button variant="ghost" icon={FileCode} onClick={() => exportReport("md")}>
Markdown
</Button>
</div>
) : undefined
}
>
{cur.output ? (
<OutputView output={cur.output} />
) : (
+5 -1
View File
@@ -295,6 +295,9 @@ type RunRow struct {
At time.Time `json:"at"`
EvalLevel string `json:"eval_level"`
EvalOverall float64 `json:"eval_overall"`
// Topic:报告类运行的主题。报告的 graph 是占位 DSL `{"topic":"…"}`,普通任务的 DSL
// 没有顶层 topic → 空串。否则运行历史里报告只能显示 report_<hex> 这种 id,读不出是啥。
Topic string `json:"topic"`
}
// RecentRuns 返回某用户最近 n 条运行(含评测分级,供「运行历史」列表)。
@@ -306,7 +309,8 @@ func (p *Postgres) RecentRuns(ctx context.Context, owner string, n int) []RunRow
var out []RunRow
q := p.db.WithContext(ctx).Table("sundynix_task as t").
Select("t.task_id, t.status, t.detail, t.created_at as at, " +
"coalesce(e.level,'') as eval_level, coalesce(e.overall,0) as eval_overall").
"coalesce(e.level,'') as eval_level, coalesce(e.overall,0) as eval_overall, " +
"coalesce(t.graph->>'topic','') as topic").
Joins("left join sundynix_eval e on e.task_id = t.task_id").
Where("t.deleted_at is null AND t.owner = ?", owner)
if tid := tenantFromCtx(ctx); tid != "" && !isSystemCtx(ctx) {