diff --git a/sundynix-desktop/frontend/src/lib/api.ts b/sundynix-desktop/frontend/src/lib/api.ts index ae0adec..33a0020 100644 --- a/sundynix-desktop/frontend/src/lib/api.ts +++ b/sundynix-desktop/frontend/src/lib/api.ts @@ -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 { diff --git a/sundynix-desktop/frontend/src/lib/run.test.ts b/sundynix-desktop/frontend/src/lib/run.test.ts index bc711c8..67f999a 100644 --- a/sundynix-desktop/frontend/src/lib/run.test.ts +++ b/sundynix-desktop/frontend/src/lib/run.test.ts @@ -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 { @@ -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); + }); +}); diff --git a/sundynix-desktop/frontend/src/lib/run.ts b/sundynix-desktop/frontend/src/lib/run.ts index a0932f7..5cbcee2 100644 --- a/sundynix-desktop/frontend/src/lib/run.ts +++ b/sundynix-desktop/frontend/src/lib/run.ts @@ -146,6 +146,13 @@ function splitBrief(detail?: string): { brief?: string; output?: string } { return { output: detail }; } +// isReportRun 判定一次运行是不是「报告生成」。 +// 靠 task_id 前缀:后端 newReportID() 出 "report_"、普通任务是 "task_", +// 而报告导出接口 /reports/:id/export 本来就按同一个 id 寻址——这个前缀是既有契约,不是我临时约的。 +export function isReportRun(taskId: string | undefined): boolean { + return !!taskId && taskId.startsWith("report_"); +} + // teamNow 决定并发时间轴的右端。直播时是此刻——未收口的工位应持续生长; // 复盘时必须是最后一个事件的时间戳:回放一条卡在 running 的历史任务(崩溃/超时留下的), // 未收口工位若量到 Date.now(),时间轴会一路拉到今天,跨度直接爆表。 diff --git a/sundynix-desktop/frontend/src/views/ReportView.tsx b/sundynix-desktop/frontend/src/views/ReportView.tsx index 982b012..530c10d 100644 --- a/sundynix-desktop/frontend/src/views/ReportView.tsx +++ b/sundynix-desktop/frontend/src/views/ReportView.tsx @@ -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"; // 报告生成:输入主题(+可选知识库) → 触发后端专用编排 diff --git a/sundynix-desktop/frontend/src/views/RunsView.tsx b/sundynix-desktop/frontend/src/views/RunsView.tsx index fcebcae..f61cd27 100644 --- a/sundynix-desktop/frontend/src/views/RunsView.tsx +++ b/sundynix-desktop/frontend/src/views/RunsView.tsx @@ -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([]); const [sel, setSel] = useState(null); // null = 当前实时运行 const [replay, setReplay] = useState(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")}> - {r.task_id} + {r.topic ? ( + // 报告类运行显示主题——列表里挂个 report_ 谁也认不出是哪份报告。 + {r.topic} + ) : ( + {r.task_id} + )} {r.status} · {relTime(r.at)} {r.eval_level && {r.eval_overall.toFixed(2)}} @@ -167,7 +187,22 @@ export function RunsView({ run }: { run: RunState }) { {/* 右:模型输出(Markdown,固定面板,与之前一致) */} - + + + + + ) : undefined + } + > {cur.output ? ( ) : ( diff --git a/sundynix-gateway/internal/store/pgsql.go b/sundynix-gateway/internal/store/pgsql.go index 700608b..2bd7fab 100644 --- a/sundynix-gateway/internal/store/pgsql.go +++ b/sundynix-gateway/internal/store/pgsql.go @@ -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_ 这种 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) {