修复 embed 跨域并持久化圆桌 Step3 结果

- Gateway 默认 CORS 白名单增加 5174,embed 文档补充跨域配置说明
- 圆桌草稿新增 step3 快照字段与迁移,结果绘制 HTML 可随草稿恢复

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-06-03 16:27:42 +08:00
1 parent 40ba81ee6a
commit aea7724cdb
15 files changed
+527 -89

No files matched your search

+20 -2
View File
@@ -348,8 +348,26 @@ const timer = setInterval(async () => {
### 5.3 跨域注意
- 父页面 `fetch` 的是 **Gateway 源**(如 `8001`),不是 iframe 的 Vite 源;需 Gateway 配置 CORS 允许父页面 origin(若跨域)。
- iframe `src` 的前端域名需与你们部署一致;后端地址由 DeerFlow 前端 `.env` 的 `VITE_BACKEND_BASE_URL` 决定。
父页面从浏览器 `fetch` Gateway(如 `http://127.0.0.1:8001`)时,**Origin 必须是后端 CORS 白名单里的完整地址**(含协议、主机、端口),与 API 地址是否写 `localhost` / `127.0.0.1` 无关。
**本地开发**(前端默认 `5174`)请在 Gateway 环境变量中配置:
```bash
# offline-backend-20260512/.env 或启动脚本注入
GATEWAY_CORS_ORIGINS=http://localhost:5174,http://127.0.0.1:5174,http://localhost:5173,http://127.0.0.1:5173
```
修改后**重启 Gateway**(`make dev` / `make gateway`)。若父页面部署在其它域名,把该域名的 origin 一并加入逗号分隔列表。
临时调试(勿用于公网生产):
```bash
GATEWAY_CORS_ALLOW_ALL=1
```
常见报错:`No 'Access-Control-Allow-Origin' header` → 当前页面的 origin 未出现在 `GATEWAY_CORS_ORIGINS` 中。
### 5.4 其它部署注意
---
@@ -56,6 +56,22 @@ export interface DraftStep2Snapshot {
chain?: { id: string; title: string } | null;
}
/**
* Step 3「结果绘制」snapshot — the generated visualization report, stored inline
* so a reloaded draft can show it without depending on the ephemeral report
* thread. Optional — drafts saved before this feature (or never drawn) won't carry it.
*/
export interface DraftStep3Snapshot {
/** The full self-contained HTML written to outputs/方案总览.html. */
html: string;
/** ISO timestamp when the report was generated. */
generatedAt: string;
/** The inference model used to draw it. */
model: string;
/** The agent's closing one-liner (rendered as the report message bubble). */
summary?: string;
}
/** Lightweight metadata row returned by the list endpoint. */
export interface DraftMeta {
id: string;
@@ -70,6 +86,7 @@ export interface DraftMeta {
export interface DraftRecord extends DraftMeta {
step1: DraftStep1Snapshot | null;
step2: DraftStep2Snapshot | null;
step3: DraftStep3Snapshot | null;
}
/** Fields accepted when creating a draft. */
@@ -80,6 +97,7 @@ export interface DraftCreatePayload {
furthestStep: 1 | 2 | 3;
step1: DraftStep1Snapshot;
step2: DraftStep2Snapshot;
step3?: DraftStep3Snapshot | null;
}
/** Partial update — send only what changed. Omitting `title` preserves a rename. */
@@ -88,6 +106,7 @@ export interface DraftUpdatePayload {
furthestStep?: 1 | 2 | 3;
step1?: DraftStep1Snapshot;
step2?: DraftStep2Snapshot;
step3?: DraftStep3Snapshot | null;
}
const BASE = () => `${getBackendBaseURL()}/api/roundtable-drafts`;
@@ -115,6 +134,7 @@ interface ServerDraftMeta {
interface ServerDraftFull extends ServerDraftMeta {
step1: DraftStep1Snapshot | null;
step2: DraftStep2Snapshot | null;
step3: DraftStep3Snapshot | null;
}
function toMeta(s: ServerDraftMeta): DraftMeta {
@@ -129,7 +149,12 @@ function toMeta(s: ServerDraftMeta): DraftMeta {
}
function toRecord(s: ServerDraftFull): DraftRecord {
return { ...toMeta(s), step1: s.step1 ?? null, step2: s.step2 ?? null };
return {
...toMeta(s),
step1: s.step1 ?? null,
step2: s.step2 ?? null,
step3: s.step3 ?? null,
};
}
function fromCreate(p: DraftCreatePayload) {
@@ -139,6 +164,7 @@ function fromCreate(p: DraftCreatePayload) {
furthest_step: p.furthestStep,
step1: p.step1,
step2: p.step2,
...(p.step3 !== undefined ? { step3: p.step3 } : {}),
};
}
@@ -148,6 +174,7 @@ function fromUpdate(p: DraftUpdatePayload) {
if (p.furthestStep !== undefined) out.furthest_step = p.furthestStep;
if (p.step1 !== undefined) out.step1 = p.step1;
if (p.step2 !== undefined) out.step2 = p.step2;
if (p.step3 !== undefined) out.step3 = p.step3;
return out;
}
@@ -26,6 +26,7 @@ import {
Step3ReportMode,
type ModelOption,
} from "@/roundtable-planning/components/Step3ReportMode";
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
/**
@@ -70,6 +71,10 @@ interface HighFidelityReportProps {
availableModels: ReadonlyArray<ModelOption>;
modelsLoading: boolean;
setSelectedModel: (model: string) => void;
/** 草稿里已存的上次绘制结果 —— 透传给「结果绘制」恢复展示。 */
savedReport?: DraftStep3Snapshot | null;
/** 一次绘制完成时回调,把结果交给上层存进草稿 step3。 */
onReportSaved?: (snapshot: DraftStep3Snapshot) => void;
/** Resets state and navigates back to Step 1. */
onReset: () => void;
}
@@ -107,6 +112,8 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
availableModels,
modelsLoading,
setSelectedModel,
savedReport,
onReportSaved,
onReset,
}) => {
// 「结果绘制」模式:把现有报告内容收起到左栏,右侧由方案可视化智能体绘制图表 HTML。
@@ -249,6 +256,8 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
availableModels={availableModels}
modelsLoading={modelsLoading}
setSelectedModel={setSelectedModel}
savedReport={savedReport}
onReportSaved={onReportSaved}
onBack={() => setDrawMode(false)}
/>
);
@@ -8,6 +8,7 @@
* 拉取 / 文本类预览 / window.open 下载 / 「重新起草」按钮等。
*/
import { HighFidelityReport } from "@/roundtable-planning/components/HighFidelityReport";
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode";
import type { Step2Dialogue } from "@/roundtable-planning/hooks/useStep2Orchestration";
import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
@@ -30,6 +31,9 @@ export interface Step3PanelProps {
availableModels: ReadonlyArray<ModelOption>;
modelsLoading: boolean;
setSelectedModel: (model: string) => void;
/** 草稿里已存的上次绘制结果 + 完成回调 —— 透传给「结果绘制」做持久化。 */
savedReport?: DraftStep3Snapshot | null;
onReportSaved?: (snapshot: DraftStep3Snapshot) => void;
onReset: () => void;
}
@@ -46,6 +50,8 @@ export function Step3Panel(props: Step3PanelProps) {
availableModels,
modelsLoading,
setSelectedModel,
savedReport,
onReportSaved,
onReset,
} = props;
@@ -74,6 +80,8 @@ export function Step3Panel(props: Step3PanelProps) {
availableModels={availableModels}
modelsLoading={modelsLoading}
setSelectedModel={setSelectedModel}
savedReport={savedReport}
onReportSaved={onReportSaved}
onReset={onReset}
/>
);
@@ -20,11 +20,15 @@ import {
FileText,
ListOrdered,
Loader2,
PanelLeftClose,
PanelLeftOpen,
RefreshCw,
Sparkles,
X,
} from "lucide-react";
import { useArtifacts } from "@/components/workspace/artifacts";
import { cn } from "@/lib/utils";
import {
buildArtifactDownloadUrl,
listSessionArtifacts,
@@ -36,6 +40,7 @@ import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToB
import { Step2Message } from "@/roundtable-planning/components/Step2Panel";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import {
useStep3Report,
@@ -65,6 +70,10 @@ interface Step3ReportModeProps {
availableModels: ReadonlyArray<ModelOption>;
modelsLoading: boolean;
setSelectedModel: (model: string) => void;
/** 草稿里已存的上次绘制结果 —— 有则进入时恢复展示,不重新绘制。 */
savedReport?: DraftStep3Snapshot | null;
/** 一次绘制完成时回调,把结果交给上层存进草稿 step3。 */
onReportSaved?: (snapshot: DraftStep3Snapshot) => void;
onBack: () => void;
}
@@ -82,6 +91,8 @@ export function Step3ReportMode({
availableModels,
modelsLoading,
setSelectedModel,
savedReport,
onReportSaved,
onBack,
}: Step3ReportModeProps) {
const reportSeatDeliveries = useMemo(
@@ -97,6 +108,7 @@ export function Step3ReportMode({
seatStubMessages,
activeArtifactThreadId,
start,
hydrateSaved,
redraw,
abort,
openArtifactFromStep,
@@ -106,16 +118,35 @@ export function Step3ReportMode({
seatDeliveries: reportSeatDeliveries,
consensusText,
selectedModel,
savedReport,
onReportSaved,
});
const [modal, setModal] = useState<SourceModal>(null);
// 左栏「研讨原料」收起态:打开沙箱时默认收起成图标条(对齐第 2 步侧栏收起),
// 关闭沙箱时自动展开;中途用户可手动切换(effect 只在沙箱开关变化的边沿触发)。
const { open: artifactsOpen } = useArtifacts();
const [leftCollapsed, setLeftCollapsed] = useState(false);
const prevArtifactsOpenRef = useRef(artifactsOpen);
useEffect(() => {
if (artifactsOpen !== prevArtifactsOpenRef.current) {
prevArtifactsOpenRef.current = artifactsOpen;
setLeftCollapsed(artifactsOpen);
}
}, [artifactsOpen]);
// 进入即自动开跑;卸载(含返回普通视图)中断在途请求。
// 不加"永久闸门"——否则 StrictMode 的 mount→unmount→remount 会在 cleanup 里 abort
// 掉首次 run,remount 又因闸门跳过重启,导致永远不绘制。run() 自带 runningRef 去重,
// remount 重新 start() 即可(开发期至多多建一个空线程,生产无 StrictMode 只跑一次)。
// 若草稿里已存上次绘制结果(savedReport)→ 直接恢复展示,不重新跑模型;用户可点「重新绘制」覆盖。
useEffect(() => {
start();
if (savedReport?.html) {
hydrateSaved(savedReport);
} else {
start();
}
return () => abort();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -178,34 +209,86 @@ export function Step3ReportMode({
{/* 主体:左源卡 + 右沙箱 */}
<div className="flex-1 min-h-0 flex gap-3 p-3">
{/* 左栏:收起的源内容 */}
<aside className="w-[280px] shrink-0 flex flex-col gap-3 overflow-y-auto pr-0.5">
<SourceCard
icon={<CheckCircle className="w-4 h-4 text-emerald-500" />}
title="总控最终结论"
hint={consensusText ? "点击查看结论全文" : "暂无结论"}
badge={hasConsensus ? "已共识" : undefined}
disabled={!consensusText}
onClick={() => setModal("consensus")}
/>
<SourceCard
icon={<FileText className="w-4 h-4 text-primary" />}
title="各席位最终交付"
hint="点击逐席位查看交付"
badge={String(seatDeliveries.length)}
disabled={seatDeliveries.length === 0}
onClick={() => setModal("seats")}
/>
<SourceCard
icon={<Download className="w-4 h-4 text-primary" />}
title="各席位生成文件"
hint="点击查看/预览产出文件"
onClick={() => setModal("files")}
/>
{/* 左栏:研讨原料源卡。打开沙箱时收起成 52px 图标条(对齐第 2 步侧栏收起样式)。 */}
<aside
className={cn(
"shrink-0 flex flex-col gap-3 overflow-y-auto pr-0.5 transition-[width] duration-200 ease-out",
leftCollapsed ? "w-[52px]" : "w-[280px]",
)}
>
{leftCollapsed ? (
<>
<button
type="button"
onClick={() => setLeftCollapsed(false)}
title="展开研讨原料"
aria-expanded={false}
className="w-full shrink-0 flex h-10 items-center justify-center rounded-2xl border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors shadow-[0_2px_8px_rgba(0,0,0,0.01)]"
>
<PanelLeftOpen className="h-4 w-4" />
</button>
<CollapsedSourceCard
icon={<CheckCircle className="w-4 h-4 text-emerald-500" />}
title="总控最终结论"
disabled={!consensusText}
onClick={() => setModal("consensus")}
/>
<CollapsedSourceCard
icon={<FileText className="w-4 h-4 text-primary" />}
title="各席位最终交付"
disabled={seatDeliveries.length === 0}
onClick={() => setModal("seats")}
/>
<CollapsedSourceCard
icon={<Download className="w-4 h-4 text-primary" />}
title="各席位生成文件"
onClick={() => setModal("files")}
/>
</>
) : (
<>
<div className="shrink-0 flex items-center justify-between gap-2 px-1">
<span className="text-[11px] font-bold text-slate-500 uppercase tracking-widest">
研讨原料
</span>
<button
type="button"
onClick={() => setLeftCollapsed(true)}
title="收起研讨原料"
aria-expanded
className="shrink-0 flex h-7 w-7 items-center justify-center rounded-lg border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors"
>
<PanelLeftClose className="h-3.5 w-3.5" />
</button>
</div>
<SourceCard
icon={<CheckCircle className="w-4 h-4 text-emerald-500" />}
title="总控最终结论"
hint={consensusText ? "点击查看结论全文" : "暂无结论"}
badge={hasConsensus ? "已共识" : undefined}
disabled={!consensusText}
onClick={() => setModal("consensus")}
/>
<SourceCard
icon={<FileText className="w-4 h-4 text-primary" />}
title="各席位最终交付"
hint="点击逐席位查看交付"
badge={String(seatDeliveries.length)}
disabled={seatDeliveries.length === 0}
onClick={() => setModal("seats")}
/>
<SourceCard
icon={<Download className="w-4 h-4 text-primary" />}
title="各席位生成文件"
hint="点击查看/预览产出文件"
onClick={() => setModal("files")}
/>
<div className="mt-1 rounded-2xl border border-dashed border-slate-200 bg-slate-50/60 p-3 text-[11px] leading-relaxed text-slate-400">
原研讨内容已收起到这里,随时点开查阅。右侧由「方案总结智能体」据此绘制可视化看板。
</div>
<div className="mt-1 rounded-2xl border border-dashed border-slate-200 bg-slate-50/60 p-3 text-[11px] leading-relaxed text-slate-400">
原研讨内容已收起到这里,随时点开查阅。右侧由「方案总结智能体」据此绘制可视化汇报。
</div>
</>
)}
</aside>
{/* 右栏:智能体进度 + 沙箱(复用第 2 步) */}
@@ -300,6 +383,31 @@ function SourceCard({
);
}
// 收起态的源卡:仅图标,居中,点击仍打开对应弹窗(对齐第 2 步 CollapsedSidebarSection 样式)。
function CollapsedSourceCard({
icon,
title,
disabled,
onClick,
}: {
icon: React.ReactNode;
title: string;
disabled?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
title={title}
disabled={disabled}
onClick={onClick}
className="w-full shrink-0 flex items-center justify-center rounded-2xl border border-slate-200/50 bg-white p-2.5 text-slate-500 shadow-[0_2px_8px_rgba(0,0,0,0.01)] transition hover:text-primary hover:border-primary/30 disabled:opacity-40 disabled:cursor-not-allowed"
>
{icon}
</button>
);
}
// ── 右栏:智能体进度面板(沙箱的 60% 区域)────────────────────────────────
function AgentProgressPanel({
@@ -27,6 +27,7 @@ import {
getDraft,
listDrafts,
updateDraft,
type DraftStep3Snapshot,
} from "@/roundtable-planning/api/drafts";
/** 历史下拉渲染用的轻量列表项(`name` 映射自后端 `title`)。 */
@@ -46,10 +47,14 @@ export interface UseDraftPersistenceOptions {
getStep1Snapshot: () => Step1Snapshot;
/** 读 Step 2 当前快照(由 useStep2Orchestration 提供的 getter)。 */
getStep2Snapshot: () => Step2Snapshot;
/** 读 Step 3「结果绘制」当前快照(无则 null)。 */
getStep3Snapshot?: () => DraftStep3Snapshot | null;
/** 加载草稿时把 Step 1 状态打回去。 */
hydrateStep1: (snapshot: Step1Snapshot) => void;
/** 加载草稿时把 Step 2 状态打回去。 */
hydrateStep2: (snapshot: Step2Snapshot) => void;
/** 加载草稿时把 Step 3 结果打回去(无则传 null 清空)。 */
hydrateStep3?: (snapshot: DraftStep3Snapshot | null) => void;
/** 加载草稿前先中断所有 Step 1 / Step 2 in-flight。 */
cancelAllStreams: () => void;
/**
@@ -67,6 +72,11 @@ export interface UseDraftPersistenceOptions {
* 子智能体刚交付完一轮,触发一次自动保存。流式增量不改变该计数,因此不会刷屏式保存。
*/
step2FinalizedCount: number;
/**
* Step 3「结果绘制」每完成一次绘制递增 —— 作为草稿自动保存触发信号,把最新
* 报告(step3)落进草稿。仿 step2FinalizedCount。
*/
step3SavedTick?: number;
}
export interface UseDraftPersistenceReturn {
@@ -102,12 +112,15 @@ export function useDraftPersistence(
triggerToast,
getStep1Snapshot,
getStep2Snapshot,
getStep3Snapshot,
hydrateStep1,
hydrateStep2,
hydrateStep3,
cancelAllStreams,
setStep2InitGate,
intentSending,
step2FinalizedCount,
step3SavedTick = 0,
} = options;
const [drafts, setDrafts] = useState<DraftListItem[]>([]);
@@ -187,6 +200,8 @@ export function useDraftPersistence(
const step1 = getStep1Snapshot();
const step2 = getStep2Snapshot();
if (!hasSavableContent(step1, step2)) return null;
// 仅在有结果时带上 step3,避免普通的 step1/step2 自动保存把已存的报告抹掉。
const step3 = getStep3Snapshot?.() ?? null;
const id = currentDraftIdRef.current;
if (id) {
@@ -194,6 +209,7 @@ export function useDraftPersistence(
...(furthestStep !== undefined ? { furthestStep } : {}),
step1,
step2,
...(step3 ? { step3 } : {}),
});
if (updated) return updated.id;
// 服务端已不存在(被删/换账号)→ 落回新建。
@@ -206,6 +222,7 @@ export function useDraftPersistence(
furthestStep: furthestStep ?? ((currentStepRef.current as 1 | 2 | 3) || 1),
step1,
step2,
...(step3 ? { step3 } : {}),
});
currentDraftIdRef.current = created.id;
setCurrentDraftId(created.id);
@@ -215,7 +232,7 @@ export function useDraftPersistence(
saveQueueRef.current = next.catch(() => undefined);
return next as Promise<string | null>;
},
[getStep1Snapshot, getStep2Snapshot, hasSavableContent, setCurrentDraftId],
[getStep1Snapshot, getStep2Snapshot, getStep3Snapshot, hasSavableContent, setCurrentDraftId],
);
const ensureDraft = useCallback(
@@ -289,6 +306,15 @@ export function useDraftPersistence(
if (step2FinalizedCount > prev) void autoSave();
}, [step2FinalizedCount, autoSave]);
// ── 触发器 3:Step 3「结果绘制」完成一次(step3SavedTick 递增)。 ──
// 此刻页面已把最新报告写进 step3Report state,getStep3Snapshot 读到的是新值。
const prevStep3TickRef = useRef<number>(step3SavedTick);
useEffect(() => {
const prev = prevStep3TickRef.current;
prevStep3TickRef.current = step3SavedTick;
if (step3SavedTick > prev) void autoSave();
}, [step3SavedTick, autoSave]);
const handleLoadDraft = useCallback(
(id: string) => {
void (async () => {
@@ -316,6 +342,8 @@ export function useDraftPersistence(
if (draft.step1) hydrateStep1(draft.step1 as Step1Snapshot);
if (draft.step2) hydrateStep2(draft.step2 as Step2Snapshot);
// 恢复 Step 3 报告(无则传 null 清掉上一条草稿残留),进入「结果绘制」即可看回。
hydrateStep3?.((draft.step3 as DraftStep3Snapshot | null) ?? null);
setStep2InitGate(draft.furthestStep >= 2);
setCurrentDraftId(draft.id);
@@ -329,6 +357,7 @@ export function useDraftPersistence(
cancelAllStreams,
hydrateStep1,
hydrateStep2,
hydrateStep3,
refreshList,
setCurrentDraftId,
setCurrentStep,
@@ -14,9 +14,10 @@
* `args.content`,HTML 文件在流式中显示代码、`isStreaming` 落 false 后自动切预览;
* - 沙箱/消息渲染零自研,完全复用第 2 步的 `Step2SandboxLayout` + `Step2Message`。
*/
import { useCallback, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useArtifacts } from "@/components/workspace/artifacts";
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
import {
initReportThread,
listSessionArtifacts,
@@ -29,6 +30,9 @@ import type {
} from "@/roundtable-planning/hooks/useStep2Orchestration";
import { nowTime } from "@/roundtable-planning/lib/roundtable-constants";
/** 报告输出文件的固定虚拟路径(恢复历史快照时用它重建 stub)。 */
const REPORT_OUTPUT_PATH = "/mnt/user-data/outputs/方案总览.html";
/** Step 1 整理出的结构化意图(用于喂给可视化智能体)。 */
export interface ReportIntent {
objective: string;
@@ -49,6 +53,10 @@ interface UseStep3ReportOptions {
seatDeliveries: ReportSeatDelivery[];
consensusText: string;
selectedModel: string;
/** 草稿里已存的上次绘制结果 —— 有则进入时恢复展示,不重新绘制。 */
savedReport?: DraftStep3Snapshot | null;
/** 一次绘制成功完成时回调(传出 html/model/时间/收尾语),供上层存草稿。 */
onReportSaved?: (snapshot: DraftStep3Snapshot) => void;
}
export interface UseStep3ReportReturn {
@@ -63,6 +71,8 @@ export interface UseStep3ReportReturn {
activeArtifactThreadId: string | null;
/** 启动绘制(幂等:进行中重复调用会被忽略)。 */
start: () => void;
/** 从草稿快照恢复展示(建合成 stub + 气泡 + 打开沙箱,phase=done,不重新绘制)。 */
hydrateSaved: (snapshot: DraftStep3Snapshot) => void;
/** 重新绘制(先中断在途请求再重跑)。 */
redraw: () => void;
/** 中断在途请求(卸载 / 切回普通视图时调用)。 */
@@ -84,8 +94,13 @@ function buildReportPrompt(opts: {
const { intentReady, seatDeliveries, consensusText } = opts;
const parts: string[] = [];
parts.push(
"请基于以下圆桌多智能体研讨的成果,绘制一个以图表为主、图文并茂的 HTML 可视化「方案总览看板」," +
"并用 write_file 工具写入 /mnt/user-data/outputs/方案总览.html。",
"请基于以下圆桌多智能体研讨的成果,撰写一份图文并茂、以文字叙述为主线的 HTML「方案研讨汇报」," +
"用 write_file 工具写入 /mnt/user-data/outputs/方案总览.html。",
);
parts.push(
"目标:让用户能轻松读懂这群智能体讨论出了什么方案、为什么是它。请逐个席位提炼其核心观点与交付写成文字," +
"再综合成最终方案;图表用来点睛(风险矩阵、时间线、资源占比、贡献雷达等),详略得当——" +
"缺明确数字时也要据研讨内容合理估算出图并标注「定性/估算」,不要通篇只有文字。",
);
parts.push("");
@@ -119,8 +134,10 @@ function buildReportPrompt(opts: {
}
parts.push(
"请严格遵循你的职责设定:自包含离线(纯 inline SVG 图表、禁止任何外链 CDN)、" +
"至少 4 种图表、浅红主题(主色 #c25555);写完文件后只用一两句话总结,不要把 HTML 源码贴进对话。",
"请严格遵循你的职责设定:自包含离线(纯 inline SVG 图表、禁止任何外链 CDN)、研讨叙事型结构" +
"(结论+KPI → 背景目标 → 各席位观点 → 综合分析 → 最终共识方案 → 执行路线)、" +
"文字为主图表点睛(全篇穿插 3–5 个有信息量的图、每图配一两句解读)、浅红主题(主色 #c25555);" +
"写完文件后只用一两句话总结,不要把 HTML 源码贴进对话。",
);
return parts.join("\n");
}
@@ -149,7 +166,14 @@ function reportPhaseLabel(phase: StreamPhase, toolName?: string): string {
export function useStep3Report(
options: UseStep3ReportOptions,
): UseStep3ReportReturn {
const { intentReady, seatDeliveries, consensusText, selectedModel } = options;
const { intentReady, seatDeliveries, consensusText, selectedModel, onReportSaved } =
options;
// onReportSaved 身份可能每次 render 变 —— 打到 ref,避免进 run() 的 deps 反复重建。
const onReportSavedRef = useRef(onReportSaved);
useEffect(() => {
onReportSavedRef.current = onReportSaved;
}, [onReportSaved]);
const {
setArtifacts,
@@ -277,6 +301,7 @@ export function useStep3Report(
);
// ── stub 写入(幂等,按 (toolName, path) 视为同一次调用,后续帧覆盖 args)──
// 仅 write_file 走这里(str_replace 不写 stub,见 handleArtifactToolPhase)。
const upsertStub = useCallback(
(threadId: string, toolName: string, args: Record<string, unknown>) => {
const path = typeof args.path === "string" ? args.path : "";
@@ -294,7 +319,22 @@ export function useStep3Report(
);
const toolCallId =
idx >= 0 ? base.tool_calls[idx].id : `tc-${threadId}-${base.tool_calls.length}`;
const nextTc = { id: toolCallId, name: toolName, args };
// 防抹空:智能体「校验→重新绘制」时,重写的早期帧 content 可能是空/缺失(partial
// JSON 还没解析到 content),若直接覆盖会把已显示的完整 HTML 抹成空白。只要新内容
// 还是空、而旧内容非空,就保留旧内容;等重写真正吐出非空内容再替换。
let storeArgs = args;
if (idx >= 0) {
const newContent =
typeof args.content === "string" ? args.content : "";
const prevContent =
typeof (base.tool_calls[idx].args as { content?: unknown })?.content === "string"
? ((base.tool_calls[idx].args as { content?: string }).content as string)
: "";
if (!newContent.trim() && prevContent.trim()) {
storeArgs = { ...args, content: prevContent };
}
}
const nextTc = { id: toolCallId, name: toolName, args: storeArgs };
const nextToolCalls =
idx >= 0
? base.tool_calls.map((tc, i) => (i === idx ? nextTc : tc))
@@ -385,7 +425,11 @@ export function useStep3Report(
threadId: string,
) => {
if (streamPhase !== "tool_calling") return;
if (toolName !== "write_file" && toolName !== "str_replace") return;
// 只让 write_file 驱动沙箱预览。str_replace 的 args 是 {path,old_str,new_str},
// **没有 content 字段** —— 若也给它建虚拟 URL 并切过去,沙箱读 args.content 得到
// undefined 就会变空白(用户「校验→修补」后沙箱被清空的根因)。str_replace 直接忽略,
// 沙箱继续显示已写入的 write_file 内容。
if (toolName !== "write_file") return;
const path = args?.path;
if (typeof path !== "string" || !path || !args) return;
// 守卫:流式早期 parsePartialJson 会给出「半截路径」(如 /mnt、/mnt/user-data/out),
@@ -412,12 +456,13 @@ export function useStep3Report(
if (!tc || typeof tc !== "object") continue;
const o = tc as Record<string, unknown>;
const name = typeof o.name === "string" ? o.name : "";
if (name !== "write_file" && name !== "str_replace") continue;
// 同 handleArtifactToolPhase:只接 write_file(str_replace 无 content,忽略)。
if (name !== "write_file") continue;
const a = o.args;
if (!a || typeof a !== "object") continue;
const p = (a as Record<string, unknown>).path;
if (typeof p !== "string") continue;
// 同 handleArtifactToolPhase:只接「basename 带扩展名」的完整路径,过滤半截路径。
// 只接「basename 带扩展名」的完整路径,过滤半截路径。
if (!(p.split("/").pop() ?? p).includes(".")) continue;
upsertStub(threadId, name, a as Record<string, unknown>);
}
@@ -425,26 +470,6 @@ export function useStep3Report(
[upsertStub],
);
// 点击工具步骤打开沙箱(write_file/str_replace 走虚拟预览)。
const openArtifactFromStep = useCallback(
(
dialogue: Step2Dialogue,
step: NonNullable<Step2Dialogue["steps"]>[number],
) => {
const threadId = dialogue.agentThreadId;
if (!threadId) return;
const [, toolName] = step.key.split(":");
if (toolName === "write_file" || toolName === "str_replace") {
const path = step.args?.path;
if (typeof path !== "string" || !step.args) return;
const { messageId, toolCallId } = upsertStub(threadId, toolName, step.args);
const virtualUrl = buildWriteFileVirtualUrl(path, messageId, toolCallId);
openArtifact(threadId, virtualUrl);
}
},
[upsertStub, buildWriteFileVirtualUrl, openArtifact],
);
// 点击文件卡打开沙箱:优先反查 stub 走虚拟预览,未命中再走真实 outputs 路径。
const openArtifactPreview = useCallback(
(threadId: string, path: string) => {
@@ -476,6 +501,29 @@ export function useStep3Report(
],
);
// 点击工具步骤打开沙箱。只对 write_file 直接建虚拟 URL;str_replace 无 content,
// 按 path 反查到 write_file 的内容来预览(修补不改虚拟 URL、不会清空沙箱)。
const openArtifactFromStep = useCallback(
(
dialogue: Step2Dialogue,
step: NonNullable<Step2Dialogue["steps"]>[number],
) => {
const threadId = dialogue.agentThreadId;
if (!threadId) return;
const [, toolName] = step.key.split(":");
const path = step.args?.path;
if (typeof path !== "string" || !path) return;
if (toolName === "write_file") {
const { messageId, toolCallId } = upsertStub(threadId, toolName, step.args!);
const virtualUrl = buildWriteFileVirtualUrl(path, messageId, toolCallId);
openArtifact(threadId, virtualUrl);
} else if (toolName === "str_replace") {
openArtifactPreview(threadId, path);
}
},
[upsertStub, buildWriteFileVirtualUrl, openArtifact, openArtifactPreview],
);
// 兜底:流式期间没捕捉到 write_file(极少见),收尾后从 outputs 列表直接选真实文件。
const openRealArtifactFallback = useCallback(
async (threadId: string) => {
@@ -558,6 +606,23 @@ export function useStep3Report(
finalizeDialogue(bubbleId, res.content, "绘制完成");
setPhase("done");
// 绘制成功 → 把完整 HTML 等抛给上层存进草稿(step3)。从 stub 取最新 write_file 内容。
const writeTc = stubRef.current[threadId]?.[0]?.tool_calls.find(
(tc) => tc.name === "write_file",
);
const html =
typeof (writeTc?.args as { content?: unknown })?.content === "string"
? ((writeTc!.args as { content?: string }).content as string)
: "";
if (html.trim()) {
onReportSavedRef.current?.({
html,
generatedAt: new Date().toISOString(),
model: selectedModel || "",
summary: res.content || "",
});
}
} catch (err) {
if (aborter.signal.aborted) return;
const msg = err instanceof Error ? err.message : String(err);
@@ -586,6 +651,69 @@ export function useStep3Report(
void run();
}, [run]);
// 从草稿快照恢复:不跑 SSE,用保存的 HTML 重建一条「完成」气泡 + 合成 stub,
// 并打开沙箱预览。合成 thread id 只用于虚拟 URL 解析(不依赖原报告线程是否还在)。
const hydrateSaved = useCallback(
(snap: DraftStep3Snapshot) => {
abort();
runningRef.current = false;
const synthThreadId = "roundtable-report-restored";
const messageId = `roundtable-report-stub-${synthThreadId}`;
const toolCallId = `tc-${synthThreadId}-0`;
const stub: Record<string, StubAiMessage[]> = {
[synthThreadId]: [
{
id: messageId,
type: "ai",
content: "",
tool_calls: [
{
id: toolCallId,
name: "write_file",
args: { path: REPORT_OUTPUT_PATH, content: snap.html },
},
],
},
],
};
stubRef.current = stub;
lastAutoOpenedUrlRef.current = null;
setSeatStubMessages(stub);
setReportThreadId(synthThreadId);
setActiveArtifactThreadId(synthThreadId);
const bubbleId = Date.now() + Math.random();
bubbleIdRef.current = bubbleId;
setDialogues([
{
id: bubbleId,
sender: "方案总结智能体",
avatarType: "blue",
content: snap.summary?.trim() || "已从历史记录恢复上次生成的可视化研讨汇报。",
time: nowTime(),
confidence: "历史记录",
streaming: false,
steps: [
{
id: `${bubbleId}-0`,
key: "tool_calling:write_file",
label: "已绘制图表 HTML",
ts: nowTime(),
args: { path: REPORT_OUTPUT_PATH },
},
],
agentThreadId: synthThreadId,
},
]);
setError(null);
setPhase("done");
const virtualUrl = buildWriteFileVirtualUrl(REPORT_OUTPUT_PATH, messageId, toolCallId);
openArtifact(synthThreadId, virtualUrl);
},
[abort, buildWriteFileVirtualUrl, openArtifact],
);
const redraw = useCallback(() => {
abort();
void run();
@@ -599,6 +727,7 @@ export function useStep3Report(
seatStubMessages,
activeArtifactThreadId,
start,
hydrateSaved,
redraw,
abort,
openArtifactFromStep,
@@ -58,6 +58,7 @@ import { RecommendAgentsDialog } from "@/roundtable-planning/components/Recommen
import { Step1Panel } from "@/roundtable-planning/components/Step1Panel";
import { Step2Panel } from "@/roundtable-planning/components/Step2Panel";
import { Step3Panel } from "@/roundtable-planning/components/Step3Panel";
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
import {
useStep1Intent,
type InteractiveMessage,
@@ -94,6 +95,11 @@ export default function RoundtablePlanningPage() {
const [chainMeta, setChainMeta] = useState<{ id: string; title: string } | null>(null);
const [step2SidebarExpanded, setStep2SidebarExpanded] = useState(true);
// Step 3「结果绘制」结果 —— 持久化进草稿 step3。绘制完成由 onReportSaved 写入;
// 加载草稿时 hydrateStep3 恢复;新建任务清空。step3SavedTick 递增触发一次草稿自动保存。
const [step3Report, setStep3Report] = useState<DraftStep3Snapshot | null>(null);
const [step3SavedTick, setStep3SavedTick] = useState<number>(0);
// 角色面板(LeftSidebar)。原页面把它当成两步共用的展示数据,这里继续放在
// 主页 —— Step 1 / Step 2 / Step 3 都会读 / 写它,任何 hook 单独持有都不合适。
const [roles, setRoles] = useState<RoleEntry[]>([]);
@@ -201,6 +207,9 @@ export default function RoundtablePlanningPage() {
triggerToast,
intentSending: step1.isIntentSending,
step2FinalizedCount,
step3SavedTick,
getStep3Snapshot: () => step3Report,
hydrateStep3: (snapshot) => setStep3Report(snapshot),
getStep1Snapshot: () => ({
intentThreadId: step1.intentThreadId,
intentReady: step1.intentReady,
@@ -313,6 +322,7 @@ export default function RoundtablePlanningPage() {
step2.resetStep2State();
setOrchestrationMode("recommend");
setChainMeta(null);
setStep3Report(null);
step1.stopIntentStream();
drafts.loadedFromDraftRef.current = false;
drafts.setCurrentDraftId(null);
@@ -584,6 +594,11 @@ export default function RoundtablePlanningPage() {
availableModels={availableModels}
modelsLoading={modelsLoading}
setSelectedModel={setSelectedModel}
savedReport={step3Report}
onReportSaved={(snap) => {
setStep3Report(snap);
setStep3SavedTick((t) => t + 1);
}}
onReset={handleStartNewTask}
/>
)}