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:
Blizzard
2026-06-12 14:02:21 +08:00
parent 8469cfc0db
commit ba8c6b3c43
15 changed files with 744 additions and 10 deletions
+3
View File
@@ -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() {
<StudioView onRun={onRun} phase={run.phase} />
) : view === "kb" ? (
<KbView />
) : view === "report" ? (
<ReportView identity={identity} />
) : view === "memory" ? (
<MemoryView identity={identity} />
) : (
+22
View File
@@ -131,6 +131,28 @@ export async function searchKb(kb: string, q: string, topK = 5): Promise<KbHit[]
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)。
export async function setMemory(
id: Identity,
@@ -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" },
@@ -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>
);
}