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