第二次生成结果后台挂起
This commit is contained in:
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"),
|
||||
)
|
||||
|
||||
+46
@@ -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")
|
||||
+3
@@ -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 对齐)。
|
||||
|
||||
+4
-1
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user