From 60626c384acc2b97278c17f349de488edd392e76 Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Fri, 19 Jun 2026 20:51:05 +0800 Subject: [PATCH 1/5] =?UTF-8?q?fix(roundtable):=20=E4=BC=9A=E5=95=86=20Ste?= =?UTF-8?q?p2/Step3=20=E6=B2=99=E7=AE=B1=E4=BA=A7=E7=89=A9=E6=8C=89?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E5=88=9B=E5=BB=BA=E8=80=85=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E5=A4=8D=E5=8E=86=E5=8F=B2/=E8=B7=A8?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E6=9F=A5=E7=9C=8B=E4=B8=8D=E4=BA=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 圆桌会商系统线程(metadata.thread_type==roundtable)的 Step2 席位/总控 + Step3 报告/总结/大屏/结构化 沙箱产物(md/流程图/报告)落在创建者目录下, 之前按当前登录解析路径 + owner_check 校验 → 历史草稿重开或 taskId 会商被 其他账号打开时 403/404 查看不了。沿用任务深链对话方案(23e6685)扩展到圆桌: - thread_paths._is_creator_shared_thread:taskId 或 thread_type==roundtable 都解析到对话创建者(threads_meta.user_id),覆盖所有线程路径关口 - ThreadMetaRepository.check_access:圆桌系统线程同样跳过 owner 过滤 (artifacts 路由 owner_check 据此放行其他账号/历史查看) 线程 id 仅存在于草稿中,无关账号无法发现,与 taskId 共享模型一致。 Co-Authored-By: Claude Opus 4.8 (1M context) --- offline-backend-20260512/backend/CLAUDE.md | 2 +- .../deerflow/persistence/thread_meta/sql.py | 9 +++++-- .../harness/deerflow/runtime/thread_paths.py | 25 ++++++++++++++++++- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index 17eb0b9b..8fcc4e42 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -459,7 +459,7 @@ Operators upgrading an existing DB run the whole file (`USE ;` then execute) - Physical: `backend/.deer-flow/users/{user_id}/threads/{thread_id}/user-data/...`, `deer-flow/skills/` - Translation: `replace_virtual_path()` / `replace_virtual_paths_in_command()` - Detection: `is_local_sandbox()` checks `sandbox_id == "local"` -- **`{user_id}` for path resolution = `resolve_path_user_id(thread_id)`, NOT the current login.** Task-deeplink conversations (rwfx/3qfx/xdfx, `threads_meta.metadata.taskId`) are shared across all users (see the `taskId` escape branches in `thread_meta/sql.py`), but generated files/uploads live under a *per-user* dir. Resolving by current login meant account B opening a shared task conversation 404'd on the md/artifacts account A generated ("切换账号后生成的文件找不到"). `deerflow/runtime/thread_paths.py` (`resolve_path_user_id` sync / `aresolve_path_user_id` async) resolves a task-shared thread to its **creator** (`threads_meta.user_id`), so every account reads/writes the same physical directory; personal threads resolve to the current login unchanged (no behavior change). Wired at every thread-path seam: write source `ThreadDataMiddleware.before_agent` (drives all sandbox writes via `thread_data` paths), `present_file_tool`, `uploads/manager.get_uploads_dir` + `uploads_middleware` + uploads router, artifact serving (`path_utils.{a,}resolve_thread_virtual_path` → artifacts/skills routers), embedded `client.get_artifact`. Positive (taskId→creator) results are cached process-wide (immutable mapping); any failure / memory-mode store / missing row falls back to the current login. **Caveat**: files a *non-creator* wrote into a shared thread before this fix stay orphaned under that writer's dir (no data loss); all new writes consolidate under the creator. Tests: `tests/test_thread_paths_owner.py`. +- **`{user_id}` for path resolution = `resolve_path_user_id(thread_id)`, NOT the current login.** Task-deeplink conversations (rwfx/3qfx/xdfx, `threads_meta.metadata.taskId`) are shared across all users (see the `taskId` escape branches in `thread_meta/sql.py`), but generated files/uploads live under a *per-user* dir. Resolving by current login meant account B opening a shared task conversation 404'd on the md/artifacts account A generated ("切换账号后生成的文件找不到"). `deerflow/runtime/thread_paths.py` (`resolve_path_user_id` sync / `aresolve_path_user_id` async) resolves a task-shared thread to its **creator** (`threads_meta.user_id`), so every account reads/writes the same physical directory; personal threads resolve to the current login unchanged (no behavior change). Wired at every thread-path seam: write source `ThreadDataMiddleware.before_agent` (drives all sandbox writes via `thread_data` paths), `present_file_tool`, `uploads/manager.get_uploads_dir` + `uploads_middleware` + uploads router, artifact serving (`path_utils.{a,}resolve_thread_virtual_path` → artifacts/skills routers), embedded `client.get_artifact`. Positive (taskId→creator) results are cached process-wide (immutable mapping); any failure / memory-mode store / missing row falls back to the current login. **Caveat**: files a *non-creator* wrote into a shared thread before this fix stay orphaned under that writer's dir (no data loss); all new writes consolidate under the creator. **Extended to 圆桌会商 system threads** (`metadata.thread_type == "roundtable"` — Step 2 seats/coordinator + Step 3 report/summary/dashboard/structure, created by `multi_agent.py` with `_ROUNDTABLE_THREAD_METADATA`): their Step2/Step3 sandbox artifacts (md/流程图/报告) must stay viewable when a历史草稿 is reopened or a taskId会商 draft is opened by another account, so `thread_paths._is_creator_shared_thread(metadata)` treats both `taskId` **and** `thread_type=="roundtable"` as creator-shared, and `ThreadMetaRepository.check_access` (the artifacts router's `owner_check`) bypasses the owner filter for both — roundtable thread ids are only surfaced inside the owner's draft, so this does not let unrelated accounts *discover* others' 会商 files. Tests: `tests/test_thread_paths_owner.py`, `tests/test_thread_paths_roundtable.py`. **Sandbox Tools** (in `packages/harness/deerflow/sandbox/tools.py`): - `bash` - Execute commands with path translation and error handling diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/thread_meta/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/thread_meta/sql.py index 780a3182..ae749047 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/thread_meta/sql.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/thread_meta/sql.py @@ -97,8 +97,13 @@ class ThreadMetaRepository(ThreadMetaStore): row = await session.get(ThreadMetaRow, thread_id) if row is None: return not require_existing - # 任务深链对话(metadata.taskId)全局共享、不区分用户:任何登录用户都可读写/续聊。 - if (row.metadata_json or {}).get("taskId"): + md = row.metadata_json or {} + # 全局共享、不区分用户(任何登录用户都可读写/续聊): + # ① 任务深链对话(metadata.taskId); + # ② 圆桌会商系统线程(metadata.thread_type=="roundtable")——其 Step2/Step3 + # 沙箱产物(md/流程图/报告)需让历史草稿或其他账号打开同一会商时也能查看, + # 与任务深链对话一致(线程 id 仅存在于草稿中,不会被无关账号发现)。 + if md.get("taskId") or md.get("thread_type") == "roundtable": return True if row.user_id is None: return True diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/thread_paths.py b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/thread_paths.py index fdbae69a..28358b5c 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/thread_paths.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/thread_paths.py @@ -78,11 +78,34 @@ async def _fetch_task_owner(thread_id: str) -> str | None: return None metadata = row.get("metadata") or {} owner = row.get("user_id") - if metadata.get("taskId") and owner: + if owner and _is_creator_shared_thread(metadata): return str(owner) return None +def _is_creator_shared_thread(metadata: dict) -> bool: + """True iff a thread's files should resolve to its **creator** (shared bucket). + + Two kinds of threads are shared to one stable directory rather than the + current login: + + - **Task-deeplink conversations** (``metadata.taskId``) — rwfx / 3qfx / xdfx, + explicitly shared across all users; + - **Roundtable 会商 system threads** (``metadata.thread_type == "roundtable"``) + — the Step 2 seats / coordinator and Step 3 report / summary / dashboard / + structure threads. Their sandbox artifacts (md / 流程图 / 报告) live under the + creator's directory, but a历史草稿 reopened later — or a taskId会商 draft opened + by **another account** — must still be able to view them. Resolving to the + creator keeps the *file* layer consistent with the *draft-sharing* layer. + + Roundtable thread ids are random UUIDs surfaced only inside the owner's draft + (personal drafts are owner-scoped; taskId drafts are already shared), so this + does not let an unrelated account *discover* another user's 会商 files — it only + lets a caller that already holds the thread id read the creator's bucket. + """ + return bool(metadata.get("taskId")) or metadata.get("thread_type") == "roundtable" + + def _run_coro_blocking(coro): """Run an async coroutine to completion from sync code. From 8ca16c74bcddd45f25750f7a21cd351c838b19b9 Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Fri, 19 Jun 2026 22:12:45 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix(roundtable):=20=E4=BC=9A=E5=95=86?= =?UTF-8?q?=E7=AC=AC=E4=B8=89=E6=AD=A5=E5=90=84=E5=B8=AD=E4=BD=8D=E4=BA=A7?= =?UTF-8?q?=E7=89=A9=E5=BC=B9=E7=AA=97=E7=BB=8F=20portal=20=E6=8C=82=20bod?= =?UTF-8?q?y=EF=BC=8C=E4=BF=AE=E5=A4=8D=E9=A1=B6=E9=83=A8=E8=A2=AB?= =?UTF-8?q?=E9=A1=B6=E6=A0=8F=E7=9B=96=E4=BD=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 页面
的 `relative z-0` 新建层叠上下文,把内部 fixed 弹窗困在 z-0 层级, 被会商页头(z-40)/全局顶栏(z-45)盖住标题栏。ContentModal 与 ArtifactPreviewModal 改用 createPortal 挂到 document.body,脱离该上下文后正常盖在顶栏之上。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/ArtifactPreviewModal.tsx | 8 ++++++-- .../roundtable-planning/components/Step3SummaryMode.tsx | 9 +++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/frontend-web/src/roundtable-planning/components/ArtifactPreviewModal.tsx b/frontend-web/src/roundtable-planning/components/ArtifactPreviewModal.tsx index 1073a515..04beb374 100644 --- a/frontend-web/src/roundtable-planning/components/ArtifactPreviewModal.tsx +++ b/frontend-web/src/roundtable-planning/components/ArtifactPreviewModal.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; import type { ReactNode } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; @@ -214,7 +215,9 @@ export const ArtifactPreviewModal: React.FC = ({ [content], ); - return ( + // 经 portal 挂到 body:脱离会商页
(`relative z-0`) 的层叠上下文, + // 否则 fixed 弹窗会被会商页头(z-40)/全局顶栏(z-45)盖住顶部。 + return createPortal(
{ @@ -287,6 +290,7 @@ export const ArtifactPreviewModal: React.FC = ({ )}
- + , + document.body, ); }; diff --git a/frontend-web/src/roundtable-planning/components/Step3SummaryMode.tsx b/frontend-web/src/roundtable-planning/components/Step3SummaryMode.tsx index b6a900f3..e16151cf 100644 --- a/frontend-web/src/roundtable-planning/components/Step3SummaryMode.tsx +++ b/frontend-web/src/roundtable-planning/components/Step3SummaryMode.tsx @@ -10,6 +10,7 @@ * 互不影响(report 画 HTML 看板,summary 写 Markdown 报告 + 问答)。 */ import { useEffect, useMemo, useRef, useState, type FormEvent } from "react"; +import { createPortal } from "react-dom"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { @@ -1005,7 +1006,10 @@ function ContentModal({ onClose: () => void; headerAction?: React.ReactNode; }) { - return ( + // 经 portal 挂到 body:页面
是 `relative z-0`(浅蓝主题层叠基线),会把 + // 内部 fixed 弹窗困在 z-0 层叠上下文里,被会商页头(z-40)/全局顶栏(z-45)盖住顶部。 + // 挂到 body 后脱离该上下文,z-50 才真正盖在顶栏之上,弹窗标题栏正常显示。 + return createPortal(
{children}
- + , + document.body, ); } From 4a403c95c5b9682d7eeb061bada33f2af6cc06a0 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 22:55:43 +0800 Subject: [PATCH 3/5] =?UTF-8?q?feat(roundtable):=20=E4=BC=9A=E5=95=86?= =?UTF-8?q?=E8=AF=8A=E6=96=AD=E6=97=A5=E5=BF=97=20+=20=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E6=8C=82=E8=B5=B7=E5=8D=A1=E6=AD=BB=E5=85=9C=E5=BA=95(idle=20?= =?UTF-8?q?=E8=B6=85=E6=97=B6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增会商全流程诊断日志:新表 roundtable_diagnostics + 仓储 + admin API (/api/roundtable-diagnostics) + 管理员诊断页(会商页右上角设置按钮入口), 覆盖第一/二/三步及后台执行器/进程内网关各处报错与关键事件,支持按 范围/阶段/级别/事件/关键词筛选 + 分页。诊断写入全程 best-effort,绝不打断会商。 - 修复后台挂起「执行完第一个席位后永久卡死」:进程内网关每轮 agent run 改为 idle(无活动)超时(默认 20min)——只要还在持续产出 token/工具事件就一直等, 仅当连续无任何输出才判卡死并取消+报错+落 seat_turn_timeout 诊断;慢但活着的 生成绝不打断。可经 ROUNDTABLE_SEAT_IDLE_TIMEOUT_SECONDS 调整。 - 一并提交进行中的会商 seat-skill 注入 / 业务链条编辑器相关改动。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/pages/RoundtableDiagnosticsPage.tsx | 272 ++++++++++++++++++ frontend-web/src/pages/StrategyRoutes.tsx | 11 + .../components/BusinessChainPicker.tsx | 7 +- .../roundtable-planning/lib/business-chain.ts | 21 +- .../roundtable-planning/lib/chain-stages.ts | 14 + .../pages/BusinessChainEditorPage.tsx | 30 ++ .../pages/RoundtablePlanningPage.tsx | 77 ++++- .../api/roundtable-diagnostics.ts | 131 +++++++++ offline-backend-20260512/backend/CLAUDE.md | 1 + .../backend/app/gateway/app.py | 2 + .../backend/app/gateway/deps.py | 16 ++ .../backend/app/gateway/roundtable_diag.py | 63 ++++ .../gateway/roundtable_inprocess_gateway.py | 218 +++++++++++++- .../app/gateway/roundtable_job_executor.py | 32 ++- .../app/gateway/roundtable_seat_skills.py | 95 ++++++ .../backend/app/gateway/routers/intent.py | 17 ++ .../app/gateway/routers/multi_agent.py | 206 ++++++++++--- .../backend/app/gateway/routers/recommend.py | 11 + .../app/gateway/routers/roundtable_chains.py | 5 + .../gateway/routers/roundtable_diagnostics.py | 173 +++++++++++ .../agents/roundtable_orchestrator/engine.py | 57 ++++ .../roundtable_orchestrator/progress.py | 33 +++ .../20260619_01_roundtable_diagnostics.py | 70 +++++ .../deerflow/persistence/models/__init__.py | 2 + .../roundtable_diagnostics/__init__.py | 112 ++++++++ .../roundtable_diagnostics/model.py | 52 ++++ .../persistence/roundtable_diagnostics/sql.py | 206 +++++++++++++ 27 files changed, 1873 insertions(+), 61 deletions(-) create mode 100644 frontend-web/src/pages/RoundtableDiagnosticsPage.tsx create mode 100644 frontend-web/src/strategy-components/api/roundtable-diagnostics.ts create mode 100644 offline-backend-20260512/backend/app/gateway/roundtable_diag.py create mode 100644 offline-backend-20260512/backend/app/gateway/roundtable_seat_skills.py create mode 100644 offline-backend-20260512/backend/app/gateway/routers/roundtable_diagnostics.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260619_01_roundtable_diagnostics.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_diagnostics/__init__.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_diagnostics/model.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_diagnostics/sql.py diff --git a/frontend-web/src/pages/RoundtableDiagnosticsPage.tsx b/frontend-web/src/pages/RoundtableDiagnosticsPage.tsx new file mode 100644 index 00000000..e4fc405d --- /dev/null +++ b/frontend-web/src/pages/RoundtableDiagnosticsPage.tsx @@ -0,0 +1,272 @@ +"use client"; + +/** + * 会商诊断日志(仅 admin):展示多智能体会商全流程(第一步意图 / 第二步研讨 / + * 第三步报告 + 后台挂起执行器 / 进程内网关)各处的报错与关键事件,支持按 + * scope(前台/后台)、阶段、级别、事件码、作业/任务/草稿 id、关键词筛选 + 分页。 + * + * 数据源:`GET /api/roundtable-diagnostics`(admin-gated)。入口:多智能体会商页右上角 + * 设置按钮(admin)。见 [[roundtable-diagnostics]]。 + */ + +import { useMemo, useState } from "react"; +import { Navigate, useNavigate } from "react-router-dom"; +import { ArrowLeft, RefreshCw, Search } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Pagination } from "@/components/ui/pagination"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { cn } from "@/lib/utils"; +import { getStoredAuth } from "@/core/auth"; +import { + type DiagnosticFilters, + type DiagnosticItem, + useRoundtableDiagnosticFacets, + useRoundtableDiagnostics, +} from "@/strategy-components/api/roundtable-diagnostics"; + +const PAGE_SIZE = 50; + +const ALL = "__all__"; + +// 阶段中文名(已知集合;未知阶段原样显示)。 +const STAGE_LABELS: Record = { + step1_intent: "第一步 · 意图", + step2_init: "第二步 · 初始化", + step2_leader: "第二步 · 总控", + step2_seat: "第二步 · 席位", + step2_recommend: "第二步 · 推荐", + step2_orchestration: "第二步 · 编排", + step3_summary: "第三步 · 总结报告", + step3_report: "第三步 · 结果绘制", + step3_dashboard: "第三步 · 大屏数据", + step3_structure: "第三步 · 数据入库", + upstream: "回环代理", + job: "后台作业", +}; + +const SCOPE_LABELS: Record = { + background: "后台挂起", + foreground: "前台实时", +}; + +const LEVEL_STYLES: Record = { + error: "bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-300", + warning: "bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-300", + info: "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300", +}; + +function stageLabel(stage: string): string { + return STAGE_LABELS[stage] ?? stage; +} + +function fmtTime(iso: string | null): string { + if (!iso) return ""; + try { + const d = new Date(iso); + return d.toLocaleString("zh-CN", { hour12: false }); + } catch { + return iso; + } +} + +function DiagnosticRow({ item }: { item: DiagnosticItem }) { + const [open, setOpen] = useState(false); + const hasDetail = item.detail != null && Object.keys(item.detail as object).length > 0; + return ( +
+
+ + {item.level} + +
+
+ + {stageLabel(item.stage)} + + {item.event} + {item.agentName ? ( + · {item.agentName} + ) : null} + {fmtTime(item.createdAt)} +
+

+ {item.message || (无说明)} +

+
+ {SCOPE_LABELS[item.scope] ?? item.scope} + {item.jobId ? job: {item.jobId.slice(0, 8)} : null} + {item.taskId ? task: {item.taskId} : null} + {item.draftId ? draft: {item.draftId.slice(0, 8)} : null} + {item.cycle != null ? cycle: {item.cycle} : null} + {hasDetail ? ( + + ) : null} +
+ {open && hasDetail ? ( +
+              {JSON.stringify(item.detail, null, 2)}
+            
+ ) : null} +
+
+
+ ); +} + +export default function RoundtableDiagnosticsPage() { + const isAdmin = getStoredAuth()?.system_role === "admin"; + const navigate = useNavigate(); + + const [scope, setScope] = useState(ALL); + const [stage, setStage] = useState(ALL); + const [level, setLevel] = useState(ALL); + const [event, setEvent] = useState(ALL); + const [qInput, setQInput] = useState(""); + const [q, setQ] = useState(""); + const [page, setPage] = useState(1); + + const filters: DiagnosticFilters = useMemo( + () => ({ + scope: scope === ALL ? undefined : scope, + stage: stage === ALL ? undefined : stage, + level: level === ALL ? undefined : level, + event: event === ALL ? undefined : event, + q: q || undefined, + limit: PAGE_SIZE, + offset: (page - 1) * PAGE_SIZE, + }), + [scope, stage, level, event, q, page], + ); + + const { data, isLoading, isFetching, refetch } = useRoundtableDiagnostics(filters, isAdmin); + const { data: facets } = useRoundtableDiagnosticFacets(isAdmin); + + if (!isAdmin) { + return ; + } + + const items = data?.items ?? []; + const total = data?.total ?? 0; + + const resetPageAnd = (fn: () => void) => { + fn(); + setPage(1); + }; + + const applySearch = () => resetPageAnd(() => setQ(qInput.trim())); + + return ( +
+ {/* Header */} +
+ +
+

会商诊断日志

+

+ 多智能体会商全流程报错与关键事件(含后台挂起卡死、超时、产物缺失等) +

+
+ +
+ + {/* Filters */} +
+ resetPageAnd(() => setScope(v))} + options={facets?.scope ?? []} labels={SCOPE_LABELS} /> + resetPageAnd(() => setStage(v))} + options={facets?.stage ?? []} labels={STAGE_LABELS} /> + resetPageAnd(() => setLevel(v))} + options={facets?.level ?? []} /> + resetPageAnd(() => setEvent(v))} + options={facets?.event ?? []} /> +
+ setQInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && applySearch()} + placeholder="搜索报错说明…" + className="h-8 w-48" + /> + +
+ 共 {total} 条 +
+ + {/* List */} +
+ {isLoading ? ( +

加载中…

+ ) : items.length === 0 ? ( +

暂无诊断记录

+ ) : ( +
+ {items.map((it) => ( + + ))} + +
+ )} +
+
+ ); +} + +function FilterSelect({ + label, + value, + onChange, + options, + labels, +}: { + label: string; + value: string; + onChange: (v: string) => void; + options: string[]; + labels?: Record; +}) { + return ( + + ); +} diff --git a/frontend-web/src/pages/StrategyRoutes.tsx b/frontend-web/src/pages/StrategyRoutes.tsx index 0bab56b0..6c9bda77 100644 --- a/frontend-web/src/pages/StrategyRoutes.tsx +++ b/frontend-web/src/pages/StrategyRoutes.tsx @@ -41,6 +41,7 @@ import LightAppManagementPage from "./LightAppManagementPage"; import MenuManagementPage from "./MenuManagementPage"; import SensitiveWordsPage from "./SensitiveWordsPage"; import TaskButtonsPage from "./TaskButtonsPage"; +import RoundtableDiagnosticsPage from "./RoundtableDiagnosticsPage"; import LightAppNewNotePage from "./LightAppNewNotePage"; import LightAppNoteListPage from "./LightAppNoteListPage"; import LightAppNoteQAPage from "./LightAppNoteQAPage"; @@ -393,6 +394,16 @@ export default function StrategyRoutes() { ) } /> + + ) : ( + + ) + } + /> [s.agent_id, s.name])); const nameOf = (id: string) => nameById.get(id) ?? seatNameById.get(id) ?? id; + // 席位模型覆盖(链条配置):按 agent_id 取,空/缺省 = 跟随全局模型。 + const modelById = new Map(selectedChain.seats.map((s) => [s.agent_id, s.model ?? null])); + const modelOf = (id: string) => modelById.get(id) ?? null; // 链条带分层、过滤存活后仍并行 → dag 编排(携带快速/沙箱子模式);否则退回线性 chain。 if (dagPlan) { // dag:席位顺序按 stage 展平(与 plan 一致)。 const agents = dagPlan.stages .flatMap((st) => st.agentIds) - .map((id) => ({ agent_id: id, name: nameOf(id) })); + .map((id) => ({ agent_id: id, name: nameOf(id), model: modelOf(id) })); onConfirm({ agents, mode: "dag", chain: chainMeta, plan: dagPlan, dagExecMode }); return; } - const agents = survivingSeats.map((s) => ({ agent_id: s.agent_id, name: nameOf(s.agent_id) })); + const agents = survivingSeats.map((s) => ({ agent_id: s.agent_id, name: nameOf(s.agent_id), model: modelOf(s.agent_id) })); onConfirm({ agents, mode: "chain", chain: chainMeta }); }; diff --git a/frontend-web/src/roundtable-planning/lib/business-chain.ts b/frontend-web/src/roundtable-planning/lib/business-chain.ts index a8a824f4..46b8d2a6 100644 --- a/frontend-web/src/roundtable-planning/lib/business-chain.ts +++ b/frontend-web/src/roundtable-planning/lib/business-chain.ts @@ -22,6 +22,13 @@ export interface ChainSeat { name: string; /** 描述快照,同样作为实时数据的回退。 */ description?: string; + /** + * 可选「席位模型覆盖」:该席位作为子智能体执行时用的 LLM model name(须是后端 + * `config.yaml` `models[]` 里的一个 `name`)。空 / 缺省 = 不覆盖,运行时回退到全局 + * 所选模型。选入研讨时灌进 `agentConfigs[speakerId].model`(见 `RoundtablePlanningPage` + * 的 `handleConfirmAgents`)→ 走现有 per-seat 模型链路。 + */ + model?: string | null; } /** 一条可复用的业务链条(与后端 `/api/roundtable-chains` 记录对应)。 */ @@ -70,8 +77,11 @@ export interface BusinessChain { * `agents` + 一个 `mode`。 */ export interface SeatSelectionResult { - /** 有序席位 —— 与现有 `handleConfirmAgents` 入参兼容。dag 模式下按 stage 展平排序。 */ - agents: Array<{ agent_id: string; name: string }>; + /** + * 有序席位 —— 与现有 `handleConfirmAgents` 入参兼容。dag 模式下按 stage 展平排序。 + * `model` 为该席位在来源链条里配置的模型覆盖(空 / 缺省 = 跟随全局模型)。 + */ + agents: Array<{ agent_id: string; name: string; model?: string | null }>; /** * 决定 Step 2 走哪种编排: * - `recommend` 总控自由派活; @@ -122,6 +132,9 @@ export function buildChainSeatSelection( const nameById = new Map(candidates.map((c) => [c.agent_id, c.name])); const seatNameById = new Map(chain.seats.map((s) => [s.agent_id, s.name])); const nameOf = (id: string) => nameById.get(id) ?? seatNameById.get(id) ?? id; + // 席位模型覆盖(链条配置):按 agent_id 取,空/缺省 = 跟随全局。 + const modelById = new Map(chain.seats.map((s) => [s.agent_id, s.model ?? null])); + const modelOf = (id: string) => modelById.get(id) ?? null; const survivingSeats = chain.seats.filter((s) => existingIds.has(s.agent_id)); if (survivingSeats.length < CHAIN_MIN_SEATS) return null; @@ -143,12 +156,12 @@ export function buildChainSeatSelection( if (hasParallel || hasGoal || hasCoord) { const agents = plan.stages .flatMap((st) => st.agentIds) - .map((id) => ({ agent_id: id, name: nameOf(id) })); + .map((id) => ({ agent_id: id, name: nameOf(id), model: modelOf(id) })); return { agents, mode: "dag", chain: chainMeta, plan, dagExecMode: "fast", seatMode: chain.seatMode }; } } - const agents = survivingSeats.map((s) => ({ agent_id: s.agent_id, name: nameOf(s.agent_id) })); + const agents = survivingSeats.map((s) => ({ agent_id: s.agent_id, name: nameOf(s.agent_id), model: modelOf(s.agent_id) })); return { agents, mode: "chain", chain: chainMeta, seatMode: chain.seatMode }; } diff --git a/frontend-web/src/roundtable-planning/lib/chain-stages.ts b/frontend-web/src/roundtable-planning/lib/chain-stages.ts index 117ca058..a077cc0c 100644 --- a/frontend-web/src/roundtable-planning/lib/chain-stages.ts +++ b/frontend-web/src/roundtable-planning/lib/chain-stages.ts @@ -112,6 +112,20 @@ export function setStageGoal(groups: StageGroups, stageIdx: number, goal: string return groups.map((g, i) => (i === stageIdx ? { ...g, goal } : g)); } +/** + * 设置某个席位的「模型覆盖」(不可变,供编辑器席位下拉 onChange)。 + * `model` 为 null/"" 表示清除覆盖(跟随全局模型)。按 agent_id 定位,跨 stage 适用。 + */ +export function setSeatModel(groups: StageGroups, agentId: string, model: string | null): StageGroups { + return groups.map((g) => { + if (!g.seats.some((s) => s.agent_id === agentId)) return g; + return { + ...g, + seats: g.seats.map((s) => (s.agent_id === agentId ? { ...s, model: model || null } : s)), + }; + }); +} + // ── 变换(均返回新结构,不可变)────────────────────────────────────────────── /** 末尾追加一个**新单席位 stage**(线性追加,pool 点击加入默认行为)。 */ diff --git a/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx b/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx index dfa096f0..85934dbf 100644 --- a/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx @@ -65,6 +65,7 @@ import { CSS } from "@dnd-kit/utilities"; import { cn } from "@/lib/utils"; import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree"; import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar"; +import { ModelNameSelect } from "@/components/workspace/model-name-select"; import { listAgents } from "@/core/agents"; import { @@ -103,6 +104,7 @@ import { moveStage, removeSeat as removeSeatFromGroups, seatExistsInGroups, + setSeatModel, setStageGoal, splitSeatToNewStage, stageGroupsToSeats, @@ -359,6 +361,11 @@ export default function BusinessChainEditorPage() { setStageGroups((prev) => setStageGoal(prev, stageIdx, goal)); }, []); + // 席位模型覆盖:编辑器席位下拉 onChange(null = 清除,跟随全局)。 + const setSeatModelCb = useCallback((agentId: string, model: string | null) => { + setStageGroups((prev) => setSeatModel(prev, agentId, model)); + }, []); + // ── 提示词 AI 优化(总控提示词 / 各 Stage 阶段目标)──────────────────────── const [optimizingCoordinator, setOptimizingCoordinator] = useState(false); const [goalOptimizingIdx, setGoalOptimizingIdx] = useState(null); @@ -555,6 +562,8 @@ export default function BusinessChainEditorPage() { agent_id: s.agent_id, name: live?.name ?? s.name, description: live?.description ?? s.description ?? "", + // 席位模型覆盖:随席位一起落库(空 → null = 跟随全局)。 + model: s.model ?? null, }; }); // 总控编排提示:trim 后空串落 null(清空),与后端「空=不注入」一致。 @@ -1121,6 +1130,7 @@ export default function BusinessChainEditorPage() { onRemoveSeat={removeSeat} onMergeSeat={mergeSeatToStage} onSplitSeat={splitSeat} + onSeatModelChange={setSeatModelCb} onStageGoalChange={setGoal} goalOptimizingIdx={goalOptimizingIdx} onOptimizeGoal={handleOptimizeStageGoal} @@ -1298,6 +1308,7 @@ function StagesColumn({ onRemoveSeat, onMergeSeat, onSplitSeat, + onSeatModelChange, onStageGoalChange, goalOptimizingIdx, onOptimizeGoal, @@ -1313,6 +1324,7 @@ function StagesColumn({ onRemoveSeat: (agentId: string) => void; onMergeSeat: (agentId: string, targetStageIdx: number) => void; onSplitSeat: (agentId: string) => void; + onSeatModelChange: (agentId: string, model: string | null) => void; onStageGoalChange: (stageIdx: number, goal: string) => void; /** 正在 AI 优化阶段目标的 stage 下标(null = 没有进行中的优化)。 */ goalOptimizingIdx: number | null; @@ -1410,6 +1422,7 @@ function StagesColumn({ onRemoveSeat={onRemoveSeat} onMergeSeat={onMergeSeat} onSplitSeat={onSplitSeat} + onSeatModelChange={onSeatModelChange} onGoalChange={(goal) => onStageGoalChange(stageIdx, goal)} goalOptimizing={goalOptimizingIdx === stageIdx} anyGoalOptimizing={goalOptimizingIdx !== null} @@ -1438,6 +1451,7 @@ function StageSection({ onRemoveSeat, onMergeSeat, onSplitSeat, + onSeatModelChange, onGoalChange, goalOptimizing, anyGoalOptimizing, @@ -1455,6 +1469,7 @@ function StageSection({ onRemoveSeat: (agentId: string) => void; onMergeSeat: (agentId: string, targetStageIdx: number) => void; onSplitSeat: (agentId: string) => void; + onSeatModelChange: (agentId: string, model: string | null) => void; onGoalChange: (goal: string) => void; /** 本 Stage 的阶段目标正在 AI 优化中。 */ goalOptimizing: boolean; @@ -1615,6 +1630,7 @@ function StageSection({ onMergeUp={() => onMergeSeat(seat.agent_id, stageIdx - 1)} onMergeDown={() => onMergeSeat(seat.agent_id, stageIdx + 1)} onSplit={() => onSplitSeat(seat.agent_id)} + onModelChange={(model) => onSeatModelChange(seat.agent_id, model)} onDetail={() => onSeatDetail(seat.agent_id, seat.name)} /> ))} @@ -1637,6 +1653,7 @@ function SeatStrip({ onMergeUp, onMergeDown, onSplit, + onModelChange, onDetail, }: { seat: ChainSeat; @@ -1649,6 +1666,8 @@ function SeatStrip({ onMergeUp: () => void; onMergeDown: () => void; onSplit: () => void; + /** 席位模型覆盖变更(null = 清除,跟随全局)。 */ + onModelChange: (model: string | null) => void; onDetail: () => void; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = @@ -1697,6 +1716,17 @@ function SeatStrip({ {seat.description} )} + {/* 席位模型覆盖:空 = 跟随全局所选模型;选了则该席位作为子智能体执行时用此模型。 + 技能重的席位配强模型、边角席位配快模型,配合技能白名单实现「强弱搭配」。 */} +
+ 模型 + +
{/* 控制:查看详情 / 并入上一阶段 / 独立成阶段 / 并入下一阶段 / 移除 */} diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index 3d0b8d28..7f40db12 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -24,6 +24,7 @@ */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useLocation } from "react-router-dom"; +import { getStoredAuth } from "@/core/auth"; import { Activity, BookOpen, @@ -195,6 +196,11 @@ function readWujiePrefill(): RoundtablePrefill | null { */ const ROUNDTABLE_DEEPLINK_PREFILL_KEY = "roundtable:deeplink-prefill"; +// 进第三步三流「错峰起跑」的基准间隔(毫秒)。结果绘制延迟 1×、大屏延迟 2×,把三个 +// start_run 的写尖峰摊开,避免单 worker + 单 sqlite 锁瞬时被打满导致回环 502(削峰, +// 不影响并行展示进度)。取值偏小,用户基本无感。 +const STEP3_STAGGER_MS = 300; + /** 按路由 path 持久化深链 prefill,供刷新后恢复。隐私模式/quota 异常静默失败。 */ function persistDeeplinkPrefill(path: string, prefill: RoundtablePrefill): void { try { @@ -912,6 +918,19 @@ export default function RoundtablePlanningPage() { dashboardFlow.abort(); step2.resetStep2State(); step2.setSelectedAgents(normalized); + // per-seat 模型覆盖:业务链条为某些席位指定了模型 → 写进 agentConfigs。speakerId = + // 席位序号 + 2(与 selectedAgents 索引对齐,1 号是总控),与派活时 + // `agentConfigs[meta.speakerId]?.model` 的读取一致。空 / 未指定 → model:""(跟随全局), + // 同时清掉上一轮选择残留的席位模型覆盖;prompt/temp/weight 沿用既有 Persona 配置。 + step2.setAgentConfigs((prev) => { + const next = { ...prev }; + result.agents.forEach((a, i) => { + const speakerId = i + 2; + const base = next[speakerId] ?? { model: "", weight: 85, temp: 0.3, prompt: "" }; + next[speakerId] = { ...base, model: a.model ?? "" }; + }); + return next; + }); step2.resetInitGate(); // 这是从第一步确认席位、开启的一轮**实时**研讨(非查看历史/草稿) → 清掉 // “查看历史”闸门,否则之前看过历史记录留下的 true 会一直把「后台挂起」按钮藏掉。 @@ -1124,8 +1143,13 @@ export default function RoundtablePlanningPage() { } return; } + // 管理员:打开「会商诊断日志」页(全流程报错排查)。非管理员维持原占位提示。 + if (getStoredAuth()?.system_role === "admin") { + navigate("/page/strategy/admin/roundtable-diagnostics"); + return; + } triggerToast("⚙️ 系统管理面板拉取中(暂不开放外部审计)"); - }, [buildWujieConsensusPayload, emitConsensusToParent, inWujieEmbed, triggerToast]); + }, [buildWujieConsensusPayload, emitConsensusToParent, inWujieEmbed, navigate, triggerToast]); // C4(无界回传):第二步**本次 live 研讨** newly 达成共识时,把总控结论 + 各智能体交付广播给父页面。 // 草稿/任务回显(抽屉重开)已在 hydrate 时 markRestoredConsensusAsEmitted,不再重复 emit。 @@ -1251,6 +1275,19 @@ export default function RoundtablePlanningPage() { // 已有存稿则恢复展示。只在「本次会话首次进入第三步」时编排一次 —— 之后进出第三步 // 全是续看(流在页面层不中断);切换草稿 / 新建任务时复位入口闸门。 const step3EnteredRef = useRef(false); + // 进第三步「错峰起跑」定时器。三流虽互无依赖、要并行展示进度,但若**同一刻**一起 + // 调 start()(各自 init thread + run/stream),会把单 worker 网关 + 单连接 sqlite + // checkpoint 锁的瞬时压力顶满,回环 /runs/stream 概率性返回非 200 → 前端冒 502。 + // 这里把后两个流的**起跑**各往后挪一点点(仅错开 start_run 的写尖峰,不是串行等前一个 + // 跑完),削峰即可。定时器存 ref,切草稿 / 重新 hydrate / 新建任务时统一清掉,避免迟发的 + // start() 打到已被复位/中断的上下文。 + const step3StartTimersRef = useRef([]); + const clearStep3StartTimers = useCallback(() => { + for (const t of step3StartTimersRef.current) { + if (typeof window !== "undefined") window.clearTimeout(t); + } + step3StartTimersRef.current = []; + }, []); // step3Report 用 ref 持最新值,供 hydrate 同步读取(避免闭包取到旧草稿快照)。 const step3ReportRef = useRef(step3Report); step3ReportRef.current = step3Report; @@ -1272,8 +1309,24 @@ export default function RoundtablePlanningPage() { // 保证总结/经典/大屏的展示数据跟随当前草稿实时切换。 const hydrateStep3Flows = useCallback(() => { step3EnteredRef.current = true; + // 重新编排前先清掉上一轮可能还没触发的错峰定时器(切草稿重 hydrate 时)。 + clearStep3StartTimers(); const rep = step3ReportRef.current; - // ① 方案总结报告 + // 错峰起跑助手:把一个尚未起跑的流延迟 delayMs 后再 start();触发前再校验 + // step3EnteredRef(被新建任务 / 切草稿复位为 false 则跳过),避免迟发的 start() + // 打到已被中断的上下文。delay=0 表示立即(同步)起跑。 + const staggerStart = (start: () => void, delayMs: number) => { + if (delayMs <= 0 || typeof window === "undefined") { + start(); + return; + } + const id = window.setTimeout(() => { + if (!step3EnteredRef.current) return; + start(); + }, delayMs); + step3StartTimersRef.current.push(id); + }; + // ① 方案总结报告(主页,立即起跑) const savedSum = rep?.summaryReport; if (savedSum && (savedSum.md.trim() || (savedSum.closing ?? "").trim())) { summaryFlow.hydrateSaved(savedSum); @@ -1281,14 +1334,14 @@ export default function RoundtablePlanningPage() { summaryFlow.start(); } // ② 结果绘制(经典模式一)。旧版草稿可能把大屏 HTML 存在 html 槽位(带 marker), - // 那不是经典产物 → 经典照常启动。 + // 那不是经典产物 → 经典照常启动。错峰 ~300ms,削掉与总结的同刻 start 尖峰。 if (rep?.html && !isDashboardHtml(rep.html)) { reportFlow.hydrateSaved(rep); } else { - reportFlow.start(); // 与总结报告并行绘制,进入「结果绘制」即见进行中过程/结果 + staggerStart(() => reportFlow.start(), STEP3_STAGGER_MS); // 仍并行绘制,只是起跑错开 } // ③ 大屏多页(模式二):优先 dashboardJson(按当前主题渲染);旧版 marker html 兜底。 - // 带上大屏生成过程的完整对话(文字 + 步骤条),恢复时整段回显。 + // 带上大屏生成过程的完整对话(文字 + 步骤条),恢复时整段回显。错峰 ~600ms。 const dashSaved = { dialogues: rep?.dashboardDialogues, seatStubMessages: rep?.dashboardSeatStubMessages, @@ -1298,10 +1351,10 @@ export default function RoundtablePlanningPage() { } else if (rep?.html && isDashboardHtml(rep.html)) { dashboardFlow.hydrateSaved(rep.html, dashSaved); } else { - dashboardFlow.start(); + staggerStart(() => dashboardFlow.start(), STEP3_STAGGER_MS * 2); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [summaryFlow, reportFlow, dashboardFlow]); + }, [summaryFlow, reportFlow, dashboardFlow, clearStep3StartTimers]); // 切换草稿:先停掉旧草稿的在途 Step3 流(防串台)。声明在 hydrate effect **之前**, // 保证同一提交内先 abort 旧流、再由下方 effect hydrate 新草稿,顺序不颠倒。 @@ -1312,6 +1365,7 @@ export default function RoundtablePlanningPage() { const prev = abortPrevDraftIdRef.current; abortPrevDraftIdRef.current = drafts.currentDraftId; if (prev && drafts.currentDraftId && prev !== drafts.currentDraftId) { + clearStep3StartTimers(); // 切草稿:取消尚未触发的错峰 start,防打到新草稿上下文 summaryFlow.abort(); reportFlow.abort(); dashboardFlow.abort(); @@ -1373,6 +1427,7 @@ export default function RoundtablePlanningPage() { setStep3Report(null); // 停掉页面层的 Step3 三个生成流(新任务不该带着旧任务的流跑)+ 复位进入闸门, // 并停止后台作业实时跟随(作业本身继续在后台跑)。 + clearStep3StartTimers(); // 取消尚未触发的错峰 start summaryFlow.abort(); reportFlow.abort(); dashboardFlow.abort(); @@ -1819,7 +1874,13 @@ export default function RoundtablePlanningPage() { + ))} + 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, From a8c06ca7dcab561ea39ade5dd6eea8981b35238d 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:46:53 +0800 Subject: [PATCH 5/5] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- offline-backend-20260512/backend/config.yaml | 38 ++++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/offline-backend-20260512/backend/config.yaml b/offline-backend-20260512/backend/config.yaml index b12eec02..cecb48c2 100644 --- a/offline-backend-20260512/backend/config.yaml +++ b/offline-backend-20260512/backend/config.yaml @@ -10,32 +10,32 @@ gateway: port: 8001 models: - - name: deepseek-chat - display_name: DeepSeek Chat - use: langchain_openai:ChatOpenAI - model: deepseek-chat - api_key: sk-35345154983047aba9e12faa86c5f62c - base_url: https://api.deepseek.com - request_timeout: 600.0 - max_retries: 2 - max_tokens: 4096 - temperature: 1.0 - supports_vision: false - supports_thinking: true - - # - name: zai-org/GLM-5-FP8 - # display_name: zai-org/GLM-5-FP8 + # - name: deepseek-chat + # display_name: DeepSeek Chat # use: langchain_openai:ChatOpenAI - # model: zai-org/GLM-5-FP8 - # api_key: modalresearch_gWu46FDpVLYZWPVEA3b_2fq8Jw2UJiKMgEm_Rxmn1SA - # base_url: https://api.us-west-2.modal.direct/v1 + # model: deepseek-chat + # api_key: sk-35345154983047aba9e12faa86c5f62c + # base_url: https://api.deepseek.com # request_timeout: 600.0 # max_retries: 2 # max_tokens: 4096 # temperature: 1.0 - # supports_vision: true + # supports_vision: false # supports_thinking: true + - name: zai-org/GLM-5-FP8 + display_name: zai-org/GLM-5-FP8 + use: langchain_openai:ChatOpenAI + model: zai-org/GLM-5-FP8 + api_key: modalresearch_gWu46FDpVLYZWPVEA3b_2fq8Jw2UJiKMgEm_Rxmn1SA + base_url: https://api.us-west-2.modal.direct/v1 + request_timeout: 600.0 + max_retries: 2 + max_tokens: 4096 + temperature: 1.0 + supports_vision: true + supports_thinking: true + # - name: deepseek-v4-flash # display_name: DeepSeek V4 Flash # use: langchain_openai:ChatOpenAI