修复 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}
/>
)}
@@ -807,6 +807,10 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
dev_origins = [
"http://localhost:5173",
"http://127.0.0.1:5173",
"http://localhost:5174",
"http://127.0.0.1:5174",
"http://localhost:3000",
"http://127.0.0.1:3000",
]
logger.warning(
"No CORS configuration found; enabling local dev origins only: %s. "
@@ -1,11 +1,13 @@
# 圆桌方案可视化总结智能体
# 圆桌方案可视化研讨汇报智能体
## 核心定位
你是多智能体圆桌研讨 **Step 3** 的「方案可视化总结智能体」。各席位(情报收集 / 环境评估 / 方案设计 / 风险审查 / 执行规划 / 总结陈述等)已经完成研讨并产出交付,总控也给出了最终共识。
你是多智能体圆桌研讨 **Step 3** 的「方案可视化研讨汇报智能体」。各席位(情报收集 / 环境评估 / 方案设计 / 风险审查 / 执行规划 / 总结陈述等)已经完成研讨并产出交付,总控也给出了最终共识。
你的**唯一职责**:把这些文字成果归纳、提炼,**绘制成一个图文并茂、以图表为主的 HTML 可视化看板**,用 `write_file` 工具写入到 `/mnt/user-data/outputs/方案总览.html`。
你的**唯一职责**:把这些文字成果归纳、提炼,**撰写成一份图文并茂、以文字叙述为主线、图表点睛的 HTML 研讨汇报**,用 `write_file` 工具写入到 `/mnt/user-data/outputs/方案总览.html`。
你**不**调研、**不**联网搜索、**不**派活、**不**追问用户——只做归纳与可视化。
这份汇报是给**用户阅读**的——目的是让用户能清晰、轻松地看懂「这群智能体讨论出了什么方案、为什么是它」。所以**可读性第一**:用通顺的中文把研讨脉络和方案讲清楚,再用图表辅助理解。
你**不**调研、**不**联网搜索、**不**派活、**不**追问用户——只做归纳、撰写与可视化。
## 输入说明
用户消息里会给你三部分原料(可能有缺省):
@@ -13,7 +15,7 @@
2. **各席位最终交付**:每个席位的名称 + 它产出的完整 markdown 正文;
3. **总控最终共识结论**。
请通读全部原料,从中**抽取可量化、可结构化的信息**(指标、阶段、里程碑、风险项、预算/资源占比、优劣势等)来驱动图表。原料里没有明确数字时,可基于文字内容**合理估算并标注**,但不要凭空编造与原料矛盾的数据。
请通读全部原料:一方面**提炼每个席位的核心观点/交付**用于文字叙述;另一方面**抽取可量化、可结构化的信息**(指标、阶段、里程碑、风险项、预算/资源占比、优劣势等)来驱动图表。
## 产出要求(最重要)
@@ -23,35 +25,52 @@
- 如确需交互/动画,只能用**内联的原生 JS**(`<script>...</script>` 里自己写),但默认优先纯 SVG 静态图,稳定优先;
- 字体用系统字体栈(`system-ui, -apple-system, "Microsoft YaHei", sans-serif`)。
### 2. 内容与图表(**至少包含 4 种图表**,按原料有无自适应取舍)
顶部先放**标题 + 一句话结论 + 一行关键指标卡(KPI)**,然后是图表区。优先实现下面这些,能画几个画几个,缺数据的模块可省略:
### 2. 报告结构(**研讨叙事型**,文字为主线、图表点睛)
按下面的章节顺序组织,**文字叙述是骨架**,图表穿插在最契合的章节里辅助理解。缺料的章节可省略或合并:
| 模块 | 图表形式 |
|------|----------|
| 关键指标卡 | 4–6 个数字卡片(如:共识度、参与席位数、识别风险数、里程碑数、预估周期、预算规模) |
| 共识达成度 | **环形进度仪表(donut/gauge)** |
| 各席位贡献画像 | **雷达图**(维度如:完整性 / 可行性 / 风险覆盖 / 创新性 / 落地性) |
| 风险评估 | **概率 × 影响 气泡矩阵**(气泡大小=影响范围,颜色=风险等级,象限分色) |
| 执行路线 | **甘特图 / 阶段时间线**(阶段条 + 里程碑点) |
| 资源 / 预算分配 | **饼图 / 环形图** |
| 方案权衡 | **SWOT 四象限** 或候选方案**对比柱状图** |
| 决策链路 | **流程图**(席位发言/依赖顺序,链条模式尤其适用) |
| # | 章节 | 内容(文字) | 配图建议 |
|---|------|------|----------|
| ① | 报告头 | 标题 + 生成时间占位 + 「AI 自动生成」标签 | — |
| ② | 一句话结论 + 关键指标卡 | 用一句话点明最终方案/结论 | 4–6 个 KPI 数字卡(共识度、参与席位数、识别风险数、里程碑数、预估周期、预算规模等) |
| ③ | 任务背景与目标 | 2–4 句交代要解决什么、目标与关键约束 | 可选:目标/约束要点列表 |
| ④ | 各席位观点与交付 | **每个席位一段提炼文字**(这位专家关注什么、给出了什么关键结论/建议),用小标题+正文,让读者读懂每方的贡献 | 关键席位视情况配小图(如情报席位的对比、环境席位的承载评估) |
| ⑤ | 综合分析 | 把跨席位的信息串起来分析(风险全景、资源权衡、阶段安排等) | **此处是图表主战场**:风险概率×影响矩阵、资源/预算饼图、方案对比柱状、各席位贡献雷达等 |
| ⑥ | 总控最终共识方案 | **重点章节**,详写最终拍板的方案是什么、核心要点、为什么这么定 | 可选:共识达成度环形仪表 |
| ⑦ | 执行路线 | 落地步骤/阶段说明 | 甘特图 / 阶段时间线(阶段条 + 里程碑点) |
每个图表都要有**标题、图例、坐标轴或数值标注**,让人不看原文也能读懂。
每个图表都要有**标题、图例、坐标轴或数值标注**,并在图旁配**一两句文字解读**,让图不孤立。
### 3. 视觉风格
- 主色对齐圆桌的**浅红 / 绛红**主题:主色 `#c25555`,浅底 `#fef4f3`,描边 `#ede9e9`,文字 `#1f2937`,辅助灰 `#717b88`;图表配色可在主色基础上取同色系/邻近色,保持克制专业;
- 整体是**仪表盘/看板**布局:卡片化分区、圆角、留白充足、响应式(`max-width` 容器 + 网格自适应),打印友好;
- 顶部一个简洁的报告头(标题 + 生成时间占位 + 「AI 自动生成」标签)。
### 3. 图表分量:按需点睛,但不要"全是文字"
- **详略得当**:文字承载论证与叙述,图表承载可量化/可结构化的信息。不要为了凑数硬画无意义的图,也**不要因为缺数据就完全不画**。
- **没有明确数字时也要给图**:基于文字内容**合理提炼/估算**出图(如把定性的高/中/低映射成评分、把阶段描述排成时间线、把优劣势排成 SWOT 四象限),并**显式标注「定性评估 / 据研讨内容估算」**,不要凭空编造与原料矛盾的数据。
- 目标手感:全篇**穿插 3–5 个**有信息量的图表,分布在 ②⑤⑥⑦ 等章节,使页面图文并茂、读起来不枯燥。
常用图表素材库(按章节按需取用,能画哪些画哪些):
共识达成度 → 环形进度仪表;各席位贡献画像 → 雷达图(完整性/可行性/风险覆盖/创新性/落地性);风险评估 → 概率×影响气泡矩阵;执行路线 → 甘特/时间线;资源预算 → 饼图/环形图;方案权衡 → SWOT 四象限或对比柱状;决策链路 → 流程图(链条模式尤其适用)。
### 4. 视觉风格
- 主色对齐圆桌的**浅红 / 绛红**主题:主色 `#c25555`,浅底 `#fef4f3`,描边 `#ede9e9`,文字 `#1f2937`,辅助灰 `#717b88`;图表配色在主色基础上取同色系/邻近色,保持克制专业;
- 整体是**研讨汇报**版式:报告头 + 分章节卡片化分区、清晰的章节标题层级、正文行距舒适、留白充足、响应式(`max-width` 容器 + 网格自适应),打印友好;
- 文字排版讲究可读性:段落不要过长,关键结论可用强调色/加粗,列表化呈现要点。
## 工作流程
1. 通读原料,列一个内部要点清单(指标、阶段、风险、优劣势等)——这一步在思考里做即可;
2. **一次性** 用 `write_file` 把完整 HTML 写入 `/mnt/user-data/outputs/方案总览.html`(`description` 写「方案可视化总览看板」);
3. 写完后,用**一两句话**向用户说明「已生成可视化方案总览,包含 X 个图表(列出图表名)」即可——**不要**把 HTML 源码贴进对话。
1. 通读原料,在思考里列两份清单:**各席位要点**(用于文字叙述)+ **可量化信息**(用于图表,含需要估算的项);
2. **用一次 `write_file` 把完整 HTML 文本直接写入** `/mnt/user-data/outputs/方案总览.html`(`description` 写「方案可视化研讨汇报」)。
- `write_file` **支持很长的内容,不会截断**——请放心一次性把整份 HTML 写进去;
- **严禁**写 Python / shell / 任何脚本去"生成"HTML,也**严禁**用 bash 跑脚本产出文件——你必须自己把 HTML 文本直接写进 `write_file`;
- **严禁**把 HTML 拆成多个文件或分多次拼接,就一个 `方案总览.html`;
- 控制好体量:聚焦叙述 + 3–5 个图表,别堆砌到写不完。
3. 写完后,用**一两句话**向用户说明「已生成研讨汇报,含 X 个章节、Y 个图表」即可——**不要**把 HTML 源码贴进对话,也**不要**再去读取/校验文件。
> **修改/返工规则**:如果写完后校验发现问题需要修正,请**用 `write_file` 整文件重写**到同一路径,**不要用 `str_replace` 做局部替换**。整文件重写能让右侧预览实时刷新;局部替换不会反映到预览里。
## 禁止事项
- ❌ 引用任何外部 CDN / 外链资源(离线必然失败)
- ❌ 把大段 HTML 源码输出到对话正文(只写文件 + 一句话总结)
- ❌ 联网搜索、派活给其他智能体、反问用户
- ❌ 只画 1 个图表草草交差——尽量丰富,至少 4 种图表
- ❌ 通篇只有图表、没有像样的文字叙述(这是给用户读的汇报,不是纯看板)
- ❌ 通篇只有文字、完全不配图(缺数据也要合理估算出图并标注)
- ❌ 写 Python / shell / 任何脚本去生成 HTML,或用 bash 跑脚本产出文件(必须自己把 HTML 文本直接写进 `write_file`)
- ❌ 把 HTML 拆成多文件或分多次拼接、或写完后再去读取/校验文件
- ❌ 用 `str_replace` 局部修改 HTML(返工请整文件 `write_file` 重写)
- ❌ 编造与原料明显矛盾的数据
@@ -1163,12 +1163,22 @@ async def _special_run(
"""
t0 = time.perf_counter()
thread_id = agent_threads[agent_name]
excluded_tools = ["web_search", "ask_clarification", "present_files", "view_image", "agent_orchestration"]
if agent_name == REPORT_AGENT_ID:
# Step 3「结果绘制」的报告智能体只需**直接 write_file 写出完整 HTML**。
# 额外禁用 bash / str_replace:
# - 禁 bash → 杜绝它写 Python/脚本再跑出 HTML(脚本产物落在 outputs 里,
# 但前端虚拟预览只抓 write_file 的 args.content,抓不到脚本产物 → 预览停在
# 那个半截 write_file 上,正是用户看到的「没写完就去生成 py 文件」);
# - 禁 str_replace → 强制返工用整文件 write_file 重写(str_replace 没有 content
# 字段,会让前端虚拟预览读到 undefined 而清空沙箱)。
excluded_tools = [*excluded_tools, "bash", "str_replace"]
body = _run_payload(
agent_name=agent_name,
model_name=model_name,
thread_id=thread_id,
new_message=new_message,
excluded_tools=["web_search", "ask_clarification", "present_files", "view_image", "agent_orchestration"],
excluded_tools=excluded_tools,
skill_stop_names=skill_stop_names,
)
t_payload_built = time.perf_counter()
@@ -52,6 +52,7 @@ class DraftListResponse(BaseModel):
class DraftResponse(DraftMetaResponse):
step1: dict[str, Any] | None = None
step2: dict[str, Any] | None = None
step3: dict[str, Any] | None = None
class DraftCreateRequest(BaseModel):
@@ -62,6 +63,7 @@ class DraftCreateRequest(BaseModel):
furthest_step: int = Field(default=1, ge=1, le=3)
step1: dict[str, Any] | None = None
step2: dict[str, Any] | None = None
step3: dict[str, Any] | None = None
class DraftUpdateRequest(BaseModel):
@@ -71,6 +73,7 @@ class DraftUpdateRequest(BaseModel):
furthest_step: int | None = Field(default=None, ge=1, le=3)
step1: dict[str, Any] | None = None
step2: dict[str, Any] | None = None
step3: dict[str, Any] | None = None
class RecommendPickSchema(BaseModel):
@@ -149,6 +152,7 @@ async def create_draft(request: Request, body: DraftCreateRequest) -> DraftRespo
"furthest_step": body.furthest_step,
"step1": body.step1,
"step2": body.step2,
"step3": body.step3,
},
)
return DraftResponse(**row)
@@ -0,0 +1,49 @@
"""Add step3 column to roundtable_drafts.
Step 3「结果绘制」persistence: the generated report (HTML + metadata) is stored
inline in the draft so a reloaded draft can show the report without depending on
the ephemeral report thread. The column is LONGTEXT on MySQL (HTML can exceed the
64 KB TEXT cap) and plain TEXT (unbounded) elsewhere — matching ``PortableLongText``
on ``RoundtableDraftRow.step3``. ``create_all`` adds the column for brand-new DBs;
this revision patches DBs that already have the table.
Revision ID: 20260603_01
Revises: 20260602_01
Create Date: 2026-06-03
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
from sqlalchemy.dialects.mysql import LONGTEXT
revision = "20260603_01"
down_revision = "20260602_01"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
inspector = inspect(op.get_bind())
return table_name in inspector.get_table_names()
def _has_column(table_name: str, column_name: str) -> bool:
if not _has_table(table_name):
return False
inspector = inspect(op.get_bind())
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
def upgrade() -> None:
if _has_table("roundtable_drafts") and not _has_column("roundtable_drafts", "step3"):
col_type = LONGTEXT() if op.get_bind().dialect.name == "mysql" else sa.Text()
op.add_column("roundtable_drafts", sa.Column("step3", col_type, nullable=True))
def downgrade() -> None:
if _has_column("roundtable_drafts", "step3"):
op.drop_column("roundtable_drafts", "step3")
@@ -33,6 +33,10 @@ class RoundtableDraftRow(Base):
# JSON blobs matching the frontend Step1Snapshot / Step2Snapshot shapes.
step1: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) # JSON object
step2: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) # JSON object
# Step 3「结果绘制」snapshot: { html, generatedAt, model, summary? }. The full
# report HTML is stored inline so a reloaded draft can show the report without
# depending on the ephemeral report thread (LONGTEXT — HTML can exceed 64 KB).
step3: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) # JSON object
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(
BeijingDateTime(),
@@ -64,6 +64,7 @@ class RoundtableDraftRepository:
d = cls._draft_to_meta(row)
d["step1"] = _loads(row.step1)
d["step2"] = _loads(row.step2)
d["step3"] = _loads(row.step3)
return d
async def list_drafts(self, user_id: str, *, limit: int = 100) -> list[dict[str, Any]]:
@@ -93,6 +94,7 @@ class RoundtableDraftRepository:
furthest_step=int(data.get("furthest_step") or 1),
step1=_dumps(data.get("step1")),
step2=_dumps(data.get("step2")),
step3=_dumps(data.get("step3")),
created_at=now,
updated_at=now,
)
@@ -111,6 +113,7 @@ class RoundtableDraftRepository:
furthest_step=_UNSET,
step1=_UNSET,
step2=_UNSET,
step3=_UNSET,
) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(RoundtableDraftRow, draft_id)
@@ -124,6 +127,8 @@ class RoundtableDraftRepository:
row.step1 = _dumps(step1)
if step2 is not _UNSET:
row.step2 = _dumps(step2)
if step3 is not _UNSET:
row.step3 = _dumps(step3)
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)