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
+432 -17

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 {
+1 -1
View File
@@ -247,7 +247,7 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
| **Public Shares** (`/api/public/shares`) | **Unauthenticated** (the `/api/public/` prefix is whitelisted in `auth_middleware._PUBLIC_PATH_PREFIXES`). `GET /{code}` - read-only conversation snapshot (title + serialized messages + artifacts) for a `view`-mode share; messages are read straight from the latest checkpoint. `GET /{code}/files/{path}` - serves a file referenced by that conversation (images, Markdown, text, PDF, ?) so the shared page's links are openable; path resolution is confined to the owner's thread directory. Active content (`text/html`, `application/xhtml+xml`, `image/svg+xml`) is forced to download (`_public_share_disposition`); everything else is served inline. Missing/revoked/non-`view` codes all return 404. |
| **Roundtable Drafts** (`/api/roundtable-drafts`) | **Strictly per-user** persistence for the roundtable-planning page (replaces the old browser `localStorage`). `GET /` - list drafts (lightweight meta); `POST /` - create; `GET/PUT/DELETE /{id}` - full read / partial update / delete (all owner-scoped). A draft = one full session (Step 1 intent + Step 2 roundtable), step snapshots stored as JSON in `PortableLongText`. Recommendation history bound to a draft: `GET /{id}/recommendations` - list newest-first; `POST /{id}/recommendations` - append one run (objective/status/rationale/picks/candidates). taskId 深链会商记录 are **not** here — they live in the separate `/api/roundtable-task-drafts` store (see below) so personal history never mixes with task records. Backed by `deerflow.persistence.roundtable_drafts` (tables `roundtable_drafts`, `roundtable_recommend_history`); store wired in `deps.py` as `app.state.roundtable_draft_store`. Tests: `tests/test_roundtable_task_drafts.py`. |
| **Roundtable Task Drafts** (`/api/roundtable-task-drafts`) | **Task-scoped, unpartitioned** store for taskId-deeplink roundtable 聊天记录 (无界嵌入抽屉). Keyed **only by `task_id`** (one task → many records), **never partitioned by user** — any authenticated caller may read/write/delete a task's records (`created_by` is audit-only). Physically separate from `roundtable_drafts` so the two never mix. `GET /by-task/{task_id}` - all drafts for a task, newest-first (history dropdown), enriched with the latest background-job status per draft via `RoundtableJobRepository.status_map_by_drafts`; `GET /by-task/{task_id}/latest` - newest draft (回显最新一条); `POST /` - create (`task_id` required); `GET/PUT/DELETE /{id}` - full read / partial update / delete (all user-agnostic). Backed by `deerflow.persistence.roundtable_task_drafts` (table `roundtable_task_drafts`, auto-created by `create_all`; migration `20260618_01`); store wired in `deps.py` as `app.state.roundtable_task_draft_store`. Existing `task_id`-bearing `roundtable_drafts` rows are migrated over by `scripts/migrate_roundtable_task_drafts.py`. Tests: `tests/test_roundtable_task_drafts.py`, `tests/test_roundtable_drafts_by_task.py`. |
| **Roundtable Diagnostics** (`/api/roundtable-diagnostics`) | **Admin-only** read of 会商全流程诊断日志 (报错 + 关键事件). One row per event in `roundtable_diagnostics`: `{scope (background/foreground), stage (step1_intent/step2_init/step2_leader/step2_seat/step2_recommend/step2_orchestration/step3_summary/step3_report/step3_dashboard/step3_structure/upstream/job), level (info/warning/error), event, message, detail (JSON), job_id/draft_id/task_id/user_id/agent_id/agent_name/cycle, created_at}`. `GET /` - filter (scope/stage/level/event/job_id/draft_id/task_id/user_id/q/since/until) + paginate (newest-first); `GET /facets` - distinct values for filter dropdowns; `POST /admin/cleanup` - retention purge. Backed by `deerflow.persistence.roundtable_diagnostics` (table `roundtable_diagnostics`, auto-created by `create_all`; migration `20260619_01`); store wired in `deps.py` as `app.state.roundtable_diagnostic_store` + a process-level default store (`register_default_store`) so the harness-side engine/gateway can write across the app/harness boundary. **Writers** (all best-effort — diagnostics never break a run): background path uses `DiagnosticsRecorder` (bound to job ctx) threaded through `RoundtableJobExecutor` → `JobProgressWriter.diag(...)` (engine transitions/step3 failures/empty-delivery) + `InProcessRoundtableGateway` (per-turn timeout/report·summary·dashboard truncation+missing); foreground path uses `app/gateway/roundtable_diag.py::record_foreground_diag` in `multi_agent.py`/`intent.py`/`recommend.py` error frames. **Bug-fix shipped alongside**: `InProcessRoundtableGateway._run_agent_turn` guards each agent run with an **inactivity (idle) timeout** (`_await_run_with_idle_guard`, `ROUNDTABLE_SEAT_IDLE_TIMEOUT_SECONDS` default 1200s = 20min, tuned high for unstable intranet models) — **not** a total-duration cap, so a slow-but-alive run that keeps streaming tokens runs as long as it needs; only a run that emits **zero StreamBridge events for `idle_timeout` seconds straight** (a true freeze: 内网模型连接池挂死 / sqlite 锁在挂载卷上挂死) is cancelled + raises (顶层把 job 标 error,而非永久冻结) + records a `seat_turn_timeout` diagnostic. Activity is observed via `app.state.stream_bridge` (the worker publishes every chunk there). Optional absolute cap `ROUNDTABLE_SEAT_MAX_TOTAL_SECONDS` (default 0 = off). Frontend: admin page `pages/RoundtableDiagnosticsPage.tsx` (route `/page/strategy/admin/roundtable-diagnostics`), opened from the **会商页右上角设置按钮** (admin only — `handleSettingsClick` in `roundtable-planning/pages/RoundtablePlanningPage.tsx`); client `strategy-components/api/roundtable-diagnostics.ts`. Tests: `tests/test_roundtable_diagnostics.py`, `tests/test_roundtable_inprocess_timeout.py`. |
| **Roundtable Diagnostics** (`/api/roundtable-diagnostics`) | 会商全流程诊断日志 (报错 + 关键事件). One row per event in `roundtable_diagnostics`: `{scope (background = 后台挂起 / foreground = 前台服务端观测 / client = 前端上报), stage (step1_intent/step2_init/step2_leader/step2_seat/step2_recommend/step2_orchestration/step3_summary/step3_report/step3_dashboard/step3_structure/upstream/job), level (info/warning/error), event, message, detail (JSON), job_id/draft_id/task_id/user_id/agent_id/agent_name/cycle, created_at}`. **Reads are admin-only**: `GET /` - filter (scope/stage/level/event/job_id/draft_id/task_id/user_id/q/since/until; `scope` accepts a comma-separated set so 前端「前台调用」= `foreground,client`) + paginate (newest-first), each item enriched with `userName` (user_id → 邮箱 `@`-prefix via `get_local_provider().get_user`, best-effort batched cache); `GET /facets` - distinct values for filter dropdowns; `POST /admin/cleanup` - retention purge. **`POST /report` is NOT admin-gated** — any authenticated user's frontend posts the errors *the user actually hit* (HTTP 502 before any SSE frame / network drop / SSE error frame) so they're collected too; stored as `scope="client"`. Backed by `deerflow.persistence.roundtable_diagnostics` (table `roundtable_diagnostics`, auto-created by `create_all`; migration `20260619_01`); store wired in `deps.py` as `app.state.roundtable_diagnostic_store` + a process-level default store (`register_default_store`) so the harness-side engine/gateway can write across the app/harness boundary. **Writers** (all best-effort — diagnostics never break a run): background path uses `DiagnosticsRecorder` (bound to job ctx) threaded through `RoundtableJobExecutor` → `JobProgressWriter.diag(...)` (engine transitions/step3 failures/empty-delivery) + `InProcessRoundtableGateway` (per-turn timeout/report·summary·dashboard truncation+missing); foreground path uses `app/gateway/roundtable_diag.py::record_foreground_diag` in `multi_agent.py`/`intent.py`/`recommend.py` error frames. **Bug-fix shipped alongside**: `InProcessRoundtableGateway._run_agent_turn` guards each agent run with an **inactivity (idle) timeout** (`_await_run_with_idle_guard`, `ROUNDTABLE_SEAT_IDLE_TIMEOUT_SECONDS` default 1200s = 20min, tuned high for unstable intranet models) — **not** a total-duration cap, so a slow-but-alive run that keeps streaming tokens runs as long as it needs; only a run that emits **zero StreamBridge events for `idle_timeout` seconds straight** (a true freeze: 内网模型连接池挂死 / sqlite 锁在挂载卷上挂死) is cancelled + raises (顶层把 job 标 error,而非永久冻结) + records a `seat_turn_timeout` diagnostic. Activity is observed via `app.state.stream_bridge` (the worker publishes every chunk there). Optional absolute cap `ROUNDTABLE_SEAT_MAX_TOTAL_SECONDS` (default 0 = off). Frontend: admin page `pages/RoundtableDiagnosticsPage.tsx` (route `/page/strategy/admin/roundtable-diagnostics`), opened from the **会商页右上角设置按钮** (admin only — `handleSettingsClick` in `roundtable-planning/pages/RoundtablePlanningPage.tsx`); admin read client `strategy-components/api/roundtable-diagnostics.ts`. **Client error reporter** `roundtable-planning/api/diagnostics-report.ts` (`reportRoundtableError` fire-and-forget + `keepalive`, `setRoundtableErrorContext` for draft/task attribution set by the page, `stageForRoundtableAgent`) is called at every client-observable failure: `multi-agent.ts` (`streamMultiAgent` — covers Step2 leader/seat AND Step3 report/summary/dashboard/structure — reports HTTP error + SSE `{status:"error"}` frame + worker `event:error` frame [模型调用没成功] + fetch-reject [网络不可达] + mid-stream drop [连接重置/超时], deduped via a `reported` flag; `initMultiAgent` HTTP/error-frame/incomplete/mid-stream), `intent.ts` (Step1 HTTP+error-frame), `recommend.ts` (Step2 recommend HTTP+error-frame), `roundtable-jobs.ts` (job api/stream). The admin page's first filter is a **后台挂起 / 前台调用** toggle (frontend = `foreground,client`); the stage dropdown options adapt to it (background → step2+step3+job; frontend → step1+step2+step3) and each row shows the reporting user's name + 👤. Tests: `tests/test_roundtable_diagnostics.py`, `tests/test_roundtable_inprocess_timeout.py`. **Seat skill-recognition (弱模型补偿)**: a seat = one custom agent run whose configured skills (`agent config.yaml → skills`) are injected **only** via the system prompt's `<skill_system>` block (model must `read_file` the SKILL.md itself) — weak models (deepseek-chat 等) ignore it and never use their skills. `app/gateway/roundtable_seat_skills.py::append_seat_skill_directive(agent_id, task)` appends an explicit Chinese directive (each configured-and-enabled skill's name + description + SKILL.md container path + "先 read_file 再按其流程取信息/执行") to the **seat's per-turn tLine truncated
| **Roundtable Draft Shares** (`/api/roundtable-draft-shares`) | Per-draft sharing for the roundtable-planning page, mirroring Thread Shares but the shared resource is a `roundtable_drafts` row. Two modes (`roundtable_draft_shares.mode`). **`import`**: `POST /` `{draft_id, mode}` - create (or reuse) a revocable code; `GET /{code}/preview` + `POST /{code}/import` - copy that draft (step1/2/3) into the caller's account under a deterministic per-(code,recipient) id (`imp_<sha1>` ? re-import is a no-op). **`view`**: a public, read-only result page ? see Public Roundtable Shares. Both: `GET /` - list my codes; `DELETE /{code}` - revoke. Backed by `deerflow.persistence.roundtable_draft_shares` (table `roundtable_draft_shares`, migration `20260606_01`); store wired in `deps.py` as `app.state.roundtable_draft_share_store`. Tests: `tests/test_roundtable_draft_shares.py`. |
| **Public Roundtable Shares** (`/api/public/roundtable-shares`) | **Unauthenticated** (`/api/public/` whitelisted). `GET /{code}` - read-only snapshot for a `view`-mode draft share: returns the Step 3??????report HTML (`step3.html`, a self-contained document) + title/summary/`has_report`. The frontend renders it in a sandboxed `iframe` (no `allow-scripts`) at `#/roundtable-share/<code>`. Missing/revoked/non-`view` codes all return 404. |
| **Scheduled Tasks** (`/api/scheduled-tasks`) | Per-user cron-like agent tasks. `GET/POST /` - list/create owned tasks; `PATCH/DELETE /{id}` - update/delete (owner); `POST /{id}/{pause,resume,trigger}`; `GET /{id}/runs`, `GET /runs/{rid}`, run-file preview/edit/download; `GET /runs/{rid}/messages` - agent messages for the run's underlying agent run (used to inspect a stuck/running run; authorized via `_load_run`); `GET /runs/{rid}/html` - generated HTML document for an `html_page` run; `DELETE /runs/{rid}` - owner-scoped, with an admin fallback so admins can delete any user's run; publish/subscribe under `/{id}/{publish,unpublish,subscribe}` and `GET /published`. **Admin (cross-user):** `GET /admin/all` - every task; `PATCH/DELETE /admin/{id}` - edit/delete any task; `POST /admin/{id}/trigger`; `GET /admin/{id}/runs`. Admin routes require `system_role == "admin"`. |
@@ -18,6 +18,8 @@ from typing import Any
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field
from deerflow.runtime.user_context import get_effective_user_id
router = APIRouter(prefix="/api/roundtable-diagnostics", tags=["roundtable-diagnostics"])
@@ -28,6 +30,16 @@ def _get_store(request: Request):
return store
def _resolve_user_id(request: Request) -> str | None:
user = getattr(request.state, "user", None)
if user is not None:
return str(user.id)
try:
return get_effective_user_id()
except Exception: # noqa: BLE001
return None
async def _require_admin(request: Request) -> None:
"""放行 admin / auth-disabled,其余 403(与 roundtable_jobs / task_buttons 同套语义)。"""
from app.gateway.deps import get_optional_user_from_request
@@ -55,6 +67,7 @@ class DiagnosticItem(BaseModel):
agentName: str | None = None
cycle: int | None = None
createdAt: str | None = None
userName: str | None = None
class DiagnosticListResponse(BaseModel):
@@ -81,6 +94,22 @@ class CleanupResponse(BaseModel):
elapsed_seconds: float
class ReportRequest(BaseModel):
"""前端上报一条会商调用报错(任意登录用户可调,**非 admin**)。"""
stage: str = "client"
event: str = ""
level: str = "error"
message: str | None = None
detail: Any = None
jobId: str | None = None
draftId: str | None = None
taskId: str | None = None
agentId: str | None = None
agentName: str | None = None
cycle: int | None = None
def _to_item(row: dict[str, Any]) -> DiagnosticItem:
return DiagnosticItem(
id=row["id"],
@@ -98,9 +127,48 @@ def _to_item(row: dict[str, Any]) -> DiagnosticItem:
agentName=row.get("agent_name"),
cycle=row.get("cycle"),
createdAt=row.get("created_at"),
userName=row.get("_user_name"),
)
def _username_from_email(email: str | None, uid: str | None) -> str | None:
"""展示用用户名:邮箱 ``@`` 前缀(与个人页一致);无邮箱回退到短 uid。"""
if email:
return email.split("@", 1)[0] if "@" in email else email
if uid:
return uid[:8]
return None
async def _enrich_user_names(request: Request, items: list[dict[str, Any]]) -> None:
"""把每条诊断的 user_id 解析成用户名(邮箱 @ 前缀),写进 dict 的 ``_user_name``。
Best-effort + 批量去重缓存:解析不到就回退短 uid,绝不因此让列表查询失败。
"""
try:
from app.gateway.deps import get_local_provider
provider = get_local_provider()
except Exception: # noqa: BLE001
provider = None
cache: dict[str, str | None] = {}
for it in items:
uid = it.get("user_id")
if not uid:
it["_user_name"] = None
continue
if uid not in cache:
email: str | None = None
if provider is not None:
try:
user = await provider.get_user(uid)
email = getattr(user, "email", None) if user else None
except Exception: # noqa: BLE001
email = None
cache[uid] = _username_from_email(email, uid)
it["_user_name"] = cache[uid]
def _parse_dt(raw: str | None) -> datetime | None:
if not raw:
return None
@@ -130,8 +198,13 @@ async def list_diagnostics(
) -> DiagnosticListResponse:
await _require_admin(request)
store = _get_store(request)
# scope 支持逗号分隔多值(前端「前台调用」= foreground,client 两种 scope 合并查)。
scope_param: str | list[str] | None = None
if scope:
parts = [s.strip() for s in scope.split(",") if s.strip()]
scope_param = parts if len(parts) > 1 else (parts[0] if parts else None)
items, total = await store.list_logs(
scope=scope or None,
scope=scope_param,
stage=stage or None,
level=level or None,
event=event or None,
@@ -145,9 +218,41 @@ async def list_diagnostics(
limit=limit,
offset=offset,
)
await _enrich_user_names(request, items)
return DiagnosticListResponse(items=[_to_item(r) for r in items], total=total, limit=limit, offset=offset)
@router.post("/report")
async def report_client_error(request: Request, body: ReportRequest) -> dict[str, bool]:
"""前端把一次会商调用的报错上报上来(HTTP 502/网络失败/SSE error 帧等用户实际遇到的)。
**不 admin-gated**——任何登录用户在遇到报错时都应能上报;写入 scope="client" 以区分
「服务端观测到的(foreground)」与「前端上报的(client)」。best-effort,写失败也返回 ok。
"""
store = getattr(request.app.state, "roundtable_diagnostic_store", None)
if store is None:
return {"ok": False}
try:
await store.record(
scope="client",
stage=body.stage or "client",
level=body.level or "error",
event=body.event or "",
message=body.message,
detail=body.detail,
job_id=body.jobId,
draft_id=body.draftId,
task_id=body.taskId,
user_id=_resolve_user_id(request),
agent_id=body.agentId,
agent_name=body.agentName,
cycle=body.cycle,
)
except Exception: # noqa: BLE001 — 上报失败不影响前端
return {"ok": False}
return {"ok": True}
@router.get("/facets", response_model=FacetsResponse)
async def get_facets(request: Request) -> FacetsResponse:
await _require_admin(request)
@@ -105,7 +105,7 @@ class RoundtableDiagnosticRepository:
self,
stmt,
*,
scope: str | None,
scope: str | list[str] | None,
stage: str | None,
level: str | None,
event: str | None,
@@ -119,6 +119,11 @@ class RoundtableDiagnosticRepository:
):
R = RoundtableDiagnosticRow
if scope:
if isinstance(scope, (list, tuple, set)):
vals = [s for s in scope if s]
if vals:
stmt = stmt.where(R.scope.in_(vals))
else:
stmt = stmt.where(R.scope == scope)
if stage:
stmt = stmt.where(R.stage == stage)
@@ -146,7 +151,7 @@ class RoundtableDiagnosticRepository:
async def list_logs(
self,
*,
scope: str | None = None,
scope: str | list[str] | None = None,
stage: str | None = None,
level: str | None = None,
event: str | None = None,