diff --git a/frontend-web/src/roundtable-planning/api/drafts.ts b/frontend-web/src/roundtable-planning/api/drafts.ts index ed15557a..bcca76bc 100644 --- a/frontend-web/src/roundtable-planning/api/drafts.ts +++ b/frontend-web/src/roundtable-planning/api/drafts.ts @@ -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; + 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 diff --git a/frontend-web/src/roundtable-planning/api/roundtable-jobs.ts b/frontend-web/src/roundtable-planning/api/roundtable-jobs.ts index a75e581f..1a4a664a 100644 --- a/frontend-web/src/roundtable-planning/api/roundtable-jobs.ts +++ b/frontend-web/src/roundtable-planning/api/roundtable-jobs.ts @@ -67,6 +67,7 @@ function mapJob(raw: Record): 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, }; diff --git a/frontend-web/src/roundtable-planning/hooks/useDraftPersistence.ts b/frontend-web/src/roundtable-planning/hooks/useDraftPersistence.ts index 758ef135..a0cf8b8a 100644 --- a/frontend-web/src/roundtable-planning/hooks/useDraftPersistence.ts +++ b/frontend-web/src/roundtable-planning/hooks/useDraftPersistence.ts @@ -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); diff --git a/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts b/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts index 910beeed..f7dbf50b 100644 --- a/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts +++ b/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts @@ -336,6 +336,7 @@ export function useStep2Orchestration( const [selectedAgents, setSelectedAgents] = useState( DEFAULT_SELECTED_AGENTS, ); + const [initNonce, setInitNonce] = useState(0); const [threadIds, setThreadIds] = useState(null); const [coordinatorName, setCoordinatorName] = useState(null); const [isInitializing, setIsInitializing] = useState(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( diff --git a/frontend-web/src/roundtable-planning/lib/job-types.ts b/frontend-web/src/roundtable-planning/lib/job-types.ts index ae2595fc..fc957fa1 100644 --- a/frontend-web/src/roundtable-planning/lib/job-types.ts +++ b/frontend-web/src/roundtable-planning/lib/job-types.ts @@ -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; } diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index d66f5e3d..ab993096 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -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([]); + const [activeStep2RunId, setActiveStep2RunId] = useState(null); // Step 3「结果绘制」结果 —— 持久化进草稿 step3。绘制完成由 onReportSaved 写入; // 加载草稿时 hydrateStep3 恢复;新建任务清空。step3SavedTick 递增触发一次草稿自动保存。 const [step3Report, setStep3Report] = useState(null); const [step3SavedTick, setStep3SavedTick] = useState(0); + const step2RunsRef = useRef([]); + const activeStep2RunIdRef = useRef(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> = {}, + ): 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["intentReady"]; sidebarExpanded: boolean; setSidebarExpanded: React.Dispatch>; + 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({ + ) : ( @@ -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 ( +
+
+ +

生成结果

+
+
+ {runs.map((run) => { + const active = run.id === activeRunId; + return ( + + ); + })} +
+
+ ); +} + function TaskOverviewCard({ currentStep }: { currentStep: number }) { return (
diff --git a/offline-backend-20260512/backend/app/gateway/roundtable_inprocess_gateway.py b/offline-backend-20260512/backend/app/gateway/roundtable_inprocess_gateway.py index 203ccb08..4353da31 100644 --- a/offline-backend-20260512/backend/app/gateway/roundtable_inprocess_gateway.py +++ b/offline-backend-20260512/backend/app/gateway/roundtable_inprocess_gateway.py @@ -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 diff --git a/offline-backend-20260512/backend/app/gateway/roundtable_job_executor.py b/offline-backend-20260512/backend/app/gateway/roundtable_job_executor.py index 5732a1bb..b08c9ab4 100644 --- a/offline-backend-20260512/backend/app/gateway/roundtable_job_executor.py +++ b/offline-backend-20260512/backend/app/gateway/roundtable_job_executor.py @@ -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`` 终态由 diff --git a/offline-backend-20260512/backend/app/gateway/routers/roundtable_jobs.py b/offline-backend-20260512/backend/app/gateway/routers/roundtable_jobs.py index fbad2d4b..4e3d4b9e 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/roundtable_jobs.py +++ b/offline-backend-20260512/backend/app/gateway/routers/roundtable_jobs.py @@ -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"), ) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260605_02_roundtable_jobs_step3.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260605_02_roundtable_jobs_step3.py new file mode 100644 index 00000000..35548cf0 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260605_02_roundtable_jobs_step3.py @@ -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") diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_jobs/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_jobs/model.py index 1dc0ab8a..1c4c2034 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_jobs/model.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_jobs/model.py @@ -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 对齐)。 diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_jobs/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_jobs/sql.py index 93ee52e9..e9e9a92a 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_jobs/sql.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_jobs/sql.py @@ -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)