feat(roundtable): 会商前端调用报错收集 + 诊断页用户名/范围切换

- 前端调用报错收集(用户实际遇到的报错,服务端诊断看不到的也收):新增
  POST /api/roundtable-diagnostics/report(非 admin,scope="client")+ 前端上报器
  roundtable-planning/api/diagnostics-report.ts。streamMultiAgent(第二步席位 +
  第三步报告/结构化/绘制/大屏)覆盖 HTTP 错误(502)、SSE error 帧、worker error 帧
  (模型没调用成功)、fetch 网络不可达、流式中途中断,reported 去重;initMultiAgent /
  intent / recommend / 后台作业接口同步上报。
- 诊断页:每条报错显示上报用户名(👤,user_id→邮箱@前缀,后端 best-effort 解析);
  第一个筛选改为「后台挂起 / 前台调用」切换(前台调用=foreground,client,后端 scope
  支持逗号多值),阶段下拉随范围联动(后台→二三步;前台→一二三步)。

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 23:45:34 +08:00
1 parent 4a403c95c5
commit 762d3620c4
12 files changed
+433 -18

No files matched your search

@@ -55,7 +55,37 @@ const STAGE_LABELS: Record<string, string> = {
const SCOPE_LABELS: Record<string, string> = {
background: "后台挂起",
foreground: "前台实时",
foreground: "前台实时(服务端)",
client: "前端上报",
};
// 范围切换:后台挂起(background) / 前台调用(foreground 服务端观测 + client 前端上报)。
type ScopeCategory = "background" | "frontend";
// 每个范围下可选的阶段(按用户要求:后台挂起只到第二/三步;前台调用含第一步)。
const STAGE_OPTIONS: Record<ScopeCategory, string[]> = {
background: [
"step2_init",
"step2_leader",
"step2_seat",
"step2_orchestration",
"step3_summary",
"step3_report",
"step3_dashboard",
"step3_structure",
"job",
],
frontend: [
"step1_intent",
"step2_init",
"step2_leader",
"step2_seat",
"step2_recommend",
"step3_summary",
"step3_report",
"step3_dashboard",
"step3_structure",
],
};
const LEVEL_STYLES: Record<string, string> = {
@@ -101,6 +131,11 @@ function DiagnosticRow({ item }: { item: DiagnosticItem }) {
{item.agentName ? (
<span className="text-xs text-slate-500 dark:text-muted-foreground">· {item.agentName}</span>
) : null}
{item.userName ? (
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-600 dark:bg-slate-800 dark:text-slate-300">
👤 {item.userName}
</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">
@@ -133,7 +168,7 @@ export default function RoundtableDiagnosticsPage() {
const isAdmin = getStoredAuth()?.system_role === "admin";
const navigate = useNavigate();
const [scope, setScope] = useState<string>(ALL);
const [scopeCategory, setScopeCategory] = useState<ScopeCategory>("background");
const [stage, setStage] = useState<string>(ALL);
const [level, setLevel] = useState<string>(ALL);
const [event, setEvent] = useState<string>(ALL);
@@ -141,9 +176,12 @@ export default function RoundtableDiagnosticsPage() {
const [q, setQ] = useState("");
const [page, setPage] = useState(1);
// 「前台调用」= 服务端观测(foreground) + 前端上报(client) 两种 scope 合并查(后端按逗号分隔多值)。
const scopeParam = scopeCategory === "background" ? "background" : "foreground,client";
const filters: DiagnosticFilters = useMemo(
() => ({
scope: scope === ALL ? undefined : scope,
scope: scopeParam,
stage: stage === ALL ? undefined : stage,
level: level === ALL ? undefined : level,
event: event === ALL ? undefined : event,
@@ -151,7 +189,7 @@ export default function RoundtableDiagnosticsPage() {
limit: PAGE_SIZE,
offset: (page - 1) * PAGE_SIZE,
}),
[scope, stage, level, event, q, page],
[scopeParam, stage, level, event, q, page],
);
const { data, isLoading, isFetching, refetch } = useRoundtableDiagnostics(filters, isAdmin);
@@ -169,6 +207,13 @@ export default function RoundtableDiagnosticsPage() {
setPage(1);
};
// 切换范围时清空阶段筛选(两种范围的阶段选项不同)+ 回到第一页。
const switchScope = (next: ScopeCategory) => {
setScopeCategory(next);
setStage(ALL);
setPage(1);
};
const applySearch = () => resetPageAnd(() => setQ(qInput.trim()));
return (
@@ -199,10 +244,26 @@ export default function RoundtableDiagnosticsPage() {
{/* 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} />
{/* 范围切换:后台挂起 / 前台调用 */}
<div className="inline-flex rounded-lg border border-slate-200 dark:border-border p-0.5">
{(["background", "frontend"] as ScopeCategory[]).map((c) => (
<button
key={c}
type="button"
onClick={() => switchScope(c)}
className={cn(
"rounded-md px-3 py-1 text-xs font-medium transition-colors",
scopeCategory === c
? "bg-slate-800 text-white dark:bg-primary dark:text-primary-foreground"
: "text-slate-500 hover:text-slate-700 dark:text-muted-foreground dark:hover:text-foreground",
)}
>
{c === "background" ? "后台挂起" : "前台调用"}
</button>
))}
</div>
<FilterSelect label="阶段" value={stage} onChange={(v) => resetPageAnd(() => setStage(v))}
options={facets?.stage ?? []} labels={STAGE_LABELS} />
options={STAGE_OPTIONS[scopeCategory]} labels={STAGE_LABELS} />
<FilterSelect label="级别" value={level} onChange={(v) => resetPageAnd(() => setLevel(v))}
options={facets?.level ?? []} />
<FilterSelect label="事件" value={event} onChange={(v) => resetPageAnd(() => setEvent(v))}
@@ -0,0 +1,90 @@
/**
* 会商「前端调用报错」上报器。
*
* 服务端诊断只能记到「处理函数跑起来后抛的错」;而用户实际遇到的很多报错(nginx/网关
* 返回 502、网络中断、fetch 直接失败、SSE 里收到的 error 帧)发生在**客户端**侧。这里把
* 这些前端可观测到的报错 best-effort 上报到 `POST /api/roundtable-diagnostics/report`
* (scope="client",非 admin 接口),便于管理员在诊断页统一筛选用户使用时的报错。
*
* 设计:fire-and-forget + `keepalive`(页面跳转也能发出去)+ 绝不抛错(上报失败就算了,
* 不能让「报错上报」本身再影响会商流程)。
*/
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
export interface RoundtableErrorReport {
stage: string;
event: string;
message: string;
level?: "error" | "warning" | "info";
detail?: unknown;
agentId?: string;
agentName?: string;
cycle?: number;
draftId?: string;
taskId?: string;
jobId?: string;
httpStatus?: number;
}
// 页面级上下文(draftId / taskId),由会商页设置,使每条上报都能归属到草稿/任务,
// 而无需把它们穿进每个底层 api 调用。
let _ctx: { draftId?: string; taskId?: string } = {};
export function setRoundtableErrorContext(ctx: { draftId?: string; taskId?: string }): void {
_ctx = { ..._ctx, ...ctx };
}
/** 据 agentType + agentName 推断诊断 stage(与后端 `_stage_for_agent` 对齐)。 */
export function stageForRoundtableAgent(
agentType: string | undefined,
agentName: string | null | undefined,
): string {
switch (agentName) {
case "roundtable-report":
return "step3_report";
case "roundtable-summary":
return "step3_summary";
case "roundtable-dashboard":
return "step3_dashboard";
case "roundtable-structure":
return "step3_structure";
default:
return agentType === "leader" ? "step2_leader" : "step2_seat";
}
}
export function reportRoundtableError(input: RoundtableErrorReport): void {
try {
const detail =
input.httpStatus != null && typeof input.detail !== "object"
? { httpStatus: input.httpStatus, raw: input.detail }
: input.httpStatus != null
? { httpStatus: input.httpStatus, ...(input.detail as Record<string, unknown>) }
: input.detail;
const body = JSON.stringify({
stage: input.stage,
event: input.event,
level: input.level ?? "error",
message: (input.message ?? "").slice(0, 2000),
detail,
jobId: input.jobId,
draftId: input.draftId ?? _ctx.draftId,
taskId: input.taskId ?? _ctx.taskId,
agentId: input.agentId,
agentName: input.agentName,
cycle: input.cycle,
});
void apiFetch(`${getBackendBaseURL()}/api/roundtable-diagnostics/report`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
keepalive: true,
}).catch(() => {
/* 上报失败静默 */
});
} catch {
/* 绝不抛错 */
}
}
@@ -2,6 +2,7 @@ import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import type { ClarificationPayload } from "./multi-agent";
import { reportRoundtableError } from "./diagnostics-report";
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
export type { ClarificationPayload };
@@ -340,6 +341,11 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
if (!res.ok || !res.body) {
const text = await res.text().catch(() => "");
reportRoundtableError({
stage: "step1_intent", event: "intent_stream_http_error",
message: `第一步意图生成失败 (HTTP ${res.status}): ${text.slice(0, 500)}`,
httpStatus: res.status,
});
throw new Error(`intent/stream failed (${res.status}): ${text}`);
}
@@ -425,6 +431,11 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
}
if (parsed.status === "error") {
result.errorDetail = parseIntentErrorDetail(parsed.error_detail);
reportRoundtableError({
stage: "step1_intent", event: "intent_error_frame",
message: `第一步意图生成报错:${(result.content || "").slice(0, 800)}`,
detail: result.errorDetail,
});
}
if (parsed.status === "clarification" && result.clarifications.length === 0) {
// Fallback path: no live `type:"tool"` clarification frames were seen
@@ -2,6 +2,7 @@ import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
import { reportRoundtableError, stageForRoundtableAgent } from "@/roundtable-planning/api/diagnostics-report";
export type ThreadIdMap = Record<string, string>;
@@ -74,6 +75,12 @@ export async function initMultiAgent(
tlog(`fetch.headers status=${res.status}`);
if (!res.ok || !res.body) {
const text = await res.text().catch(() => "");
reportRoundtableError({
stage: "step2_init", event: "init_http_error",
message: `第二步会商初始化失败 (HTTP ${res.status}): ${text.slice(0, 500)}`,
httpStatus: res.status,
detail: { agentNames: payload.agentNames, mainAgentName: payload.mainAgentName },
});
throw new Error(`init multi-agent failed (${res.status}): ${text}`);
}
@@ -150,18 +157,34 @@ export async function initMultiAgent(
}
} catch (err) {
aborted = (err as Error)?.name === "AbortError" || payload.signal?.aborted === true;
if (!aborted) throw err;
if (!aborted) {
reportRoundtableError({
stage: "step2_init", event: "init_stream_exception",
message: `第二步会商初始化中断(网络不可达 / 连接中断 / 超时):${(err as Error)?.message ?? String(err)}`,
detail: { seatCount },
});
throw err;
}
}
tlog(`total ${(performance.now() - t0).toFixed(0)}ms (seats=${seatCount})`);
if (errorDetail) {
reportRoundtableError({
stage: "step2_init", event: "init_error_frame",
message: `第二步会商初始化报错:${errorDetail.slice(0, 800)}`,
});
throw new Error(`init multi-agent failed: ${errorDetail}`);
}
if (aborted) {
throw new DOMException("init aborted", "AbortError");
}
if (!threadIds || !mainAgentName) {
reportRoundtableError({
stage: "step2_init", event: "init_incomplete",
message: "第二步会商初始化流提前结束(未收到 init_done,可能连接被中断)",
detail: { seatCount, hasThreadIds: !!threadIds, mainAgentName },
});
throw new Error("init multi-agent stream ended without init_done");
}
return {
@@ -442,6 +465,11 @@ function extractAiTextDelta(parsed: unknown): { text: string; id: string } | nul
export async function streamMultiAgent(
req: StreamMultiAgentRequest,
): Promise<StreamMultiAgentResult> {
// 整段包一层兜底:除已在具体分支上报的(HTTP 错误 / error 帧 / worker error 事件)外,
// 再兜住 fetch 直接 reject(网络不可达 / 连接被拒)与流式中途中断(连接重置 / 超时),
// 做到「能收集到的报错越全越好」。`reported` 防止与具体分支重复上报。
let reported = false;
try {
const res = await apiFetch(`${getBackendBaseURL()}/api/multi-agent/run/stream`, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -474,6 +502,17 @@ export async function streamMultiAgent(
if (!res.ok || !res.body) {
const text = await res.text().catch(() => "");
// 用户实际遇到的报错(含进第三步常见的 502):HTTP 层就失败、SSE 都没建起来。
reportRoundtableError({
stage: stageForRoundtableAgent(req.agentType, req.agentName),
event: "run_stream_http_error",
message: `「${req.displayName || req.agentName}」调用失败 (HTTP ${res.status}): ${text.slice(0, 500)}`,
httpStatus: res.status,
agentId: req.agentName,
agentName: req.displayName || req.agentName,
detail: { agentType: req.agentType },
});
reported = true;
throw new Error(`run/stream failed (${res.status}): ${text}`);
}
@@ -611,6 +650,21 @@ export async function streamMultiAgent(
frameSeq += 1;
if (debugEnabled) console.debug(debugLabel, `frame#${frameSeq} event=${lastEvent}`, parsed);
// worker 把 run 内异常(常见:第二步席位/Step3 模型调用失败)发成 `event: error`
// SSE 帧 `{message,name}`——它既不是 HTTP 错误也不是 `{status:"error"}` 终帧,
// 单独在这里捕获上报,否则这类「模型没调用成功」的报错会漏收集。
if (lastEvent === "error") {
const obj = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : { raw: parsed };
reportRoundtableError({
stage: stageForRoundtableAgent(req.agentType, req.agentName),
event: "run_worker_error_event",
message: `「${req.displayName || req.agentName}」运行报错:${String(obj.message ?? obj.name ?? "").slice(0, 800)}`,
agentId: req.agentName,
agentName: req.displayName || req.agentName,
detail: { ...obj, agentType: req.agentType, framesBeforeError: frameSeq },
});
}
// Phase inference from LangGraph messages-tuple frames. We only flip
// to tool_calling/tool_result on chunks that clearly indicate them;
// everything else with non-empty AI text means we're streaming.
@@ -711,6 +765,18 @@ export async function streamMultiAgent(
if (typeof parsed.agent_name === "string") {
result.agentName = parsed.agent_name;
}
// SSE error 帧(后端 stream_generator 在席位/Step3 run 失败时下发
// `{status:"error", content, agent_name}`):上报,便于收集用户侧报错。
if (result.message === "error") {
reportRoundtableError({
stage: stageForRoundtableAgent(req.agentType, result.agentName || req.agentName),
event: "run_stream_error_frame",
message: `「${req.displayName || req.agentName}」运行报错:${(result.content || "").slice(0, 800)}`,
agentId: result.agentName || req.agentName,
agentName: req.displayName || req.agentName,
detail: { agentType: req.agentType, framesBeforeError: frameSeq },
});
}
continue;
}
@@ -728,6 +794,19 @@ export async function streamMultiAgent(
console.debug(debugLabel, `stream ended after ${frameSeq} frames; final content length=${result.content.length}`);
}
return result;
} catch (err) {
if (!reported && req.signal?.aborted !== true && (err as Error)?.name !== "AbortError") {
reportRoundtableError({
stage: stageForRoundtableAgent(req.agentType, req.agentName),
event: "run_stream_exception",
message: `「${req.displayName || req.agentName}」调用异常(网络不可达 / 连接中断 / 超时):${(err as Error)?.message ?? String(err)}`,
agentId: req.agentName,
agentName: req.displayName || req.agentName,
detail: { agentType: req.agentType },
});
}
throw err;
}
}
export interface SessionArtifactFile {
@@ -829,6 +908,11 @@ export async function initReportThread(
});
if (!res.ok) {
const text = await res.text().catch(() => "");
reportRoundtableError({
stage: "step3_report", event: "init_thread_http_error",
message: `结果绘制线程创建失败 (HTTP ${res.status}): ${text.slice(0, 500)}`,
httpStatus: res.status, agentId: "roundtable-report", agentName: "结果绘制",
});
throw new Error(`init report thread failed (${res.status}): ${text}`);
}
const json = (await res.json()) as { agent_name: string; thread_id: string };
@@ -855,6 +939,11 @@ export async function initDashboardThread(
});
if (!res.ok) {
const text = await res.text().catch(() => "");
reportRoundtableError({
stage: "step3_dashboard", event: "init_thread_http_error",
message: `大屏页面线程创建失败 (HTTP ${res.status}): ${text.slice(0, 500)}`,
httpStatus: res.status, agentId: "roundtable-dashboard", agentName: "大屏页面",
});
throw new Error(`init dashboard thread failed (${res.status}): ${text}`);
}
const json = (await res.json()) as { agent_name: string; thread_id: string };
@@ -880,6 +969,11 @@ export async function initSummaryThread(
});
if (!res.ok) {
const text = await res.text().catch(() => "");
reportRoundtableError({
stage: "step3_summary", event: "init_thread_http_error",
message: `方案报告线程创建失败 (HTTP ${res.status}): ${text.slice(0, 500)}`,
httpStatus: res.status, agentId: "roundtable-summary", agentName: "方案报告",
});
throw new Error(`init summary thread failed (${res.status}): ${text}`);
}
const json = (await res.json()) as { agent_name: string; thread_id: string };
@@ -906,6 +1000,11 @@ export async function initStructureThread(
});
if (!res.ok) {
const text = await res.text().catch(() => "");
reportRoundtableError({
stage: "step3_structure", event: "init_thread_http_error",
message: `结构化输出线程创建失败 (HTTP ${res.status}): ${text.slice(0, 500)}`,
httpStatus: res.status, agentId: "roundtable-structure", agentName: "结构化输出",
});
throw new Error(`init structure thread failed (${res.status}): ${text}`);
}
const json = (await res.json()) as { agent_name: string; thread_id: string };
@@ -3,6 +3,7 @@ import { getBackendBaseURL } from "@/core/config";
import type { Agent } from "@/core/agents";
import type { IntentSummary } from "./intent";
import { reportRoundtableError } from "./diagnostics-report";
/**
* Agent-recommendation API. Sits between Step 1 (intent clarification) and
@@ -318,6 +319,11 @@ export async function streamRecommend(req: StreamRecommendRequest): Promise<Stre
if (!res.ok || !res.body) {
const text = await res.text().catch(() => "");
reportRoundtableError({
stage: "step2_recommend", event: "recommend_stream_http_error",
message: `智能体推荐失败 (HTTP ${res.status}): ${text.slice(0, 500)}`,
httpStatus: res.status,
});
throw new Error(`recommend/stream failed (${res.status}): ${text}`);
}
@@ -373,6 +379,12 @@ export async function streamRecommend(req: StreamRecommendRequest): Promise<Stre
if (Array.isArray(parsed.picks)) {
result.picks = parsed.picks as RecommendPick[];
}
if (parsed.status === "error") {
reportRoundtableError({
stage: "step2_recommend", event: "recommend_error_frame",
message: `智能体推荐报错:${(result.content || "").slice(0, 800)}`,
});
}
continue;
}
@@ -15,6 +15,7 @@ import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { getAvatarTypeFor } from "../lib/roundtable-constants";
import { reportRoundtableError } from "./diagnostics-report";
import { seatModeToRunParams } from "../lib/seat-mode";
import {
isTerminalJobStatus,
@@ -29,6 +30,11 @@ const BASE = () => `${getBackendBaseURL()}/api/roundtable-jobs`;
async function readJson<T>(res: Response, ctx: string): Promise<T> {
if (!res.ok) {
const text = await res.text().catch(() => "");
reportRoundtableError({
stage: "job", event: "job_api_http_error",
message: `后台作业接口「${ctx}」失败 (HTTP ${res.status}): ${text.slice(0, 500)}`,
httpStatus: res.status,
});
throw new Error(`${ctx} failed (${res.status}): ${text}`);
}
return (await res.json()) as T;
@@ -221,7 +227,13 @@ export function streamJob(jobId: string, handlers: StreamJobHandlers, taskId?: s
continue;
}
if (frame.event === "error") {
handlers.onError?.(String(frame.detail ?? "unknown stream error"));
const detail = String(frame.detail ?? "unknown stream error");
reportRoundtableError({
stage: "job", event: "job_stream_error_frame",
message: `后台作业进度流报错:${detail.slice(0, 500)}`,
jobId, detail: { taskId },
});
handlers.onError?.(detail);
continue;
}
const job = mapJob(frame);
@@ -1716,15 +1716,25 @@ function SeatStrip({
{seat.description}
</div>
)}
{/* 席位模型覆盖:空 = 跟随全局所选模型;选了则该席位作为子智能体执行时用此模型。
技能重的席位配强模型、边角席位配快模型,配合技能白名单实现「强弱搭配」。 */}
<div className="mt-1.5 flex items-center gap-1.5">
</div>
{/* 席位模型覆盖(右侧操作区):空 = 跟随全局所选模型;选了则该席位作为子智能体
执行时用此模型。技能重的席位配强模型、边角席位配快模型,配合技能白名单实现
「强弱搭配」。放到右侧操作区,与名称/描述解耦,卡片更整洁。 */}
<div className="flex shrink-0 items-center border-l border-border px-2.5">
<div
className={cn(
"flex items-center gap-1.5 border border-border/70 bg-muted/40 py-1 pl-2 pr-1",
R_CTRL,
)}
title="该席位作为子智能体执行时使用的模型;「跟随全局」= 用第二步顶部所选模型"
>
<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]"
triggerClassName="h-6 max-w-[150px] border-none bg-transparent text-[11px] shadow-none hover:bg-background/60"
/>
</div>
</div>
@@ -25,6 +25,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { getStoredAuth } from "@/core/auth";
import { setRoundtableErrorContext } from "@/roundtable-planning/api/diagnostics-report";
import {
Activity,
BookOpen,
@@ -1131,6 +1132,14 @@ export default function RoundtablePlanningPage() {
return emitWujieEvent("roundtable:consensus", buildWujieConsensusPayload());
}, [buildWujieConsensusPayload, inWujieEmbed]);
// 让前端报错上报器能把每条报错归属到当前草稿 / 任务(无需穿进每个底层 api 调用)。
useEffect(() => {
setRoundtableErrorContext({
draftId: drafts.currentDraftId ?? undefined,
taskId: prefillRef.current?.taskId ?? undefined,
});
}, [drafts.currentDraftId]);
const handleSettingsClick = useCallback(() => {
if (inWujieEmbed) {
const deliverables = buildWujieConsensusPayload().deliverables;
@@ -28,6 +28,7 @@ export interface DiagnosticItem {
agentName: string | null;
cycle: number | null;
createdAt: string | null;
userName: string | null;
}
export interface DiagnosticListResponse {