From 762d3620c40ca75bf547b450ce715e699d0c7805 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Fri, 19 Jun 2026 23:45:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(roundtable):=20=E4=BC=9A=E5=95=86=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E8=B0=83=E7=94=A8=E6=8A=A5=E9=94=99=E6=94=B6=E9=9B=86?= =?UTF-8?q?=20+=20=E8=AF=8A=E6=96=AD=E9=A1=B5=E7=94=A8=E6=88=B7=E5=90=8D/?= =?UTF-8?q?=E8=8C=83=E5=9B=B4=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 前端调用报错收集(用户实际遇到的报错,服务端诊断看不到的也收):新增 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) --- .../src/pages/RoundtableDiagnosticsPage.tsx | 75 ++++++++++-- .../api/diagnostics-report.ts | 90 +++++++++++++++ .../src/roundtable-planning/api/intent.ts | 11 ++ .../roundtable-planning/api/multi-agent.ts | 101 ++++++++++++++++- .../src/roundtable-planning/api/recommend.ts | 12 ++ .../api/roundtable-jobs.ts | 14 ++- .../pages/BusinessChainEditorPage.tsx | 18 ++- .../pages/RoundtablePlanningPage.tsx | 9 ++ .../api/roundtable-diagnostics.ts | 1 + offline-backend-20260512/backend/CLAUDE.md | 2 +- .../gateway/routers/roundtable_diagnostics.py | 107 +++++++++++++++++- .../persistence/roundtable_diagnostics/sql.py | 11 +- 12 files changed, 433 insertions(+), 18 deletions(-) create mode 100644 frontend-web/src/roundtable-planning/api/diagnostics-report.ts diff --git a/frontend-web/src/pages/RoundtableDiagnosticsPage.tsx b/frontend-web/src/pages/RoundtableDiagnosticsPage.tsx index e4fc405d..1804cd56 100644 --- a/frontend-web/src/pages/RoundtableDiagnosticsPage.tsx +++ b/frontend-web/src/pages/RoundtableDiagnosticsPage.tsx @@ -55,7 +55,37 @@ const STAGE_LABELS: Record = { const SCOPE_LABELS: Record = { background: "后台挂起", - foreground: "前台实时", + foreground: "前台实时(服务端)", + client: "前端上报", +}; + +// 范围切换:后台挂起(background) / 前台调用(foreground 服务端观测 + client 前端上报)。 +type ScopeCategory = "background" | "frontend"; + +// 每个范围下可选的阶段(按用户要求:后台挂起只到第二/三步;前台调用含第一步)。 +const STAGE_OPTIONS: Record = { + 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 = { @@ -101,6 +131,11 @@ function DiagnosticRow({ item }: { item: DiagnosticItem }) { {item.agentName ? ( · {item.agentName} ) : null} + {item.userName ? ( + + 👤 {item.userName} + + ) : null} {fmtTime(item.createdAt)}

@@ -133,7 +168,7 @@ export default function RoundtableDiagnosticsPage() { const isAdmin = getStoredAuth()?.system_role === "admin"; const navigate = useNavigate(); - const [scope, setScope] = useState(ALL); + const [scopeCategory, setScopeCategory] = useState("background"); const [stage, setStage] = useState(ALL); const [level, setLevel] = useState(ALL); const [event, setEvent] = useState(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 */}

- resetPageAnd(() => setScope(v))} - options={facets?.scope ?? []} labels={SCOPE_LABELS} /> + {/* 范围切换:后台挂起 / 前台调用 */} +
+ {(["background", "frontend"] as ScopeCategory[]).map((c) => ( + + ))} +
resetPageAnd(() => setStage(v))} - options={facets?.stage ?? []} labels={STAGE_LABELS} /> + options={STAGE_OPTIONS[scopeCategory]} labels={STAGE_LABELS} /> resetPageAnd(() => setLevel(v))} options={facets?.level ?? []} /> resetPageAnd(() => setEvent(v))} diff --git a/frontend-web/src/roundtable-planning/api/diagnostics-report.ts b/frontend-web/src/roundtable-planning/api/diagnostics-report.ts new file mode 100644 index 00000000..6b3cfa31 --- /dev/null +++ b/frontend-web/src/roundtable-planning/api/diagnostics-report.ts @@ -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) } + : 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 { + /* 绝不抛错 */ + } +} diff --git a/frontend-web/src/roundtable-planning/api/intent.ts b/frontend-web/src/roundtable-planning/api/intent.ts index 1fa934e1..47d1f4e2 100644 --- a/frontend-web/src/roundtable-planning/api/intent.ts +++ b/frontend-web/src/roundtable-planning/api/intent.ts @@ -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 ""); + 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; @@ -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 { + // 整段包一层兜底:除已在具体分支上报的(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) : { 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 }; diff --git a/frontend-web/src/roundtable-planning/api/recommend.ts b/frontend-web/src/roundtable-planning/api/recommend.ts index 0f7c5c79..1df1b859 100644 --- a/frontend-web/src/roundtable-planning/api/recommend.ts +++ b/frontend-web/src/roundtable-planning/api/recommend.ts @@ -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 ""); + 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 `${getBackendBaseURL()}/api/roundtable-jobs`; async function readJson(res: Response, ctx: string): Promise { 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); diff --git a/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx b/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx index 85934dbf..9fd1e2eb 100644 --- a/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx @@ -1716,15 +1716,25 @@ function SeatStrip({ {seat.description}
)} - {/* 席位模型覆盖:空 = 跟随全局所选模型;选了则该席位作为子智能体执行时用此模型。 - 技能重的席位配强模型、边角席位配快模型,配合技能白名单实现「强弱搭配」。 */} -
+
+ + {/* 席位模型覆盖(右侧操作区):空 = 跟随全局所选模型;选了则该席位作为子智能体 + 执行时用此模型。技能重的席位配强模型、边角席位配快模型,配合技能白名单实现 + 「强弱搭配」。放到右侧操作区,与名称/描述解耦,卡片更整洁。 */} +
+
模型
diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index 7f40db12..617d0eb9 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -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; diff --git a/frontend-web/src/strategy-components/api/roundtable-diagnostics.ts b/frontend-web/src/strategy-components/api/roundtable-diagnostics.ts index 4c6379cf..714f4b9d 100644 --- a/frontend-web/src/strategy-components/api/roundtable-diagnostics.ts +++ b/frontend-web/src/strategy-components/api/roundtable-diagnostics.ts @@ -28,6 +28,7 @@ export interface DiagnosticItem { agentName: string | null; cycle: number | null; createdAt: string | null; + userName: string | null; } export interface DiagnosticListResponse { diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index 33043886..7085ae3c 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -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 `` 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 task (human turn)** — far stronger pull on weak models than the system prompt. Wired into **both** seat paths: foreground `multi_agent._special_run` (only `role in {seat, seat_parallel}`; Step3 singletons report/summary/dashboard/ingest excluded) + background `InProcessRoundtableGateway.run_seat`. No-op (returns task unchanged) when the seat has no configured skills; `ROUNDTABLE_SEAT_SKILL_DIRECTIVE=0` disables it (default on). Tests: `tests/test_roundtable_seat_skills.py`. | | **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_` ? 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/`. 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"`. | diff --git a/offline-backend-20260512/backend/app/gateway/routers/roundtable_diagnostics.py b/offline-backend-20260512/backend/app/gateway/routers/roundtable_diagnostics.py index 98e850bd..d3cc07e2 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/roundtable_diagnostics.py +++ b/offline-backend-20260512/backend/app/gateway/routers/roundtable_diagnostics.py @@ -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) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_diagnostics/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_diagnostics/sql.py index 7094127e..7dd1831d 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_diagnostics/sql.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_diagnostics/sql.py @@ -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,7 +119,12 @@ class RoundtableDiagnosticRepository: ): R = RoundtableDiagnosticRow if scope: - stmt = stmt.where(R.scope == 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) if level: @@ -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,