feat(report): 报告生成端到端 — 规划→分章并行检索撰写→渲染真实 Word
- shared: 新增 intent=report 任务约定 + ReportPath(跨进程共享落盘目录,零配置对齐) - dispatcher: handleReport 专用编排(DeepSeek 规划大纲 → 各章并行 RAG 检索+撰写 → 汇聚 → report_render),Pool.Chat 非流式聚合;进度与正文经 Token 流实时回流 - mcp-go: 用标准库 archive/zip + OOXML 拼出真实可打开的 .docx(零额外依赖), report_render 工具落盘到共享目录;附 docx 有效性测试 - gateway: POST /reports 触发;GET /reports/:id/download 下发 Word - desktop: 新增「报告」页(主题→实时编排进度→下载 Word),左导航置为就绪 实测:DeepSeek 生成 5 章报告 → 渲染 5KB docx → file 识别为 Microsoft Word 2007+ → textutil 提取标题/各章正文完整。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -76,7 +76,8 @@ make desktop-build # → sundynix-desktop/build/bin/sundynix_desktop.app
|
|||||||
```
|
```
|
||||||
|
|
||||||
完成后即可:编排 Agent 图 → 运行(注入画像/历史,真实流式);知识库入库(docx/xlsx/pdf) →
|
完成后即可:编排 Agent 图 → 运行(注入画像/历史,真实流式);知识库入库(docx/xlsx/pdf) →
|
||||||
三路混合检索(向量+全文+图谱)。桌面端是客户端,经 HTTP 连 Gateway:8080,需先起后端。
|
三路混合检索(向量+全文+图谱);**报告生成**(「报告」页输入主题 → 规划大纲 → 各章并行检索+撰写 →
|
||||||
|
渲染真实 Word(.docx) → 一键下载)。桌面端是客户端,经 HTTP 连 Gateway:8080,需先起后端。
|
||||||
|
|
||||||
> 后端 4 模块用根 `go.work` 串联;`sundynix-shared` 经各 go.mod `replace` 指向本地。
|
> 后端 4 模块用根 `go.work` 串联;`sundynix-shared` 经各 go.mod `replace` 指向本地。
|
||||||
> 桌面端是自包含模块,`make desktop`/`desktop-build` 用 `GOWORK=off` 独立构建(不入 go.work)。
|
> 桌面端是自包含模块,`make desktop`/`desktop-build` 用 `GOWORK=off` 独立构建(不入 go.work)。
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { BottomDrawer } from "./shell/BottomDrawer";
|
|||||||
import { StudioView } from "./studio/StudioView";
|
import { StudioView } from "./studio/StudioView";
|
||||||
import { MemoryView } from "./views/MemoryView";
|
import { MemoryView } from "./views/MemoryView";
|
||||||
import { KbView } from "./views/KbView";
|
import { KbView } from "./views/KbView";
|
||||||
|
import { ReportView } from "./views/ReportView";
|
||||||
import { Home } from "./views/Home";
|
import { Home } from "./views/Home";
|
||||||
import { Placeholder } from "./views/Placeholder";
|
import { Placeholder } from "./views/Placeholder";
|
||||||
import { submitTask, streamTokens, type Identity } from "./lib/api";
|
import { submitTask, streamTokens, type Identity } from "./lib/api";
|
||||||
@@ -81,6 +82,8 @@ export default function App() {
|
|||||||
<StudioView onRun={onRun} phase={run.phase} />
|
<StudioView onRun={onRun} phase={run.phase} />
|
||||||
) : view === "kb" ? (
|
) : view === "kb" ? (
|
||||||
<KbView />
|
<KbView />
|
||||||
|
) : view === "report" ? (
|
||||||
|
<ReportView identity={identity} />
|
||||||
) : view === "memory" ? (
|
) : view === "memory" ? (
|
||||||
<MemoryView identity={identity} />
|
<MemoryView identity={identity} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -131,6 +131,28 @@ export async function searchKb(kb: string, q: string, topK = 5): Promise<KbHit[]
|
|||||||
return data.hits ?? [];
|
return data.hits ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// generateReport: POST /api/v1/reports —— 触发报告生成,返回 task_id。
|
||||||
|
// 用 streamTokens(task_id) 看实时进度,完成后用 reportDownloadUrl(task_id) 下载 Word。
|
||||||
|
export async function generateReport(id: Identity, topic: string, kb?: string): Promise<string> {
|
||||||
|
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)。
|
// setMemory: PUT /api/v1/memory,登记一条用户偏好(→ mcp-go memory_upsert)。
|
||||||
export async function setMemory(
|
export async function setMemory(
|
||||||
id: Identity,
|
id: Identity,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const ITEMS: Item[] = [
|
|||||||
{ key: "home", label: "工作台", icon: "▤", ready: true },
|
{ key: "home", label: "工作台", icon: "▤", ready: true },
|
||||||
{ key: "studio", label: "编排", icon: "◆", group: "BUILD", ready: true },
|
{ key: "studio", label: "编排", icon: "◆", group: "BUILD", ready: true },
|
||||||
{ key: "kb", 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: "runs", label: "运行", icon: "▸", group: "RUN" },
|
||||||
{ key: "memory", label: "记忆", icon: "◇", group: "MANAGE", ready: true },
|
{ key: "memory", label: "记忆", icon: "◇", group: "MANAGE", ready: true },
|
||||||
{ key: "market", label: "市场", icon: "⌧", group: "MANAGE" },
|
{ key: "market", label: "市场", icon: "⌧", group: "MANAGE" },
|
||||||
|
|||||||
@@ -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<Phase>("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 (
|
||||||
|
<div className="flex h-full min-h-0 flex-col gap-4 overflow-y-auto p-6">
|
||||||
|
<header>
|
||||||
|
<h1 className="text-lg font-semibold text-slate-100">报告生成</h1>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
|
规划大纲 → 各章并行(知识库检索 + LLM 撰写)→ 汇聚 → 渲染真实 Word(.docx)。依赖已配置对话模型;挂知识库则引用其资料。
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* 输入区 */}
|
||||||
|
<div className="grid grid-cols-[1fr_220px] gap-3 rounded-xl border border-line bg-ink-900 p-4 shadow-card">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-[11px] font-medium text-slate-400">报告主题</label>
|
||||||
|
<input
|
||||||
|
value={topic}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-[11px] font-medium text-slate-400">知识库(可选)</label>
|
||||||
|
<input
|
||||||
|
value={kb}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onGenerate}
|
||||||
|
disabled={running || !topic.trim()}
|
||||||
|
className="rounded-lg bg-gradient-to-r from-violet-500 to-cyan-500 px-4 py-2 text-sm font-medium text-white shadow-glow transition disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{running ? "生成中…" : "生成报告"}
|
||||||
|
</button>
|
||||||
|
{phase === "done" && taskId && (
|
||||||
|
<a
|
||||||
|
href={reportDownloadUrl(taskId)}
|
||||||
|
className="rounded-lg border border-violet-500/60 px-4 py-2 text-sm font-medium text-violet-300 transition hover:bg-violet-500/10"
|
||||||
|
>
|
||||||
|
⬇ 下载 Word
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
{running && "正在编排,实时进度见下方…"}
|
||||||
|
{phase === "done" && "已完成"}
|
||||||
|
{phase === "error" && <span className="text-rose-400">出错:{err}</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 实时进度 / 正文 */}
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col rounded-xl border border-line bg-ink-900 shadow-card">
|
||||||
|
<div className="flex items-center gap-2 border-b border-line px-4 py-2.5 text-[11px] text-slate-500">
|
||||||
|
<span className={`h-2 w-2 rounded-full ${running ? "animate-pulse bg-cyan-400" : phase === "done" ? "bg-emerald-400" : "bg-slate-600"}`} />
|
||||||
|
实时编排 · {taskId || "未开始"}
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
||||||
|
{out ? (
|
||||||
|
<pre className="whitespace-pre-wrap break-words font-sans text-sm leading-relaxed text-slate-300">{out}</pre>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-slate-600">
|
||||||
|
输入主题并点击「生成报告」,这里将实时显示规划与撰写过程。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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)
|
log.Printf("[eino] circuit open, drop task %s", t.ID)
|
||||||
return nil
|
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))
|
log.Printf("[eino] task %s received (graph=%d bytes), compiling DSL → Eino graph...", t.ID, len(t.Graph))
|
||||||
|
|
||||||
run, err := o.compileFlow(ctx, t)
|
run, err := o.compileFlow(ctx, t)
|
||||||
|
|||||||
@@ -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.<id> 流回客户端;
|
||||||
|
// 最终调 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]) + "…"
|
||||||
|
}
|
||||||
@@ -112,6 +112,14 @@ func (p *Pool) ChatStream(ctx context.Context, msgs []ChatMessage, onToken func(
|
|||||||
return sc.Err()
|
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 间隔。
|
// 占位参数:模拟真实后端的 TTFT(首 token 延迟) 与逐 token 间隔。
|
||||||
|
|||||||
@@ -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[:])
|
||||||
|
}
|
||||||
@@ -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.GET("/kb/ingest/:id/stream", h.KbIngestStream) // 入库进度 SSE(实时监控)
|
||||||
api.POST("/kb/search", h.KbSearch) // 知识库检索台(→ mcp-go kb_search)
|
api.POST("/kb/search", h.KbSearch) // 知识库检索台(→ mcp-go kb_search)
|
||||||
api.GET("/kb/graph", h.KbGraph) // 知识图谱三元组(→ mcp-go kb_graph,Neo4j)
|
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)
|
api.GET("/billing", h.Billing)
|
||||||
|
|
||||||
// 运维控制面:LLM 模型配置(独立运维控制台调用)。
|
// 运维控制面:LLM 模型配置(独立运维控制台调用)。
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
sharedbus "github.com/sundynix/sundynix-shared/bus"
|
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/history"
|
||||||
"github.com/sundynix/sundynix-mcp-go/internal/memory"
|
"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/rag"
|
||||||
"github.com/sundynix/sundynix-mcp-go/internal/search"
|
"github.com/sundynix/sundynix-mcp-go/internal/search"
|
||||||
)
|
)
|
||||||
@@ -37,7 +40,7 @@ func (g *Gateway) Serve(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer func() { _ = unsub() }()
|
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)
|
contract.SubjectToolsGoAll, contract.QueueToolsGo)
|
||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
@@ -55,6 +58,8 @@ func (g *Gateway) dispatch(ctx context.Context, call *contract.ToolCall) *contra
|
|||||||
return g.kbSearch(ctx, call)
|
return g.kbSearch(ctx, call)
|
||||||
case "kb_graph":
|
case "kb_graph":
|
||||||
return g.kbGraph(ctx, call)
|
return g.kbGraph(ctx, call)
|
||||||
|
case "report_render":
|
||||||
|
return g.reportRender(ctx, call)
|
||||||
case "memory_get":
|
case "memory_get":
|
||||||
return g.memoryGet(ctx, call)
|
return g.memoryGet(ctx, call)
|
||||||
case "memory_upsert":
|
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)}
|
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)。
|
// 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 {
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package office
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRenderReportValidDocx(t *testing.T) {
|
||||||
|
data, err := NewRenderer().RenderReport(context.Background(), "测试报告 <X&Y>", []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))
|
||||||
|
}
|
||||||
@@ -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
|
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 的 <w:body> 内部段落串
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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("<w:p>")
|
||||||
|
// 段落属性:间距 + 居中。
|
||||||
|
d.body.WriteString("<w:pPr>")
|
||||||
|
if o.spaceBefore > 0 || o.spaceAfter > 0 {
|
||||||
|
fmt.Fprintf(&d.body, `<w:spacing w:before="%d" w:after="%d"/>`, o.spaceBefore, o.spaceAfter)
|
||||||
|
}
|
||||||
|
if o.center {
|
||||||
|
d.body.WriteString(`<w:jc w:val="center"/>`)
|
||||||
|
}
|
||||||
|
d.body.WriteString("</w:pPr>")
|
||||||
|
// 文本 run。
|
||||||
|
d.body.WriteString("<w:r><w:rPr>")
|
||||||
|
if o.bold {
|
||||||
|
d.body.WriteString("<w:b/>")
|
||||||
|
}
|
||||||
|
if o.sizeHalfPt > 0 {
|
||||||
|
fmt.Fprintf(&d.body, `<w:sz w:val="%d"/><w:szCs w:val="%d"/>`, o.sizeHalfPt, o.sizeHalfPt)
|
||||||
|
}
|
||||||
|
d.body.WriteString("</w:rPr>")
|
||||||
|
fmt.Fprintf(&d.body, `<w:t xml:space="preserve">%s</w:t>`, escapeXML(text))
|
||||||
|
d.body.WriteString("</w:r></w:p>")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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{}
|
type Renderer struct{}
|
||||||
|
|
||||||
func NewRenderer() *Renderer { return &Renderer{} }
|
func NewRenderer() *Renderer { return &Renderer{} }
|
||||||
|
|
||||||
// RenderDocx 生成 Word 文档并返回字节流。
|
// RenderReport 渲染「大标题 + 多章节」结构的报告为 .docx 字节流。
|
||||||
func (r *Renderer) RenderDocx(ctx context.Context, payload map[string]any) ([]byte, error) {
|
func (r *Renderer) RenderReport(_ context.Context, title string, sections []Section) ([]byte, error) {
|
||||||
// TODO: 使用 unioffice/document 构建并序列化
|
doc := NewDoc()
|
||||||
return nil, nil
|
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 = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||||
|
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||||
|
<Default Extension="xml" ContentType="application/xml"/>
|
||||||
|
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
||||||
|
</Types>`
|
||||||
|
|
||||||
|
const relsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||||
|
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
|
||||||
|
</Relationships>`
|
||||||
|
|
||||||
|
func documentXML(body string) string {
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>` +
|
||||||
|
body +
|
||||||
|
`<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440"/></w:sectPr></w:body></w:document>`
|
||||||
|
}
|
||||||
|
|
||||||
|
func escapeXML(s string) string {
|
||||||
|
r := strings.NewReplacer(
|
||||||
|
"&", "&",
|
||||||
|
"<", "<",
|
||||||
|
">", ">",
|
||||||
|
`"`, """,
|
||||||
|
"'", "'",
|
||||||
|
)
|
||||||
|
return r.Replace(s)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -34,6 +34,13 @@ const (
|
|||||||
// Gateway 持有配置,消费方(Dispatcher/mcp-go)经 NATS 取用/订阅变更。
|
// Gateway 持有配置,消费方(Dispatcher/mcp-go)经 NATS 取用/订阅变更。
|
||||||
ConfigKindChat = "chat" // 对话模型(Dispatcher 用)
|
ConfigKindChat = "chat" // 对话模型(Dispatcher 用)
|
||||||
ConfigKindEmbedding = "embedding" // 向量模型(mcp-go RAG 用)
|
ConfigKindEmbedding = "embedding" // 向量模型(mcp-go RAG 用)
|
||||||
|
|
||||||
|
// 报告生成:Task.Meta[MetaIntent]==IntentReport 时,Dispatcher 走专用多步编排
|
||||||
|
// (规划大纲 → 各章节并行检索+撰写 → 汇聚 → 渲染 Word),而非通用对话图。
|
||||||
|
MetaIntent = "intent"
|
||||||
|
IntentReport = "report"
|
||||||
|
MetaTopic = "topic" // 报告主题
|
||||||
|
MetaKB = "kb" // 报告依据的知识库(可空,则不挂检索)
|
||||||
)
|
)
|
||||||
|
|
||||||
// ConfigGetSubject / ConfigUpdatedSubject 返回某类配置的 request / 广播主题。
|
// ConfigGetSubject / ConfigUpdatedSubject 返回某类配置的 request / 广播主题。
|
||||||
|
|||||||
Reference in New Issue
Block a user