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:
@@ -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} />
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user