2664 lines
108 KiB
TypeScript
2664 lines
108 KiB
TypeScript
/**
|
||
* 圆桌规划主页面(三步合一)。
|
||
*
|
||
* 重构后的主文件只负责"组合 + 编排":
|
||
* - 顶栏(步骤切换 + 草稿菜单 + 新建任务 + 设置)
|
||
* - 左侧栏: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"
|
||
>
|
||
查看全部 >
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|