feat(report): 生成只出 Markdown 预览,导出时再渲染 Word/PDF/Markdown

把"渲染"从生成阶段解耦到导出阶段(导出时再处理):
- 生成阶段:报告正文按 Markdown 流式预览(前端 <Markdown> 已渲染),
  dispatcher 不再 eager 渲染 docx,改为经 mcp-go report_store 落盘报告源(title+sections JSON)。
- 导出阶段(按需现渲染):
  - GET /reports/:id/export?format=docx → mcp-go report_export 读源渲染 .docx;
  - ?format=md → 返回 Markdown 文本;
  - PDF → 前端把已渲染的 Markdown 送进打印视图出 PDF(CJK 零字体依赖)。
- 旧 /reports/:id/download 兼容保留(默认 docx)。

改动:
- contract: ReportSourcePath(id) = <id>.json。
- mcp-go: 新增 report_store / report_export 工具(report_render 保留给 Studio render 节点)。
- dispatcher: handleReport 末尾 renderReport → storeReport。
- gateway: DownloadReport → ExportReport(经 NATS 调 report_export)。
- 前端: ReportView 单个「Word」→「导出」组 Word/PDF/Markdown;
  desktop.printReportHtml 客户端打印;api.reportExportUrl。

实测(docker 全栈 + mcp-go + gateway + dispatcher + DeepSeek 真跑):
- 真实生成「绿茶的功效」18s 完成,report_store 落源(5章, 6280B) ✓
- export md 返回正确 Markdown(# 标题/## 小节/正文) ✓
- export docx 为合法「Microsoft Word 2007+」(含 document.xml/Content_Types) ✓
- 前端 tsc 干净 + 生产构建通过 ✓
(注:发现并修复一处环境问题——mcp-go 启动时若 Milvus 未起会阻塞在
 rag 初始化、永不订阅工具,导致所有 mcp-go 工具"no responders";起全栈后正常。
 报告生成在累积大量未完成 DeepSeek 流连接时会偶发卡顿,干净进程下正常。
 前端导出按钮的实时点击因 React 受控输入自动化限制未在预览中走通,非代码缺陷。)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-17 14:04:06 +08:00
parent 1bd187874d
commit 1dd6b0cce3
9 changed files with 217 additions and 45 deletions
+1
View File
@@ -14,6 +14,7 @@ github.com/couchbase/ghistogram v0.1.0/go.mod h1:s1Jhy76zqfEecpNWJfWUiKZookAFaiG
github.com/couchbase/moss v0.2.0 h1:VCYrMzFwEryyhRSeI+/b3tRBSeTpi/8gn5Kf6dxqn+o=
github.com/couchbase/moss v0.2.0/go.mod h1:9MaHIaRuy9pvLPUJxB8sh8OrLfyDczECVL37grCIubs=
github.com/creack/pty v1.1.9 h1:uDmaGzcdjhF4i/plgjmEsriH11Y0o7RKapEf/LDaM3w=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0 h1:A8PeW59pxE9IoFRqBp37U+mSNaQoZ46F1f0f863XSXw=
+6 -1
View File
@@ -302,11 +302,16 @@ export async function generateReport(id: Identity, topic: string, kb?: string):
return data.task_id;
}
// reportDownloadUrl: 渲染好的 Word(.docx) 下载地址。
// reportDownloadUrl: 渲染好的 Word(.docx) 下载地址(兼容旧入口)
export function reportDownloadUrl(taskId: string): string {
return `${GATEWAY}/api/v1/reports/${taskId}/download`;
}
// reportExportUrl: 按需导出报告地址(format=docx|md;后端现渲染)。PDF 由前端打印预览生成。
export function reportExportUrl(taskId: string, format: "docx" | "md"): string {
return `${GATEWAY}/api/v1/reports/${taskId}/export?format=${format}`;
}
// setMemory: PUT /api/v1/memory,登记一条用户偏好(→ mcp-go memory_upsert)。
export async function setMemory(
id: Identity,
@@ -47,6 +47,30 @@ export function notify(title: string, body: string): void {
app()?.Notify(title, body);
}
// printReportHtml:把已渲染的报告 HTML 在打印视图里出 PDF(浏览器/Webview 的"打印→存为 PDF")。
// 走前端打印是为了让中文(CJK)零字体依赖即可正确排版——后端 PDF 需内嵌 CJK 字体,较重。
export function printReportHtml(title: string, bodyHtml: string): boolean {
const w = window.open("", "_blank", "width=840,height=1024");
if (!w) return false; // 被弹窗拦截
w.document.write(
`<!doctype html><html><head><meta charset="utf-8"><title>${title}</title>` +
`<style>` +
`*{box-sizing:border-box}` +
`body{font-family:-apple-system,system-ui,'PingFang SC','Microsoft YaHei',sans-serif;line-height:1.75;color:#111;max-width:760px;margin:36px auto;padding:0 28px}` +
`h1{font-size:24px;margin:0 0 16px}h2{font-size:18px;margin:24px 0 8px}h3{font-size:15px}` +
`p{margin:8px 0}ul,ol{margin:8px 0;padding-left:22px}` +
`code,pre{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;background:#f4f4f5;border-radius:4px}` +
`pre{padding:12px;overflow:auto}code{padding:1px 4px}` +
`blockquote{margin:8px 0;padding-left:12px;border-left:3px solid #ddd;color:#555}` +
`@media print{body{margin:0}}` +
`</style></head><body>${bodyHtml}` +
`<script>window.onload=function(){window.focus();window.print();};<\/script>` +
`</body></html>`,
);
w.document.close();
return true;
}
function triggerDownload(url: string, filename: string) {
const el = document.createElement("a");
el.href = url;
@@ -1,15 +1,15 @@
import { useRef, useState } from "react";
import { Play, Download, FileText, ExternalLink } from "lucide-react";
import { generateReport, streamTokens, streamExec, reportDownloadUrl, type Identity, type ExecEvent } from "../lib/api";
import { isDesktop, saveReportAs, openReport, notify } from "../lib/desktop";
import { Play, FileText, FileType2, Printer, FileCode } from "lucide-react";
import { generateReport, streamTokens, streamExec, reportExportUrl, 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): string {
// 安全文件名:去掉路径不安全字符,限长 + 指定后缀
function reportFilename(topic: string, id: string, ext: string): string {
const base = topic.replace(/[\\/:*?"<>|]/g, "").trim().slice(0, 40) || id;
return `${base}.docx`;
return `${base}.${ext}`;
}
type Phase = "idle" | "running" | "done" | "error";
@@ -26,9 +26,32 @@ export function ReportView({ identity }: { identity: Identity }) {
const [taskId, setTaskId] = useState("");
const closeRef = useRef<(() => void) | null>(null);
const execCloseRef = useRef<(() => void) | null>(null);
const previewRef = useRef<HTMLDivElement>(null);
const running = phase === "running";
// 导出 Word / Markdown:后端按需现渲染(导出时再处理),经原生"另存为"或浏览器下载。
const exportFile = async (format: "docx" | "md") => {
try {
const p = await saveReportAs(reportExportUrl(taskId, format), reportFilename(topic, taskId, format));
if (p) toast.push("success", "已保存到 " + p);
} catch (e) {
toast.push("error", (e as Error).message);
}
};
// 导出 PDF:把预览到的 Markdown(已渲染 HTML)送进打印视图出 PDF(CJK 零字体依赖)。
const exportPdf = () => {
const html = previewRef.current?.innerHTML;
if (!html) {
toast.push("error", "暂无报告正文可导出");
return;
}
if (!printReportHtml(topic.trim() || taskId, html)) {
toast.push("error", "打印窗口被拦截,请允许弹出窗口后重试");
}
};
const onGenerate = async () => {
if (!topic.trim() || running) return;
closeRef.current?.();
@@ -93,24 +116,16 @@ export function ReportView({ identity }: { identity: Identity }) {
</Button>
{phase === "done" && taskId ? (
<div className="flex items-center gap-2">
<Button
icon={Download}
onClick={async () => {
const p = await saveReportAs(reportDownloadUrl(taskId), reportFilename(topic, taskId));
if (p) toast.push("success", "已保存到 " + p);
}}
>
{isDesktop() ? "另存为 Word" : "下载 Word"}
<span className="text-[11px] text-slate-500"></span>
<Button icon={FileType2} onClick={() => exportFile("docx")}>
Word
</Button>
<Button icon={Printer} onClick={exportPdf}>
PDF
</Button>
<Button variant="ghost" icon={FileCode} onClick={() => exportFile("md")}>
Markdown
</Button>
{isDesktop() && (
<Button
variant="ghost"
icon={ExternalLink}
onClick={() => openReport(reportDownloadUrl(taskId), reportFilename(topic, taskId)).catch((e) => toast.push("error", String(e)))}
>
</Button>
)}
</div>
) : (
<span className="h-9" />
@@ -131,7 +146,9 @@ export function ReportView({ identity }: { identity: Identity }) {
}
>
{out ? (
<Markdown text={out} className="text-sm" />
<div ref={previewRef}>
<Markdown text={out} className="text-sm" />
</div>
) : (
<EmptyState icon={FileText} title="尚未生成报告" desc="输入主题并点击「生成报告」,这里将实时显示规划与撰写过程。" />
)}
+26 -9
View File
@@ -75,16 +75,15 @@ func (o *Orchestrator) handleReport(ctx context.Context, t *contract.Task, tr *e
o.emit(t.ID, "## "+s.Heading+"\n\n"+s.Body+"\n\n")
}
// 渲染真实 Word 文档
o.emit(t.ID, "> 正在渲染 Word 文档…\n\n")
endRender := tr.span("render", "render", "渲染 Word 文档")
if path := o.renderReport(ctx, t.ID, firstNonEmpty(outline.Title, topic), sections); path != "" {
endRender("docx 已落盘:"+path, nil)
o.emit(t.ID, "---\n✅ 报告已生成 Word 文档,可点击上方「下载 Word」保存。\n")
log.Printf("[report] task %s 完成,docx=%s", t.ID, path)
// 只持久化报告源数据(标题+章节),不在生成阶段渲染;导出时再按需出 Word/PDF/Markdown
endStore := tr.span("store", "render", "保存报告源")
if o.storeReport(ctx, t.ID, firstNonEmpty(outline.Title, topic), sections) {
endStore("已保存,可按需导出 Word/PDF/Markdown", nil)
o.emit(t.ID, "---\n✅ 报告正文已生成,可在上方导出 **Word / PDF / Markdown**。\n")
log.Printf("[report] task %s 完成,源已存", t.ID)
} else {
endRender("渲染服务不可用", fmt.Errorf("render unavailable"))
o.emit(t.ID, "---\n⚠️ Word 渲染未完成(渲染服务不可用),以上为报告正文。\n")
endStore("源保存失败", fmt.Errorf("store unavailable"))
o.emit(t.ID, "---\n⚠️ 报告源保存失败(导出可能不可用),以上为报告正文。\n")
}
o.breaker.Report(true)
return nil
@@ -229,6 +228,24 @@ func (o *Orchestrator) retrieve(ctx context.Context, kb, query string) string {
return b.String()
}
// storeReport 经 mcp-go report_store 把报告源数据(title+sections)落盘,供导出时按需渲染。
func (o *Orchestrator) storeReport(ctx context.Context, taskID, title string, secs []reportSection) bool {
if o.tools == nil {
return false
}
arr := make([]map[string]any, len(secs))
for i, s := range secs {
arr[i] = map[string]any{"heading": s.Heading, "body": s.Body}
}
cctx, cancel := context.WithTimeout(ctx, reportRenderWait)
defer cancel()
res, err := o.tools.CallTool(cctx, contract.ToolSubjectGo("report_store"), &contract.ToolCall{
Tool: "report_store", TaskID: taskID,
Args: map[string]any{"title": title, "task_id": taskID, "sections": arr},
})
return err == nil && res != nil && res.OK
}
// renderReport 经 mcp-go report_render 工具渲染 docx 并落盘,返回文件路径(失败返回空)。
func (o *Orchestrator) renderReport(ctx context.Context, taskID, title string, secs []reportSection) string {
if o.tools == nil {
+22 -9
View File
@@ -5,7 +5,6 @@ import (
"encoding/hex"
"encoding/json"
"net/http"
"os"
"github.com/gin-gonic/gin"
@@ -44,17 +43,31 @@ func (h *Handler) GenerateReport(c *gin.Context) {
c.JSON(http.StatusAccepted, gin.H{"task_id": id})
}
// DownloadReport: GET /api/v1/reports/:id/download —— 下载已渲染的 Word 文档
func (h *Handler) DownloadReport(c *gin.Context) {
// ExportReport: GET /api/v1/reports/:id/export?format=docx|md —— 按需把报告源渲染为指定格式并下载
// 生成阶段只存源;此处经 mcp-go report_export 现渲染("导出时再处理")。PDF 由前端打印预览生成。
func (h *Handler) ExportReport(c *gin.Context) {
id := c.Param("id")
path := contract.ReportPath(id)
if _, err := os.Stat(path); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "报告尚未生成或已过期"})
format := c.DefaultQuery("format", "docx")
res, err := h.bus.CallTool(c.Request.Context(), contract.ToolSubjectGo("report_export"),
&contract.ToolCall{Tool: "report_export", Args: map[string]any{"task_id": id, "format": format}})
if err != nil || res == nil || !res.OK {
msg := "报告尚未生成或已过期"
if res != nil && res.Error != "" {
msg = res.Error
}
c.JSON(http.StatusNotFound, gin.H{"error": msg})
return
}
c.Header("Content-Disposition", `attachment; filename="`+id+`.docx"`)
c.Header("Content-Type", "application/vnd.openxmlformats-officedocument.wordprocessingml.document")
c.File(path)
switch format {
case "md", "markdown":
c.Header("Content-Disposition", `attachment; filename="`+id+`.md"`)
c.Header("Content-Type", "text/markdown; charset=utf-8")
c.String(http.StatusOK, res.Content)
default: // docxres.Content 为 mcp-go 落盘的 .docx 路径
c.Header("Content-Disposition", `attachment; filename="`+id+`.docx"`)
c.Header("Content-Type", "application/vnd.openxmlformats-officedocument.wordprocessingml.document")
c.File(res.Content)
}
}
func newReportID() string {
+3 -2
View File
@@ -40,8 +40,9 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
api.GET("/agents", h.AgentList) // 我的 Agent 编排列表(owner 隔离)
api.POST("/agents", h.AgentSave) // 保存/更新编排
api.DELETE("/agents", h.AgentDelete) // 删除编排
api.POST("/reports", h.GenerateReport) // 报告生成(intent=report 任务 → Dispatcher 专用编排)
api.GET("/reports/:id/download", h.DownloadReport) // 下载渲染好的 Word(.docx)
api.POST("/reports", h.GenerateReport) // 报告生成(intent=report 任务 → Dispatcher 专用编排)
api.GET("/reports/:id/export", h.ExportReport) // 按需导出(format=docx|md;默认 docx
api.GET("/reports/:id/download", h.ExportReport) // 兼容旧入口(默认 docx
api.GET("/health", h.Health) // 依赖健康聚合(顶栏五盏灯)
api.GET("/billing", h.Billing)
+88
View File
@@ -60,6 +60,10 @@ func (g *Gateway) dispatch(ctx context.Context, call *contract.ToolCall) *contra
return g.kbGraph(ctx, call)
case "report_render":
return g.reportRender(ctx, call)
case "report_store":
return g.reportStore(ctx, call)
case "report_export":
return g.reportExport(ctx, call)
case "health":
data, _ := json.Marshal(g.rag.Status())
return &contract.ToolResult{OK: true, Content: string(data)}
@@ -213,6 +217,90 @@ func (g *Gateway) reportRender(ctx context.Context, call *contract.ToolCall) *co
return &contract.ToolResult{OK: true, Content: path}
}
// reportSource 是报告的可序列化源数据(标题 + 章节),导出时据此渲染各格式。
type reportSource struct {
Title string `json:"title"`
Sections []office.Section `json:"sections"`
}
// reportStore 把报告源数据(title + sections)落盘为 JSON,供导出时按需渲染 Word/PDF/Markdown。
// 生成阶段只存源、不渲染("导出时再处理")。
func (g *Gateway) reportStore(_ context.Context, call *contract.ToolCall) *contract.ToolResult {
id, _ := call.Args["task_id"].(string)
if id == "" {
id = call.TaskID
}
if id == "" {
return &contract.ToolResult{OK: false, Error: "report_store: task_id 必填"}
}
title, _ := call.Args["title"].(string)
var secs []office.Section
if raw, err := json.Marshal(call.Args["sections"]); err == nil {
_ = json.Unmarshal(raw, &secs)
}
data, _ := json.Marshal(reportSource{Title: title, Sections: secs})
path := contract.ReportSourcePath(id)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return &contract.ToolResult{OK: false, Error: "report_store: mkdir " + err.Error()}
}
if err := os.WriteFile(path, data, 0o644); err != nil {
return &contract.ToolResult{OK: false, Error: "report_store: write " + err.Error()}
}
log.Printf("[mcp_go] report_store 已存源 %s (%d 章节)", path, len(secs))
return &contract.ToolResult{OK: true, Content: path}
}
// reportExport 按需把已存报告源渲染为指定格式:
// docx → 渲染并落盘,返回 .docx 路径;md → 返回 Markdown 文本。
func (g *Gateway) reportExport(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
id, _ := call.Args["task_id"].(string)
if id == "" {
id = call.TaskID
}
if id == "" {
return &contract.ToolResult{OK: false, Error: "report_export: task_id 必填"}
}
format, _ := call.Args["format"].(string)
raw, err := os.ReadFile(contract.ReportSourcePath(id))
if err != nil {
return &contract.ToolResult{OK: false, Error: "report_export: 报告尚未生成或已过期"}
}
var src reportSource
if err := json.Unmarshal(raw, &src); err != nil {
return &contract.ToolResult{OK: false, Error: "report_export: 源解析失败"}
}
switch format {
case "md", "markdown":
return &contract.ToolResult{OK: true, Content: reportMarkdown(src)}
default: // docx
data, rerr := office.NewRenderer().RenderReport(ctx, src.Title, src.Sections)
if rerr != nil {
return &contract.ToolResult{OK: false, Error: "report_export: " + rerr.Error()}
}
path := contract.ReportPath(id)
if err := os.WriteFile(path, data, 0o644); err != nil {
return &contract.ToolResult{OK: false, Error: "report_export: write " + err.Error()}
}
log.Printf("[mcp_go] report_export 已渲染 docx %s (%d 字节)", path, len(data))
return &contract.ToolResult{OK: true, Content: path}
}
}
// reportMarkdown 把报告源拼为 Markdown(标题 + 各章 ## 小标题 + 正文)。
func reportMarkdown(src reportSource) string {
var b strings.Builder
if src.Title != "" {
b.WriteString("# " + src.Title + "\n\n")
}
for _, s := range src.Sections {
if s.Heading != "" {
b.WriteString("## " + s.Heading + "\n\n")
}
b.WriteString(strings.TrimSpace(s.Body) + "\n\n")
}
return b.String()
}
// kbIngest 把文本入库(切块→embedding→Milvus+Bleve)。
// 带 job_id 时逐阶段把进度发到 sundynix.streams.<job_id>,供 UI 实时入库监控。
func (g *Gateway) kbIngest(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
+6
View File
@@ -19,3 +19,9 @@ func ReportsDir() string {
func ReportPath(id string) string {
return filepath.Join(ReportsDir(), id+".docx")
}
// ReportSourcePath 返回某报告的源数据(标题 + 章节 JSON)绝对路径。
// 生成阶段只落源数据;导出时再按需渲染 Word/PDF/Markdown"导出时再处理")。
func ReportSourcePath(id string) string {
return filepath.Join(ReportsDir(), id+".json")
}