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:
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 {
|
||||
|
||||
Reference in new issue
Block a user