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:
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>
|
||||
);
|
||||
}
|
||||
@@ -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={
|
||||
|
||||
Reference in new issue
Block a user