diff --git a/frontend-web/src/pages/RoundtableDiagnosticsPage.tsx b/frontend-web/src/pages/RoundtableDiagnosticsPage.tsx new file mode 100644 index 00000000..1804cd56 --- /dev/null +++ b/frontend-web/src/pages/RoundtableDiagnosticsPage.tsx @@ -0,0 +1,333 @@ +"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: "前台实时(服务端)", + 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 = { + 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} + {item.userName ? ( + + 👤 {item.userName} + + ) : 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 [scopeCategory, setScopeCategory] = useState("background"); + 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); + + // 「前台调用」= 服务端观测(foreground) + 前端上报(client) 两种 scope 合并查(后端按逗号分隔多值)。 + const scopeParam = scopeCategory === "background" ? "background" : "foreground,client"; + + const filters: DiagnosticFilters = useMemo( + () => ({ + scope: scopeParam, + 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, + }), + [scopeParam, 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 switchScope = (next: ScopeCategory) => { + setScopeCategory(next); + setStage(ALL); + setPage(1); + }; + + const applySearch = () => resetPageAnd(() => setQ(qInput.trim())); + + return ( +
+ {/* Header */} +
+ +
+

会商诊断日志

+

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

+
+ +
+ + {/* Filters */} +
+ {/* 范围切换:后台挂起 / 前台调用 */} +
+ {(["background", "frontend"] as ScopeCategory[]).map((c) => ( + + ))} +
+ resetPageAnd(() => setStage(v))} + options={STAGE_OPTIONS[scopeCategory]} 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() { ) } /> + + ) : ( + + ) + } + /> ) } + : 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/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/BusinessChainPicker.tsx b/frontend-web/src/roundtable-planning/components/BusinessChainPicker.tsx index 60dda10c..0254d886 100644 --- a/frontend-web/src/roundtable-planning/components/BusinessChainPicker.tsx +++ b/frontend-web/src/roundtable-planning/components/BusinessChainPicker.tsx @@ -147,18 +147,21 @@ export function BusinessChainPicker({ const chainMeta = { id: selectedChain.id, title: selectedChain.title }; const seatNameById = new Map(selectedChain.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(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/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, ); } 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..9fd1e2eb 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 } = @@ -1699,6 +1718,27 @@ function SeatStrip({ )} + {/* 席位模型覆盖(右侧操作区):空 = 跟随全局所选模型;选了则该席位作为子智能体 + 执行时用此模型。技能重的席位配强模型、边角席位配快模型,配合技能白名单实现 + 「强弱搭配」。放到右侧操作区,与名称/描述解耦,卡片更整洁。 */} +
+
+ 模型 + +
+
+ {/* 控制:查看详情 / 并入上一阶段 / 独立成阶段 / 并入下一阶段 / 移除 */}