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={