Files
deerflow-code/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx
T
2026-06-12 20:27:34 +08:00

2664 lines
108 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 圆桌规划主页面(三步合一)。
*
* 重构后的主文件只负责"组合 + 编排":
* - 顶栏(步骤切换 + 草稿菜单 + 新建任务 + 设置)
* - 左侧栏:Step1/2 为任务概览 + 角色 + 人工参与;Step2 侧栏可整体收起为图标条,含可收起的意图理解
* - 中间视区:Step1Panel / Step2Panel / Step3Panel(`currentStep` 切换);Step2 顶栏含推理模型下拉
* - 右侧栏:仅 Step1(推理模型 / 当前意图摘要 / 澄清记录);Step2/3 隐藏以让中间区自适应
* - 推荐弹窗 + Toast
*
* 业务状态全部下沉到 hook:
* - useStep1Intent —— 任务理解、INTENT_READY、澄清卡
* - useStep2Orchestration —— 编排循环、对话流、Persona 覆盖、澄清回复
* - useDraftPersistence —— 本地草稿(自动保存 / 手动 / 加载 / 重命名 / 删除)
*
* 历史踩坑请保留(细节见各 hook 的文件头注释):
* 1) special 顺序派活,**不要**改回 `Promise.all`(详见 §5.4)
* 2) step1InitGenRef / step2InitRef 闸门用于规避 StrictMode 双 mount 副作用
* 3) 离开 Step 2 不销毁 threadIds,允许返回继续看
* 4) 草稿自动保存:前进切换(1→2、2→3)推进 furthestStep;此外每轮模型回答完
* (Step 1 一轮任务理解结束 / Step 2 每个气泡收尾)也增量回写一次,后者不动 furthestStep
* 5) Persona 抽屉改的 model 不会立即重启编排,下一轮才生效
* 6) 模型选择全局默认 `availableModels[0].name`,Persona 覆盖更高优先级
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import {
Activity,
Check,
CheckCircle,
ChevronRight,
ChevronDown,
ChevronUp,
PanelLeftClose,
PanelLeftOpen,
Clipboard,
Clock,
Cpu,
Download,
History,
ListOrdered,
Loader2,
Pencil,
Plus,
Save,
Settings,
Sparkles,
Trash2,
Upload,
Users,
X,
} from "lucide-react";
import { useTheme } from "@/contexts/ThemeContext";
import { useArtifacts } from "@/components/workspace/artifacts";
import { useSidebarSafe } from "@/components/ui/sidebar";
import { cn } from "@/lib/utils";
import { useModels } from "@/core/models/hooks";
import { getAvatar, Man1Avatar, RobotAvatar } from "@/roundtable-planning/components/Avatars";
import { ImportRoundtableDraftDialog } from "@/components/workspace/roundtable-draft-share-dialogs";
import { RecommendAgentsDialog } from "@/roundtable-planning/components/RecommendAgentsDialog";
import { BusinessChainListDialog } from "@/roundtable-planning/components/BusinessChainListDialog";
import { DispatchChainJobModal } from "@/roundtable-planning/components/DispatchChainJobModal";
import { Step1Panel } from "@/roundtable-planning/components/Step1Panel";
import { Step2Panel } from "@/roundtable-planning/components/Step2Panel";
import { Step3Panel } from "@/roundtable-planning/components/Step3Panel";
import type {
DraftStep2RunSnapshot,
DraftStep2Snapshot,
DraftStep3Snapshot,
} from "@/roundtable-planning/api/drafts";
import {
useStep1Intent,
type InteractiveMessage,
} from "@/roundtable-planning/hooks/useStep1Intent";
import {
useStep2Orchestration,
type Step2Snapshot,
} from "@/roundtable-planning/hooks/useStep2Orchestration";
import { useDraftPersistence } from "@/roundtable-planning/hooks/useDraftPersistence";
import {
BUILTIN_AGENT_META,
makeRolesFromTemplates,
nowTime,
type RoleEntry,
type SelectedAgent,
} from "@/roundtable-planning/lib/roundtable-constants";
import {
seatFilterForRole,
USER_SEAT_FILTER,
type SeatFilter,
} from "@/roundtable-planning/lib/seat-filter";
import {
buildChainSeatSelection,
type SeatSelectionResult,
} from "@/roundtable-planning/lib/business-chain";
import type { OrchestrationPlan } from "@/roundtable-planning/lib/dag-types";
import type { IntentSummary } from "@/roundtable-planning/api/intent";
import { getJobByDraft, startJob } from "@/roundtable-planning/api/roundtable-jobs";
import { getChain } from "@/roundtable-planning/api/chains";
import { filterRecommendCandidates } from "@/roundtable-planning/api/recommend";
import { listAgents } from "@/core/agents";
import { listBusinessMappings } from "@/strategy-components/api/business-mapping";
import { emitWujieEvent, isWujieEnv, onWujieEvent, readWujieProps } from "@/core/embed/wujie";
import { collectRoundtableDeliverables } from "@/roundtable-planning/lib/roundtable-deliverables";
/** 从任务页带进来的预填(经 navigation state 传递)。见会商自动化串联(模块 C)。 */
interface RoundtablePrefill {
/** 任务名称(注入 Step 1 首条用户消息)。 */
taskTitle?: string;
/** 任务描述(注入 Step 1 首条用户消息)。 */
taskGoal?: string;
/** 指定业务链条 id(wujie 抽屉直传,优先于 businessCode;据此自动选链)。 */
chainId?: string;
/** 来源业务码(如 "6BF"):据此查业务映射拿到要自动选用的业务链。 */
businessCode?: string;
/** 外部任务 id(无界抽屉):聊天记录按此存取、打开时回显最新一条。 */
taskId?: string;
/** 是否进入即自动开跑 Step 1 意图分析。 */
autoStart?: boolean;
}
/**
* 无界(wujie)抽屉场景:父页面通过 `window.$wujie.props` 注入「任务名称/描述/业务链条」。
* iframe 降级时 props 可能晚于首帧,故再读 hash 路由 query 作兜底(宿主 `appendEmbedQueryToUrl` 写入)。
*/
function readWujiePrefill(): RoundtablePrefill | null {
if (!isWujieEnv()) return null;
const p = readWujieProps();
const taskTitle = typeof p.taskTitle === "string" ? p.taskTitle : undefined;
const taskGoal = typeof p.taskGoal === "string" ? p.taskGoal : undefined;
const chainId = typeof p.chainId === "string" ? p.chainId : undefined;
const businessCode = typeof p.businessCode === "string" ? p.businessCode : undefined;
const taskId = typeof p.taskId === "string" ? p.taskId : undefined;
if (!taskTitle && !taskGoal && !chainId && !businessCode && !taskId) {
return readEmbedHashQueryPrefill();
}
return { taskTitle, taskGoal, chainId, businessCode, taskId, autoStart: true };
}
/** 从 `#/embed/roundtable?…` 的 hash query 读取预填(iframe 降级兜底)。 */
function readEmbedHashQueryPrefill(): RoundtablePrefill | null {
if (typeof window === "undefined") return null;
const hash = window.location.hash || "";
if (!hash.includes("/embed/roundtable")) return null;
const qIndex = hash.indexOf("?");
if (qIndex < 0) return null;
const params = new URLSearchParams(hash.slice(qIndex + 1));
const taskTitle = params.get("taskTitle") ?? undefined;
const taskGoal = params.get("taskGoal") ?? undefined;
const chainId = params.get("chainId") ?? undefined;
const businessCode = params.get("businessCode") ?? undefined;
const taskId = params.get("taskId") ?? undefined;
if (!taskTitle && !taskGoal && !chainId && !businessCode && !taskId) return null;
return { taskTitle, taskGoal, chainId, businessCode, taskId, autoStart: true };
}
export default function RoundtablePlanningPage() {
const { theme } = useTheme();
const isDark = theme === "dark";
const navigate = useNavigate();
const location = useLocation();
/** 无界(wujie)嵌入抽屉:隐藏业务链条入口,设置按钮改作手动回传交付。 */
const inWujieEmbed = isWujieEnv();
// ─── 会商自动化串联(模块 C):任务页带进来的预填 ─────────────────
// 只在首次渲染读一次 navigation state;随后清掉 history state,避免刷新/后退重放。
// navigation state(站内跳转)优先;无界抽屉场景回退读 window.$wujie.props。
const prefillRef = useRef<RoundtablePrefill | null>(
((location.state as { roundtablePrefill?: RoundtablePrefill } | null)?.roundtablePrefill) ??
readWujiePrefill(),
);
// 由 prefill.businessCode 解析出的「自动选用业务链」结果;有值时「进入研讨」跳过选链弹窗直接进 Step 2。
const [autoChainResult, setAutoChainResult] = useState<SeatSelectionResult | null>(null);
/** 无界直传 chainId 时,链条解析完成前禁止弹选链窗(避免 modal 布局抖动)。 */
const [autoChainLoading, setAutoChainLoading] = useState(
() =>
inWujieEmbed &&
!!(prefillRef.current?.chainId || prefillRef.current?.businessCode),
);
const autoStartedRef = useRef(false);
const prefillClearedRef = useRef(false);
// 共识达成后只向父页面(无界)回传一次交付结果。
const consensusEmittedRef = useRef(false);
// 带 taskId 时,先按任务回显最新草稿再决定是否 autoStart。用 state 而非 ref:
// 解析完成需触发重渲染让 C2 effect 重跑(否则 intentThreadId 先就绪会错过 autoStart)。
// 无 taskId 时初始即「已解析」,autoStart 照常。
const [taskRestoreResolved, setTaskRestoreResolved] = useState<boolean>(
!prefillRef.current?.taskId,
);
const { models: availableModels, isLoading: modelsLoading } = useModels();
// ─── 顶层导航 / 跨步状态 ─────────────────────────────────────
const [currentStep, setCurrentStep] = useState<number>(1);
const [showToast, setShowToast] = useState<string | null>(null);
const [isRecommendDialogOpen, setIsRecommendDialogOpen] = useState<boolean>(false);
// 「业务链条」按钮 → 打开链条列表弹窗(弹窗里再决定进配置页 / 切到某条链条)。
const [isChainListOpen, setIsChainListOpen] = useState<boolean>(false);
// 历史下拉点击「进行中」任务 → 打开派活链路流程图弹窗(订阅该作业 SSE)。
const [chainModalJobId, setChainModalJobId] = useState<string | null>(null);
// 「导入会商」弹窗(输入分享码把别人分享的会商复制到本账号)。
const [isImportOpen, setIsImportOpen] = useState<boolean>(false);
const [selectedModel, setSelectedModel] = useState<string>("");
// Step 2 编排模式:recommend = 总控自由派活;chain = 总控按业务链条顺序派活;
// dag = 分层 DAG(stage 内并行、stage 间串行,配合 orchestrationPlan)。
// 由推荐弹窗确认时的 SeatSelectionResult.mode 决定(见 handleConfirmAgents)。
const [orchestrationMode, setOrchestrationMode] = useState<"recommend" | "chain" | "dag">(
"recommend",
);
// chain 模式来源链条(仅展示/持久化用)。
const [chainMeta, setChainMeta] = useState<{ id: string; title: string } | null>(null);
// dag 模式的分层编排计划(Phase 2 编辑器产出;持久化进 step2.orchestrationPlan)。
const [orchestrationPlan, setOrchestrationPlan] = useState<OrchestrationPlan | null>(null);
// dag 执行子模式:fast(并行双列禁写)/ sandbox(并行单列可写、沙箱跟随)。
// 由推荐弹窗的「执行方式」选择决定(仅 dag 有意义)。
const [dagExecMode, setDagExecMode] = useState<"fast" | "sandbox">("fast");
const [step2SidebarExpanded, setStep2SidebarExpanded] = useState(true);
const [step2Runs, setStep2Runs] = useState<DraftStep2RunSnapshot[]>([]);
const [activeStep2RunId, setActiveStep2RunId] = useState<string | null>(null);
// 第二步「按席位筛选会商记录」—— 左侧角色卡片与中间消息头像共用的筛选标识。
const [seatFilter, setSeatFilter] = useState<SeatFilter | 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);
// 并行执行时收起左侧栏 —— 全局工作区侧栏(最左)走 useSidebarSafe(与「打开沙箱」同一路径,
// 见 artifacts/context.tsx 的 sidebar.setOpen(false)),圆桌「参与角色」栏走 step2SidebarExpanded。
const sidebar = useSidebarSafe();
// 角色面板(LeftSidebar)。原页面把它当成两步共用的展示数据,这里继续放在
// 主页 —— Step 1 / Step 2 / Step 3 都会读 / 写它,任何 hook 单独持有都不合适。
const [roles, setRoles] = useState<RoleEntry[]>([]);
const triggerToast = useCallback((msg: string) => {
setShowToast(msg);
}, []);
useEffect(() => {
if (!showToast) return;
const timer = setTimeout(() => setShowToast(null), 3500);
return () => clearTimeout(timer);
}, [showToast]);
// 首次 /api/models 返回后初始化 selectedModel;后端配置变化导致当前选中
// 不在列表里时也回退到首项。与 /page/canvas/ai-writing 行为对齐。
useEffect(() => {
if (availableModels.length === 0) return;
const stillExists = availableModels.some((m) => m.name === selectedModel);
if (!selectedModel || !stillExists) {
setSelectedModel(availableModels[0].name);
}
}, [availableModels, selectedModel]);
// ─── 业务 hook ────────────────────────────────────────────
// 草稿 hook 提供 loadedFromDraftRef,Step 2 init 与左侧角色面板都会读它。
// 先用一个临时 ref,等 draft hook 初始化后再共享。注意:hook 之间不能循环依赖,
// 这里通过把 ref 作为参数传给 step2 hook 来打破。
const loadedFromDraftRefHolder = useRef<boolean>(false);
// Step 2 hook 拿到 selectedAgents 等;但 buildInitialIntent 需要 Step 1 状态,
// 这是循环依赖。解法:用 ref 把 step1 的最新值暴露给 buildInitialIntent。
const step1StateRef = useRef<{
intentReady: ReturnType<typeof useStep1Intent>["intentReady"];
interactiveMessages: InteractiveMessage[];
}>({ intentReady: null, interactiveMessages: [] });
const buildInitialIntent = useCallback((): string => {
const { intentReady, interactiveMessages } = step1StateRef.current;
if (intentReady) {
const parts: string[] = [intentReady.objective];
if (intentReady.constraints?.length) {
parts.push("");
parts.push("约束条件:");
for (const c of intentReady.constraints) parts.push(`- ${c}`);
}
if (intentReady.assumptions?.length) {
parts.push("");
parts.push("关键假设:");
for (const a of intentReady.assumptions) parts.push(`- ${a}`);
}
parts.push("");
parts.push(
"用户已经过任务理解智能体澄清,以上信息可以直接作为研讨依据。请基于上述目标与约束,在圆桌内调度合适的子智能体完成研讨,不要再反问用户。",
);
return parts.join("\n");
}
const userTurns = interactiveMessages
.filter((m) => m.sender === "user")
.map((m) => m.content?.trim())
.filter((s): s is string => !!s);
if (userTurns.length === 0) {
return "用户尚未在第一步明确任务意图。请先调用 ask_clarification,简要询问目标、范围与硬性约束,再进入研讨。";
}
const conversation = userTurns.map((t, i) => `用户#${i + 1}: ${t}`).join("\n");
return `${conversation}\n\n注意:用户尚未完成结构化的任务理解流程,以上是原始输入。若信息不足以开展研讨,请先调用 ask_clarification 询问关键约束,再开始派活。`;
}, []);
const step2 = useStep2Orchestration({
currentStep,
selectedModel,
orchestrationMode,
orchestrationPlan,
dagExecMode,
buildInitialIntent,
triggerToast,
setRoles,
loadedFromDraftRef: loadedFromDraftRefHolder,
});
const step1 = useStep1Intent({
currentStep,
selectedModel,
triggerToast,
});
// 同步 step1StateRef,让 buildInitialIntent / Persona / Step 3 都能读到最新值。
useEffect(() => {
step1StateRef.current = {
intentReady: step1.intentReady,
interactiveMessages: step1.interactiveMessages,
};
}, [step1.intentReady, step1.interactiveMessages]);
// Step 2 已收尾(非 streaming)的气泡数量 —— 递增即「某轮模型刚交付完」,
// 作为草稿自动保存的触发信号(详见 useDraftPersistence)。流式增量不计入,
// 所以不会在 token 级别刷屏式保存。
const step2FinalizedCount = useMemo(
() => step2.step2RoundtableDialogues.filter((d) => !d.streaming).length,
[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,
orchestrationPlan,
dagExecMode,
}),
[
chainMeta,
orchestrationMode,
orchestrationPlan,
dagExecMode,
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,
orchestrationPlan: snapshot.orchestrationPlan ?? null,
dagExecMode: snapshot.dagExecMode,
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);
setOrchestrationPlan(run.orchestrationPlan ?? null);
setDagExecMode(run.dagExecMode ?? "fast");
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,
setCurrentStep,
triggerToast,
taskId: prefillRef.current?.taskId ?? null,
intentSending: step1.isIntentSending,
step2FinalizedCount,
step3SavedTick,
getStep3Snapshot: () => step3Report,
hydrateStep3: (snapshot) => setStep3Report(snapshot),
getStep1Snapshot: () => ({
intentThreadId: step1.intentThreadId,
intentReady: step1.intentReady,
interactiveMessages: step1.interactiveMessages,
timelineEntries: step1.timelineEntries,
}),
getStep2Snapshot: getStep2SnapshotWithRuns,
hydrateStep1: step1.hydrateFromDraft,
// 包一层:先把页面持有的编排模式 / 链条恢复出来(老草稿无字段 → recommend),
// 再交给 step2 hook 恢复其余 Step 2 状态。
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);
setOrchestrationPlan(snapshot.orchestrationPlan ?? null);
setDagExecMode(snapshot.dagExecMode ?? "fast");
step2.hydrateFromDraft(snapshot as unknown as Step2Snapshot);
},
cancelAllStreams: () => {
step2.abortAllInFlight();
step1.stopIntentStream();
// resetStep2State 内部也会 abortAllInFlight + setIsOrchestrating(false),
// 这里**不**调用 resetStep2State —— 加载草稿要保留 dialogues 内容,
// 只中断 in-flight 即可。
},
setStep2InitGate: (initialized) => {
if (initialized) {
// 已经走过 Step 2 的草稿:直接复用 threadIds,跳过自动 init。
// step2 hook 的 hydrateFromDraft 已经把 step2InitRef.current 置为 true,
// 这里无需再做事。但用一个空闭包避免后续重构忘记这种语义。
} else {
step2.resetInitGate();
}
},
});
// 共享 ref:把 draft hook 的 latch 同步给 step2 hook 用的占位 ref。
useEffect(() => {
loadedFromDraftRefHolder.current = drafts.loadedFromDraftRef.current;
});
// ─── 用户手动编辑「当前意图摘要」并保存 ───────────────────────
// 先 updateIntentReady 覆盖意图,再 bump 计数触发一次草稿保存。**必须用计数 + effect**
// 而非直接调 handleManualSave:enqueueSave 闭包捕获的 getStep1Snapshot 读的是本次渲染的
// step1.intentReady,同步调用会在 setState flush 前存进旧意图(踩坑);放到 effect 里,等
// 重渲染后再保存,getStep1Snapshot 才读到新值(与 step2/step3 的计数触发器同构)。
const [intentEditTick, setIntentEditTick] = useState(0);
const handleSaveIntent = useCallback(
(next: IntentSummary) => {
step1.updateIntentReady(next);
setIntentEditTick((t) => t + 1);
},
[step1],
);
useEffect(() => {
if (intentEditTick === 0) return;
drafts.handleManualSave();
// 仅在 tick 递增时保存;handleManualSave 取最新闭包(effect 跑在重渲染之后)。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [intentEditTick]);
// ─── 从侧边栏「多智能体会商」记录进入 ───────────────────────
// 侧边栏点击某条草稿时:不在本页 → 把 id 存进 sessionStorage 再跳转,挂载时消费;
// 已在本页 → 直接派 `roundtable:open-draft` 事件。两条路径都走 handleLoadDraft。
// 用 ref 持有最新 handleLoadDraft,让下面的 effect 保持「挂载只跑一次」,避免
// handleLoadDraft 身份变化导致重复消费 / 重复加载。
const handleLoadDraftRef = useRef(drafts.handleLoadDraft);
useEffect(() => {
handleLoadDraftRef.current = drafts.handleLoadDraft;
}, [drafts.handleLoadDraft]);
useEffect(() => {
const pending = sessionStorage.getItem("roundtable:pending-draft");
if (pending) {
sessionStorage.removeItem("roundtable:pending-draft");
handleLoadDraftRef.current(pending);
}
const onOpenDraft = (e: Event) => {
const id = (e as CustomEvent<string>).detail;
if (id) handleLoadDraftRef.current(id);
};
window.addEventListener("roundtable:open-draft", onOpenDraft);
return () => window.removeEventListener("roundtable:open-draft", onOpenDraft);
}, []);
// ─── 角色面板转场 ────────────────────────────────────────
// Step 1: 仅指挥员 + 任务理解智能体两行(状态跟随 intent init)。
// Step 2: 完整席位(loadedFromDraft 时直接 "在线",否则 "正在上线")。
// Step 3: 全部 "已完成"。
useEffect(() => {
if (currentStep === 1) {
setRoles([
{ id: 1, name: "指挥员 / 主持人", type: "human", status: "在线" },
{
id: 100,
name: "任务理解",
agentId: "roundtable-intent",
type: "agent",
status: step1.intentInitError
? "异常"
: step1.intentThreadId
? "在线"
: "正在上线",
},
]);
} else if (currentStep === 2) {
const baseStatus = drafts.loadedFromDraftRef.current ? "在线" : "正在上线";
setRoles(makeRolesFromTemplates(step2.roleTemplates, baseStatus, { 1: "在线" }));
} else {
setRoles(makeRolesFromTemplates(step2.roleTemplates, "已完成"));
}
}, [
currentStep,
step1.intentThreadId,
step1.intentInitError,
step2.roleTemplates,
drafts.loadedFromDraftRef,
]);
// 并行执行 → 收起左侧栏腾出横向空间放两列(和「打开沙箱」一样的收起体验):
// - 全局工作区侧栏(最左):sidebar.setOpen(false)(与 artifacts 打开沙箱同一路径);
// - 圆桌「参与角色」栏:setStep2SidebarExpanded(false)。
// **不自动复原**:并行结束后再进入单智能体回答时保持收起(用户需要时手动展开),
// 与沙箱收起后不自动回弹一致。
useEffect(() => {
if (step2.isParallelRunning) {
sidebar?.setOpen(false);
setStep2SidebarExpanded(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [step2.isParallelRunning]);
// ─── 步骤间转场 handler ───────────────────────────────────
const handleConfirmAgents = useCallback(
(result: SeatSelectionResult) => {
const normalized: SelectedAgent[] = result.agents.map(({ agent_id, name }) => ({
agent_id,
// 内置 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 / dag 都来自业务链条,保留来源链名;recommend 无。
chain: result.mode === "recommend" ? null : result.chain ?? null,
orchestrationPlan: result.mode === "dag" ? result.plan ?? null : null,
dagExecMode: result.mode === "dag" ? result.dagExecMode ?? "fast" : undefined,
step3: null,
};
setStep2Runs([...syncedRuns, newRun]);
setActiveStep2RunId(newRunId);
setStep3Report(null);
step2.resetStep2State();
step2.setSelectedAgents(normalized);
step2.resetInitGate();
// 新一轮研讨 → 允许共识达成后再次向父页面回传。
consensusEmittedRef.current = false;
// result.mode 决定 Step 2 编排:recommend = 总控自由派活;chain = 按链条顺序派活;
// dag = 分层并行(来源链条带 stages,携带 result.plan)。
setOrchestrationMode(result.mode);
setChainMeta(result.mode === "recommend" ? null : result.chain ?? null);
setOrchestrationPlan(result.mode === "dag" ? result.plan ?? null : null);
setDagExecMode(result.mode === "dag" ? result.dagExecMode ?? "fast" : "fast");
setIsRecommendDialogOpen(false);
setCurrentStep(2);
triggerToast(
result.mode === "dag"
? `成功确认!将按业务链条「${result.chain?.title ?? ""}」分层并行研讨`
: result.mode === "chain"
? `成功确认!将按业务链条「${result.chain?.title ?? ""}」顺序研讨`
: "成功确认!已切换至第二阶段圆桌研讨系统",
);
},
[buildCurrentStep2Snapshot, mergeActiveRun, step2, step3Report, triggerToast],
);
// ─── 会商自动化串联(模块 C) ──────────────────────────────────
// 消费 prefill 后清掉 history state,避免刷新 / 后退重放自动开跑与自动选链。
useEffect(() => {
if (prefillClearedRef.current || !prefillRef.current) return;
prefillClearedRef.current = true;
try {
window.history.replaceState({ ...window.history.state, usr: null }, "");
} catch {
/* noop */
}
}, []);
// C1.5(无界回显):带 taskId 时,挂载即按任务取最新草稿回显。命中 → 抑制 autoStart
// (避免重复发首条消息);未命中 → 放行 autoStart 走全新任务。只跑一次。
useEffect(() => {
const tid = prefillRef.current?.taskId;
if (!tid) return;
let cancelled = false;
void (async () => {
try {
const restored = await drafts.loadLatestByTask(tid);
if (cancelled) return;
if (restored) autoStartedRef.current = true; // 命中历史 → 不再自动发首条消息
} finally {
if (!cancelled) setTaskRestoreResolved(true);
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // 仅挂载时执行一次
// C2:带 autoStart 时,等 Step 1 意图线程就绪后自动把「任务名称/描述」作为首条用户消息发出,
// 触发任务理解智能体自动分析(只发一次)。
useEffect(() => {
const prefill = prefillRef.current;
if (!prefill?.autoStart || autoStartedRef.current) return;
// 带 taskId 时,等「按任务回显」决策完成再决定是否 autoStart,避免与历史回显抢跑。
if (!taskRestoreResolved) return;
if (currentStep !== 1) return;
if (!step1.intentThreadId || step1.intentReady) return;
const title = prefill.taskTitle?.trim();
const goal = prefill.taskGoal?.trim();
const text = [title ? `任务名称:${title}` : "", goal ? `任务描述:${goal}` : ""]
.filter(Boolean)
.join("\n");
if (!text) return;
autoStartedRef.current = true;
void step1.sendIntentMessage(text);
}, [currentStep, taskRestoreResolved, step1.intentThreadId, step1.intentReady, step1.sendIntentMessage]);
// C3:据 prefill.businessCode 查业务映射 → 取 chainId → 拉公共链 + 候选智能体 → 构造自动选链结果。
// 解析成功后「进入研讨」会跳过选链弹窗直接据此进 Step 2;解析失败(未配置 / 链不存在 / 席位不足)
// 静默回退到弹窗手选。
// 不加「已启动」永久闸门 —— StrictMode 的 mount→unmount→remount 会让首次 run 在 cleanup
// 里被 cancelled,闸门又让 remount 跳过重启,导致 autoChainResult 永远设不上(与
// useStep1Intent 同款坑)。改为:每次 setup 都重跑,靠 cancelled 丢弃过期的首次结果,
// 末次 setup 正常落地(生产无 StrictMode 只跑一次)。
useEffect(() => {
const prefill = prefillRef.current;
if (!prefill?.chainId && !prefill?.businessCode) return;
setAutoChainLoading(true);
let cancelled = false;
void (async () => {
try {
// chainId 直传(无界抽屉)优先;否则据 businessCode 查业务映射拿 chainId。
let chainId: string | null | undefined = prefill.chainId;
if (!chainId && prefill.businessCode) {
const mappings = await listBusinessMappings();
chainId = mappings.find((m) => m.businessCode === prefill.businessCode)?.chainId;
}
if (!chainId) return;
const [chain, agents] = await Promise.all([
getChain(chainId),
listAgents().catch(() => []),
]);
if (cancelled || !chain) return;
const candidates = filterRecommendCandidates(agents);
const result = buildChainSeatSelection(chain, candidates);
if (!cancelled && result) setAutoChainResult(result);
} catch {
/* 解析失败 → 保持 null,回退弹窗手选 */
} finally {
if (!cancelled) setAutoChainLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
/** 组装无界回传载荷:总控共识 + 各智能体最终交付(剔除总控席位)。 */
const buildWujieConsensusPayload = useCallback(
() => ({
taskTitle: prefillRef.current?.taskTitle ?? null,
chainId: prefillRef.current?.chainId ?? null,
chainTitle: chainMeta?.title ?? null,
coordinatorName: step2.coordinatorName,
consensusText: step2.lastLeaderContent,
deliverables: collectRoundtableDeliverables(
step2.step2RoundtableDialogues,
step2.coordinatorName,
),
}),
[
chainMeta,
step2.coordinatorName,
step2.lastLeaderContent,
step2.step2RoundtableDialogues,
],
);
const emitConsensusToParent = useCallback(() => {
if (!inWujieEmbed) return false;
return emitWujieEvent("roundtable:consensus", buildWujieConsensusPayload());
}, [buildWujieConsensusPayload, inWujieEmbed]);
const handleSettingsClick = useCallback(() => {
if (inWujieEmbed) {
const deliverables = buildWujieConsensusPayload().deliverables;
if (deliverables.length === 0) {
triggerToast("暂无可回传的智能体交付结果");
return;
}
if (emitConsensusToParent()) {
triggerToast(`已回传 ${deliverables.length} 个智能体交付结果`);
}
return;
}
triggerToast("⚙️ 系统管理面板拉取中(暂不开放外部审计)");
}, [buildWujieConsensusPayload, emitConsensusToParent, inWujieEmbed, triggerToast]);
// C4(无界回传):第二步达成共识后,把总控结论 + 各智能体交付一次性广播给父页面。
// 用 ref 防重(共识态会多次触发渲染);非无界环境静默跳过。
useEffect(() => {
if (!step2.hasConsensus || consensusEmittedRef.current) return;
if (!inWujieEmbed) return;
consensusEmittedRef.current = true;
emitConsensusToParent();
}, [step2.hasConsensus, inWujieEmbed, emitConsensusToParent]);
// 把页面重置回「第一步全新任务」——新建任务 + 后台挂起后都复用。
// 注意:只清前端状态,不删后端已建的草稿 / 已挂起的后台作业(它们继续在后台跑,
// 在历史记录里可见)。
const resetToStep1 = useCallback(() => {
step2.stopOrchestration();
step2.resetStep2State();
setStep2Runs([]);
setActiveStep2RunId(null);
setOrchestrationMode("recommend");
setChainMeta(null);
setOrchestrationPlan(null);
setDagExecMode("fast");
setStep3Report(null);
step1.stopIntentStream();
drafts.loadedFromDraftRef.current = false;
drafts.setCurrentDraftId(null);
// 重置后新一轮共识仍需回传父页面。
consensusEmittedRef.current = false;
setCurrentStep(1);
// 同步触发 Step 1 init,而不是依赖 useEffect —— 已经在 Step 1 时
// currentStep 不变,useEffect 不会 re-fire。
step1.runStep1Init();
}, [drafts, step1, step2]);
const handleStartNewTask = useCallback(() => {
resetToStep1();
triggerToast("✨ 已开始新任务,请在第一步与任务理解智能体澄清需求");
}, [resetToStep1, triggerToast]);
// 无界抽屉保活复用:父页面重开抽屉时 `bus.$emit('roundtable:reset')` → 子应用回到第一步。
// 非无界环境下 onWujieEvent 返回 no-op,安全无副作用。
useEffect(() => {
return onWujieEvent("roundtable:reset", () => {
resetToStep1();
});
}, [resetToStep1]);
// 「后台挂起」:把当前研讨交给后端自动跑到 Step3 结果绘制完成;模型沿用第二步选择。
// 挂起后页面像「新建任务」一样重置回第一步(后台作业继续在历史记录里跑)。
const handleBackgroundSuspend = useCallback(async () => {
const agents = step2.selectedAgents.map((a) => ({ agentId: a.agent_id, name: a.name }));
if (agents.length === 0) {
triggerToast("⚠️ 请先选择研讨席位再后台挂起");
return;
}
try {
// 确保后端存在对应草稿(作业按 draftId 关联,历史下拉据此显示「进行中」)。
const draftId = await drafts.ensureDraft(2);
// 防重复:该草稿已有在跑作业就不再新建(避免重复作业 / 前后端双跑)。
const existing = draftId ? await getJobByDraft(draftId).catch(() => null) : null;
const alreadyRunning =
existing &&
(existing.status === "running" ||
existing.status === "awaiting_input" ||
existing.status === "queued");
if (!alreadyRunning) {
await startJob({
draftId: draftId ?? undefined,
intent: buildInitialIntent(),
agents,
model: selectedModel || undefined, // 模型沿用第二步选择
// 后台作业引擎现已支持 dag(后端 _run_dag:stage 串行 / stage 内并行)——
// 直接把当前编排模式透传,业务链的分层 / 并行 / 阶段目标 / 总控提示词全部保留。
orchestrationMode,
chain: chainMeta,
// dag 模式:把整份分层计划带给后端(含 stages / 阶段目标 / 总控提示词 / finalSynthesis)。
// 非 dag 时为 null(后端按 mode 走 chain/recommend)。
orchestrationPlan: orchestrationMode === "dag" ? orchestrationPlan : null,
// 不传 threadIds:后台作业由真网关自建全新线程,绝不复用前端本地研讨的线程
// (那些线程可能有活跃 run,复用会撞 409)。
});
}
} catch (err) {
triggerToast(`❌ 后台挂起失败:${(err as Error).message}`);
return;
}
// 重置回第一步(同新建任务);后台作业在历史记录里继续跑、可点开看进度。
resetToStep1();
triggerToast("🚀 已转入后台,任务会自动跑到结果绘制完成;可在「历史记录」查看进度");
void drafts.refresh();
}, [
step2,
drafts,
selectedModel,
orchestrationMode,
orchestrationPlan,
chainMeta,
buildInitialIntent,
resetToStep1,
triggerToast,
]);
// 本地 demo handler:继续追问 / 跳过此项 —— 不发请求,只本地 push 一条气泡。
const handleSkipQuestion = useCallback(() => {
step1.appendLocalMessage({
id: Date.now() + Math.random(),
sender: "agent",
senderName: "系统提示",
content: "⚠️ 您跳过了当前追问,系统已使用深度预测模型填充默认最稳健配置。",
time: nowTime(),
});
triggerToast("已跳过,自动填充最优配置");
}, [step1, triggerToast]);
const handleAgentFollowUp = useCallback(() => {
step1.appendLocalMessage({
id: Date.now() + Math.random(),
sender: "agent",
senderName: "任务理解智能体",
content:
"🔎 为了使方案时间边界更加安全:请问72小时内是否允许团队进行全仿真机房集成演练?这需要额外调拨3% of 预算冗余,但安全系数能提升12%。",
time: nowTime(),
});
triggerToast("追问生成就绪!");
}, [step1, triggerToast]);
// ─── Render ──────────────────────────────────────────────
return (
<div
className={cn(
"roundtable-planning theme-transition flex h-full min-h-0 flex-col bg-[#fef6f6] font-sans text-slate-800 selection:bg-red-200",
isDark && "roundtable-planning--dark",
)}
>
<RecommendAgentsDialog
open={isRecommendDialogOpen}
intent={step1.intentReady}
model={selectedModel || undefined}
draftId={drafts.currentDraftId}
onCancel={() => setIsRecommendDialogOpen(false)}
onConfirm={handleConfirmAgents}
/>
<BusinessChainListDialog
open={isChainListOpen}
onOpenChange={setIsChainListOpen}
onConfigure={(chainId) => {
setIsChainListOpen(false);
navigate(
chainId
? `/page/workspace/roundtable/chains?chainId=${encodeURIComponent(chainId)}`
: "/page/workspace/roundtable/chains",
);
}}
/>
{/* 导入会商弹窗:输入分享码 → 复制副本 → 加载到当前页面继续研讨 */}
<ImportRoundtableDraftDialog
open={isImportOpen}
onOpenChange={setIsImportOpen}
onImported={(draftId) => {
void drafts.refresh();
if (draftId) drafts.handleLoadDraft(draftId);
}}
/>
{/* 派活链路流程图弹窗(订阅进行中作业的真实 SSE 进度) */}
<DispatchChainJobModal
jobId={chainModalJobId}
open={chainModalJobId !== null}
onOpenChange={(o) => {
if (!o) setChainModalJobId(null);
}}
onView={(job) => {
// 「查看」:进入该任务。done → 加载草稿(含 step3)落到 Step3 看报告;
// 进行中 / 待澄清 → 进去后置暂停态,避免前后端「双跑」同一批 thread。
setChainModalJobId(null);
if (!job.draftId) return;
if (job.status === "running" || job.status === "awaiting_input") {
step2.setIsRoundTableRunning(false);
step2.stopOrchestration();
}
drafts.handleLoadDraft(job.draftId);
}}
/>
{showToast && (
<div className="fixed top-4 left-1/2 transform -translate-x-1/2 z-50 bg-primary text-white text-xs px-4 py-2.5 rounded-xl shadow-lg border border-primary/40 font-medium flex items-center gap-2 animate-bounce">
<Sparkles className="h-4 w-4 shrink-0" />
<span>{showToast}</span>
</div>
)}
{/* HEADER */}
<header className="flex shrink-0 items-center justify-between gap-4 border-b border-slate-100 bg-white px-6 py-3 shadow-[0_1px_3px_rgba(0,0,0,0.02)] dark:border-border dark:bg-card dark:shadow-none">
<StepNavigation
currentStep={currentStep}
intentReady={step1.intentReady}
hasConsensus={step2.hasConsensus}
onStepClick={(s) => setCurrentStep(s)}
triggerToast={triggerToast}
/>
<div className="flex items-center gap-2">
{!inWujieEmbed && (
<button
id="business-chain-btn"
onClick={() => setIsChainListOpen(true)}
title="查看 / 配置可复用的业务链条(有序智能体编排)"
className="bg-white hover:bg-slate-50 border border-[#e2e8f0] text-[#334155] text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition dark:border-border dark:bg-secondary dark:text-foreground dark:hover:bg-accent"
>
<ListOrdered className="w-3.5 h-3.5 text-primary" />
业务链条
</button>
)}
<button
id="new-task-btn"
onClick={handleStartNewTask}
title="清空当前会话,从第一步开始一个全新任务"
className="bg-emerald-600 text-white hover:bg-emerald-700 text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition-all shadow-sm shadow-emerald-500/10 active:scale-95"
>
<Sparkles className="w-3.5 h-3.5" />
新建任务
</button>
<button
id="save-draft-header-btn"
onClick={drafts.handleManualSave}
className="bg-white hover:bg-slate-50 border border-[#e2e8f0] text-[#334155] text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition dark:border-border dark:bg-secondary dark:text-foreground dark:hover:bg-accent"
>
<Save className="w-3.5 h-3.5 text-slate-500" />
保存草稿
</button>
{/* 「导入会商」按钮先隐藏(保留代码 + 弹窗逻辑,便于以后恢复)。 */}
{false && (
<button
id="import-roundtable-btn"
onClick={() => setIsImportOpen(true)}
title="输入分享码,把别人分享的会商复制到我的账号"
className="bg-white hover:bg-slate-50 border border-[#e2e8f0] text-[#334155] text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition dark:border-border dark:bg-secondary dark:text-foreground dark:hover:bg-accent"
>
<Download className="w-3.5 h-3.5 text-slate-500" />
导入会商
</button>
)}
<HistoryDropdown
innerRef={drafts.historyDropdownRef}
isOpen={drafts.isHistoryOpen}
setIsOpen={drafts.setIsHistoryOpen}
drafts={drafts.drafts}
currentDraftId={drafts.currentDraftId}
renamingDraftId={drafts.renamingDraftId}
renameDraftValue={drafts.renameDraftValue}
setRenameDraftValue={drafts.setRenameDraftValue}
onLoad={drafts.handleLoadDraft}
onOpen={() => void drafts.refresh()}
onOpenJob={(jobId) => {
drafts.setIsHistoryOpen(false);
setChainModalJobId(jobId);
}}
onDelete={drafts.handleDeleteDraft}
onStartRename={drafts.handleStartRename}
onCommitRename={drafts.handleCommitRename}
onCancelRename={drafts.handleCancelRename}
/>
<div className="h-6 w-[1px] bg-slate-200 mx-1" />
<button
id="settings-btn"
onClick={handleSettingsClick}
title={inWujieEmbed ? "回传各智能体交付结果给宿主" : "系统设置"}
className="p-1.5 text-slate-400 hover:text-slate-600 rounded-lg hover:bg-slate-100 transition-colors dark:text-muted-foreground dark:hover:bg-accent dark:hover:text-foreground"
>
<Settings className="w-4 h-4" />
</button>
</div>
</header>
{/* MAIN */}
<Step2SandboxSidebarSync
currentStep={currentStep}
sidebarExpanded={step2SidebarExpanded}
setSidebarExpanded={setStep2SidebarExpanded}
/>
<main className="flex-1 overflow-hidden p-4 flex gap-4 min-h-0">
{/* LEFT SIDEBAR */}
<LeftSidebar
currentStep={currentStep}
roles={roles}
agentNameToRole={step2.agentNameToRole}
budgetLimit={step2.budgetLimit}
intentReady={step1.intentReady}
sidebarExpanded={step2SidebarExpanded}
setSidebarExpanded={setStep2SidebarExpanded}
step2Runs={step2Runs}
activeStep2RunId={activeStep2RunId}
onSelectStep2Run={handleSelectStep2Run}
seatFilter={seatFilter}
setSeatFilter={setSeatFilter}
/>
{/* MIDDLE VIEWPORT */}
<section
id="middle-viewport"
className="flex-1 flex flex-col min-w-0 bg-white border border-slate-200/50 rounded-2xl shadow-[0_4px_16px_rgba(0,0,0,0.015)] overflow-hidden"
>
{currentStep === 1 && (
<Step1Panel
interactiveMessages={step1.interactiveMessages}
intentThreadId={step1.intentThreadId}
intentReady={step1.intentReady}
isIntentSending={step1.isIntentSending}
pendingIntentClarificationMsgId={step1.pendingIntentClarificationMsgId}
intentClarificationSelections={step1.intentClarificationSelections}
setIntentClarificationSelections={step1.setIntentClarificationSelections}
onSendMessage={(text, files) => void step1.sendIntentMessage(text, files)}
onStopIntent={step1.stopIntentStream}
onSkipQuestion={handleSkipQuestion}
onFollowUp={handleAgentFollowUp}
onEnterRoundtable={() => {
if (!step1.intentReady) {
triggerToast("⚠️ 请先与任务理解智能体完成澄清,生成任务摘要后再进入");
return;
}
// 会商自动化串联(模块 C):若任务页带来的业务链已解析成功,跳过选链弹窗,
// 直接据该业务链进入 Step 2 研讨(用户无需再手选)。
if (autoChainResult) {
const result = autoChainResult;
void drafts.ensureDraft(1).finally(() => handleConfirmAgents(result));
return;
}
if (
inWujieEmbed &&
autoChainLoading &&
(prefillRef.current?.chainId || prefillRef.current?.businessCode)
) {
triggerToast("正在加载业务链条,请稍候…");
return;
}
// 先把草稿落地拿到 draft_id,推荐历史才有归属;失败也照常打开弹窗
// (弹窗在无 draftId 时退化为「直接推荐、不存历史」)。
void drafts.ensureDraft(1).finally(() => setIsRecommendDialogOpen(true));
}}
onManualSave={drafts.handleManualSave}
onTriggerToast={triggerToast}
submitIntentClarifications={step1.submitIntentClarifications}
/>
)}
{currentStep === 2 && (
<Step2Panel
step2RoundtableDialogues={step2.step2RoundtableDialogues}
isInitializing={step2.isInitializing}
isRoundTableRunning={step2.isRoundTableRunning}
isOrchestrating={step2.isOrchestrating}
pendingClarification={step2.pendingClarification}
clarificationDraft={step2.clarificationDraft}
customAgentInput={step2.customAgentInput}
hasConsensus={step2.hasConsensus}
agentConfigs={step2.agentConfigs}
selectedAgentId={step2.selectedAgentId}
roles={roles}
seatFilter={seatFilter}
setSeatFilter={setSeatFilter}
parallelBatch={step2.parallelBatch}
selectedParallelSeats={step2.selectedParallelSeats}
isParallelRunning={step2.isParallelRunning}
onToggleParallelSeat={step2.toggleParallelSeat}
orchestrationMode={orchestrationMode}
orchestrationPlan={orchestrationPlan}
dagNodeStates={step2.dagNodeStates}
agentNameToRole={step2.agentNameToRole}
selectedModel={selectedModel}
availableModels={availableModels}
modelsLoading={modelsLoading}
setSelectedModel={setSelectedModel}
activeArtifactThreadId={step2.activeArtifactThreadId}
seatStubMessages={step2.seatStubMessages}
openArtifactFromStep={step2.openArtifactFromStep}
openArtifactPreview={step2.openArtifactPreview}
onPauseRoundtable={step2.pauseRoundtable}
onResumeRoundtable={step2.resumeRoundtable}
onHandleInjectToRoundtable={step2.handleInjectToRoundtable}
setClarificationDraft={step2.setClarificationDraft}
setCustomAgentInput={step2.setCustomAgentInput}
setAgentConfigs={step2.setAgentConfigs}
setSelectedAgentId={step2.setSelectedAgentId}
onSubmitClarification={step2.submitClarification}
onSavePersona={step2.handleSaveAgentAndRecalculate}
onProceedToStep3={() => {
if (!step2.hasConsensus) {
triggerToast("⚠️ 圆桌尚未自然收敛,请等待总控派活全部完成");
return;
}
setCurrentStep(3);
triggerToast(
"🎉 多角色智能体博弈共识达成,最终交付全套路线成果文档及方案图!",
);
}}
onBackgroundSuspend={handleBackgroundSuspend}
onTriggerToast={triggerToast}
/>
)}
{currentStep === 3 && (
<Step3Panel
threadIds={step2.threadIds}
coordinatorName={step2.coordinatorName}
consensusText={step2.lastLeaderContent}
hasConsensus={step2.hasConsensus}
step2RoundtableDialogues={step2.step2RoundtableDialogues}
agentNameToRole={step2.agentNameToRole}
intentReady={step1.intentReady}
selectedModel={selectedModel}
availableModels={availableModels}
modelsLoading={modelsLoading}
setSelectedModel={setSelectedModel}
savedReport={step3Report}
onReportSaved={(snap) => {
setStep3Report(snap);
setStep2Runs((prev) =>
prev.map((run) =>
run.id === activeStep2RunIdRef.current ? { ...run, step3: snap } : run,
),
);
setStep3SavedTick((t) => t + 1);
}}
// 业务链总控编排提示 → 注入「总结报告」智能体(带 coordinatorPrompt 的链走 dag)。
coordinatorPrompt={orchestrationPlan?.coordinatorPrompt ?? null}
savedSummary={step3Report?.summaryReport ?? null}
onSummarySaved={(snap) => {
// 把总结结果合并进 step3(与 HTML 报告并存,不互相覆盖)。
setStep3Report((prev) => ({ ...(prev ?? {}), summaryReport: snap }));
setStep2Runs((prev) =>
prev.map((run) =>
run.id === activeStep2RunIdRef.current
? { ...run, step3: { ...(run.step3 ?? {}), summaryReport: snap } }
: run,
),
);
setStep3SavedTick((t) => t + 1);
}}
onReset={handleStartNewTask}
openArtifactPreview={step2.openArtifactPreview}
seatStubMessages={step2.seatStubMessages}
/>
)}
</section>
{/* RIGHT SIDEBAR */}
<RightSidebar
currentStep={currentStep}
selectedModel={selectedModel}
setSelectedModel={setSelectedModel}
availableModels={availableModels}
modelsLoading={modelsLoading}
intentReady={step1.intentReady}
timelineEntries={step1.timelineEntries}
triggerToast={triggerToast}
onSaveIntent={handleSaveIntent}
/>
</main>
</div>
);
}
// ────────────────────────────────────────────────────────────
// 子组件 —— 这些 JSX 只在主页面里出现一次,没有跨文件复用价值,
// 留在本文件里阅读起来比抽到 components/ 更直观。
// ────────────────────────────────────────────────────────────
interface StepNavigationProps {
currentStep: number;
intentReady: object | null;
hasConsensus: boolean;
onStepClick: (step: number) => void;
triggerToast: (msg: string) => void;
}
function StepNavigation({
currentStep,
intentReady,
hasConsensus,
onStepClick,
triggerToast,
}: StepNavigationProps) {
const step2Locked = !intentReady;
const step3Locked = !hasConsensus;
return (
<div className="flex items-center gap-1.5 rounded-2xl border border-slate-200/50 bg-slate-50 p-1.5 dark:border-border dark:bg-muted">
<StepButton
index={1}
label="任务意图理解"
active={currentStep === 1}
locked={false}
onClick={() => onStepClick(1)}
/>
<ChevronRight className="h-3 w-3 text-slate-300 dark:text-muted-foreground" />
<StepButton
index={2}
label="多智能体研讨"
active={currentStep === 2}
locked={step2Locked}
onClick={() => {
if (step2Locked) {
triggerToast("⚠️ 请先在第一步完成任务理解,确认[INTENT_READY]信号后再进入");
return;
}
onStepClick(2);
}}
/>
<ChevronRight className="h-3 w-3 text-slate-300 dark:text-muted-foreground" />
<StepButton
index={3}
label="方案结果展示"
active={currentStep === 3}
locked={step3Locked}
onClick={() => {
if (step3Locked) {
triggerToast(
intentReady
? "⚠️ 请先在第二步达成共识(总控自然收敛)再进入"
: "⚠️ 请先依次完成第一步和第二步",
);
return;
}
onStepClick(3);
}}
/>
</div>
);
}
function StepButton({
index,
label,
active,
locked,
onClick,
}: {
index: number;
label: string;
active: boolean;
locked: boolean;
onClick: () => void;
}) {
return (
<button
id={`step-${index}-btn`}
type="button"
onClick={onClick}
disabled={locked}
aria-disabled={locked}
title={locked ? "请先完成上一步" : undefined}
className={`flex items-center gap-2 px-4 py-1.5 rounded-xl text-xs font-semibold tracking-wide transition-all relative ${
active
? "bg-white text-primary shadow-sm font-bold scale-[1.02] dark:bg-background"
: locked
? "text-slate-300 cursor-not-allowed dark:text-slate-600"
: "text-slate-500 hover:text-slate-800 dark:text-muted-foreground dark:hover:text-foreground"
}`}
>
<span
className={`w-5 h-5 flex items-center justify-center rounded-full text-[10px] font-bold ${
active
? "bg-primary text-white"
: locked
? "border border-slate-200 text-slate-300 dark:border-slate-700 dark:text-slate-600"
: "border-2 border-slate-400 text-slate-500 dark:border-muted-foreground dark:text-muted-foreground"
}`}
>
{index}
</span>
<span>{label}</span>
{locked && <span className="text-[9px] ml-0.5">🔒</span>}
{active && (
<span className="absolute bottom-0 left-1/2 -translate-x-1/2 w-8 h-1 bg-primary rounded-full" />
)}
</button>
);
}
interface HistoryDropdownProps {
innerRef: React.MutableRefObject<HTMLDivElement | null>;
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
drafts: ReturnType<typeof useDraftPersistence>["drafts"];
currentDraftId: string | null;
renamingDraftId: string | null;
renameDraftValue: string;
setRenameDraftValue: React.Dispatch<React.SetStateAction<string>>;
onLoad: (id: string) => void;
/** 下拉展开时回调(刷新列表拿最新作业状态)。 */
onOpen?: () => void;
/** 点击「进行中 / 待澄清」的任务 → 打开派活链路流程图弹窗。 */
onOpenJob: (jobId: string) => void;
onDelete: (id: string) => void;
onStartRename: (d: ReturnType<typeof useDraftPersistence>["drafts"][number]) => void;
onCommitRename: (id: string) => void;
onCancelRename: () => void;
}
function HistoryDropdown(props: HistoryDropdownProps) {
const {
innerRef,
isOpen,
setIsOpen,
drafts,
currentDraftId,
renamingDraftId,
renameDraftValue,
setRenameDraftValue,
onLoad,
onOpen,
onOpenJob,
onDelete,
onStartRename,
onCommitRename,
onCancelRename,
} = props;
return (
<div className="relative" ref={innerRef}>
<button
id="history-dropdown-btn"
type="button"
onClick={() =>
setIsOpen((open) => {
const next = !open;
if (next) onOpen?.();
return next;
})
}
className="bg-white hover:bg-slate-50 border border-[#e2e8f0] text-[#334155] text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition dark:border-border dark:bg-secondary dark:text-foreground dark:hover:bg-accent"
>
<History className="w-3.5 h-3.5 text-slate-500" />
历史记录
<span className="text-[10px] font-mono text-slate-400 bg-slate-100 px-1.5 py-0.5 rounded">
{drafts.length}
</span>
</button>
{isOpen && (
<div className="absolute right-0 top-full mt-2 w-[360px] max-h-[60vh] overflow-y-auto bg-white border border-slate-200 rounded-2xl shadow-xl z-50 dark:bg-secondary dark:border-border">
<div className="px-4 py-3 border-b border-slate-100 flex items-center justify-between sticky top-0 bg-white z-10 dark:bg-secondary dark:border-border">
<span className="text-xs font-black text-slate-800 tracking-wide dark:text-foreground">历史草稿</span>
<span className="text-[10px] font-mono text-slate-400">{drafts.length} 条</span>
</div>
{drafts.length === 0 ? (
<div className="px-4 py-6 text-[11px] text-slate-400 italic text-center">
暂无历史。点击「保存草稿」或走完一步会自动保存到这里。
</div>
) : (
<ul className="divide-y divide-slate-100 dark:divide-border">
{drafts.map((d) => {
const isCurrent = d.id === currentDraftId;
const isRenaming = d.id === renamingDraftId;
// 「进行中 / 待澄清」且有 jobId → 点击打开流程图弹窗,而非加载草稿。
const isActiveJob =
(d.status === "running" || d.status === "awaiting_input") && !!d.jobId;
const openOrLoad = () =>
isActiveJob && d.jobId ? onOpenJob(d.jobId) : onLoad(d.id);
return (
<li key={d.id} className={`px-3 py-2.5 ${isCurrent ? "bg-emerald-50/40" : ""}`}>
<div className="flex items-start gap-2">
<div className="flex-1 min-w-0">
{isRenaming ? (
<input
type="text"
autoFocus
value={renameDraftValue}
onChange={(e) => setRenameDraftValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") onCommitRename(d.id);
if (e.key === "Escape") onCancelRename();
}}
onBlur={() => onCommitRename(d.id)}
className="w-full text-[11.5px] font-semibold text-slate-800 border border-slate-300 rounded px-2 py-1 outline-none focus:border-primary dark:bg-background dark:text-foreground"
/>
) : (
<button
type="button"
onClick={openOrLoad}
className="block w-full text-left"
title={isActiveJob ? "点击查看进度" : "点击加载"}
>
<span className="text-[11.5px] font-semibold text-slate-800 line-clamp-2 dark:text-foreground">
{d.name}
</span>
<span className="text-[10px] text-slate-400 mt-0.5 flex items-center gap-1 font-mono">
{d.status === "running" ? (
<span className="inline-flex items-center gap-1 text-primary font-semibold">
<Loader2 className="w-3 h-3 animate-spin" />
进行中
</span>
) : d.status === "awaiting_input" ? (
<span className="inline-flex items-center gap-1 text-amber-500 font-semibold">
<span className="inline-block w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse" />
待澄清
</span>
) : d.status === "error" ? (
<span className="text-rose-500 font-semibold">出错</span>
) : (
<span>Step {d.furthestStep}</span>
)}
<span className="text-slate-300">·</span>
{new Date(d.updatedAt).toLocaleString("zh-CN", { hour12: false })}
</span>
</button>
)}
</div>
<div className="flex items-center gap-0.5 shrink-0">
{!isRenaming && (
<>
<button
type="button"
onClick={openOrLoad}
className="p-1 text-slate-500 hover:text-primary hover:bg-slate-100 rounded transition"
title={isActiveJob ? "查看进度" : "加载"}
>
{isActiveJob ? (
<Activity className="w-3.5 h-3.5 text-primary" />
) : (
<Upload className="w-3.5 h-3.5" />
)}
</button>
<button
type="button"
onClick={() => onStartRename(d)}
className="p-1 text-slate-500 hover:text-primary hover:bg-slate-100 rounded transition"
title="重命名"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => onDelete(d.id)}
className="p-1 text-slate-500 hover:text-rose-600 hover:bg-rose-50 rounded transition"
title="删除"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</>
)}
</div>
</div>
</li>
);
})}
</ul>
)}
</div>
)}
</div>
);
}
interface LeftSidebarProps {
currentStep: number;
roles: RoleEntry[];
agentNameToRole: Record<
string,
{ speakerId: number; avatarType: string; displayName: string }
>;
budgetLimit: number;
intentReady: ReturnType<typeof useStep1Intent>["intentReady"];
sidebarExpanded: boolean;
setSidebarExpanded: React.Dispatch<React.SetStateAction<boolean>>;
step2Runs: DraftStep2RunSnapshot[];
activeStep2RunId: string | null;
onSelectStep2Run: (runId: string) => void;
/** 第二步「按席位筛选会商记录」—— 点角色卡片筛选,与中间消息头像共用。 */
seatFilter: SeatFilter | null;
setSeatFilter: React.Dispatch<React.SetStateAction<SeatFilter | null>>;
}
function Step2SandboxSidebarSync({
currentStep,
sidebarExpanded,
setSidebarExpanded,
}: {
currentStep: number;
sidebarExpanded: boolean;
setSidebarExpanded: React.Dispatch<React.SetStateAction<boolean>>;
}) {
const { open } = useArtifacts();
const prevOpenRef = useRef(open);
useEffect(() => {
if (currentStep !== 2) {
prevOpenRef.current = open;
return;
}
if (open && !prevOpenRef.current && sidebarExpanded) {
setSidebarExpanded(false);
}
prevOpenRef.current = open;
}, [currentStep, open, setSidebarExpanded, sidebarExpanded]);
return null;
}
function LeftSidebar({
currentStep,
roles,
agentNameToRole,
budgetLimit: _budgetLimit,
intentReady,
sidebarExpanded,
setSidebarExpanded,
step2Runs,
activeStep2RunId,
onSelectStep2Run,
seatFilter,
setSeatFilter,
}: LeftSidebarProps) {
const isStep2 = currentStep === 2;
// 仅第二步可按席位筛选会商记录;点中已选则取消(回到显示全部)。
const toggleSeatFilter = (filter: SeatFilter) =>
setSeatFilter((cur) => (cur === filter ? null : filter));
// Step 3 隐藏左侧栏(原「参与与结论贡献」列),让中间「最终方案展示」自适应
// 撑满整行宽度(右侧栏在 Step !==1 时也已 return null)。
if (currentStep === 3) return null;
return (
<section
id="left-sidebar"
className={cn(
"shrink-0 flex flex-col gap-3 overflow-y-auto pr-1 transition-[width] duration-200 ease-out",
isStep2 && !sidebarExpanded ? "w-[56px]" : "w-[280px]",
)}
>
{isStep2 && !sidebarExpanded && (
<button
type="button"
onClick={() => setSidebarExpanded(true)}
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>
)}
{currentStep === 2 ? (
sidebarExpanded ? (
<>
<IntentSummaryCard
intentReady={intentReady}
title="意图理解"
collapsible
sidebarToggle={{
expanded: sidebarExpanded,
onToggle: () => setSidebarExpanded((v) => !v),
}}
/>
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)] flex-1 flex flex-col min-h-[240px]">
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-3">
<Users 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.5 overflow-y-auto flex-1 pr-1 custom-scrollbar">
{roles.map((role) => {
const filter = seatFilterForRole(role);
return (
<RoleRow
key={role.id}
role={role}
agentNameToRole={agentNameToRole}
active={seatFilter === filter}
onSelect={() => toggleSeatFilter(filter)}
/>
);
})}
</div>
</div>
<HumanParticipationCard
active={seatFilter === USER_SEAT_FILTER}
onSelect={() => toggleSeatFilter(USER_SEAT_FILTER)}
/>
<Step2RunSwitcher
runs={step2Runs}
activeRunId={activeStep2RunId}
onSelect={onSelectStep2Run}
/>
<TaskOverviewCard currentStep={currentStep} />
</>
) : (
<>
<CollapsedSidebarSection title="意图理解">
<IntentSectionIcon className="h-5 w-5" />
</CollapsedSidebarSection>
<CollapsedSidebarSection title="参与角色">
<div className="flex flex-col items-center gap-2">
{roles.map((role) => {
const filter = seatFilterForRole(role);
return (
<RoleAvatarBadge
key={role.id}
role={role}
agentNameToRole={agentNameToRole}
size="h-8 w-8"
showSpeakingAnimation
active={seatFilter === filter}
onSelect={() => toggleSeatFilter(filter)}
/>
);
})}
</div>
</CollapsedSidebarSection>
<CollapsedSidebarSection title="人工参与">
<button
type="button"
onClick={() => toggleSeatFilter(USER_SEAT_FILTER)}
aria-pressed={seatFilter === USER_SEAT_FILTER}
title={
seatFilter === USER_SEAT_FILTER
? "取消筛选,显示全部发言"
: "只看 用户 的发言"
}
className={cn(
"shrink-0 cursor-pointer rounded-full transition hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/60",
seatFilter === USER_SEAT_FILTER &&
"ring-2 ring-primary ring-offset-2 ring-offset-white",
)}
>
<Man1Avatar size="h-8 w-8" />
</button>
</CollapsedSidebarSection>
<CollapsedSidebarSection title="任务概览">
<Clipboard className="h-5 w-5 text-primary" strokeWidth={2.5} />
</CollapsedSidebarSection>
</>
)
) : (
<>
<TaskOverviewCard currentStep={currentStep} />
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)] flex-1 flex flex-col min-h-[300px]">
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-3">
<Users 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.5 overflow-y-auto flex-1 pr-1 custom-scrollbar">
{roles.map((role) => (
<RoleRow key={role.id} role={role} agentNameToRole={agentNameToRole} />
))}
</div>
</div>
<HumanParticipationCard />
</>
)}
</section>
);
}
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)]">
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-2.5">
<Clipboard 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-3">
<div>
<span className="text-[10px] uppercase tracking-wider text-slate-400 font-bold block mb-1">
当前节点
</span>
<div className="flex items-center justify-between gap-2.5">
<span className="text-sm font-bold text-slate-800">初始任务</span>
<span
className={`text-[10px] font-bold px-2 py-0.5 rounded-md border shrink-0 ${
currentStep === 1
? "text-[#ff9500] bg-orange-50 border-orange-200/50"
: currentStep === 2
? "text-[var(--brand-900)] bg-[var(--brand-200)] border-red-200/50"
: "text-emerald-600 bg-emerald-50 border-emerald-200/50"
}`}
>
{currentStep === 1 ? "待澄清" : currentStep === 2 ? "研讨中" : "已规划交付"}
</span>
</div>
<p className="text-xs text-slate-500 mt-2.5 leading-relaxed bg-slate-50 border border-slate-100 p-2 rounded-lg">
围绕某复杂任务制定规划方案
</p>
</div>
</div>
</div>
);
}
function HumanParticipationCard({
active = false,
onSelect,
}: {
/** 当前筛选是否命中「用户」(命中则高亮)。 */
active?: boolean;
/** 传入则可点击,用于筛选出用户的插话 / 澄清回复。 */
onSelect?: () => void;
} = {}) {
const clickable = Boolean(onSelect);
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-2.5">
<Activity 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
role={clickable ? "button" : undefined}
tabIndex={clickable ? 0 : undefined}
aria-pressed={clickable ? active : undefined}
title={
clickable
? active
? "取消筛选,显示全部发言"
: "只看 用户 的发言"
: undefined
}
onClick={onSelect}
onKeyDown={
clickable
? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect?.();
}
}
: undefined
}
className={cn(
"flex min-h-[56px] items-center justify-between gap-3 rounded-xl border border-slate-100 bg-slate-50 p-3 transition",
clickable && "cursor-pointer hover:border-primary/30 hover:bg-primary/5",
active && "ring-2 ring-primary ring-offset-1 ring-offset-white !border-primary/40",
)}
>
<div className="flex min-w-0 items-center gap-3">
<Man1Avatar size="h-10 w-10" />
<span className="truncate text-sm font-bold leading-5 text-slate-800">用户</span>
</div>
<div className="flex shrink-0 items-center gap-1.5 rounded-full border border-white/70 bg-white/70 px-2 py-1">
<span className="flex h-1.5 w-1.5 relative">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-emerald-500"></span>
</span>
<span className="whitespace-nowrap text-xs font-bold leading-none text-emerald-600">在线</span>
</div>
</div>
</div>
);
}
interface ModelSelectorCardProps {
selectedModel: string;
setSelectedModel: React.Dispatch<React.SetStateAction<string>>;
availableModels: ReadonlyArray<{ name: string; display_name?: string }>;
modelsLoading: boolean;
}
function ModelSelectorCard({
selectedModel,
setSelectedModel,
availableModels,
modelsLoading,
}: ModelSelectorCardProps) {
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-2.5">
<Cpu className="w-4 h-4 text-primary" strokeWidth={2.5} />
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">推理模型</h2>
</div>
<select
id="global-model-select"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
disabled={modelsLoading || availableModels.length === 0}
title={
modelsLoading
? "正在加载模型列表…"
: availableModels.length === 0
? "未在 config.yaml 中配置任何模型"
: "切换全局推理模型;不影响 Persona 抽屉里已设置的 per-seat 覆盖"
}
className="w-full bg-slate-50/50 border border-slate-100 rounded-xl px-3 py-2 text-xs font-bold text-slate-700 cursor-pointer focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-secondary dark:text-foreground dark:border-border"
>
{availableModels.length === 0 && (
<option value="">{modelsLoading ? "加载中…" : "未配置模型"}</option>
)}
{availableModels.map((m) => (
<option key={m.name} value={m.name}>
{m.display_name && m.display_name !== m.name
? `${m.display_name} (${m.name})`
: m.name}
</option>
))}
</select>
</div>
);
}
/**
* 「当前意图摘要」编辑态:目标(单行/多行文本)、约束、关键假设(均为可增删的列表)。
* 受控本地草稿,保存时过滤空行、要求目标非空,再上抛 {@link IntentSummary}。
*/
function IntentSummaryEditor({
initial,
title,
onSave,
onCancel,
}: {
initial: IntentSummary;
title: string;
onSave: (next: IntentSummary) => void;
onCancel: () => void;
}) {
const [objective, setObjective] = useState(initial.objective ?? "");
const [constraints, setConstraints] = useState<string[]>(
initial.constraints?.length ? [...initial.constraints] : [""],
);
const [assumptions, setAssumptions] = useState<string[]>(
initial.assumptions?.length ? [...initial.assumptions] : [],
);
const objectiveInvalid = objective.trim().length === 0;
const updateRow = (
setter: React.Dispatch<React.SetStateAction<string[]>>,
idx: number,
value: string,
) => setter((prev) => prev.map((v, i) => (i === idx ? value : v)));
const removeRow = (setter: React.Dispatch<React.SetStateAction<string[]>>, idx: number) =>
setter((prev) => prev.filter((_, i) => i !== idx));
const addRow = (setter: React.Dispatch<React.SetStateAction<string[]>>) =>
setter((prev) => [...prev, ""]);
const handleSave = () => {
if (objectiveInvalid) return;
onSave({
objective: objective.trim(),
constraints: constraints.map((c) => c.trim()).filter(Boolean),
assumptions: assumptions.map((a) => a.trim()).filter(Boolean),
});
};
const rowList = (
label: string,
rows: string[],
setter: React.Dispatch<React.SetStateAction<string[]>>,
placeholder: string,
) => (
<div className="space-y-1.5">
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">{label}</span>
<div className="space-y-1.5">
{rows.map((row, i) => (
<div key={i} className="flex items-center gap-1.5">
<input
value={row}
onChange={(e) => updateRow(setter, i, e.target.value)}
placeholder={placeholder}
maxLength={200}
className="min-w-0 flex-1 rounded-lg border border-slate-200 bg-white px-2.5 py-1.5 text-[11.5px] text-slate-700 focus:border-primary focus:outline-none focus:ring-1 focus:ring-[var(--brand-200)]/50"
/>
<button
type="button"
onClick={() => removeRow(setter, i)}
title="删除该项"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-slate-400 hover:bg-rose-50 hover:text-rose-500 transition-colors"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))}
<button
type="button"
onClick={() => addRow(setter)}
className="inline-flex items-center gap-1 text-[11px] font-bold text-slate-500 hover:text-primary transition-colors"
>
<Plus className="h-3.5 w-3.5" />
添加{label}
</button>
</div>
</div>
);
return (
<div className="bg-white border border-primary/30 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
<div className="flex items-center justify-between gap-2 border-b border-slate-100 pb-2.5 mb-3">
<div className="flex items-center gap-2 min-w-0">
<IntentSectionIcon className="w-4 h-4" />
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest truncate">
编辑{title}
</h2>
</div>
<div className="flex items-center gap-1 shrink-0">
<button
type="button"
onClick={handleSave}
disabled={objectiveInvalid}
title="保存"
className="flex h-7 items-center gap-1 rounded-lg bg-primary px-2.5 text-[11px] font-bold text-white hover:bg-[var(--brand-900)] disabled:bg-slate-300 disabled:cursor-not-allowed transition-colors"
>
<Check className="h-3.5 w-3.5" />
保存
</button>
<button
type="button"
onClick={onCancel}
title="取消"
className="flex h-7 w-7 items-center justify-center rounded-lg text-slate-500 hover:bg-slate-50 hover:text-slate-700 transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
<div className="space-y-3">
<div className="space-y-1.5">
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">核心目标</span>
<textarea
value={objective}
onChange={(e) => setObjective(e.target.value)}
rows={3}
maxLength={500}
placeholder="一句话说明本次要达成什么"
className={cn(
"w-full resize-y rounded-lg border bg-white px-2.5 py-1.5 text-[11.5px] leading-relaxed text-slate-700 focus:outline-none focus:ring-1",
objectiveInvalid
? "border-rose-300 focus:border-rose-400 focus:ring-rose-200/50"
: "border-slate-200 focus:border-primary focus:ring-[var(--brand-200)]/50",
)}
/>
{objectiveInvalid && (
<p className="text-[10px] text-rose-500">核心目标不能为空</p>
)}
</div>
{rowList("约束", constraints, setConstraints, "如:预算 / 时间 / 合规 / 范围")}
{rowList("假设", assumptions, setAssumptions, "智能体为你做的假设(可选)")}
</div>
</div>
);
}
interface IntentSummaryCardProps {
intentReady: ReturnType<typeof useStep1Intent>["intentReady"];
title?: string;
/** Step 2 侧栏: 收起时仅展示目标,展开后显示约束与假设。 */
collapsible?: boolean;
/** 侧栏整体收起按钮,放在标题栏右侧。 */
sidebarToggle?: {
expanded: boolean;
onToggle: () => void;
};
/**
* 提供则显示「编辑」按钮,允许用户手动修改识别出的意图(目标 / 约束 / 假设)并保存。
* 保存后回调上抛新意图,由页面覆盖 `intentReady` 并持久化草稿。不传 = 只读。
*/
onSave?: (next: IntentSummary) => void;
}
function IntentSummaryCard({
intentReady,
title = "当前意图摘要",
collapsible = false,
sidebarToggle,
onSave,
}: IntentSummaryCardProps) {
const [expanded, setExpanded] = useState(!collapsible);
const showFullDetail = !collapsible || expanded;
const [editing, setEditing] = useState(false);
if (editing && intentReady) {
return (
<IntentSummaryEditor
initial={intentReady}
title={title}
onCancel={() => setEditing(false)}
onSave={(next) => {
onSave?.(next);
setEditing(false);
}}
/>
);
}
return (
<div className="bg-white border border-slate-200/50 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
<div className="flex items-center justify-between gap-2 border-b border-slate-100 pb-2.5 mb-2.5">
<div className="flex items-center gap-2 min-w-0">
<IntentSectionIcon className="w-4 h-4" />
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest truncate">{title}</h2>
</div>
<div className="flex items-center gap-1 shrink-0">
{onSave && intentReady && (
<button
type="button"
onClick={() => setEditing(true)}
title="编辑意图"
className="flex h-7 w-7 items-center justify-center rounded-lg text-slate-500 hover:text-primary hover:bg-slate-50 transition-colors"
>
<Pencil className="h-3.5 w-3.5" />
</button>
)}
{sidebarToggle && (
<button
type="button"
onClick={sidebarToggle.onToggle}
title={sidebarToggle.expanded ? "收起侧栏" : "展开侧栏"}
aria-expanded={sidebarToggle.expanded}
className="flex h-7 w-7 items-center justify-center rounded-lg text-slate-500 hover:text-primary hover:bg-slate-50 transition-colors"
>
{sidebarToggle.expanded ? (
<PanelLeftClose className="h-4 w-4" />
) : (
<PanelLeftOpen className="h-4 w-4" />
)}
</button>
)}
</div>
</div>
<div className="p-3 bg-slate-50/50 border border-slate-100 rounded-xl space-y-3 mr-0.5">
{intentReady ? (
<>
<ul className="text-xs text-slate-700 space-y-2.5 font-normal">
<li className="flex items-start gap-2 leading-relaxed">
<span className="text-primary text-base leading-none pt-0.5">•</span>
<span
className={cn(
"min-w-0 leading-relaxed",
collapsible && !expanded && "line-clamp-3",
)}
>
<b className="text-slate-900 font-bold">目标</b>: {intentReady.objective}
</span>
</li>
</ul>
{collapsible && (
<div className="flex justify-end">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="inline-flex items-center gap-0.5 text-xs font-bold text-slate-500 hover:text-primary transition-colors"
aria-expanded={expanded}
>
{expanded ? (
<>
收起
<ChevronUp className="w-3.5 h-3.5" />
</>
) : (
<>
展开
<ChevronDown className="w-3.5 h-3.5" />
</>
)}
</button>
</div>
)}
{showFullDetail && (
<ul className="text-xs text-slate-700 space-y-2.5 font-normal">
{intentReady.constraints.map((c, i) => (
<li key={`c-${i}`} className="flex items-baseline gap-2 leading-relaxed">
<span className="text-primary text-base leading-none">•</span>
<span>
<b className="text-slate-900 font-bold">约束</b>: {c}
</span>
</li>
))}
{(intentReady.assumptions ?? []).map((a, i) => (
<li key={`a-${i}`} className="flex items-baseline gap-2 leading-relaxed">
<span className="text-slate-400 text-base leading-none">•</span>
<span className="text-slate-500">
<b className="text-slate-700 font-bold">假设</b>: {a}
</span>
</li>
))}
</ul>
)}
</>
) : (
<p className="text-xs text-slate-400 italic leading-relaxed">
等待任务理解智能体澄清完成...
</p>
)}
</div>
</div>
);
}
function IntentSectionIcon({ className }: { className?: string }) {
return (
<svg
className={cn("text-primary shrink-0", className)}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
>
<circle cx="12" cy="12" r="10" />
<circle cx="12" cy="12" r="6" />
<circle cx="12" cy="12" r="2" />
</svg>
);
}
function CollapsedSidebarSection({
title,
children,
className,
}: {
title: string;
children: React.ReactNode;
className?: string;
}) {
return (
<div
title={title}
className={cn(
"w-full shrink-0 bg-white border border-slate-200/50 rounded-2xl p-2 shadow-[0_2px_8px_rgba(0,0,0,0.01)] flex flex-col items-center justify-center",
className,
)}
>
{children}
</div>
);
}
function renderRoleAvatar(
role: RoleEntry,
agentNameToRole: Record<
string,
{ speakerId: number; avatarType: string; displayName: string }
>,
size: string,
) {
if (role.type === "human") {
return <Man1Avatar size={size} />;
}
return getAvatar(
role.agentId ? agentNameToRole[role.agentId]?.avatarType ?? "blue" : "blue",
size,
);
}
function RoleAvatarBadge({
role,
agentNameToRole,
size = "h-7 w-7",
showSpeakingAnimation = false,
active = false,
onSelect,
}: {
role: RoleEntry;
agentNameToRole: Record<
string,
{ speakerId: number; avatarType: string; displayName: string }
>;
size?: string;
showSpeakingAnimation?: boolean;
/** 当前筛选是否命中本席位(命中则头像高亮)。 */
active?: boolean;
/** 传入则头像可点击,用于「按席位筛选会商记录」(收起态)。 */
onSelect?: () => void;
}) {
const isOngoing = role.status === "研讨中";
const isDone = role.status === "已完成";
const animate = showSpeakingAnimation && isOngoing;
const clickable = Boolean(onSelect);
const inner = (
<div
className={cn(
"relative shrink-0 rounded-full",
animate && "ring-2 ring-primary/50 ring-offset-2 ring-offset-white",
active && "ring-2 ring-primary ring-offset-2 ring-offset-white",
)}
>
{animate && (
<span className="pointer-events-none absolute inset-0 rounded-full border-2 border-primary/35 animate-ping" />
)}
<div className={cn("relative rounded-full", animate && "animate-pulse")}>
{renderRoleAvatar(role, agentNameToRole, size)}
</div>
{/* 收起态:输出完成的席位在头像右下角叠加「已完成」勾选角标。 */}
{isDone && (
<span className="pointer-events-none absolute -bottom-0.5 -right-0.5 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-emerald-500 ring-2 ring-white">
<Check className="h-2.5 w-2.5 text-white" strokeWidth={3} />
</span>
)}
</div>
);
if (!clickable) {
return <div title={`${role.name} · ${role.status}`}>{inner}</div>;
}
return (
<button
type="button"
onClick={onSelect}
aria-pressed={active}
title={active ? "取消筛选,显示全部发言" : `只看 ${role.name} 的发言`}
className="shrink-0 cursor-pointer rounded-full transition hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
>
{inner}
</button>
);
}
function RoleRow({
role,
agentNameToRole,
active = false,
onSelect,
}: {
role: RoleEntry;
agentNameToRole: Record<
string,
{ speakerId: number; avatarType: string; displayName: string }
>;
/** 当前筛选是否命中本席位(命中则整卡高亮)。 */
active?: boolean;
/** 传入则整卡可点击,用于「按席位筛选会商记录」。 */
onSelect?: () => void;
}) {
const isActive =
role.status === "在线" || role.status === "研讨中" || role.status === "已完成";
const isOngoing = role.status === "研讨中";
const isBootingUp = role.status === "正在上线";
const clickable = Boolean(onSelect);
return (
<div
id={`role-item-${role.id}`}
role={clickable ? "button" : undefined}
tabIndex={clickable ? 0 : undefined}
aria-pressed={clickable ? active : undefined}
title={
clickable
? active
? "取消筛选,显示全部发言"
: `只看 ${role.name} 的发言`
: undefined
}
onClick={onSelect}
onKeyDown={
clickable
? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect?.();
}
}
: undefined
}
className={cn(
"flex min-h-[60px] items-center justify-between gap-3 rounded-xl p-3 transition",
isActive
? "bg-slate-50 border border-slate-100"
: isBootingUp
? "bg-amber-50/60 border border-amber-100"
: "opacity-60",
clickable && "cursor-pointer hover:border-primary/30 hover:bg-primary/5",
active &&
"ring-2 ring-primary ring-offset-1 ring-offset-white !border-primary/40 !opacity-100",
)}
>
<div className="flex min-w-0 items-center gap-3">
<RoleAvatarBadge role={role} agentNameToRole={agentNameToRole} size="h-10 w-10" />
<div className="flex min-w-0 flex-col gap-0.5">
<span className="truncate text-sm font-bold leading-5 tracking-tight text-slate-800">
{role.name}
</span>
{role.type === "agent" && (
<span className="text-[11px] text-primary font-medium leading-4">AI 智能体</span>
)}
</div>
</div>
<div className="flex shrink-0 items-center gap-1.5 rounded-full border border-white/70 bg-white/70 px-2 py-1">
{isOngoing ? (
<span className="flex h-1.5 w-1.5 relative">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary/60 opacity-75"></span>
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-primary"></span>
</span>
) : role.status === "在线" ? (
<span className="flex h-1.5 w-1.5 relative">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-emerald-500"></span>
</span>
) : role.status === "已完成" ? (
<Check className="h-3.5 w-3.5 text-emerald-500 font-bold" />
) : isBootingUp ? (
<Loader2 className="h-3 w-3 text-amber-500 animate-spin" />
) : (
<span className="h-1.5 w-1.5 rounded-full bg-slate-300" />
)}
<span
className={`whitespace-nowrap text-xs font-semibold leading-none ${
isOngoing
? "text-[var(--brand-900)]"
: role.status === "在线"
? "text-emerald-600 font-bold"
: role.status === "已完成"
? "text-emerald-600"
: isBootingUp
? "text-amber-600"
: "text-slate-400"
}`}
>
{role.status}
</span>
</div>
</div>
);
}
interface RightSidebarProps {
currentStep: number;
selectedModel: string;
setSelectedModel: React.Dispatch<React.SetStateAction<string>>;
availableModels: ReadonlyArray<{ name: string; display_name?: string }>;
modelsLoading: boolean;
intentReady: ReturnType<typeof useStep1Intent>["intentReady"];
timelineEntries: ReturnType<typeof useStep1Intent>["timelineEntries"];
triggerToast: (msg: string) => void;
/** 用户编辑并保存意图的回调(透传给 IntentSummaryCard)。 */
onSaveIntent: (next: IntentSummary) => void;
}
function RightSidebar({
currentStep,
selectedModel,
setSelectedModel,
availableModels,
modelsLoading,
intentReady,
timelineEntries,
triggerToast,
onSaveIntent,
}: RightSidebarProps) {
// Step 2 侧栏内容已迁至左侧;Step 3 沿用原逻辑不展示右侧栏。
if (currentStep !== 1) return null;
return (
<section
id="right-sidebar"
className="w-[300px] shrink-0 flex flex-col gap-4 overflow-y-auto pl-1"
>
<ModelSelectorCard
selectedModel={selectedModel}
setSelectedModel={setSelectedModel}
availableModels={availableModels}
modelsLoading={modelsLoading}
/>
<IntentSummaryCard intentReady={intentReady} onSave={onSaveIntent} />
{/* 澄清记录 Timeline */}
<div className="bg-white border border-slate-200/50 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.01)] flex-1 flex flex-col min-h-[300px]">
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-3 shrink-0">
<Clock 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="flex-1 overflow-y-auto pr-1 font-normal custom-scrollbar">
<div className="relative ml-2 space-y-4 border-l border-red-50 pb-2 pl-4 pt-1.5">
{timelineEntries.map((entry) => (
<div
key={entry.id}
className="relative rounded-lg px-1.5 py-1 transition-colors hover:bg-slate-50/80"
>
<div className="absolute -left-[20.5px] top-2 h-3 w-3 rounded-full bg-primary border-2 border-white ring-2 ring-[var(--brand-200)]" />
<div className="grid grid-cols-[68px_minmax(0,1fr)] gap-x-2">
<span className="font-mono text-xs leading-5 tracking-tight text-slate-400">
{entry.time}
</span>
<div className="min-w-0">
<h4 className="break-words text-sm font-bold leading-5 tracking-tight text-slate-800">
{entry.title}
</h4>
<p className="mt-1 break-words text-xs leading-5 text-slate-500">
{entry.desc}
</p>
</div>
</div>
</div>
))}
</div>
</div>
<div className="shrink-0 pt-2.5 border-t border-slate-100 flex items-center justify-between text-xs mt-2.5">
<span className="text-slate-400 font-medium">共 {timelineEntries.length} 条记录</span>
<button
onClick={() =>
triggerToast(`📊 正在渲染全部 ${timelineEntries.length} 个历史事件归档`)
}
className="text-primary hover:text-[var(--brand-900)] font-bold hover:underline"
>
查看全部 &gt;
</button>
</div>
</div>
</section>
);
}