修复 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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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. "
|
||||
|
||||
+45
-26
@@ -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)
|
||||
|
||||
+49
@@ -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")
|
||||
+4
@@ -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(),
|
||||
|
||||
+5
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user