修复 embed 跨域并持久化圆桌 Step3 结果
- Gateway 默认 CORS 白名单增加 5174,embed 文档补充跨域配置说明 - 圆桌草稿新增 step3 快照字段与迁移,结果绘制 HTML 可随草稿恢复 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
40ba81ee6a
commit
aea7724cdb
15 files changed
+527
-89
No files matched your search
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user