feat(roundtable): 会商诊断日志 + 后台挂起卡死兜底(idle 超时)

- 新增会商全流程诊断日志:新表 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) <noreply@anthropic.com>
This commit is contained in:
董宏凯andClaude Opus 4.8 committed 2026-06-19 22:55:56 +08:00
1 parent 8ca16c74bc
commit 4a403c95c5
27 files changed
+1873 -61

No files matched your search

@@ -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<string, string> = {
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<string, string> = {
background: "后台挂起",
foreground: "前台实时",
};
const LEVEL_STYLES: Record<string, string> = {
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 (
<div className="rounded-lg border border-slate-200 dark:border-border bg-white dark:bg-card px-3 py-2 text-sm">
<div className="flex items-start gap-2">
<span
className={cn(
"shrink-0 rounded px-1.5 py-0.5 text-xs font-medium uppercase",
LEVEL_STYLES[item.level] ?? LEVEL_STYLES.info,
)}
>
{item.level}
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<Badge variant="secondary" className="text-xs">
{stageLabel(item.stage)}
</Badge>
<code className="text-xs text-slate-500 dark:text-muted-foreground">{item.event}</code>
{item.agentName ? (
<span className="text-xs text-slate-500 dark:text-muted-foreground">· {item.agentName}</span>
) : null}
<span className="ml-auto text-xs text-slate-400 dark:text-muted-foreground">{fmtTime(item.createdAt)}</span>
</div>
<p className="mt-1 whitespace-pre-wrap break-words text-slate-700 dark:text-foreground">
{item.message || <span className="text-slate-400">(无说明)</span>}
</p>
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-0.5 text-xs text-slate-400 dark:text-muted-foreground">
<span>{SCOPE_LABELS[item.scope] ?? item.scope}</span>
{item.jobId ? <span>job: {item.jobId.slice(0, 8)}</span> : null}
{item.taskId ? <span>task: {item.taskId}</span> : null}
{item.draftId ? <span>draft: {item.draftId.slice(0, 8)}</span> : null}
{item.cycle != null ? <span>cycle: {item.cycle}</span> : null}
{hasDetail ? (
<button type="button" className="text-blue-500 hover:underline" onClick={() => setOpen((v) => !v)}>
{open ? "收起详情" : "展开详情"}
</button>
) : null}
</div>
{open && hasDetail ? (
<pre className="mt-2 max-h-64 overflow-auto rounded bg-slate-50 dark:bg-muted p-2 text-xs text-slate-600 dark:text-muted-foreground">
{JSON.stringify(item.detail, null, 2)}
</pre>
) : null}
</div>
</div>
</div>
);
}
export default function RoundtableDiagnosticsPage() {
const isAdmin = getStoredAuth()?.system_role === "admin";
const navigate = useNavigate();
const [scope, setScope] = useState<string>(ALL);
const [stage, setStage] = useState<string>(ALL);
const [level, setLevel] = useState<string>(ALL);
const [event, setEvent] = useState<string>(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 <Navigate to="/page/workspace/chats/new" replace />;
}
const items = data?.items ?? [];
const total = data?.total ?? 0;
const resetPageAnd = (fn: () => void) => {
fn();
setPage(1);
};
const applySearch = () => resetPageAnd(() => setQ(qInput.trim()));
return (
<div className="flex h-full flex-col bg-slate-50 dark:bg-background">
{/* Header */}
<header className="flex items-center gap-3 border-b border-slate-200 dark:border-border bg-white dark:bg-card px-4 py-3">
<Button variant="ghost" size="sm" onClick={() => navigate(-1)} className="gap-1">
<ArrowLeft className="h-4 w-4" />
返回
</Button>
<div>
<h1 className="text-base font-semibold text-slate-800 dark:text-foreground">会商诊断日志</h1>
<p className="text-xs text-slate-400 dark:text-muted-foreground">
多智能体会商全流程报错与关键事件(含后台挂起卡死、超时、产物缺失等)
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
className="ml-auto gap-1"
disabled={isFetching}
>
<RefreshCw className={cn("h-4 w-4", isFetching && "animate-spin")} />
刷新
</Button>
</header>
{/* Filters */}
<div className="flex flex-wrap items-center gap-2 border-b border-slate-200 dark:border-border bg-white dark:bg-card px-4 py-2">
<FilterSelect label="范围" value={scope} onChange={(v) => resetPageAnd(() => setScope(v))}
options={facets?.scope ?? []} labels={SCOPE_LABELS} />
<FilterSelect label="阶段" value={stage} onChange={(v) => resetPageAnd(() => setStage(v))}
options={facets?.stage ?? []} labels={STAGE_LABELS} />
<FilterSelect label="级别" value={level} onChange={(v) => resetPageAnd(() => setLevel(v))}
options={facets?.level ?? []} />
<FilterSelect label="事件" value={event} onChange={(v) => resetPageAnd(() => setEvent(v))}
options={facets?.event ?? []} />
<div className="flex items-center gap-1">
<Input
value={qInput}
onChange={(e) => setQInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && applySearch()}
placeholder="搜索报错说明…"
className="h-8 w-48"
/>
<Button variant="secondary" size="sm" onClick={applySearch} className="gap-1">
<Search className="h-4 w-4" />
</Button>
</div>
<span className="ml-auto text-xs text-slate-400 dark:text-muted-foreground">共 {total} 条</span>
</div>
{/* List */}
<div className="flex-1 overflow-auto px-4 py-3">
{isLoading ? (
<p className="py-10 text-center text-sm text-slate-400">加载中…</p>
) : items.length === 0 ? (
<p className="py-10 text-center text-sm text-slate-400">暂无诊断记录</p>
) : (
<div className="flex flex-col gap-2">
{items.map((it) => (
<DiagnosticRow key={it.id} item={it} />
))}
<Pagination page={page} pageSize={PAGE_SIZE} total={total} onPageChange={setPage} className="pt-2" />
</div>
)}
</div>
</div>
);
}
function FilterSelect({
label,
value,
onChange,
options,
labels,
}: {
label: string;
value: string;
onChange: (v: string) => void;
options: string[];
labels?: Record<string, string>;
}) {
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger className="h-8 w-auto min-w-[120px] text-xs">
<SelectValue placeholder={label} />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>{label}:全部</SelectItem>
{options.map((o) => (
<SelectItem key={o} value={o}>
{labels?.[o] ?? o}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
+11
View File
@@ -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() {
)
}
/>
<Route
path="admin/roundtable-diagnostics"
element={
getStoredAuth()?.system_role === "admin" ? (
<RoundtableDiagnosticsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/scheduled-tasks"
element={
@@ -147,18 +147,21 @@ export function BusinessChainPicker({
const chainMeta = { id: selectedChain.id, title: selectedChain.title };
const seatNameById = new Map(selectedChain.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(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 });
};
@@ -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 };
}
@@ -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 点击加入默认行为)。 */
@@ -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<number | null>(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}
</div>
)}
{/* 席位模型覆盖:空 = 跟随全局所选模型;选了则该席位作为子智能体执行时用此模型。
技能重的席位配强模型、边角席位配快模型,配合技能白名单实现「强弱搭配」。 */}
<div className="mt-1.5 flex items-center gap-1.5">
<span className="shrink-0 select-none text-[10px] font-medium text-muted-foreground">模型</span>
<ModelNameSelect
value={seat.model ?? null}
onChange={onModelChange}
defaultLabel="跟随全局"
triggerClassName="h-6 max-w-[180px] text-[11px]"
/>
</div>
</div>
{/* 控制:查看详情 / 并入上一阶段 / 独立成阶段 / 并入下一阶段 / 移除 */}
@@ -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<number[]>([]);
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() {
<button
id="settings-btn"
onClick={handleSettingsClick}
title={inWujieEmbed ? "回传各智能体交付结果给宿主" : "系统设置"}
title={
inWujieEmbed
? "回传各智能体交付结果给宿主"
: getStoredAuth()?.system_role === "admin"
? "会商诊断日志"
: "系统设置"
}
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" />
@@ -0,0 +1,131 @@
/**
* 圆桌会商诊断日志(管理员)API 客户端 + React Query hooks。
*
* 后端:`/api/roundtable-diagnostics`(见 app/gateway/routers/roundtable_diagnostics.py)。
* 全部 admin-gated。snake_case ⇄ camelCase 在此边界收敛。
*/
import { useQuery } from "@tanstack/react-query";
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
const BASE = () => `${getBackendBaseURL()}/api/roundtable-diagnostics`;
export interface DiagnosticItem {
id: string;
scope: string;
stage: string;
level: string;
event: string;
message: string | null;
detail: unknown;
jobId: string | null;
draftId: string | null;
taskId: string | null;
userId: string | null;
agentId: string | null;
agentName: string | null;
cycle: number | null;
createdAt: string | null;
}
export interface DiagnosticListResponse {
items: DiagnosticItem[];
total: number;
limit: number;
offset: number;
}
export interface DiagnosticFacets {
scope: string[];
stage: string[];
level: string[];
event: string[];
}
export interface DiagnosticFilters {
scope?: string;
stage?: string;
level?: string;
event?: string;
jobId?: string;
draftId?: string;
taskId?: string;
userId?: string;
q?: string;
since?: string;
until?: string;
limit?: number;
offset?: number;
}
async function readJson<T>(res: Response, ctx: string): Promise<T> {
if (!res.ok) {
const data = await res.json().catch(() => null as unknown);
const detail =
data && typeof data === "object" && "detail" in data
? String((data as { detail: unknown }).detail)
: `${ctx} failed (${res.status})`;
throw new Error(detail);
}
return (await res.json()) as T;
}
function buildQuery(f: DiagnosticFilters): string {
const p = new URLSearchParams();
const map: Record<string, string | number | undefined> = {
scope: f.scope,
stage: f.stage,
level: f.level,
event: f.event,
job_id: f.jobId,
draft_id: f.draftId,
task_id: f.taskId,
user_id: f.userId,
q: f.q,
since: f.since,
until: f.until,
limit: f.limit,
offset: f.offset,
};
for (const [k, v] of Object.entries(map)) {
if (v !== undefined && v !== null && String(v).length > 0) p.set(k, String(v));
}
const qs = p.toString();
return qs ? `?${qs}` : "";
}
export async function listDiagnostics(f: DiagnosticFilters): Promise<DiagnosticListResponse> {
const res = await apiFetch(`${BASE()}${buildQuery(f)}`, { method: "GET" });
return readJson<DiagnosticListResponse>(res, "list diagnostics");
}
export async function getDiagnosticFacets(): Promise<DiagnosticFacets> {
const res = await apiFetch(`${BASE()}/facets`, { method: "GET" });
return readJson<DiagnosticFacets>(res, "diagnostic facets");
}
// ── React Query hooks ──────────────────────────────────────────────────────
const KEY = "roundtable-diagnostics";
export function useRoundtableDiagnostics(filters: DiagnosticFilters, enabled = true) {
return useQuery({
queryKey: [KEY, filters],
queryFn: () => listDiagnostics(filters),
enabled,
refetchOnWindowFocus: false,
staleTime: 10_000,
});
}
export function useRoundtableDiagnosticFacets(enabled = true) {
return useQuery({
queryKey: [KEY, "facets"],
queryFn: getDiagnosticFacets,
enabled,
refetchOnWindowFocus: false,
staleTime: 60_000,
});
}