From 4a403c95c5b9682d7eeb061bada33f2af6cc06a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Fri, 19 Jun 2026 22:55:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(roundtable):=20=E4=BC=9A=E5=95=86=E8=AF=8A?= =?UTF-8?q?=E6=96=AD=E6=97=A5=E5=BF=97=20+=20=E5=90=8E=E5=8F=B0=E6=8C=82?= =?UTF-8?q?=E8=B5=B7=E5=8D=A1=E6=AD=BB=E5=85=9C=E5=BA=95(idle=20=E8=B6=85?= =?UTF-8?q?=E6=97=B6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增会商全流程诊断日志:新表 roundtable_diagnostics + 仓储 + admin API (/api/roundtable-diagnostics) + 管理员诊断页(会商页右上角设置按钮入口), 覆盖第一/二/三步及后台执行器/进程内网关各处报错与关键事件,支持按 范围/阶段/级别/事件/关键词筛选 + 分页。诊断写入全程 best-effort,绝不打断会商。 - 修复后台挂起「执行完第一个席位后永久卡死」:进程内网关每轮 agent run 改为 idle(无活动)超时(默认 20min)——只要还在持续产出 token/工具事件就一直等, 仅当连续无任何输出才判卡死并取消+报错+落 seat_turn_timeout 诊断;慢但活着的 生成绝不打断。可经 ROUNDTABLE_SEAT_IDLE_TIMEOUT_SECONDS 调整。 - 一并提交进行中的会商 seat-skill 注入 / 业务链条编辑器相关改动。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/pages/RoundtableDiagnosticsPage.tsx | 272 ++++++++++++++++++ frontend-web/src/pages/StrategyRoutes.tsx | 11 + .../components/BusinessChainPicker.tsx | 7 +- .../roundtable-planning/lib/business-chain.ts | 21 +- .../roundtable-planning/lib/chain-stages.ts | 14 + .../pages/BusinessChainEditorPage.tsx | 30 ++ .../pages/RoundtablePlanningPage.tsx | 77 ++++- .../api/roundtable-diagnostics.ts | 131 +++++++++ offline-backend-20260512/backend/CLAUDE.md | 1 + .../backend/app/gateway/app.py | 2 + .../backend/app/gateway/deps.py | 16 ++ .../backend/app/gateway/roundtable_diag.py | 63 ++++ .../gateway/roundtable_inprocess_gateway.py | 218 +++++++++++++- .../app/gateway/roundtable_job_executor.py | 32 ++- .../app/gateway/roundtable_seat_skills.py | 95 ++++++ .../backend/app/gateway/routers/intent.py | 17 ++ .../app/gateway/routers/multi_agent.py | 206 ++++++++++--- .../backend/app/gateway/routers/recommend.py | 11 + .../app/gateway/routers/roundtable_chains.py | 5 + .../gateway/routers/roundtable_diagnostics.py | 173 +++++++++++ .../agents/roundtable_orchestrator/engine.py | 57 ++++ .../roundtable_orchestrator/progress.py | 33 +++ .../20260619_01_roundtable_diagnostics.py | 70 +++++ .../deerflow/persistence/models/__init__.py | 2 + .../roundtable_diagnostics/__init__.py | 112 ++++++++ .../roundtable_diagnostics/model.py | 52 ++++ .../persistence/roundtable_diagnostics/sql.py | 206 +++++++++++++ 27 files changed, 1873 insertions(+), 61 deletions(-) create mode 100644 frontend-web/src/pages/RoundtableDiagnosticsPage.tsx create mode 100644 frontend-web/src/strategy-components/api/roundtable-diagnostics.ts create mode 100644 offline-backend-20260512/backend/app/gateway/roundtable_diag.py create mode 100644 offline-backend-20260512/backend/app/gateway/roundtable_seat_skills.py create mode 100644 offline-backend-20260512/backend/app/gateway/routers/roundtable_diagnostics.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260619_01_roundtable_diagnostics.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_diagnostics/__init__.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_diagnostics/model.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_diagnostics/sql.py diff --git a/frontend-web/src/pages/RoundtableDiagnosticsPage.tsx b/frontend-web/src/pages/RoundtableDiagnosticsPage.tsx new file mode 100644 index 00000000..e4fc405d --- /dev/null +++ b/frontend-web/src/pages/RoundtableDiagnosticsPage.tsx @@ -0,0 +1,272 @@ +"use client"; + +/** + * 会商诊断日志(仅 admin):展示多智能体会商全流程(第一步意图 / 第二步研讨 / + * 第三步报告 + 后台挂起执行器 / 进程内网关)各处的报错与关键事件,支持按 + * scope(前台/后台)、阶段、级别、事件码、作业/任务/草稿 id、关键词筛选 + 分页。 + * + * 数据源:`GET /api/roundtable-diagnostics`(admin-gated)。入口:多智能体会商页右上角 + * 设置按钮(admin)。见 [[roundtable-diagnostics]]。 + */ + +import { useMemo, useState } from "react"; +import { Navigate, useNavigate } from "react-router-dom"; +import { ArrowLeft, RefreshCw, Search } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Pagination } from "@/components/ui/pagination"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { cn } from "@/lib/utils"; +import { getStoredAuth } from "@/core/auth"; +import { + type DiagnosticFilters, + type DiagnosticItem, + useRoundtableDiagnosticFacets, + useRoundtableDiagnostics, +} from "@/strategy-components/api/roundtable-diagnostics"; + +const PAGE_SIZE = 50; + +const ALL = "__all__"; + +// 阶段中文名(已知集合;未知阶段原样显示)。 +const STAGE_LABELS: Record = { + step1_intent: "第一步 · 意图", + step2_init: "第二步 · 初始化", + step2_leader: "第二步 · 总控", + step2_seat: "第二步 · 席位", + step2_recommend: "第二步 · 推荐", + step2_orchestration: "第二步 · 编排", + step3_summary: "第三步 · 总结报告", + step3_report: "第三步 · 结果绘制", + step3_dashboard: "第三步 · 大屏数据", + step3_structure: "第三步 · 数据入库", + upstream: "回环代理", + job: "后台作业", +}; + +const SCOPE_LABELS: Record = { + background: "后台挂起", + foreground: "前台实时", +}; + +const LEVEL_STYLES: Record = { + error: "bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-300", + warning: "bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-300", + info: "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300", +}; + +function stageLabel(stage: string): string { + return STAGE_LABELS[stage] ?? stage; +} + +function fmtTime(iso: string | null): string { + if (!iso) return ""; + try { + const d = new Date(iso); + return d.toLocaleString("zh-CN", { hour12: false }); + } catch { + return iso; + } +} + +function DiagnosticRow({ item }: { item: DiagnosticItem }) { + const [open, setOpen] = useState(false); + const hasDetail = item.detail != null && Object.keys(item.detail as object).length > 0; + return ( +
+
+ + {item.level} + +
+
+ + {stageLabel(item.stage)} + + {item.event} + {item.agentName ? ( + · {item.agentName} + ) : null} + {fmtTime(item.createdAt)} +
+

+ {item.message || (无说明)} +

+
+ {SCOPE_LABELS[item.scope] ?? item.scope} + {item.jobId ? job: {item.jobId.slice(0, 8)} : null} + {item.taskId ? task: {item.taskId} : null} + {item.draftId ? draft: {item.draftId.slice(0, 8)} : null} + {item.cycle != null ? cycle: {item.cycle} : null} + {hasDetail ? ( + + ) : null} +
+ {open && hasDetail ? ( +
+              {JSON.stringify(item.detail, null, 2)}
+            
+ ) : null} +
+
+
+ ); +} + +export default function RoundtableDiagnosticsPage() { + const isAdmin = getStoredAuth()?.system_role === "admin"; + const navigate = useNavigate(); + + const [scope, setScope] = useState(ALL); + const [stage, setStage] = useState(ALL); + const [level, setLevel] = useState(ALL); + const [event, setEvent] = useState(ALL); + const [qInput, setQInput] = useState(""); + const [q, setQ] = useState(""); + const [page, setPage] = useState(1); + + const filters: DiagnosticFilters = useMemo( + () => ({ + scope: scope === ALL ? undefined : scope, + stage: stage === ALL ? undefined : stage, + level: level === ALL ? undefined : level, + event: event === ALL ? undefined : event, + q: q || undefined, + limit: PAGE_SIZE, + offset: (page - 1) * PAGE_SIZE, + }), + [scope, stage, level, event, q, page], + ); + + const { data, isLoading, isFetching, refetch } = useRoundtableDiagnostics(filters, isAdmin); + const { data: facets } = useRoundtableDiagnosticFacets(isAdmin); + + if (!isAdmin) { + return ; + } + + const items = data?.items ?? []; + const total = data?.total ?? 0; + + const resetPageAnd = (fn: () => void) => { + fn(); + setPage(1); + }; + + const applySearch = () => resetPageAnd(() => setQ(qInput.trim())); + + return ( +
+ {/* Header */} +
+ +
+

会商诊断日志

+

+ 多智能体会商全流程报错与关键事件(含后台挂起卡死、超时、产物缺失等) +

+
+ +
+ + {/* Filters */} +
+ resetPageAnd(() => setScope(v))} + options={facets?.scope ?? []} labels={SCOPE_LABELS} /> + resetPageAnd(() => setStage(v))} + options={facets?.stage ?? []} labels={STAGE_LABELS} /> + resetPageAnd(() => setLevel(v))} + options={facets?.level ?? []} /> + resetPageAnd(() => setEvent(v))} + options={facets?.event ?? []} /> +
+ setQInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && applySearch()} + placeholder="搜索报错说明…" + className="h-8 w-48" + /> + +
+ 共 {total} 条 +
+ + {/* List */} +
+ {isLoading ? ( +

加载中…

+ ) : items.length === 0 ? ( +

暂无诊断记录

+ ) : ( +
+ {items.map((it) => ( + + ))} + +
+ )} +
+
+ ); +} + +function FilterSelect({ + label, + value, + onChange, + options, + labels, +}: { + label: string; + value: string; + onChange: (v: string) => void; + options: string[]; + labels?: Record; +}) { + return ( + + ); +} diff --git a/frontend-web/src/pages/StrategyRoutes.tsx b/frontend-web/src/pages/StrategyRoutes.tsx index 0bab56b0..6c9bda77 100644 --- a/frontend-web/src/pages/StrategyRoutes.tsx +++ b/frontend-web/src/pages/StrategyRoutes.tsx @@ -41,6 +41,7 @@ import LightAppManagementPage from "./LightAppManagementPage"; import MenuManagementPage from "./MenuManagementPage"; import SensitiveWordsPage from "./SensitiveWordsPage"; import TaskButtonsPage from "./TaskButtonsPage"; +import RoundtableDiagnosticsPage from "./RoundtableDiagnosticsPage"; import LightAppNewNotePage from "./LightAppNewNotePage"; import LightAppNoteListPage from "./LightAppNoteListPage"; import LightAppNoteQAPage from "./LightAppNoteQAPage"; @@ -393,6 +394,16 @@ export default function StrategyRoutes() { ) } /> + + ) : ( + + ) + } + /> [s.agent_id, s.name])); const nameOf = (id: string) => nameById.get(id) ?? seatNameById.get(id) ?? id; + // 席位模型覆盖(链条配置):按 agent_id 取,空/缺省 = 跟随全局模型。 + const modelById = new Map(selectedChain.seats.map((s) => [s.agent_id, s.model ?? null])); + const modelOf = (id: string) => modelById.get(id) ?? null; // 链条带分层、过滤存活后仍并行 → dag 编排(携带快速/沙箱子模式);否则退回线性 chain。 if (dagPlan) { // dag:席位顺序按 stage 展平(与 plan 一致)。 const agents = dagPlan.stages .flatMap((st) => st.agentIds) - .map((id) => ({ agent_id: id, name: nameOf(id) })); + .map((id) => ({ agent_id: id, name: nameOf(id), model: modelOf(id) })); onConfirm({ agents, mode: "dag", chain: chainMeta, plan: dagPlan, dagExecMode }); return; } - const agents = survivingSeats.map((s) => ({ agent_id: s.agent_id, name: nameOf(s.agent_id) })); + const agents = survivingSeats.map((s) => ({ agent_id: s.agent_id, name: nameOf(s.agent_id), model: modelOf(s.agent_id) })); onConfirm({ agents, mode: "chain", chain: chainMeta }); }; diff --git a/frontend-web/src/roundtable-planning/lib/business-chain.ts b/frontend-web/src/roundtable-planning/lib/business-chain.ts index a8a824f4..46b8d2a6 100644 --- a/frontend-web/src/roundtable-planning/lib/business-chain.ts +++ b/frontend-web/src/roundtable-planning/lib/business-chain.ts @@ -22,6 +22,13 @@ export interface ChainSeat { name: string; /** 描述快照,同样作为实时数据的回退。 */ description?: string; + /** + * 可选「席位模型覆盖」:该席位作为子智能体执行时用的 LLM model name(须是后端 + * `config.yaml` `models[]` 里的一个 `name`)。空 / 缺省 = 不覆盖,运行时回退到全局 + * 所选模型。选入研讨时灌进 `agentConfigs[speakerId].model`(见 `RoundtablePlanningPage` + * 的 `handleConfirmAgents`)→ 走现有 per-seat 模型链路。 + */ + model?: string | null; } /** 一条可复用的业务链条(与后端 `/api/roundtable-chains` 记录对应)。 */ @@ -70,8 +77,11 @@ export interface BusinessChain { * `agents` + 一个 `mode`。 */ export interface SeatSelectionResult { - /** 有序席位 —— 与现有 `handleConfirmAgents` 入参兼容。dag 模式下按 stage 展平排序。 */ - agents: Array<{ agent_id: string; name: string }>; + /** + * 有序席位 —— 与现有 `handleConfirmAgents` 入参兼容。dag 模式下按 stage 展平排序。 + * `model` 为该席位在来源链条里配置的模型覆盖(空 / 缺省 = 跟随全局模型)。 + */ + agents: Array<{ agent_id: string; name: string; model?: string | null }>; /** * 决定 Step 2 走哪种编排: * - `recommend` 总控自由派活; @@ -122,6 +132,9 @@ export function buildChainSeatSelection( const nameById = new Map(candidates.map((c) => [c.agent_id, c.name])); const seatNameById = new Map(chain.seats.map((s) => [s.agent_id, s.name])); const nameOf = (id: string) => nameById.get(id) ?? seatNameById.get(id) ?? id; + // 席位模型覆盖(链条配置):按 agent_id 取,空/缺省 = 跟随全局。 + const modelById = new Map(chain.seats.map((s) => [s.agent_id, s.model ?? null])); + const modelOf = (id: string) => modelById.get(id) ?? null; const survivingSeats = chain.seats.filter((s) => existingIds.has(s.agent_id)); if (survivingSeats.length < CHAIN_MIN_SEATS) return null; @@ -143,12 +156,12 @@ export function buildChainSeatSelection( if (hasParallel || hasGoal || hasCoord) { const agents = plan.stages .flatMap((st) => st.agentIds) - .map((id) => ({ agent_id: id, name: nameOf(id) })); + .map((id) => ({ agent_id: id, name: nameOf(id), model: modelOf(id) })); return { agents, mode: "dag", chain: chainMeta, plan, dagExecMode: "fast", seatMode: chain.seatMode }; } } - const agents = survivingSeats.map((s) => ({ agent_id: s.agent_id, name: nameOf(s.agent_id) })); + const agents = survivingSeats.map((s) => ({ agent_id: s.agent_id, name: nameOf(s.agent_id), model: modelOf(s.agent_id) })); return { agents, mode: "chain", chain: chainMeta, seatMode: chain.seatMode }; } diff --git a/frontend-web/src/roundtable-planning/lib/chain-stages.ts b/frontend-web/src/roundtable-planning/lib/chain-stages.ts index 117ca058..a077cc0c 100644 --- a/frontend-web/src/roundtable-planning/lib/chain-stages.ts +++ b/frontend-web/src/roundtable-planning/lib/chain-stages.ts @@ -112,6 +112,20 @@ export function setStageGoal(groups: StageGroups, stageIdx: number, goal: string return groups.map((g, i) => (i === stageIdx ? { ...g, goal } : g)); } +/** + * 设置某个席位的「模型覆盖」(不可变,供编辑器席位下拉 onChange)。 + * `model` 为 null/"" 表示清除覆盖(跟随全局模型)。按 agent_id 定位,跨 stage 适用。 + */ +export function setSeatModel(groups: StageGroups, agentId: string, model: string | null): StageGroups { + return groups.map((g) => { + if (!g.seats.some((s) => s.agent_id === agentId)) return g; + return { + ...g, + seats: g.seats.map((s) => (s.agent_id === agentId ? { ...s, model: model || null } : s)), + }; + }); +} + // ── 变换(均返回新结构,不可变)────────────────────────────────────────────── /** 末尾追加一个**新单席位 stage**(线性追加,pool 点击加入默认行为)。 */ diff --git a/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx b/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx index dfa096f0..85934dbf 100644 --- a/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx @@ -65,6 +65,7 @@ import { CSS } from "@dnd-kit/utilities"; import { cn } from "@/lib/utils"; import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree"; import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar"; +import { ModelNameSelect } from "@/components/workspace/model-name-select"; import { listAgents } from "@/core/agents"; import { @@ -103,6 +104,7 @@ import { moveStage, removeSeat as removeSeatFromGroups, seatExistsInGroups, + setSeatModel, setStageGoal, splitSeatToNewStage, stageGroupsToSeats, @@ -359,6 +361,11 @@ export default function BusinessChainEditorPage() { setStageGroups((prev) => setStageGoal(prev, stageIdx, goal)); }, []); + // 席位模型覆盖:编辑器席位下拉 onChange(null = 清除,跟随全局)。 + const setSeatModelCb = useCallback((agentId: string, model: string | null) => { + setStageGroups((prev) => setSeatModel(prev, agentId, model)); + }, []); + // ── 提示词 AI 优化(总控提示词 / 各 Stage 阶段目标)──────────────────────── const [optimizingCoordinator, setOptimizingCoordinator] = useState(false); const [goalOptimizingIdx, setGoalOptimizingIdx] = useState(null); @@ -555,6 +562,8 @@ export default function BusinessChainEditorPage() { agent_id: s.agent_id, name: live?.name ?? s.name, description: live?.description ?? s.description ?? "", + // 席位模型覆盖:随席位一起落库(空 → null = 跟随全局)。 + model: s.model ?? null, }; }); // 总控编排提示:trim 后空串落 null(清空),与后端「空=不注入」一致。 @@ -1121,6 +1130,7 @@ export default function BusinessChainEditorPage() { onRemoveSeat={removeSeat} onMergeSeat={mergeSeatToStage} onSplitSeat={splitSeat} + onSeatModelChange={setSeatModelCb} onStageGoalChange={setGoal} goalOptimizingIdx={goalOptimizingIdx} onOptimizeGoal={handleOptimizeStageGoal} @@ -1298,6 +1308,7 @@ function StagesColumn({ onRemoveSeat, onMergeSeat, onSplitSeat, + onSeatModelChange, onStageGoalChange, goalOptimizingIdx, onOptimizeGoal, @@ -1313,6 +1324,7 @@ function StagesColumn({ onRemoveSeat: (agentId: string) => void; onMergeSeat: (agentId: string, targetStageIdx: number) => void; onSplitSeat: (agentId: string) => void; + onSeatModelChange: (agentId: string, model: string | null) => void; onStageGoalChange: (stageIdx: number, goal: string) => void; /** 正在 AI 优化阶段目标的 stage 下标(null = 没有进行中的优化)。 */ goalOptimizingIdx: number | null; @@ -1410,6 +1422,7 @@ function StagesColumn({ onRemoveSeat={onRemoveSeat} onMergeSeat={onMergeSeat} onSplitSeat={onSplitSeat} + onSeatModelChange={onSeatModelChange} onGoalChange={(goal) => onStageGoalChange(stageIdx, goal)} goalOptimizing={goalOptimizingIdx === stageIdx} anyGoalOptimizing={goalOptimizingIdx !== null} @@ -1438,6 +1451,7 @@ function StageSection({ onRemoveSeat, onMergeSeat, onSplitSeat, + onSeatModelChange, onGoalChange, goalOptimizing, anyGoalOptimizing, @@ -1455,6 +1469,7 @@ function StageSection({ onRemoveSeat: (agentId: string) => void; onMergeSeat: (agentId: string, targetStageIdx: number) => void; onSplitSeat: (agentId: string) => void; + onSeatModelChange: (agentId: string, model: string | null) => void; onGoalChange: (goal: string) => void; /** 本 Stage 的阶段目标正在 AI 优化中。 */ goalOptimizing: boolean; @@ -1615,6 +1630,7 @@ function StageSection({ onMergeUp={() => onMergeSeat(seat.agent_id, stageIdx - 1)} onMergeDown={() => onMergeSeat(seat.agent_id, stageIdx + 1)} onSplit={() => onSplitSeat(seat.agent_id)} + onModelChange={(model) => onSeatModelChange(seat.agent_id, model)} onDetail={() => onSeatDetail(seat.agent_id, seat.name)} /> ))} @@ -1637,6 +1653,7 @@ function SeatStrip({ onMergeUp, onMergeDown, onSplit, + onModelChange, onDetail, }: { seat: ChainSeat; @@ -1649,6 +1666,8 @@ function SeatStrip({ onMergeUp: () => void; onMergeDown: () => void; onSplit: () => void; + /** 席位模型覆盖变更(null = 清除,跟随全局)。 */ + onModelChange: (model: string | null) => void; onDetail: () => void; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = @@ -1697,6 +1716,17 @@ function SeatStrip({ {seat.description} )} + {/* 席位模型覆盖:空 = 跟随全局所选模型;选了则该席位作为子智能体执行时用此模型。 + 技能重的席位配强模型、边角席位配快模型,配合技能白名单实现「强弱搭配」。 */} +
+ 模型 + +
{/* 控制:查看详情 / 并入上一阶段 / 独立成阶段 / 并入下一阶段 / 移除 */} diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index 3d0b8d28..7f40db12 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -24,6 +24,7 @@ */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useLocation } from "react-router-dom"; +import { getStoredAuth } from "@/core/auth"; import { Activity, BookOpen, @@ -195,6 +196,11 @@ function readWujiePrefill(): RoundtablePrefill | null { */ const ROUNDTABLE_DEEPLINK_PREFILL_KEY = "roundtable:deeplink-prefill"; +// 进第三步三流「错峰起跑」的基准间隔(毫秒)。结果绘制延迟 1×、大屏延迟 2×,把三个 +// start_run 的写尖峰摊开,避免单 worker + 单 sqlite 锁瞬时被打满导致回环 502(削峰, +// 不影响并行展示进度)。取值偏小,用户基本无感。 +const STEP3_STAGGER_MS = 300; + /** 按路由 path 持久化深链 prefill,供刷新后恢复。隐私模式/quota 异常静默失败。 */ function persistDeeplinkPrefill(path: string, prefill: RoundtablePrefill): void { try { @@ -912,6 +918,19 @@ export default function RoundtablePlanningPage() { dashboardFlow.abort(); step2.resetStep2State(); step2.setSelectedAgents(normalized); + // per-seat 模型覆盖:业务链条为某些席位指定了模型 → 写进 agentConfigs。speakerId = + // 席位序号 + 2(与 selectedAgents 索引对齐,1 号是总控),与派活时 + // `agentConfigs[meta.speakerId]?.model` 的读取一致。空 / 未指定 → model:""(跟随全局), + // 同时清掉上一轮选择残留的席位模型覆盖;prompt/temp/weight 沿用既有 Persona 配置。 + step2.setAgentConfigs((prev) => { + const next = { ...prev }; + result.agents.forEach((a, i) => { + const speakerId = i + 2; + const base = next[speakerId] ?? { model: "", weight: 85, temp: 0.3, prompt: "" }; + next[speakerId] = { ...base, model: a.model ?? "" }; + }); + return next; + }); step2.resetInitGate(); // 这是从第一步确认席位、开启的一轮**实时**研讨(非查看历史/草稿) → 清掉 // “查看历史”闸门,否则之前看过历史记录留下的 true 会一直把「后台挂起」按钮藏掉。 @@ -1124,8 +1143,13 @@ export default function RoundtablePlanningPage() { } return; } + // 管理员:打开「会商诊断日志」页(全流程报错排查)。非管理员维持原占位提示。 + if (getStoredAuth()?.system_role === "admin") { + navigate("/page/strategy/admin/roundtable-diagnostics"); + return; + } triggerToast("⚙️ 系统管理面板拉取中(暂不开放外部审计)"); - }, [buildWujieConsensusPayload, emitConsensusToParent, inWujieEmbed, triggerToast]); + }, [buildWujieConsensusPayload, emitConsensusToParent, inWujieEmbed, navigate, triggerToast]); // C4(无界回传):第二步**本次 live 研讨** newly 达成共识时,把总控结论 + 各智能体交付广播给父页面。 // 草稿/任务回显(抽屉重开)已在 hydrate 时 markRestoredConsensusAsEmitted,不再重复 emit。 @@ -1251,6 +1275,19 @@ export default function RoundtablePlanningPage() { // 已有存稿则恢复展示。只在「本次会话首次进入第三步」时编排一次 —— 之后进出第三步 // 全是续看(流在页面层不中断);切换草稿 / 新建任务时复位入口闸门。 const step3EnteredRef = useRef(false); + // 进第三步「错峰起跑」定时器。三流虽互无依赖、要并行展示进度,但若**同一刻**一起 + // 调 start()(各自 init thread + run/stream),会把单 worker 网关 + 单连接 sqlite + // checkpoint 锁的瞬时压力顶满,回环 /runs/stream 概率性返回非 200 → 前端冒 502。 + // 这里把后两个流的**起跑**各往后挪一点点(仅错开 start_run 的写尖峰,不是串行等前一个 + // 跑完),削峰即可。定时器存 ref,切草稿 / 重新 hydrate / 新建任务时统一清掉,避免迟发的 + // start() 打到已被复位/中断的上下文。 + const step3StartTimersRef = useRef([]); + const clearStep3StartTimers = useCallback(() => { + for (const t of step3StartTimersRef.current) { + if (typeof window !== "undefined") window.clearTimeout(t); + } + step3StartTimersRef.current = []; + }, []); // step3Report 用 ref 持最新值,供 hydrate 同步读取(避免闭包取到旧草稿快照)。 const step3ReportRef = useRef(step3Report); step3ReportRef.current = step3Report; @@ -1272,8 +1309,24 @@ export default function RoundtablePlanningPage() { // 保证总结/经典/大屏的展示数据跟随当前草稿实时切换。 const hydrateStep3Flows = useCallback(() => { step3EnteredRef.current = true; + // 重新编排前先清掉上一轮可能还没触发的错峰定时器(切草稿重 hydrate 时)。 + clearStep3StartTimers(); const rep = step3ReportRef.current; - // ① 方案总结报告 + // 错峰起跑助手:把一个尚未起跑的流延迟 delayMs 后再 start();触发前再校验 + // step3EnteredRef(被新建任务 / 切草稿复位为 false 则跳过),避免迟发的 start() + // 打到已被中断的上下文。delay=0 表示立即(同步)起跑。 + const staggerStart = (start: () => void, delayMs: number) => { + if (delayMs <= 0 || typeof window === "undefined") { + start(); + return; + } + const id = window.setTimeout(() => { + if (!step3EnteredRef.current) return; + start(); + }, delayMs); + step3StartTimersRef.current.push(id); + }; + // ① 方案总结报告(主页,立即起跑) const savedSum = rep?.summaryReport; if (savedSum && (savedSum.md.trim() || (savedSum.closing ?? "").trim())) { summaryFlow.hydrateSaved(savedSum); @@ -1281,14 +1334,14 @@ export default function RoundtablePlanningPage() { summaryFlow.start(); } // ② 结果绘制(经典模式一)。旧版草稿可能把大屏 HTML 存在 html 槽位(带 marker), - // 那不是经典产物 → 经典照常启动。 + // 那不是经典产物 → 经典照常启动。错峰 ~300ms,削掉与总结的同刻 start 尖峰。 if (rep?.html && !isDashboardHtml(rep.html)) { reportFlow.hydrateSaved(rep); } else { - reportFlow.start(); // 与总结报告并行绘制,进入「结果绘制」即见进行中过程/结果 + staggerStart(() => reportFlow.start(), STEP3_STAGGER_MS); // 仍并行绘制,只是起跑错开 } // ③ 大屏多页(模式二):优先 dashboardJson(按当前主题渲染);旧版 marker html 兜底。 - // 带上大屏生成过程的完整对话(文字 + 步骤条),恢复时整段回显。 + // 带上大屏生成过程的完整对话(文字 + 步骤条),恢复时整段回显。错峰 ~600ms。 const dashSaved = { dialogues: rep?.dashboardDialogues, seatStubMessages: rep?.dashboardSeatStubMessages, @@ -1298,10 +1351,10 @@ export default function RoundtablePlanningPage() { } else if (rep?.html && isDashboardHtml(rep.html)) { dashboardFlow.hydrateSaved(rep.html, dashSaved); } else { - dashboardFlow.start(); + staggerStart(() => dashboardFlow.start(), STEP3_STAGGER_MS * 2); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [summaryFlow, reportFlow, dashboardFlow]); + }, [summaryFlow, reportFlow, dashboardFlow, clearStep3StartTimers]); // 切换草稿:先停掉旧草稿的在途 Step3 流(防串台)。声明在 hydrate effect **之前**, // 保证同一提交内先 abort 旧流、再由下方 effect hydrate 新草稿,顺序不颠倒。 @@ -1312,6 +1365,7 @@ export default function RoundtablePlanningPage() { const prev = abortPrevDraftIdRef.current; abortPrevDraftIdRef.current = drafts.currentDraftId; if (prev && drafts.currentDraftId && prev !== drafts.currentDraftId) { + clearStep3StartTimers(); // 切草稿:取消尚未触发的错峰 start,防打到新草稿上下文 summaryFlow.abort(); reportFlow.abort(); dashboardFlow.abort(); @@ -1373,6 +1427,7 @@ export default function RoundtablePlanningPage() { setStep3Report(null); // 停掉页面层的 Step3 三个生成流(新任务不该带着旧任务的流跑)+ 复位进入闸门, // 并停止后台作业实时跟随(作业本身继续在后台跑)。 + clearStep3StartTimers(); // 取消尚未触发的错峰 start summaryFlow.abort(); reportFlow.abort(); dashboardFlow.abort(); @@ -1819,7 +1874,13 @@ export default function RoundtablePlanningPage() {