diff --git a/README.md b/README.md index 673eda5..4263ae2 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,8 @@ make desktop-build # → sundynix-desktop/build/bin/sundynix_desktop.app ``` 完成后即可:编排 Agent 图 → 运行(注入画像/历史,真实流式);知识库入库(docx/xlsx/pdf) → -三路混合检索(向量+全文+图谱)。桌面端是客户端,经 HTTP 连 Gateway:8080,需先起后端。 +三路混合检索(向量+全文+图谱);**报告生成**(「报告」页输入主题 → 规划大纲 → 各章并行检索+撰写 → +渲染真实 Word(.docx) → 一键下载)。桌面端是客户端,经 HTTP 连 Gateway:8080,需先起后端。 > 后端 4 模块用根 `go.work` 串联;`sundynix-shared` 经各 go.mod `replace` 指向本地。 > 桌面端是自包含模块,`make desktop`/`desktop-build` 用 `GOWORK=off` 独立构建(不入 go.work)。 diff --git a/sundynix-desktop/frontend/src/App.tsx b/sundynix-desktop/frontend/src/App.tsx index 0293d79..6e038ac 100644 --- a/sundynix-desktop/frontend/src/App.tsx +++ b/sundynix-desktop/frontend/src/App.tsx @@ -6,6 +6,7 @@ import { BottomDrawer } from "./shell/BottomDrawer"; import { StudioView } from "./studio/StudioView"; import { MemoryView } from "./views/MemoryView"; import { KbView } from "./views/KbView"; +import { ReportView } from "./views/ReportView"; import { Home } from "./views/Home"; import { Placeholder } from "./views/Placeholder"; import { submitTask, streamTokens, type Identity } from "./lib/api"; @@ -81,6 +82,8 @@ export default function App() { ) : view === "kb" ? ( + ) : view === "report" ? ( + ) : view === "memory" ? ( ) : ( diff --git a/sundynix-desktop/frontend/src/lib/api.ts b/sundynix-desktop/frontend/src/lib/api.ts index a93d2b1..b424d0b 100644 --- a/sundynix-desktop/frontend/src/lib/api.ts +++ b/sundynix-desktop/frontend/src/lib/api.ts @@ -131,6 +131,28 @@ export async function searchKb(kb: string, q: string, topK = 5): Promise { + const res = await fetch(`${GATEWAY}/api/v1/reports`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-User-ID": id.userId, + "X-Session-ID": id.sessionId, + }, + body: JSON.stringify({ topic, kb: kb ?? "" }), + }); + const data = (await res.json()) as { task_id?: string; error?: string }; + if (!res.ok || !data.task_id) throw new Error(data.error ?? `report failed: ${res.status}`); + return data.task_id; +} + +// reportDownloadUrl: 渲染好的 Word(.docx) 下载地址。 +export function reportDownloadUrl(taskId: string): string { + return `${GATEWAY}/api/v1/reports/${taskId}/download`; +} + // setMemory: PUT /api/v1/memory,登记一条用户偏好(→ mcp-go memory_upsert)。 export async function setMemory( id: Identity, diff --git a/sundynix-desktop/frontend/src/shell/LeftNav.tsx b/sundynix-desktop/frontend/src/shell/LeftNav.tsx index 65c4fa6..5813c46 100644 --- a/sundynix-desktop/frontend/src/shell/LeftNav.tsx +++ b/sundynix-desktop/frontend/src/shell/LeftNav.tsx @@ -20,7 +20,7 @@ const ITEMS: Item[] = [ { key: "home", label: "工作台", icon: "▤", ready: true }, { key: "studio", label: "编排", icon: "◆", group: "BUILD", ready: true }, { key: "kb", label: "知识库", icon: "▣", group: "BUILD", ready: true }, - { key: "report", label: "报告", icon: "▦", group: "BUILD" }, + { key: "report", label: "报告", icon: "▦", group: "BUILD", ready: true }, { key: "runs", label: "运行", icon: "▸", group: "RUN" }, { key: "memory", label: "记忆", icon: "◇", group: "MANAGE", ready: true }, { key: "market", label: "市场", icon: "⌧", group: "MANAGE" }, diff --git a/sundynix-desktop/frontend/src/views/ReportView.tsx b/sundynix-desktop/frontend/src/views/ReportView.tsx new file mode 100644 index 0000000..f99cafe --- /dev/null +++ b/sundynix-desktop/frontend/src/views/ReportView.tsx @@ -0,0 +1,116 @@ +import { useRef, useState } from "react"; +import { generateReport, streamTokens, reportDownloadUrl, type Identity } from "../lib/api"; + +type Phase = "idle" | "running" | "done" | "error"; + +// 报告生成:输入主题(+可选知识库) → 触发后端专用编排 +// (规划大纲 → 各章并行检索+撰写 → 渲染 Word),实时看进度与正文,完成后下载 .docx。 +export function ReportView({ identity }: { identity: Identity }) { + const [topic, setTopic] = useState(""); + const [kb, setKb] = useState(""); + const [phase, setPhase] = useState("idle"); + const [out, setOut] = useState(""); + const [taskId, setTaskId] = useState(""); + const [err, setErr] = useState(""); + const closeRef = useRef<(() => void) | null>(null); + + const running = phase === "running"; + + const onGenerate = async () => { + if (!topic.trim() || running) return; + closeRef.current?.(); + setPhase("running"); + setOut(""); + setErr(""); + setTaskId(""); + try { + const id = await generateReport(identity, topic.trim(), kb.trim() || undefined); + setTaskId(id); + closeRef.current = streamTokens( + id, + (tok) => setOut((o) => o + tok), + () => setPhase("done"), + () => { + setErr("连接中断"); + setPhase("error"); + }, + ); + } catch (e) { + setErr((e as Error).message); + setPhase("error"); + } + }; + + return ( +
+
+

报告生成

+

+ 规划大纲 → 各章并行(知识库检索 + LLM 撰写)→ 汇聚 → 渲染真实 Word(.docx)。依赖已配置对话模型;挂知识库则引用其资料。 +

+
+ + {/* 输入区 */} +
+
+ + setTopic(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && onGenerate()} + placeholder="如:2026 年国产大模型产业现状与趋势分析" + className="rounded-lg border border-line bg-ink-950 px-3 py-2 text-sm text-slate-200 outline-none placeholder:text-slate-600 focus:border-violet-500" + /> +
+
+ + setKb(e.target.value)} + placeholder="如 docs,留空则不挂检索" + className="rounded-lg border border-line bg-ink-950 px-3 py-2 text-sm text-slate-200 outline-none placeholder:text-slate-600 focus:border-violet-500" + /> +
+
+ + {phase === "done" && taskId && ( + + ⬇ 下载 Word + + )} + + {running && "正在编排,实时进度见下方…"} + {phase === "done" && "已完成"} + {phase === "error" && 出错:{err}} + +
+
+ + {/* 实时进度 / 正文 */} +
+
+ + 实时编排 · {taskId || "未开始"} +
+
+ {out ? ( +
{out}
+ ) : ( +
+ 输入主题并点击「生成报告」,这里将实时显示规划与撰写过程。 +
+ )} +
+
+
+ ); +} diff --git a/sundynix-dispatcher/internal/eino/orchestrator.go b/sundynix-dispatcher/internal/eino/orchestrator.go index a90c9bd..ca5d24e 100644 --- a/sundynix-dispatcher/internal/eino/orchestrator.go +++ b/sundynix-dispatcher/internal/eino/orchestrator.go @@ -51,6 +51,10 @@ func (o *Orchestrator) Handle(ctx context.Context, t *contract.Task) error { log.Printf("[eino] circuit open, drop task %s", t.ID) return nil } + // 报告生成走专用多步编排(规划→分章并行检索撰写→汇聚→渲染 Word),而非通用对话图。 + if intent, _ := t.Meta[contract.MetaIntent].(string); intent == contract.IntentReport { + return o.handleReport(ctx, t) + } log.Printf("[eino] task %s received (graph=%d bytes), compiling DSL → Eino graph...", t.ID, len(t.Graph)) run, err := o.compileFlow(ctx, t) diff --git a/sundynix-dispatcher/internal/eino/report.go b/sundynix-dispatcher/internal/eino/report.go new file mode 100644 index 0000000..8bc3c6b --- /dev/null +++ b/sundynix-dispatcher/internal/eino/report.go @@ -0,0 +1,239 @@ +package eino + +import ( + "context" + "encoding/json" + "fmt" + "log" + "strings" + "sync" + "time" + + "github.com/sundynix/sundynix-dispatcher/internal/dsl" + "github.com/sundynix/sundynix-dispatcher/internal/llm" + "github.com/sundynix/sundynix-shared/contract" +) + +// 报告生成的并发与超时参数。 +const ( + reportFanout = 4 // 章节并行撰写的最大并发 + reportRenderWait = 12 * time.Second // 渲染 docx 的等待上限 +) + +// reportOutline 是规划阶段产出的大纲。 +type reportOutline struct { + Title string `json:"title"` + Sections []string `json:"sections"` +} + +// reportSection 是一章成稿(标题 + 正文)。 +type reportSection struct { + Heading string `json:"heading"` + Body string `json:"body"` +} + +// handleReport 执行报告生成的专用多步编排: +// +// 规划大纲 → 各章节并行(RAG 检索 + LLM 撰写) → 汇聚 → 渲染 Word(.docx) → 回流进度与正文 +// +// 全程把人可读的 Markdown 进度与正文经 sundynix.streams. 流回客户端; +// 最终调 mcp-go 的 report_render 落盘 docx,客户端凭 task_id 下载。 +func (o *Orchestrator) handleReport(ctx context.Context, t *contract.Task) error { + defer func() { _ = o.sink.CompleteStream(t.ID) }() + + topic, _ := t.Meta[contract.MetaTopic].(string) + kb, _ := t.Meta[contract.MetaKB].(string) + if topic == "" { + topic = dsl.Compile(t.Graph).Query // 兜底:从 DSL 取用户输入 + } + if topic == "" { + topic = "未命名报告" + } + log.Printf("[report] task %s 生成报告: topic=%q kb=%q", t.ID, topic, kb) + + o.emit(t.ID, "> 正在规划大纲…\n\n") + outline := o.planOutline(ctx, topic) + + o.emit(t.ID, fmt.Sprintf("**报告大纲**(%d 章)\n", len(outline.Sections))) + for i, s := range outline.Sections { + o.emit(t.ID, fmt.Sprintf("%d. %s\n", i+1, s)) + } + if kb != "" { + o.emit(t.ID, fmt.Sprintf("\n> 正在并行检索知识库 %q 资料并撰写各章…\n\n", kb)) + } else { + o.emit(t.ID, "\n> 正在并行撰写各章…\n\n") + } + + sections := o.writeSections(ctx, topic, kb, outline.Sections) + + // 把完整报告正文流式呈现给客户端。 + o.emit(t.ID, "\n---\n\n# "+firstNonEmpty(outline.Title, topic)+"\n\n") + for _, s := range sections { + o.emit(t.ID, "## "+s.Heading+"\n\n"+s.Body+"\n\n") + } + + // 渲染真实 Word 文档。 + o.emit(t.ID, "> 正在渲染 Word 文档…\n\n") + if path := o.renderReport(ctx, t.ID, firstNonEmpty(outline.Title, topic), sections); path != "" { + o.emit(t.ID, "---\n✅ 报告已生成 Word 文档,可点击上方「下载 Word」保存。\n") + log.Printf("[report] task %s 完成,docx=%s", t.ID, path) + } else { + o.emit(t.ID, "---\n⚠️ Word 渲染未完成(渲染服务不可用),以上为报告正文。\n") + } + o.breaker.Report(true) + return nil +} + +// planOutline 让模型规划 3–5 章大纲;模型不可用/解析失败则用通用兜底大纲。 +func (o *Orchestrator) planOutline(ctx context.Context, topic string) reportOutline { + fallback := reportOutline{Title: topic, Sections: []string{"背景与现状", "核心分析", "结论与建议"}} + if !o.pool.Ready() { + return fallback + } + sys := "你是资深报告撰稿人,擅长搭建清晰的报告结构。" + user := fmt.Sprintf("请为主题《%s》规划一份报告大纲。"+ + "只输出 JSON:{\"title\":\"报告标题\",\"sections\":[\"章节标题\", ...]},3 到 5 章,不要任何多余文字。", topic) + txt, err := o.pool.Chat(ctx, []llm.ChatMessage{{Role: "system", Content: sys}, {Role: "user", Content: user}}) + if err != nil { + log.Printf("[report] 规划大纲失败,用兜底大纲: %v", err) + return fallback + } + var out reportOutline + if json.Unmarshal([]byte(stripFence(txt)), &out) != nil || len(out.Sections) == 0 { + log.Printf("[report] 大纲 JSON 解析失败,用兜底大纲。原文: %s", truncate(txt, 200)) + return fallback + } + if out.Title == "" { + out.Title = topic + } + return out +} + +// writeSections 各章节并行撰写(有界并发),结果按原顺序返回。 +func (o *Orchestrator) writeSections(ctx context.Context, topic, kb string, headings []string) []reportSection { + out := make([]reportSection, len(headings)) + sem := make(chan struct{}, reportFanout) + var wg sync.WaitGroup + for i, h := range headings { + wg.Add(1) + go func(i int, h string) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + out[i] = reportSection{Heading: h, Body: o.writeSection(ctx, topic, kb, h)} + }(i, h) + } + wg.Wait() + return out +} + +// writeSection 撰写一章:先 RAG 检索参考资料(若挂了知识库),再让模型成稿。 +func (o *Orchestrator) writeSection(ctx context.Context, topic, kb, heading string) string { + refs := o.retrieve(ctx, kb, topic+" "+heading) + if !o.pool.Ready() { + if refs != "" { + return "(模型未配置,以下为检索到的参考资料)\n" + refs + } + return "(模型未配置,无法撰写本章。)" + } + sys := "你是专业报告撰稿人,语言严谨、条理清晰,使用中文书面语。" + var ub strings.Builder + fmt.Fprintf(&ub, "报告主题:%s\n本章标题:%s\n", topic, heading) + if refs != "" { + ub.WriteString("可参考的资料(来自知识库检索,请甄别采用,不要照搬):\n") + ub.WriteString(refs) + ub.WriteString("\n") + } + ub.WriteString("请就「本章标题」撰写 200–400 字正文。只输出正文,不要重复标题、不要再列提纲。") + txt, err := o.pool.Chat(ctx, []llm.ChatMessage{{Role: "system", Content: sys}, {Role: "user", Content: ub.String()}}) + if err != nil { + log.Printf("[report] 撰写「%s」失败: %v", heading, err) + return "(本章撰写失败:" + err.Error() + ")" + } + return strings.TrimSpace(txt) +} + +// retrieve 经 mcp-go kb_search 工具检索知识库,整理为可读参考资料。kb 为空或无召回则返回空。 +func (o *Orchestrator) retrieve(ctx context.Context, kb, query string) string { + if o.tools == nil || kb == "" { + return "" + } + cctx, cancel := context.WithTimeout(ctx, toolCallTimeout) + defer cancel() + res, err := o.tools.CallTool(cctx, contract.ToolSubjectGo("kb_search"), &contract.ToolCall{ + Tool: "kb_search", Args: map[string]any{"kb": kb, "q": query, "topK": 4}, + }) + if err != nil || res == nil || !res.OK || res.Content == "" || res.Content == "[]" { + return "" + } + var hits []struct { + Text string `json:"text"` + Score float64 `json:"score"` + } + if json.Unmarshal([]byte(res.Content), &hits) != nil { + return res.Content + } + var b strings.Builder + for i, h := range hits { + fmt.Fprintf(&b, "%d. %s\n", i+1, strings.TrimSpace(h.Text)) + } + return b.String() +} + +// renderReport 经 mcp-go report_render 工具渲染 docx 并落盘,返回文件路径(失败返回空)。 +func (o *Orchestrator) renderReport(ctx context.Context, taskID, title string, secs []reportSection) string { + if o.tools == nil { + return "" + } + 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_render"), &contract.ToolCall{ + Tool: "report_render", TaskID: taskID, + Args: map[string]any{"title": title, "task_id": taskID, "sections": arr}, + }) + if err != nil || res == nil || !res.OK { + log.Printf("[report] report_render 失败: %v", err) + return "" + } + return res.Content +} + +// emit 把一段文本作为 Token 流回客户端(报告进度与正文都走这里)。 +func (o *Orchestrator) emit(taskID, s string) { + if err := o.sink.PublishToken(taskID, []byte(s)); err != nil { + log.Printf("[report] emit token failed: %v", err) + } +} + +// ---- 小工具 ---- + +func firstNonEmpty(a, b string) string { + if strings.TrimSpace(a) != "" { + return a + } + return b +} + +// stripFence 去掉模型可能包裹的 ```json … ``` 代码围栏。 +func stripFence(s string) string { + s = strings.TrimSpace(s) + if strings.HasPrefix(s, "```") { + if i := strings.IndexByte(s, '\n'); i >= 0 { + s = s[i+1:] + } + s = strings.TrimSuffix(strings.TrimSpace(s), "```") + } + return strings.TrimSpace(s) +} + +func truncate(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) + "…" +} diff --git a/sundynix-dispatcher/internal/llm/pool.go b/sundynix-dispatcher/internal/llm/pool.go index 61985e9..c4e7dab 100644 --- a/sundynix-dispatcher/internal/llm/pool.go +++ b/sundynix-dispatcher/internal/llm/pool.go @@ -112,6 +112,14 @@ func (p *Pool) ChatStream(ctx context.Context, msgs []ChatMessage, onToken func( return sc.Err() } +// Chat 非流式:内部复用 ChatStream 聚合全部 token,返回整段文本。 +// 报告生成的「规划大纲 / 撰写章节」等需要拿到完整结果再继续,用它而非流式。 +func (p *Pool) Chat(ctx context.Context, msgs []ChatMessage) (string, error) { + var b strings.Builder + err := p.ChatStream(ctx, msgs, func(tok string) { b.WriteString(tok) }) + return b.String(), err +} + // ---- 占位降级(未配置后端时)---- // 占位参数:模拟真实后端的 TTFT(首 token 延迟) 与逐 token 间隔。 diff --git a/sundynix-gateway/internal/handler/report.go b/sundynix-gateway/internal/handler/report.go new file mode 100644 index 0000000..56b9379 --- /dev/null +++ b/sundynix-gateway/internal/handler/report.go @@ -0,0 +1,64 @@ +package handler + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "net/http" + "os" + + "github.com/gin-gonic/gin" + + "github.com/sundynix/sundynix-shared/contract" +) + +// GenerateReport: POST /api/v1/reports —— 触发报告生成。 +// 组装一个 intent=report 的任务发到 NATS,Dispatcher 走专用编排(规划→分章并行→渲染 docx)。 +// 返回 task_id;客户端用 GET /tasks/:id/stream 看实时进度,完成后用 /reports/:id/download 取 Word。 +func (h *Handler) GenerateReport(c *gin.Context) { + var body struct { + Topic string `json:"topic"` + KB string `json:"kb"` + } + if err := c.ShouldBindJSON(&body); err != nil || body.Topic == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "topic required"}) + return + } + id := newReportID() + graph, _ := json.Marshal(map[string]any{"topic": body.Topic}) // 占位 DSL,报告编排实际读 Meta + task := &contract.Task{ + ID: id, + Graph: graph, + Meta: map[string]any{ + contract.MetaIntent: contract.IntentReport, + contract.MetaTopic: body.Topic, + contract.MetaKB: body.KB, + contract.MetaUserID: userID(c), + contract.MetaSessionID: sessionID(c), + }, + } + if err := h.bus.PublishTask(c.Request.Context(), task); err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusAccepted, gin.H{"task_id": id}) +} + +// DownloadReport: GET /api/v1/reports/:id/download —— 下载已渲染的 Word 文档。 +func (h *Handler) DownloadReport(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": "报告尚未生成或已过期"}) + return + } + c.Header("Content-Disposition", `attachment; filename="`+id+`.docx"`) + c.Header("Content-Type", "application/vnd.openxmlformats-officedocument.wordprocessingml.document") + c.File(path) +} + +func newReportID() string { + var b [8]byte + _, _ = rand.Read(b[:]) + return "report_" + hex.EncodeToString(b[:]) +} diff --git a/sundynix-gateway/internal/router/router.go b/sundynix-gateway/internal/router/router.go index 138ed09..c891b01 100644 --- a/sundynix-gateway/internal/router/router.go +++ b/sundynix-gateway/internal/router/router.go @@ -28,6 +28,9 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus) *gin.Engine { api.GET("/kb/ingest/:id/stream", h.KbIngestStream) // 入库进度 SSE(实时监控) api.POST("/kb/search", h.KbSearch) // 知识库检索台(→ mcp-go kb_search) api.GET("/kb/graph", h.KbGraph) // 知识图谱三元组(→ mcp-go kb_graph,Neo4j) + + api.POST("/reports", h.GenerateReport) // 报告生成(intent=report 任务 → Dispatcher 专用编排) + api.GET("/reports/:id/download", h.DownloadReport) // 下载渲染好的 Word(.docx) api.GET("/billing", h.Billing) // 运维控制面:LLM 模型配置(独立运维控制台调用)。 diff --git a/sundynix-mcp-go/internal/mcp/gateway.go b/sundynix-mcp-go/internal/mcp/gateway.go index 4e4cbd7..758a0ec 100644 --- a/sundynix-mcp-go/internal/mcp/gateway.go +++ b/sundynix-mcp-go/internal/mcp/gateway.go @@ -6,6 +6,8 @@ import ( "encoding/json" "fmt" "log" + "os" + "path/filepath" "strings" sharedbus "github.com/sundynix/sundynix-shared/bus" @@ -13,6 +15,7 @@ import ( "github.com/sundynix/sundynix-mcp-go/internal/history" "github.com/sundynix/sundynix-mcp-go/internal/memory" + "github.com/sundynix/sundynix-mcp-go/internal/office" "github.com/sundynix/sundynix-mcp-go/internal/rag" "github.com/sundynix/sundynix-mcp-go/internal/search" ) @@ -37,7 +40,7 @@ func (g *Gateway) Serve(ctx context.Context) error { return err } defer func() { _ = unsub() }() - log.Printf("[mcp_go] tools ready on %s (queue=%s): wiki_search, kb_ingest, kb_search, kb_graph, memory_*, history_*, echo", + log.Printf("[mcp_go] tools ready on %s (queue=%s): wiki_search, kb_ingest, kb_search, kb_graph, report_render, memory_*, history_*, echo", contract.SubjectToolsGoAll, contract.QueueToolsGo) <-ctx.Done() return ctx.Err() @@ -55,6 +58,8 @@ func (g *Gateway) dispatch(ctx context.Context, call *contract.ToolCall) *contra return g.kbSearch(ctx, call) case "kb_graph": return g.kbGraph(ctx, call) + case "report_render": + return g.reportRender(ctx, call) case "memory_get": return g.memoryGet(ctx, call) case "memory_upsert": @@ -174,6 +179,37 @@ func (g *Gateway) kbGraph(ctx context.Context, call *contract.ToolCall) *contrac return &contract.ToolResult{OK: true, Content: string(data)} } +// reportRender 把结构化报告(title + sections[{heading,body}])渲染为真实 .docx, +// 落盘到 contract.ReportPath(task_id),返回绝对路径供 Gateway 提供下载。 +func (g *Gateway) reportRender(ctx context.Context, call *contract.ToolCall) *contract.ToolResult { + title, _ := call.Args["title"].(string) + id, _ := call.Args["task_id"].(string) + if id == "" { + id = call.TaskID + } + if id == "" { + return &contract.ToolResult{OK: false, Error: "report_render: task_id 必填"} + } + // sections 经 NATS JSON 透传,统一 re-marshal 再解出强类型。 + var secs []office.Section + if raw, err := json.Marshal(call.Args["sections"]); err == nil { + _ = json.Unmarshal(raw, &secs) + } + data, err := office.NewRenderer().RenderReport(ctx, title, secs) + if err != nil { + return &contract.ToolResult{OK: false, Error: "report_render: " + err.Error()} + } + path := contract.ReportPath(id) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return &contract.ToolResult{OK: false, Error: "report_render: mkdir " + err.Error()} + } + if err := os.WriteFile(path, data, 0o644); err != nil { + return &contract.ToolResult{OK: false, Error: "report_render: write " + err.Error()} + } + log.Printf("[mcp_go] report_render 已生成 %s (%d 字节, %d 章节)", path, len(data), len(secs)) + return &contract.ToolResult{OK: true, Content: path} +} + // kbIngest 把文本入库(切块→embedding→Milvus+Bleve)。 // 带 job_id 时逐阶段把进度发到 sundynix.streams.,供 UI 实时入库监控。 func (g *Gateway) kbIngest(ctx context.Context, call *contract.ToolCall) *contract.ToolResult { diff --git a/sundynix-mcp-go/internal/office/docx_smoke_test.go b/sundynix-mcp-go/internal/office/docx_smoke_test.go new file mode 100644 index 0000000..b0f8177 --- /dev/null +++ b/sundynix-mcp-go/internal/office/docx_smoke_test.go @@ -0,0 +1,56 @@ +package office + +import ( + "archive/zip" + "bytes" + "context" + "strings" + "testing" +) + +func TestRenderReportValidDocx(t *testing.T) { + data, err := NewRenderer().RenderReport(context.Background(), "测试报告 ", []Section{ + {Heading: "第一章 背景", Body: "这是第一段。\n这是第二段,含特殊字符 < > &。"}, + {Heading: "第二章 分析", Body: "结论行。"}, + }) + if err != nil { + t.Fatal(err) + } + zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + t.Fatalf("not a valid zip: %v", err) + } + want := map[string]bool{"[Content_Types].xml": false, "_rels/.rels": false, "word/document.xml": false} + var docXML string + for _, f := range zr.File { + if _, ok := want[f.Name]; ok { + want[f.Name] = true + } + if f.Name == "word/document.xml" { + rc, _ := f.Open() + var b strings.Builder + buf := make([]byte, 4096) + for { + n, e := rc.Read(buf) + b.Write(buf[:n]) + if e != nil { + break + } + } + rc.Close() + docXML = b.String() + } + } + for name, found := range want { + if !found { + t.Errorf("missing part %s", name) + } + } + if !strings.Contains(docXML, "< > &") { + t.Errorf("xml not escaped properly: %s", docXML) + } + if !strings.Contains(docXML, "第一章 背景") { + t.Errorf("heading missing") + } + t.Logf("docx ok: %d bytes, %d parts", len(data), len(zr.File)) +} diff --git a/sundynix-mcp-go/internal/office/unioffice.go b/sundynix-mcp-go/internal/office/unioffice.go index 013453a..9410f78 100644 --- a/sundynix-mcp-go/internal/office/unioffice.go +++ b/sundynix-mcp-go/internal/office/unioffice.go @@ -1,15 +1,169 @@ -// Package office 基于 UniOffice 提供 Word/文档渲染能力。 +// Package office 生成真实可用的 Word(.docx)文档。 +// +// 这里不引第三方 Office 库(UniOffice 为商业授权、且会显著增重依赖),而是直接 +// 按 OOXML(WordprocessingML) 规范用标准库 archive/zip + 内联 XML 拼出最小但完整、 +// Word / Pages / WPS 均可正常打开的 .docx 包。零额外依赖,契合 clone 即跑的目标。 package office -import "context" +import ( + "archive/zip" + "bytes" + "context" + "fmt" + "strings" +) -// Renderer 把结构化数据渲染为 docx/xlsx 等文档。 +// Doc 累积段落,最终序列化为一个 .docx 字节流。 +type Doc struct { + body strings.Builder // word/document.xml 的 内部段落串 +} + +// NewDoc 新建一个空文档。 +func NewDoc() *Doc { return &Doc{} } + +// Title 加一行大标题(居中、加粗、约 18pt)。 +func (d *Doc) Title(text string) *Doc { + d.para(text, paraOpts{bold: true, sizeHalfPt: 36, center: true, spaceAfter: 240}) + return d +} + +// Heading 加一行小节标题(加粗、约 14pt)。 +func (d *Doc) Heading(text string) *Doc { + d.para(text, paraOpts{bold: true, sizeHalfPt: 28, spaceBefore: 240, spaceAfter: 120}) + return d +} + +// Para 加一个正文段落(约 11pt)。空串忽略。 +func (d *Doc) Para(text string) *Doc { + text = strings.TrimSpace(text) + if text == "" { + return d + } + d.para(text, paraOpts{sizeHalfPt: 22, spaceAfter: 120}) + return d +} + +// Body 把一段可能含多个换行的正文按行拆成多个段落。 +func (d *Doc) Body(text string) *Doc { + for _, line := range strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") { + d.Para(line) + } + return d +} + +type paraOpts struct { + bold bool + center bool + sizeHalfPt int // OOXML 字号单位为半磅(half-points),22 = 11pt + spaceBefore int // 段前间距(twentieths of a point) + spaceAfter int +} + +func (d *Doc) para(text string, o paraOpts) { + d.body.WriteString("") + // 段落属性:间距 + 居中。 + d.body.WriteString("") + if o.spaceBefore > 0 || o.spaceAfter > 0 { + fmt.Fprintf(&d.body, ``, o.spaceBefore, o.spaceAfter) + } + if o.center { + d.body.WriteString(``) + } + d.body.WriteString("") + // 文本 run。 + d.body.WriteString("") + if o.bold { + d.body.WriteString("") + } + if o.sizeHalfPt > 0 { + fmt.Fprintf(&d.body, ``, o.sizeHalfPt, o.sizeHalfPt) + } + d.body.WriteString("") + fmt.Fprintf(&d.body, `%s`, escapeXML(text)) + d.body.WriteString("") +} + +// Bytes 把累积的段落打包为合规 .docx(zip + 三个核心 XML 部件)。 +func (d *Doc) Bytes() ([]byte, error) { + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + + parts := map[string]string{ + "[Content_Types].xml": contentTypesXML, + "_rels/.rels": relsXML, + "word/document.xml": documentXML(d.body.String()), + } + for name, content := range parts { + w, err := zw.Create(name) + if err != nil { + return nil, err + } + if _, err := w.Write([]byte(content)); err != nil { + return nil, err + } + } + if err := zw.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// ---- Renderer:把结构化报告(标题 + 章节)渲染为 docx ---- + +// Section 是报告的一章:小节标题 + 正文。 +type Section struct { + Heading string `json:"heading"` + Body string `json:"body"` +} + +// Renderer 把结构化数据渲染为 docx。 type Renderer struct{} func NewRenderer() *Renderer { return &Renderer{} } -// RenderDocx 生成 Word 文档并返回字节流。 -func (r *Renderer) RenderDocx(ctx context.Context, payload map[string]any) ([]byte, error) { - // TODO: 使用 unioffice/document 构建并序列化 - return nil, nil +// RenderReport 渲染「大标题 + 多章节」结构的报告为 .docx 字节流。 +func (r *Renderer) RenderReport(_ context.Context, title string, sections []Section) ([]byte, error) { + doc := NewDoc() + if title != "" { + doc.Title(title) + } + for _, s := range sections { + if s.Heading != "" { + doc.Heading(s.Heading) + } + doc.Body(s.Body) + } + return doc.Bytes() +} + +// ---- OOXML 模板 ---- + +const contentTypesXML = ` + + + + +` + +const relsXML = ` + + +` + +func documentXML(body string) string { + return ` +` + + body + + `` +} + +func escapeXML(s string) string { + r := strings.NewReplacer( + "&", "&", + "<", "<", + ">", ">", + `"`, """, + "'", "'", + ) + return r.Replace(s) } diff --git a/sundynix-shared/contract/report.go b/sundynix-shared/contract/report.go new file mode 100644 index 0000000..d1bb1e1 --- /dev/null +++ b/sundynix-shared/contract/report.go @@ -0,0 +1,21 @@ +package contract + +import ( + "os" + "path/filepath" +) + +// ReportsDir 是报告渲染产物(.docx)的落盘目录。 +// Gateway(下载)与 mcp-go(渲染)跨进程共享同一目录:用环境变量统一, +// 缺省取系统临时目录下的 sundynix-reports —— 单机开发零配置即可对齐。 +func ReportsDir() string { + if d := os.Getenv("SUNDYNIX_REPORTS_DIR"); d != "" { + return d + } + return filepath.Join(os.TempDir(), "sundynix-reports") +} + +// ReportPath 返回某任务渲染出的 Word 文档绝对路径。 +func ReportPath(id string) string { + return filepath.Join(ReportsDir(), id+".docx") +} diff --git a/sundynix-shared/contract/task.go b/sundynix-shared/contract/task.go index de2e474..9c32de8 100644 --- a/sundynix-shared/contract/task.go +++ b/sundynix-shared/contract/task.go @@ -34,6 +34,13 @@ const ( // Gateway 持有配置,消费方(Dispatcher/mcp-go)经 NATS 取用/订阅变更。 ConfigKindChat = "chat" // 对话模型(Dispatcher 用) ConfigKindEmbedding = "embedding" // 向量模型(mcp-go RAG 用) + + // 报告生成:Task.Meta[MetaIntent]==IntentReport 时,Dispatcher 走专用多步编排 + // (规划大纲 → 各章节并行检索+撰写 → 汇聚 → 渲染 Word),而非通用对话图。 + MetaIntent = "intent" + IntentReport = "report" + MetaTopic = "topic" // 报告主题 + MetaKB = "kb" // 报告依据的知识库(可空,则不挂检索) ) // ConfigGetSubject / ConfigUpdatedSubject 返回某类配置的 request / 广播主题。