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:
@@ -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 h1:VCYrMzFwEryyhRSeI+/b3tRBSeTpi/8gn5Kf6dxqn+o=
|
||||||
github.com/couchbase/moss v0.2.0/go.mod h1:9MaHIaRuy9pvLPUJxB8sh8OrLfyDczECVL37grCIubs=
|
github.com/couchbase/moss v0.2.0/go.mod h1:9MaHIaRuy9pvLPUJxB8sh8OrLfyDczECVL37grCIubs=
|
||||||
github.com/creack/pty v1.1.9 h1:uDmaGzcdjhF4i/plgjmEsriH11Y0o7RKapEf/LDaM3w=
|
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/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/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/gofuzz v1.0.0 h1:A8PeW59pxE9IoFRqBp37U+mSNaQoZ46F1f0f863XSXw=
|
github.com/google/gofuzz v1.0.0 h1:A8PeW59pxE9IoFRqBp37U+mSNaQoZ46F1f0f863XSXw=
|
||||||
|
|||||||
@@ -302,11 +302,16 @@ export async function generateReport(id: Identity, topic: string, kb?: string):
|
|||||||
return data.task_id;
|
return data.task_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// reportDownloadUrl: 渲染好的 Word(.docx) 下载地址。
|
// reportDownloadUrl: 渲染好的 Word(.docx) 下载地址(兼容旧入口)。
|
||||||
export function reportDownloadUrl(taskId: string): string {
|
export function reportDownloadUrl(taskId: string): string {
|
||||||
return `${GATEWAY}/api/v1/reports/${taskId}/download`;
|
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)。
|
// setMemory: PUT /api/v1/memory,登记一条用户偏好(→ mcp-go memory_upsert)。
|
||||||
export async function setMemory(
|
export async function setMemory(
|
||||||
id: Identity,
|
id: Identity,
|
||||||
|
|||||||
@@ -47,6 +47,30 @@ export function notify(title: string, body: string): void {
|
|||||||
app()?.Notify(title, body);
|
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) {
|
function triggerDownload(url: string, filename: string) {
|
||||||
const el = document.createElement("a");
|
const el = document.createElement("a");
|
||||||
el.href = url;
|
el.href = url;
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { Play, Download, FileText, ExternalLink } from "lucide-react";
|
import { Play, FileText, FileType2, Printer, FileCode } from "lucide-react";
|
||||||
import { generateReport, streamTokens, streamExec, reportDownloadUrl, type Identity, type ExecEvent } from "../lib/api";
|
import { generateReport, streamTokens, streamExec, reportExportUrl, type Identity, type ExecEvent } from "../lib/api";
|
||||||
import { isDesktop, saveReportAs, openReport, notify } from "../lib/desktop";
|
import { saveReportAs, printReportHtml, notify } from "../lib/desktop";
|
||||||
import { ExecTrace } from "../components/ExecTrace";
|
import { ExecTrace } from "../components/ExecTrace";
|
||||||
import { Markdown } from "../components/Markdown";
|
import { Markdown } from "../components/Markdown";
|
||||||
import { Button, Input, Field, Panel, Dot, EmptyState, useToast } from "../ui";
|
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;
|
const base = topic.replace(/[\\/:*?"<>|]/g, "").trim().slice(0, 40) || id;
|
||||||
return `${base}.docx`;
|
return `${base}.${ext}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Phase = "idle" | "running" | "done" | "error";
|
type Phase = "idle" | "running" | "done" | "error";
|
||||||
@@ -26,9 +26,32 @@ export function ReportView({ identity }: { identity: Identity }) {
|
|||||||
const [taskId, setTaskId] = useState("");
|
const [taskId, setTaskId] = useState("");
|
||||||
const closeRef = useRef<(() => void) | null>(null);
|
const closeRef = useRef<(() => void) | null>(null);
|
||||||
const execCloseRef = useRef<(() => void) | null>(null);
|
const execCloseRef = useRef<(() => void) | null>(null);
|
||||||
|
const previewRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const running = phase === "running";
|
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 () => {
|
const onGenerate = async () => {
|
||||||
if (!topic.trim() || running) return;
|
if (!topic.trim() || running) return;
|
||||||
closeRef.current?.();
|
closeRef.current?.();
|
||||||
@@ -93,24 +116,16 @@ export function ReportView({ identity }: { identity: Identity }) {
|
|||||||
</Button>
|
</Button>
|
||||||
{phase === "done" && taskId ? (
|
{phase === "done" && taskId ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<span className="text-[11px] text-slate-500">导出</span>
|
||||||
icon={Download}
|
<Button icon={FileType2} onClick={() => exportFile("docx")}>
|
||||||
onClick={async () => {
|
Word
|
||||||
const p = await saveReportAs(reportDownloadUrl(taskId), reportFilename(topic, taskId));
|
</Button>
|
||||||
if (p) toast.push("success", "已保存到 " + p);
|
<Button icon={Printer} onClick={exportPdf}>
|
||||||
}}
|
PDF
|
||||||
>
|
</Button>
|
||||||
{isDesktop() ? "另存为 Word" : "下载 Word"}
|
<Button variant="ghost" icon={FileCode} onClick={() => exportFile("md")}>
|
||||||
|
Markdown
|
||||||
</Button>
|
</Button>
|
||||||
{isDesktop() && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
icon={ExternalLink}
|
|
||||||
onClick={() => openReport(reportDownloadUrl(taskId), reportFilename(topic, taskId)).catch((e) => toast.push("error", String(e)))}
|
|
||||||
>
|
|
||||||
用系统打开
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="h-9" />
|
<span className="h-9" />
|
||||||
@@ -131,7 +146,9 @@ export function ReportView({ identity }: { identity: Identity }) {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{out ? (
|
{out ? (
|
||||||
<Markdown text={out} className="text-sm" />
|
<div ref={previewRef}>
|
||||||
|
<Markdown text={out} className="text-sm" />
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState icon={FileText} title="尚未生成报告" desc="输入主题并点击「生成报告」,这里将实时显示规划与撰写过程。" />
|
<EmptyState icon={FileText} title="尚未生成报告" desc="输入主题并点击「生成报告」,这里将实时显示规划与撰写过程。" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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")
|
o.emit(t.ID, "## "+s.Heading+"\n\n"+s.Body+"\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 渲染真实 Word 文档。
|
// 只持久化报告源数据(标题+章节),不在生成阶段渲染;导出时再按需出 Word/PDF/Markdown。
|
||||||
o.emit(t.ID, "> 正在渲染 Word 文档…\n\n")
|
endStore := tr.span("store", "render", "保存报告源")
|
||||||
endRender := tr.span("render", "render", "渲染 Word 文档")
|
if o.storeReport(ctx, t.ID, firstNonEmpty(outline.Title, topic), sections) {
|
||||||
if path := o.renderReport(ctx, t.ID, firstNonEmpty(outline.Title, topic), sections); path != "" {
|
endStore("已保存,可按需导出 Word/PDF/Markdown", nil)
|
||||||
endRender("docx 已落盘:"+path, nil)
|
o.emit(t.ID, "---\n✅ 报告正文已生成,可在上方导出 **Word / PDF / Markdown**。\n")
|
||||||
o.emit(t.ID, "---\n✅ 报告已生成 Word 文档,可点击上方「下载 Word」保存。\n")
|
log.Printf("[report] task %s 完成,源已存", t.ID)
|
||||||
log.Printf("[report] task %s 完成,docx=%s", t.ID, path)
|
|
||||||
} else {
|
} else {
|
||||||
endRender("渲染服务不可用", fmt.Errorf("render unavailable"))
|
endStore("源保存失败", fmt.Errorf("store unavailable"))
|
||||||
o.emit(t.ID, "---\n⚠️ Word 渲染未完成(渲染服务不可用),以上为报告正文。\n")
|
o.emit(t.ID, "---\n⚠️ 报告源保存失败(导出可能不可用),以上为报告正文。\n")
|
||||||
}
|
}
|
||||||
o.breaker.Report(true)
|
o.breaker.Report(true)
|
||||||
return nil
|
return nil
|
||||||
@@ -229,6 +228,24 @@ func (o *Orchestrator) retrieve(ctx context.Context, kb, query string) string {
|
|||||||
return b.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 并落盘,返回文件路径(失败返回空)。
|
// renderReport 经 mcp-go report_render 工具渲染 docx 并落盘,返回文件路径(失败返回空)。
|
||||||
func (o *Orchestrator) renderReport(ctx context.Context, taskID, title string, secs []reportSection) string {
|
func (o *Orchestrator) renderReport(ctx context.Context, taskID, title string, secs []reportSection) string {
|
||||||
if o.tools == nil {
|
if o.tools == nil {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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})
|
c.JSON(http.StatusAccepted, gin.H{"task_id": id})
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadReport: GET /api/v1/reports/:id/download —— 下载已渲染的 Word 文档。
|
// ExportReport: GET /api/v1/reports/:id/export?format=docx|md —— 按需把报告源渲染为指定格式并下载。
|
||||||
func (h *Handler) DownloadReport(c *gin.Context) {
|
// 生成阶段只存源;此处经 mcp-go report_export 现渲染("导出时再处理")。PDF 由前端打印预览生成。
|
||||||
|
func (h *Handler) ExportReport(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
path := contract.ReportPath(id)
|
format := c.DefaultQuery("format", "docx")
|
||||||
if _, err := os.Stat(path); err != nil {
|
res, err := h.bus.CallTool(c.Request.Context(), contract.ToolSubjectGo("report_export"),
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "报告尚未生成或已过期"})
|
&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
|
return
|
||||||
}
|
}
|
||||||
c.Header("Content-Disposition", `attachment; filename="`+id+`.docx"`)
|
switch format {
|
||||||
c.Header("Content-Type", "application/vnd.openxmlformats-officedocument.wordprocessingml.document")
|
case "md", "markdown":
|
||||||
c.File(path)
|
c.Header("Content-Disposition", `attachment; filename="`+id+`.md"`)
|
||||||
|
c.Header("Content-Type", "text/markdown; charset=utf-8")
|
||||||
|
c.String(http.StatusOK, res.Content)
|
||||||
|
default: // docx:res.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 {
|
func newReportID() string {
|
||||||
|
|||||||
@@ -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.GET("/agents", h.AgentList) // 我的 Agent 编排列表(owner 隔离)
|
||||||
api.POST("/agents", h.AgentSave) // 保存/更新编排
|
api.POST("/agents", h.AgentSave) // 保存/更新编排
|
||||||
api.DELETE("/agents", h.AgentDelete) // 删除编排
|
api.DELETE("/agents", h.AgentDelete) // 删除编排
|
||||||
api.POST("/reports", h.GenerateReport) // 报告生成(intent=report 任务 → Dispatcher 专用编排)
|
api.POST("/reports", h.GenerateReport) // 报告生成(intent=report 任务 → Dispatcher 专用编排)
|
||||||
api.GET("/reports/:id/download", h.DownloadReport) // 下载渲染好的 Word(.docx)
|
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("/health", h.Health) // 依赖健康聚合(顶栏五盏灯)
|
||||||
api.GET("/billing", h.Billing)
|
api.GET("/billing", h.Billing)
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ func (g *Gateway) dispatch(ctx context.Context, call *contract.ToolCall) *contra
|
|||||||
return g.kbGraph(ctx, call)
|
return g.kbGraph(ctx, call)
|
||||||
case "report_render":
|
case "report_render":
|
||||||
return g.reportRender(ctx, call)
|
return g.reportRender(ctx, call)
|
||||||
|
case "report_store":
|
||||||
|
return g.reportStore(ctx, call)
|
||||||
|
case "report_export":
|
||||||
|
return g.reportExport(ctx, call)
|
||||||
case "health":
|
case "health":
|
||||||
data, _ := json.Marshal(g.rag.Status())
|
data, _ := json.Marshal(g.rag.Status())
|
||||||
return &contract.ToolResult{OK: true, Content: string(data)}
|
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}
|
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)。
|
// kbIngest 把文本入库(切块→embedding→Milvus+Bleve)。
|
||||||
// 带 job_id 时逐阶段把进度发到 sundynix.streams.<job_id>,供 UI 实时入库监控。
|
// 带 job_id 时逐阶段把进度发到 sundynix.streams.<job_id>,供 UI 实时入库监控。
|
||||||
func (g *Gateway) kbIngest(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
|
func (g *Gateway) kbIngest(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
|
||||||
|
|||||||
@@ -19,3 +19,9 @@ func ReportsDir() string {
|
|||||||
func ReportPath(id string) string {
|
func ReportPath(id string) string {
|
||||||
return filepath.Join(ReportsDir(), id+".docx")
|
return filepath.Join(ReportsDir(), id+".docx")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReportSourcePath 返回某报告的源数据(标题 + 章节 JSON)绝对路径。
|
||||||
|
// 生成阶段只落源数据;导出时再按需渲染 Word/PDF/Markdown("导出时再处理")。
|
||||||
|
func ReportSourcePath(id string) string {
|
||||||
|
return filepath.Join(ReportsDir(), id+".json")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user