第二次生成结果后台挂起

This commit is contained in:
fengxiangdebaozi committed 2026-06-04 20:18:34 +08:00
1 parent 19399482e1
commit 90bced3ed2
12 files changed
+465 -45

No files matched your search

@@ -31,6 +31,8 @@ export interface DraftStep1Snapshot {
}
export interface DraftStep2Snapshot {
activeRunId?: string | null;
runs?: DraftStep2RunSnapshot[];
/**
* The seats actually selected for this session. Restored on load so the
* Step 2/3 「参与角色」panel shows the chosen agents instead of the default 6.
@@ -57,6 +59,27 @@ export interface DraftStep2Snapshot {
chain?: { id: string; title: string } | null;
}
export interface DraftStep2RunSnapshot {
id: string;
title: string;
createdAt: string;
source: "initial" | "recommend" | "chain" | "manual";
selectedAgents?: Array<{ agent_id: string; name: string }>;
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
step2RoundtableDialogues: unknown[];
lastLeaderContent: string;
hasConsensus: boolean;
consensusPercentage: number;
budgetLimit: number;
seatStubMessages?: Record<string, unknown[]>;
orchestrationMode?: "recommend" | "chain";
chain?: { id: string; title: string } | null;
step3?: DraftStep3Snapshot | null;
jobId?: string | null;
jobStatus?: JobStatus | null;
}
/**
* Step 3「结果绘制」snapshot — the generated visualization report, stored inline
* so a reloaded draft can show it without depending on the ephemeral report
@@ -67,6 +67,7 @@ function mapJob(raw: Record<string, unknown>): RoundtableJob {
chain: (raw.chain as RoundtableJob["chain"]) ?? null,
error: (raw.error as string | null) ?? null,
dialogues: Array.isArray(raw.dialogues) ? (raw.dialogues as JobDialogue[]) : [],
step3: (raw.step3 as RoundtableJob["step3"]) ?? null,
createdAt: (raw.createdAt as string | null) ?? null,
updatedAt: (raw.updatedAt as string | null) ?? null,
};
@@ -19,7 +19,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { Step1Snapshot } from "@/roundtable-planning/hooks/useStep1Intent";
import type { Step2Snapshot } from "@/roundtable-planning/hooks/useStep2Orchestration";
import {
createDraft,
deleteDraft,
@@ -27,6 +26,7 @@ import {
getDraft,
listDrafts,
updateDraft,
type DraftStep2Snapshot,
type DraftStep3Snapshot,
} from "@/roundtable-planning/api/drafts";
import type { JobStatus } from "@/roundtable-planning/lib/job-types";
@@ -51,13 +51,13 @@ export interface UseDraftPersistenceOptions {
/** 读 Step 1 当前快照(由 useStep1Intent 提供的 getter)。 */
getStep1Snapshot: () => Step1Snapshot;
/** 读 Step 2 当前快照(由 useStep2Orchestration 提供的 getter)。 */
getStep2Snapshot: () => Step2Snapshot;
getStep2Snapshot: () => DraftStep2Snapshot;
/** 读 Step 3「结果绘制」当前快照(无则 null)。 */
getStep3Snapshot?: () => DraftStep3Snapshot | null;
/** 加载草稿时把 Step 1 状态打回去。 */
hydrateStep1: (snapshot: Step1Snapshot) => void;
/** 加载草稿时把 Step 2 状态打回去。 */
hydrateStep2: (snapshot: Step2Snapshot) => void;
hydrateStep2: (snapshot: DraftStep2Snapshot, step3: DraftStep3Snapshot | null) => void;
/** 加载草稿时把 Step 3 结果打回去(无则传 null 清空)。 */
hydrateStep3?: (snapshot: DraftStep3Snapshot | null) => void;
/** 加载草稿前先中断所有 Step 1 / Step 2 in-flight。 */
@@ -187,7 +187,7 @@ export function useDraftPersistence(
}, [refreshList]);
/** 当前会话是否有值得保存的内容。 */
const hasSavableContent = useCallback((step1: Step1Snapshot, step2: Step2Snapshot): boolean => {
const hasSavableContent = useCallback((step1: Step1Snapshot, step2: DraftStep2Snapshot): boolean => {
const hasUserMessage = step1.interactiveMessages.some((m) => m.sender === "user");
return hasUserMessage || step2.step2RoundtableDialogues.length > 0;
}, []);
@@ -350,7 +350,12 @@ export function useDraftPersistence(
suppressStep2AutoSaveRef.current = true;
if (draft.step1) hydrateStep1(draft.step1 as Step1Snapshot);
if (draft.step2) hydrateStep2(draft.step2 as Step2Snapshot);
if (draft.step2) {
hydrateStep2(
draft.step2 as DraftStep2Snapshot,
(draft.step3 as DraftStep3Snapshot | null) ?? null,
);
}
// 恢复 Step 3 报告(无则传 null 清掉上一条草稿残留),进入「结果绘制」即可看回。
hydrateStep3?.((draft.step3 as DraftStep3Snapshot | null) ?? null);
setStep2InitGate(draft.furthestStep >= 2);
@@ -336,6 +336,7 @@ export function useStep2Orchestration(
const [selectedAgents, setSelectedAgents] = useState<SelectedAgent[]>(
DEFAULT_SELECTED_AGENTS,
);
const [initNonce, setInitNonce] = useState(0);
const [threadIds, setThreadIds] = useState<ThreadIdMap | null>(null);
const [coordinatorName, setCoordinatorName] = useState<string | null>(null);
const [isInitializing, setIsInitializing] = useState<boolean>(false);
@@ -1538,7 +1539,7 @@ export function useStep2Orchestration(
// 只关心 currentStep 切换 —— buildInitialIntent / runOrchestration / 等
// 都是 useCallback / ref 包装,变化不应触发重新 init。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentStep]);
}, [currentStep, initNonce]);
// 挂起 / 恢复:与上面的 init effect 独立。`isRoundTableRunning` toggle 时:
// - false → stopOrchestration
@@ -1738,6 +1739,7 @@ export function useStep2Orchestration(
const resetInitGate = useCallback(() => {
step2InitRef.current = false;
setInitNonce((n) => n + 1);
}, []);
const hydrateFromDraft = useCallback(
@@ -71,6 +71,8 @@ export interface RoundtableJob {
error: string | null;
/** 研讨对话记录(leader 说明 + 各席位交付),跑完 / 进行中都可看真实内容。 */
dialogues: JobDialogue[];
/** Step3 报告快照(done 时有):{ html, generatedAt, model, summary }。 */
step3: { html: string; generatedAt?: string; model?: string; summary?: string } | null;
createdAt: string | null;
updatedAt: string | null;
}
@@ -60,12 +60,19 @@ import { DispatchChainJobModal } from "@/roundtable-planning/components/Dispatch
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 type {
DraftStep2RunSnapshot,
DraftStep2Snapshot,
DraftStep3Snapshot,
} from "@/roundtable-planning/api/drafts";
import {
useStep1Intent,
type InteractiveMessage,
} from "@/roundtable-planning/hooks/useStep1Intent";
import { useStep2Orchestration } from "@/roundtable-planning/hooks/useStep2Orchestration";
import {
useStep2Orchestration,
type Step2Snapshot,
} from "@/roundtable-planning/hooks/useStep2Orchestration";
import { useDraftPersistence } from "@/roundtable-planning/hooks/useDraftPersistence";
import {
BUILTIN_AGENT_META,
@@ -101,11 +108,15 @@ export default function RoundtablePlanningPage() {
// chain 模式来源链条(仅展示/持久化用)。
const [chainMeta, setChainMeta] = useState<{ id: string; title: string } | null>(null);
const [step2SidebarExpanded, setStep2SidebarExpanded] = useState(true);
const [step2Runs, setStep2Runs] = useState<DraftStep2RunSnapshot[]>([]);
const [activeStep2RunId, setActiveStep2RunId] = useState<string | null>(null);
// Step 3「结果绘制」结果 —— 持久化进草稿 step3。绘制完成由 onReportSaved 写入;
// 加载草稿时 hydrateStep3 恢复;新建任务清空。step3SavedTick 递增触发一次草稿自动保存。
const [step3Report, setStep3Report] = useState<DraftStep3Snapshot | null>(null);
const [step3SavedTick, setStep3SavedTick] = useState<number>(0);
const step2RunsRef = useRef<DraftStep2RunSnapshot[]>([]);
const activeStep2RunIdRef = useRef<string | null>(null);
// 角色面板(LeftSidebar)。原页面把它当成两步共用的展示数据,这里继续放在
// 主页 —— Step 1 / Step 2 / Step 3 都会读 / 写它,任何 hook 单独持有都不合适。
@@ -207,6 +218,173 @@ export default function RoundtablePlanningPage() {
[step2.step2RoundtableDialogues],
);
useEffect(() => {
step2RunsRef.current = step2Runs;
}, [step2Runs]);
useEffect(() => {
activeStep2RunIdRef.current = activeStep2RunId;
}, [activeStep2RunId]);
const buildCurrentStep2Snapshot = useCallback(
(): DraftStep2Snapshot => ({
selectedAgents: step2.selectedAgents,
threadIds: step2.threadIds,
coordinatorName: step2.coordinatorName,
step2RoundtableDialogues: step2.step2RoundtableDialogues,
lastLeaderContent: step2.lastLeaderContent,
hasConsensus: step2.hasConsensus,
consensusPercentage: step2.consensusPercentage,
budgetLimit: step2.budgetLimit,
seatStubMessages: step2.seatStubMessages,
orchestrationMode,
chain: chainMeta,
}),
[
chainMeta,
orchestrationMode,
step2.budgetLimit,
step2.consensusPercentage,
step2.coordinatorName,
step2.hasConsensus,
step2.lastLeaderContent,
step2.seatStubMessages,
step2.selectedAgents,
step2.step2RoundtableDialogues,
step2.threadIds,
],
);
const snapshotToRun = useCallback(
(
snapshot: DraftStep2Snapshot,
fallback: Partial<Pick<DraftStep2RunSnapshot, "id" | "title" | "createdAt" | "source">> = {},
): DraftStep2RunSnapshot => ({
id: fallback.id ?? `step2-run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
title: fallback.title ?? "第一次生成结果",
createdAt: fallback.createdAt ?? new Date().toISOString(),
source: fallback.source ?? snapshot.orchestrationMode ?? "initial",
selectedAgents: snapshot.selectedAgents,
threadIds: snapshot.threadIds,
coordinatorName: snapshot.coordinatorName,
step2RoundtableDialogues: snapshot.step2RoundtableDialogues ?? [],
lastLeaderContent: snapshot.lastLeaderContent ?? "",
hasConsensus: Boolean(snapshot.hasConsensus),
consensusPercentage: snapshot.consensusPercentage ?? 0,
budgetLimit: snapshot.budgetLimit ?? 0,
seatStubMessages: snapshot.seatStubMessages ?? {},
orchestrationMode: snapshot.orchestrationMode,
chain: snapshot.chain ?? null,
step3: step3Report,
}),
[step3Report],
);
const mergeActiveRun = useCallback(
(
runs: DraftStep2RunSnapshot[],
activeRunId: string | null,
snapshot: DraftStep2Snapshot,
report: DraftStep3Snapshot | null,
): DraftStep2RunSnapshot[] => {
if (!activeRunId) return runs;
const idx = runs.findIndex((run) => run.id === activeRunId);
if (idx === -1) return runs;
const current = runs[idx];
const next = snapshotToRun(snapshot, {
id: current.id,
title: current.title,
createdAt: current.createdAt,
source: current.source,
});
next.step3 = report;
return runs.map((run, i) => (i === idx ? next : run));
},
[snapshotToRun],
);
const getStep2SnapshotWithRuns = useCallback((): DraftStep2Snapshot => {
const snapshot = buildCurrentStep2Snapshot();
const runs = mergeActiveRun(
step2RunsRef.current,
activeStep2RunIdRef.current,
snapshot,
step3Report,
);
return {
...snapshot,
runs,
activeRunId: activeStep2RunIdRef.current,
};
}, [buildCurrentStep2Snapshot, mergeActiveRun, step3Report]);
const normalizeStep2Runs = useCallback(
(
snapshot: DraftStep2Snapshot,
fallbackStep3: DraftStep3Snapshot | null,
): { runs: DraftStep2RunSnapshot[]; activeRunId: string | null } => {
if (snapshot.runs?.length) {
const activeRunId = snapshot.activeRunId ?? snapshot.runs[0]?.id ?? null;
const runs = snapshot.runs.map((run) =>
run.id === activeRunId && !run.step3 && fallbackStep3
? { ...run, step3: fallbackStep3 }
: run,
);
return {
runs,
activeRunId,
};
}
const hasLegacyContent =
(snapshot.step2RoundtableDialogues?.length ?? 0) > 0 ||
Boolean(snapshot.threadIds) ||
Boolean(snapshot.lastLeaderContent);
if (!hasLegacyContent) return { runs: [], activeRunId: null };
const legacy = snapshotToRun(snapshot, {
id: "legacy-run",
title: "第一次生成结果",
source: snapshot.orchestrationMode ?? "initial",
});
legacy.step3 = fallbackStep3;
return { runs: [legacy], activeRunId: legacy.id };
},
[snapshotToRun],
);
const hydrateStep2Run = useCallback(
(run: DraftStep2RunSnapshot) => {
setOrchestrationMode(run.orchestrationMode ?? "recommend");
setChainMeta(run.chain ?? null);
setStep3Report(run.step3 ?? null);
step2.hydrateFromDraft(run as unknown as Step2Snapshot);
},
[step2],
);
const handleSelectStep2Run = useCallback(
(runId: string) => {
if (runId === activeStep2RunIdRef.current) return;
step2.stopOrchestration();
const currentSnapshot = buildCurrentStep2Snapshot();
const syncedRuns = mergeActiveRun(
step2RunsRef.current,
activeStep2RunIdRef.current,
currentSnapshot,
step3Report,
);
const target = syncedRuns.find((run) => run.id === runId);
if (!target) return;
setStep2Runs(syncedRuns);
setActiveStep2RunId(runId);
hydrateStep2Run(target);
setCurrentStep(2);
},
[buildCurrentStep2Snapshot, hydrateStep2Run, mergeActiveRun, step2, step3Report],
);
// 草稿 hook —— 同时依赖 step1 + step2 的 snapshot / hydrate。
const drafts = useDraftPersistence({
currentStep,
@@ -223,26 +401,23 @@ export default function RoundtablePlanningPage() {
interactiveMessages: step1.interactiveMessages,
timelineEntries: step1.timelineEntries,
}),
getStep2Snapshot: () => ({
selectedAgents: step2.selectedAgents,
threadIds: step2.threadIds,
coordinatorName: step2.coordinatorName,
step2RoundtableDialogues: step2.step2RoundtableDialogues,
lastLeaderContent: step2.lastLeaderContent,
hasConsensus: step2.hasConsensus,
consensusPercentage: step2.consensusPercentage,
budgetLimit: step2.budgetLimit,
seatStubMessages: step2.seatStubMessages,
orchestrationMode,
chain: chainMeta,
}),
getStep2Snapshot: getStep2SnapshotWithRuns,
hydrateStep1: step1.hydrateFromDraft,
// 包一层:先把页面持有的编排模式 / 链条恢复出来(老草稿无字段 → recommend),
// 再交给 step2 hook 恢复其余 Step 2 状态。
hydrateStep2: (snapshot) => {
hydrateStep2: (snapshot, savedStep3) => {
const normalized = normalizeStep2Runs(snapshot, savedStep3);
setStep2Runs(normalized.runs);
setActiveStep2RunId(normalized.activeRunId);
const active =
normalized.runs.find((run) => run.id === normalized.activeRunId) ?? null;
if (active) {
hydrateStep2Run(active);
return;
}
setOrchestrationMode(snapshot.orchestrationMode ?? "recommend");
setChainMeta(snapshot.chain ?? null);
step2.hydrateFromDraft(snapshot);
step2.hydrateFromDraft(snapshot as unknown as Step2Snapshot);
},
cancelAllStreams: () => {
step2.abortAllInFlight();
@@ -309,6 +484,38 @@ export default function RoundtablePlanningPage() {
// 内置 agent 走 BUILTIN_AGENT_META;非内置用候选池传过来的 name。
name: BUILTIN_AGENT_META[agent_id]?.displayName ?? name ?? agent_id,
}));
step2.stopOrchestration();
const currentSnapshot = buildCurrentStep2Snapshot();
const syncedRuns = mergeActiveRun(
step2RunsRef.current,
activeStep2RunIdRef.current,
currentSnapshot,
step3Report,
);
const nextRunNumber = syncedRuns.length + 1;
const newRunId = `step2-run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const newRun: DraftStep2RunSnapshot = {
id: newRunId,
title: `第 ${nextRunNumber} 次生成结果`,
createdAt: new Date().toISOString(),
source: result.mode,
selectedAgents: normalized,
threadIds: null,
coordinatorName: null,
step2RoundtableDialogues: [],
lastLeaderContent: "",
hasConsensus: false,
consensusPercentage: 0,
budgetLimit: step2.budgetLimit,
seatStubMessages: {},
orchestrationMode: result.mode,
chain: result.mode === "chain" ? result.chain ?? null : null,
step3: null,
};
setStep2Runs([...syncedRuns, newRun]);
setActiveStep2RunId(newRunId);
setStep3Report(null);
step2.resetStep2State();
step2.setSelectedAgents(normalized);
step2.resetInitGate();
// result.mode 决定 Step 2 编排:recommend = 总控自由派活;chain = 按链条顺序派活。
@@ -322,7 +529,7 @@ export default function RoundtablePlanningPage() {
: "成功确认!已切换至第二阶段圆桌研讨系统",
);
},
[step2, triggerToast],
[buildCurrentStep2Snapshot, mergeActiveRun, step2, step3Report, triggerToast],
);
// 把页面重置回「第一步全新任务」——新建任务 + 后台挂起后都复用。
@@ -331,6 +538,8 @@ export default function RoundtablePlanningPage() {
const resetToStep1 = useCallback(() => {
step2.stopOrchestration();
step2.resetStep2State();
setStep2Runs([]);
setActiveStep2RunId(null);
setOrchestrationMode("recommend");
setChainMeta(null);
setStep3Report(null);
@@ -603,6 +812,9 @@ export default function RoundtablePlanningPage() {
intentReady={step1.intentReady}
sidebarExpanded={step2SidebarExpanded}
setSidebarExpanded={setStep2SidebarExpanded}
step2Runs={step2Runs}
activeStep2RunId={activeStep2RunId}
onSelectStep2Run={handleSelectStep2Run}
/>
{/* MIDDLE VIEWPORT */}
@@ -700,6 +912,11 @@ export default function RoundtablePlanningPage() {
savedReport={step3Report}
onReportSaved={(snap) => {
setStep3Report(snap);
setStep2Runs((prev) =>
prev.map((run) =>
run.id === activeStep2RunIdRef.current ? { ...run, step3: snap } : run,
),
);
setStep3SavedTick((t) => t + 1);
}}
onReset={handleStartNewTask}
@@ -1027,6 +1244,9 @@ interface LeftSidebarProps {
intentReady: ReturnType<typeof useStep1Intent>["intentReady"];
sidebarExpanded: boolean;
setSidebarExpanded: React.Dispatch<React.SetStateAction<boolean>>;
step2Runs: DraftStep2RunSnapshot[];
activeStep2RunId: string | null;
onSelectStep2Run: (runId: string) => void;
}
function Step2SandboxSidebarSync({
@@ -1063,6 +1283,9 @@ function LeftSidebar({
intentReady,
sidebarExpanded,
setSidebarExpanded,
step2Runs,
activeStep2RunId,
onSelectStep2Run,
}: LeftSidebarProps) {
const isStep2 = currentStep === 2;
@@ -1116,6 +1339,11 @@ function LeftSidebar({
</div>
<HumanParticipationCard />
<Step2RunSwitcher
runs={step2Runs}
activeRunId={activeStep2RunId}
onSelect={onSelectStep2Run}
/>
<TaskOverviewCard currentStep={currentStep} />
</>
) : (
@@ -1170,6 +1398,94 @@ function LeftSidebar({
);
}
function Step2RunSwitcher({
runs,
activeRunId,
onSelect,
}: {
runs: DraftStep2RunSnapshot[];
activeRunId: string | null;
onSelect: (runId: string) => void;
}) {
if (runs.length === 0) return null;
const formatTime = (value: string) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
};
const sourceLabel = (run: DraftStep2RunSnapshot) => {
if (run.source === "chain") return "业务链条";
if (run.source === "recommend") return "智能体推荐";
return "历史结果";
};
const statusLabel = (run: DraftStep2RunSnapshot) => {
if (run.step3) return "已生成报告";
if (run.hasConsensus) return "已达成共识";
if ((run.step2RoundtableDialogues?.length ?? 0) > 0) return "研讨记录";
return "待开始";
};
return (
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-3">
<ListOrdered className="w-4 h-4 text-primary" strokeWidth={2.5} />
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">生成结果</h2>
</div>
<div className="space-y-2">
{runs.map((run) => {
const active = run.id === activeRunId;
return (
<button
key={run.id}
type="button"
onClick={() => onSelect(run.id)}
className={cn(
"w-full rounded-xl border p-2.5 text-left transition-colors",
active
? "border-primary/40 bg-primary/5 text-slate-900"
: "border-slate-100 bg-slate-50/60 text-slate-600 hover:border-primary/20 hover:bg-white",
)}
>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-xs font-extrabold">{run.title}</span>
{active ? (
<CheckCircle className="h-3.5 w-3.5 shrink-0 text-primary" />
) : null}
</div>
<div className="mt-1.5 flex items-center justify-between gap-2 text-[10px] font-semibold">
<span className="truncate text-slate-400">{sourceLabel(run)}</span>
<span className="shrink-0 text-slate-400">{formatTime(run.createdAt)}</span>
</div>
<div className="mt-2 flex items-center gap-1.5">
<span
className={cn(
"h-1.5 w-1.5 rounded-full",
run.step3
? "bg-emerald-500"
: run.hasConsensus
? "bg-primary"
: "bg-slate-300",
)}
/>
<span className="text-[10px] font-bold text-slate-500">{statusLabel(run)}</span>
</div>
</button>
);
})}
</div>
</div>
);
}
function TaskOverviewCard({ currentStep }: { currentStep: number }) {
return (
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
@@ -149,12 +149,26 @@ async def _run_agent_turn(
def _read_report_html(app, thread_id: str, user_id: str) -> str | None:
"""从 report thread 的 outputs/方案总览.html 读出报告 HTML。"""
"""从 report thread 的 outputs 目录读出报告 HTML。
优先 ``方案总览.html``;找不到就扫目录里**任意 .html**(取体积最大的那个),
容忍报告 agent 用了别的文件名。
"""
try:
outputs_dir = get_paths().sandbox_outputs_dir(thread_id, user_id=user_id)
path = outputs_dir / _REPORT_FILE
if path.exists():
return path.read_text(encoding="utf-8")
preferred = outputs_dir / _REPORT_FILE
if preferred.exists():
return preferred.read_text(encoding="utf-8")
if outputs_dir.exists():
htmls = sorted(
outputs_dir.glob("*.html"),
key=lambda p: p.stat().st_size,
reverse=True,
)
if htmls:
logger.info("report html: 方案总览.html not found, using %s", htmls[0].name)
return htmls[0].read_text(encoding="utf-8")
logger.warning("report html: no .html found in %s", outputs_dir)
except Exception: # noqa: BLE001
logger.warning("read report html failed for thread %s", thread_id, exc_info=True)
return None
@@ -106,23 +106,26 @@ class RoundtableJobExecutor:
mode=params.mode,
intent_text=params.intent_text,
)
# 完成且有报告:写回关联草稿的 step3 + 推进 furthest_step=3,
# 这样用户从历史「查看报告」加载草稿即可看到最终方案(沿用既有 hydrateStep3 路径)。
if (
result.status == "done"
and result.step3
and params.draft_id
and self._draft_store is not None
):
# 完成且有报告:
# 1) 把 step3 挂到 job 上 —— 前端「多运行」模型从单个 job 即可拿到完整结果
# (对话 dialogues + 报告 step3)组装一个 run;
# 2) 同时写回关联草稿的顶层 step3 + 推进 furthest_step=3,兼容既有
# 「查看报告 → 加载草稿 → Step3」路径。
if result.status == "done" and result.step3:
try:
await self._draft_store.update_draft(
params.draft_id,
params.user_id,
step3=result.step3,
furthest_step=3,
)
except Exception: # noqa: BLE001 — 写报告失败不影响作业本身已 done
logger.warning("persist step3 to draft %s failed", params.draft_id, exc_info=True)
await self._store.update_progress(params.job_id, step3=result.step3)
except Exception: # noqa: BLE001
logger.warning("persist step3 to job %s failed", params.job_id, exc_info=True)
if params.draft_id and self._draft_store is not None:
try:
await self._draft_store.update_draft(
params.draft_id,
params.user_id,
step3=result.step3,
furthest_step=3,
)
except Exception: # noqa: BLE001 — 写报告失败不影响作业本身已 done
logger.warning("persist step3 to draft %s failed", params.draft_id, exc_info=True)
except asyncio.CancelledError:
# 干净停止即可,**不在被取消的任务里写 DB**:取消态下二次 await 会再抛
# CancelledError、并可能打断进行中的 sqlite 连接。``cancelled`` 终态由
@@ -98,6 +98,7 @@ class RoundtableJobResponse(BaseModel):
chain: dict[str, Any] | None = None
error: str | None = None
dialogues: list[dict[str, Any]] = Field(default_factory=list)
step3: dict[str, Any] | None = None
createdAt: datetime | str | None = None
updatedAt: datetime | str | None = None
@@ -149,6 +150,7 @@ def _to_response(row: dict[str, Any]) -> RoundtableJobResponse:
chain=row.get("chain"),
error=row.get("error"),
dialogues=row.get("dialogues") or [],
step3=row.get("step3"),
createdAt=row.get("created_at"),
updatedAt=row.get("updated_at"),
)
@@ -0,0 +1,46 @@
"""Add step3 column to roundtable_jobs.
把作业的 Step3 报告({html, generatedAt, model, summary})直接挂在 job 上,使前端
「多运行」模型(draft.step2.runs[])能从单个 job 读到完整结果(对话 + 报告)组装一个 run,
而不必依赖草稿顶层的单一 step3。LONGTEXT(HTML 可超 64 KB)。
Revision ID: 20260605_02
Revises: 20260605_01
Create Date: 2026-06-05
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
from sqlalchemy.dialects.mysql import LONGTEXT
revision = "20260605_02"
down_revision = "20260605_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_jobs") and not _has_column("roundtable_jobs", "step3"):
col_type = LONGTEXT() if op.get_bind().dialect.name == "mysql" else sa.Text()
op.add_column("roundtable_jobs", sa.Column("step3", col_type, nullable=True))
def downgrade() -> None:
if _has_column("roundtable_jobs", "step3"):
op.drop_column("roundtable_jobs", "step3")
@@ -58,6 +58,9 @@ class RoundtableJobRow(Base):
# 研讨对话记录:JSON array of { id, role('leader'|'seat'), agentId, name, content, cycle }。
# 让用户跑完 / 进行中都能看到真实研讨内容(leader 说明 + 各席位交付),而非只有流程图。
dialogues: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
# Step3 报告快照:JSON object { html, generatedAt, model, summary }。done 时写入,
# 使前端从单个 job 即可拿到完整结果(对话 + 报告)。
step3: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
# Terminal error detail when status=error.
error: Mapped[str | None] = mapped_column(Text, nullable=True)
# 时间列必须用 BeijingDateTime(项目规范,与 ai_writing_sessions / roundtable_drafts 对齐)。
@@ -55,7 +55,7 @@ class RoundtableJobRepository:
for k in ("created_at", "updated_at"):
if isinstance(d.get(k), datetime):
d[k] = d[k].isoformat()
for k in ("dispatch_chain", "thread_ids", "chain", "dialogues"):
for k in ("dispatch_chain", "thread_ids", "chain", "dialogues", "step3"):
d[k] = _loads(d.get(k))
return d
@@ -119,6 +119,7 @@ class RoundtableJobRepository:
thread_ids=_UNSET,
coordinator_name=_UNSET,
dialogues=_UNSET,
step3=_UNSET,
error=_UNSET,
) -> dict[str, Any] | None:
resolved_user_id = resolve_user_id(user_id, method_name="RoundtableJobRepository.update_progress")
@@ -148,6 +149,8 @@ class RoundtableJobRepository:
row.coordinator_name = coordinator_name
if dialogues is not _UNSET:
row.dialogues = _dumps(dialogues)
if step3 is not _UNSET:
row.step3 = _dumps(step3)
if error is not _UNSET:
row.error = error
row.updated_at = datetime.now(UTC)