From 3907034a1df6f31cc52cfa99386c7a9bf95e7c6a Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Mon, 15 Jun 2026 18:20:20 +0800 Subject: [PATCH 1/9] =?UTF-8?q?feat(roundtable):=20=E7=AC=AC=E4=BA=8C?= =?UTF-8?q?=E6=AD=A5=E5=B8=AD=E4=BD=8D=E6=89=A7=E8=A1=8C=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=EF=BC=88=E5=BF=AB=E9=80=9F/=E6=80=9D=E8=80=83/=E4=B8=93?= =?UTF-8?q?=E4=B8=9A=E9=97=AE=E7=AD=94/=E5=A4=9A=E6=99=BA=E8=83=BD?= =?UTF-8?q?=E4=BD=93=EF=BC=89+=20=E5=AD=90=E6=99=BA=E8=83=BD=E4=BD=93?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E5=8D=A1=E7=89=87=20+=20=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E8=B7=A8=E9=A1=B5=E9=80=9A=E7=9F=A5=20+=20?= =?UTF-8?q?=E9=93=BE=E6=9D=A1=E9=BB=98=E8=AE=A4=E6=A8=A1=E5=BC=8F=E6=8C=81?= =?UTF-8?q?=E4=B9=85=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 席位执行模式:选择参会智能体弹窗加模式选择器,映射 thinking/reasoning/subagent 透传 recommend/chain/dag 三条编排;后端 _special_run 覆盖 seat policy(仅席位, 总控不变);ultra 解锁席位 task 子代理(custom 流回传 task_running 实时进度)。 - 子智能体任务卡片:复用主聊天 SubtaskCard + SubtaskContext,从席位流解析 task 调用/结果,在 Step2 气泡渲染多张任务卡片(覆盖业务链条路径)。 - 后台挂起也按所选模式跑(StartParams→inprocess gateway→run_seat 覆盖 policy)。 - 后台作业完成全工作区跨页通知:RoundtableJobNotifier 挂在 PageLayout 常驻层, 作业终态弹「达成共识」同款浮窗。 - 业务链条可配「默认执行模式」并持久化(roundtable_chains.seat_mode;选链时弹窗 默认用它,仍可临时改)。 - 修复后台 Step3 HTML 看板概率截断:deepseek max_tokens=4096 单轮写不完长 HTML, 产物自愈重试上限 2→5,让 append 续写写完整(同惠及 summary/dashboard)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend-web/src/pages/PageRoutes.tsx | 3 + .../src/roundtable-planning/api/chains.ts | 9 + .../roundtable-planning/api/multi-agent.ts | 13 ++ .../api/roundtable-jobs.ts | 11 + .../components/BusinessChainPicker.tsx | 14 +- .../components/MessageStepsCard.tsx | 2 + .../components/RecommendAgentsDialog.tsx | 55 ++++- .../components/RoundtableJobNotifier.tsx | 125 ++++++++++ .../components/Step2Panel.tsx | 28 +++ .../hooks/useStep2Orchestration.ts | 218 +++++++++++++++++- .../roundtable-planning/lib/business-chain.ts | 16 +- .../src/roundtable-planning/lib/job-types.ts | 7 + .../src/roundtable-planning/lib/seat-mode.ts | 77 +++++++ .../pages/BusinessChainEditorPage.tsx | 43 ++++ .../pages/RoundtablePlanningPage.tsx | 61 +++-- .../backend/app/gateway/deps.py | 9 +- .../gateway/roundtable_inprocess_gateway.py | 43 +++- .../app/gateway/roundtable_job_executor.py | 6 + .../app/gateway/routers/multi_agent.py | 43 +++- .../app/gateway/routers/roundtable_chains.py | 19 ++ .../app/gateway/routers/roundtable_jobs.py | 9 + .../versions/20260615_01_chain_seat_mode.py | 50 ++++ .../persistence/roundtable_chains/model.py | 3 + .../persistence/roundtable_chains/sql.py | 6 + .../scripts/sql/column_additions_mysql.sql | 4 + 25 files changed, 834 insertions(+), 40 deletions(-) create mode 100644 frontend-web/src/roundtable-planning/components/RoundtableJobNotifier.tsx create mode 100644 frontend-web/src/roundtable-planning/lib/seat-mode.ts create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260615_01_chain_seat_mode.py diff --git a/frontend-web/src/pages/PageRoutes.tsx b/frontend-web/src/pages/PageRoutes.tsx index 178c1544..95b6ac62 100644 --- a/frontend-web/src/pages/PageRoutes.tsx +++ b/frontend-web/src/pages/PageRoutes.tsx @@ -15,6 +15,7 @@ import Header from "@/strategy-components/Header"; import OpenCanvasRoutes from "@/open-canvas/routes/OpenCanvasRoutes"; import RoundtableRoutes from "./RoundtableRoutes"; import NotebookRoutes from "./NotebookRoutes"; +import { RoundtableJobNotifier } from "@/roundtable-planning/components/RoundtableJobNotifier"; function PageLayout() { const [collapsed, setCollapsed] = useState(false); @@ -35,6 +36,7 @@ function PageLayout() { // 高度链,根布局跟随容器高度。全屏下 100% 解析到视口,与 h-screen 等价;无界等嵌套 // 下容器不占满整屏时,高度才能正确收缩而非锁死整屏。 return ( +
{pageSidebarAllowed &&
}
@@ -86,6 +88,7 @@ function PageLayout() {
+
); } diff --git a/frontend-web/src/roundtable-planning/api/chains.ts b/frontend-web/src/roundtable-planning/api/chains.ts index 4be2479f..57df4440 100644 --- a/frontend-web/src/roundtable-planning/api/chains.ts +++ b/frontend-web/src/roundtable-planning/api/chains.ts @@ -14,6 +14,7 @@ import { apiFetch } from "@/core/api/fetch-client"; import { getBackendBaseURL } from "@/core/config"; import type { BusinessChain, ChainSeat } from "@/roundtable-planning/lib/business-chain"; +import type { SeatMode } from "@/roundtable-planning/lib/seat-mode"; /** Fields accepted when creating a chain. */ export interface ChainCreatePayload { @@ -28,6 +29,8 @@ export interface ChainCreatePayload { stageGoals?: (string | null)[] | null; /** Optional coordinator orchestration prompt (dag-only). null/"" = none. */ coordinatorPrompt?: string | null; + /** 默认席位执行模式(flash/thinking/pro/ultra);null = 未设。 */ + seatMode?: SeatMode | null; } /** Partial update — send only what changed. */ @@ -41,6 +44,8 @@ export interface ChainUpdatePayload { stageGoals?: (string | null)[] | null; /** 写给总控的编排提示;send `null`/"" to clear, omit to leave. */ coordinatorPrompt?: string | null; + /** 默认席位执行模式;send `null` to clear, omit to leave. */ + seatMode?: SeatMode | null; /** 发布开关:true 发布为公共 / false 取消发布;omit 不改。仅 owner 生效。 */ isPublic?: boolean; } @@ -70,6 +75,7 @@ interface ServerChain { stages?: string[][] | null; stage_goals?: (string | null)[] | null; coordinator_prompt?: string | null; + seat_mode?: string | null; is_public?: boolean; is_mine?: boolean; owner_name?: string | null; @@ -86,6 +92,7 @@ function toChain(s: ServerChain): BusinessChain { stages: Array.isArray(s.stages) ? s.stages : null, stageGoals: Array.isArray(s.stage_goals) ? s.stage_goals : null, coordinatorPrompt: s.coordinator_prompt ?? null, + seatMode: (s.seat_mode as SeatMode | null) ?? undefined, isPublic: s.is_public ?? false, // 后端缺省时按「我的」处理(向后兼容旧响应)。 isMine: s.is_mine ?? true, @@ -104,6 +111,7 @@ function fromCreate(p: ChainCreatePayload) { ...(p.stages !== undefined ? { stages: p.stages } : {}), ...(p.stageGoals !== undefined ? { stage_goals: p.stageGoals } : {}), ...(p.coordinatorPrompt !== undefined ? { coordinator_prompt: p.coordinatorPrompt } : {}), + ...(p.seatMode !== undefined ? { seat_mode: p.seatMode } : {}), }; } @@ -117,6 +125,7 @@ function fromUpdate(p: ChainUpdatePayload) { // Forward stage goals even when explicitly null (clears goals server-side). if (p.stageGoals !== undefined) out.stage_goals = p.stageGoals; if (p.coordinatorPrompt !== undefined) out.coordinator_prompt = p.coordinatorPrompt; + if (p.seatMode !== undefined) out.seat_mode = p.seatMode; if (p.isPublic !== undefined) out.is_public = p.isPublic; return out; } diff --git a/frontend-web/src/roundtable-planning/api/multi-agent.ts b/frontend-web/src/roundtable-planning/api/multi-agent.ts index bdb833f2..581f3499 100644 --- a/frontend-web/src/roundtable-planning/api/multi-agent.ts +++ b/frontend-web/src/roundtable-planning/api/multi-agent.ts @@ -202,6 +202,15 @@ export interface StreamMultiAgentRequest { displayName?: string; newMessage: string; model?: string; + /** + * 仅 special 用。席位执行模式(快速/思考/专业问答/多智能体问答)派生的运行参数, + * 覆盖后端 `roundtable_run_policy` 的 thinking/reasoning 默认值。省略时后端按 policy + * 默认执行(向后兼容,旧前端不传不破坏)。三者通常一起传(见 `lib/seat-mode.ts`)。 + */ + thinkingEnabled?: boolean; + reasoningEffort?: "minimal" | "low" | "medium" | "high"; + /** 仅 special 用;ultra 档置真,给席位解锁 `task` 子代理工具。 */ + subagentEnabled?: boolean; skillStopNames?: string[]; /** * 仅 leader 用。置 true 表示这是「派活后让总控判断继续/综合」的轮次:后端会把各已交付 @@ -444,6 +453,10 @@ export async function streamMultiAgent( display_name: req.displayName, new_message: req.newMessage, model: req.model, + // 席位模式派生参数(仅 special);undefined 时不带上 → 后端按 policy 默认执行。 + thinking_enabled: req.thinkingEnabled, + reasoning_effort: req.reasoningEffort, + subagent_enabled: req.subagentEnabled, skill_stop_names: req.skillStopNames ?? [], // 仅 leader 用;综合轮让后端注入各席位完整交付正文。省略 → 后端按 false 处理。 synthesis_mode: req.synthesisMode ?? false, diff --git a/frontend-web/src/roundtable-planning/api/roundtable-jobs.ts b/frontend-web/src/roundtable-planning/api/roundtable-jobs.ts index b0f44067..d766d0c3 100644 --- a/frontend-web/src/roundtable-planning/api/roundtable-jobs.ts +++ b/frontend-web/src/roundtable-planning/api/roundtable-jobs.ts @@ -15,6 +15,7 @@ import { apiFetch } from "@/core/api/fetch-client"; import { getBackendBaseURL } from "@/core/config"; import { getAvatarTypeFor } from "../lib/roundtable-constants"; +import { seatModeToRunParams } from "../lib/seat-mode"; import { isTerminalJobStatus, type DispatchChainNode, @@ -75,6 +76,9 @@ function mapJob(raw: Record): RoundtableJob { } function toStartBody(p: StartJobPayload) { + // 席位模式 → thinking/reasoning/subagent,与前端实时路径(/run/stream)同一份映射; + // 缺省(未选模式)时不带这三字段 → 后端按 policy 默认(≈pro),向后兼容。 + const seat = p.seatMode ? seatModeToRunParams(p.seatMode) : null; return { draftId: p.draftId ?? null, intent: p.intent, @@ -86,6 +90,13 @@ function toStartBody(p: StartJobPayload) { seedMessage: p.seedMessage, threadIds: p.threadIds ?? null, coordinatorName: p.coordinatorName ?? "roundtable-coordinator", + ...(seat + ? { + thinking_enabled: seat.thinking_enabled, + reasoning_effort: seat.reasoning_effort, + subagent_enabled: seat.subagent_enabled, + } + : {}), }; } diff --git a/frontend-web/src/roundtable-planning/components/BusinessChainPicker.tsx b/frontend-web/src/roundtable-planning/components/BusinessChainPicker.tsx index bb431011..f484cc6f 100644 --- a/frontend-web/src/roundtable-planning/components/BusinessChainPicker.tsx +++ b/frontend-web/src/roundtable-planning/components/BusinessChainPicker.tsx @@ -13,7 +13,7 @@ * * 详见 `docs/multi-agent-business-chain-dev.md` §6.2 / §7 阶段 3。 */ -import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { ArrowRightIcon, CheckIcon, @@ -44,12 +44,15 @@ export interface BusinessChainPickerProps { onCancel: () => void; /** 跳转到业务链条配置页(同时关闭弹窗)。 */ onGoConfig: () => void; + /** 选中链条变化时回调(含其 seatMode),父弹窗据此把执行模式默认设为链条的配置。 */ + onSelectedChainChange?: (chain: BusinessChain | null) => void; } export function BusinessChainPicker({ onConfirm, onCancel, onGoConfig, + onSelectedChainChange, }: BusinessChainPickerProps) { const [chains, setChains] = useState([]); const [loading, setLoading] = useState(true); @@ -102,6 +105,15 @@ export function BusinessChainPicker({ () => chains.find((c) => c.id === selectedId) ?? null, [chains, selectedId], ); + // 选中链条变化 → 通知父弹窗(用 ref 持有回调,只在 selectedChain 真正变化时触发, + // 避免父级每次 render 重置用户已手动改过的执行模式)。 + const onSelectedChainChangeRef = useRef(onSelectedChainChange); + useEffect(() => { + onSelectedChainChangeRef.current = onSelectedChainChange; + }, [onSelectedChainChange]); + useEffect(() => { + onSelectedChainChangeRef.current?.(selectedChain); + }, [selectedChain]); const survivingSeats = useMemo( () => (selectedChain ? selectedChain.seats.filter((s) => isExisting(s.agent_id)) : []), [selectedChain, isExisting], diff --git a/frontend-web/src/roundtable-planning/components/MessageStepsCard.tsx b/frontend-web/src/roundtable-planning/components/MessageStepsCard.tsx index df5f9795..37656fb8 100644 --- a/frontend-web/src/roundtable-planning/components/MessageStepsCard.tsx +++ b/frontend-web/src/roundtable-planning/components/MessageStepsCard.tsx @@ -52,6 +52,8 @@ export interface MessageStepsCardProps { /** 工具调用阶段的 step 才进入折叠卡;非工具步骤(connecting/streaming/tool_result)归到「思考」段。 */ function isToolStep(step: BubbleStep): boolean { + // `task`(派生子智能体)单独渲染成任务卡片(SubtaskCard),不在步骤折叠卡里重复展示。 + if (step.key === "tool_calling:task") return false; return step.key.startsWith("tool_calling:") && step.key.length > "tool_calling:".length; } diff --git a/frontend-web/src/roundtable-planning/components/RecommendAgentsDialog.tsx b/frontend-web/src/roundtable-planning/components/RecommendAgentsDialog.tsx index c70d5e7c..61bb1de5 100644 --- a/frontend-web/src/roundtable-planning/components/RecommendAgentsDialog.tsx +++ b/frontend-web/src/roundtable-planning/components/RecommendAgentsDialog.tsx @@ -34,6 +34,11 @@ import { MessageStepsCard } from "./MessageStepsCard"; import { BusinessChainPicker } from "./BusinessChainPicker"; import type { IntentSummary } from "../api/intent"; import type { SeatSelectionResult } from "../lib/business-chain"; +import { + DEFAULT_SEAT_MODE, + SEAT_MODE_OPTIONS, + type SeatMode, +} from "../lib/seat-mode"; import { filterRecommendCandidates, listRecommendHistory, @@ -200,6 +205,9 @@ export function RecommendAgentsDialog({ const navigate = useNavigate(); // 席位来源模式:智能推荐 / 业务链条。默认**业务链条**。 const [mode, setMode] = useState<"recommend" | "chain">("chain"); + // 席位执行模式(快速/思考/专业问答/多智能体问答)——推荐 / 业务链条两种来源通用, + // 决定第二步每个子智能体席位的推理深度。默认 pro(= 改造前席位固定行为)。 + const [seatMode, setSeatMode] = useState(DEFAULT_SEAT_MODE); const [state, setState] = useState(INITIAL_STATE); const [selectedIds, setSelectedIds] = useState>(new Set()); @@ -515,6 +523,14 @@ export function RecommendAgentsDialog({ return `已选 ${selectedCount} / ${MAX_SELECTED}`; })(); + // 统一在回传前合并席位模式,推荐 / 业务链条两条确认路径共用,保证 seatMode 总被带上。 + const handleConfirmWithMode = useCallback( + (result: SeatSelectionResult) => { + onConfirm({ ...result, seatMode }); + }, + [onConfirm, seatMode], + ); + const handleConfirm = useCallback(() => { if (!canConfirm) return; // Order: recommended-and-selected first (in recommender order), then @@ -530,8 +546,8 @@ export function RecommendAgentsDialog({ for (const c of otherCards) { if (selectedIds.has(c.agent_id)) ordered.push({ agent_id: c.agent_id, name: c.name }); } - onConfirm({ agents: ordered, mode: "recommend" }); - }, [canConfirm, recommendedCards, otherCards, selectedIds, onConfirm]); + handleConfirmWithMode({ agents: ordered, mode: "recommend" }); + }, [canConfirm, recommendedCards, otherCards, selectedIds, handleConfirmWithMode]); const handleGoConfig = useCallback(() => { onCancel(); @@ -595,6 +611,35 @@ export function RecommendAgentsDialog({ + {/* 席位执行模式:快速 / 思考 / 专业问答 / 多智能体问答。推荐 & 业务链条通用, + 决定第二步每个子智能体席位的推理深度(thinking/reasoning/subagent)。 */} +
+
+ 执行模式 +
+ {SEAT_MODE_OPTIONS.map((opt) => ( + + ))} +
+
+

+ {SEAT_MODE_OPTIONS.find((o) => o.value === seatMode)?.description} +

+
+ {/* ===== 智能推荐模式(现状,零行为变更)===== */} {mode === "recommend" && ( <> @@ -750,9 +795,13 @@ export function RecommendAgentsDialog({ {/* ===== 业务链条模式 ===== */} {mode === "chain" && ( { + // 选中链条若配了默认执行模式 → 把顶部模式选择器默认设为它(用户仍可临时改)。 + if (chain?.seatMode) setSeatMode(chain.seatMode); + }} /> )} diff --git a/frontend-web/src/roundtable-planning/components/RoundtableJobNotifier.tsx b/frontend-web/src/roundtable-planning/components/RoundtableJobNotifier.tsx new file mode 100644 index 00000000..4d61dbc4 --- /dev/null +++ b/frontend-web/src/roundtable-planning/components/RoundtableJobNotifier.tsx @@ -0,0 +1,125 @@ +/** + * 圆桌「后台挂起作业」全工作区跨页完成通知器。 + * + * 挂在 `PageLayout`(`/page/*` 的常驻层,导航切页不卸载)里:圆桌页点「后台挂起」后 + * 调 `watchJob(jobId)` 注册监听,本 Provider 用 `streamJob` 订阅该作业 SSE;作业跑到 + * 终态时弹一个**与「达成共识」同款**的顶部浮窗(bg-primary + Sparkles + bounce), + * 因此用户切到聊天/任务/其它工作区页面也能收到完成提示。 + * + * 自带浮窗(不走 sonner)—— 各工作区页各自挂了一个 sonner ``,若这里也用 + * sonner 会出现多 Toaster 重复弹;自渲染浮窗由本常驻 Provider 独占,天然不重复。 + * + * 不含「刷新恢复」:watchedJobIds 是内存态,整页刷新后丢失(本期范围)。Provider 缺失 + * 时(如 embed 路由在 PageRoutes 之外)`watchJob` 为 no-op,调用方不报错。 + */ +import { + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, + type ReactNode, +} from "react"; +import { Sparkles } from "lucide-react"; + +import { streamJob } from "@/roundtable-planning/api/roundtable-jobs"; +import type { RoundtableJob } from "@/roundtable-planning/lib/job-types"; + +interface RoundtableJobNotifierValue { + /** 注册一个后台作业 id,作业完成(done/error)时弹全局提示。重复 id 自动去重。 */ + watchJob: (jobId: string) => void; +} + +const RoundtableJobNotifierContext = createContext({ + watchJob: () => { + /* 无 Provider(如 embed 路由)时 no-op,不报错 */ + }, +}); + +export function useRoundtableJobNotifier(): RoundtableJobNotifierValue { + return useContext(RoundtableJobNotifierContext); +} + +/** 单个作业的 SSE 监听器(无 UI):到终态回调 onTerminal;传输中断回调 onStreamError。 */ +function JobWatcher({ + jobId, + onTerminal, + onStreamError, +}: { + jobId: string; + onTerminal: (job: RoundtableJob) => void; + onStreamError: (jobId: string) => void; +}) { + const onTerminalRef = useRef(onTerminal); + const onStreamErrorRef = useRef(onStreamError); + useEffect(() => { + onTerminalRef.current = onTerminal; + onStreamErrorRef.current = onStreamError; + }, [onTerminal, onStreamError]); + useEffect(() => { + const stop = streamJob(jobId, { + onProgress: () => {}, + onDone: (job) => onTerminalRef.current(job), + onError: () => onStreamErrorRef.current(jobId), + }); + return () => stop(); + }, [jobId]); + return null; +} + +/** 提示浮窗存活时长(ms)—— 比页内 toast(3.5s) 略长,给跨页用户更多时间看到。 */ +const NOTICE_DURATION_MS = 6000; + +export function RoundtableJobNotifier({ children }: { children: ReactNode }) { + const [watchedJobIds, setWatchedJobIds] = useState([]); + const [notice, setNotice] = useState(null); + + const watchJob = useCallback((jobId: string) => { + setWatchedJobIds((prev) => (prev.includes(jobId) ? prev : [...prev, jobId])); + }, []); + + const handleTerminal = useCallback((job: RoundtableJob) => { + setWatchedJobIds((prev) => prev.filter((id) => id !== job.id)); + if (job.status === "done") { + setNotice("🎉 后台研讨已完成!可在「历史记录」查看结论与报告"); + } else if (job.status === "error") { + setNotice("❌ 后台任务执行出错,请在「历史记录」中查看"); + } + // cancelled:用户主动取消,不提示。 + }, []); + + const handleStreamError = useCallback((jobId: string) => { + // SSE 传输中断(非作业终态):静默停止监听,不误报完成。 + setWatchedJobIds((prev) => prev.filter((id) => id !== jobId)); + }, []); + + useEffect(() => { + if (!notice) return; + const timer = setTimeout(() => setNotice(null), NOTICE_DURATION_MS); + return () => clearTimeout(timer); + }, [notice]); + + return ( + + {children} + {watchedJobIds.map((id) => ( + + ))} + {notice && ( +
+ + {notice} +
+ )} +
+ ); +} diff --git a/frontend-web/src/roundtable-planning/components/Step2Panel.tsx b/frontend-web/src/roundtable-planning/components/Step2Panel.tsx index 128d2b05..7835841a 100644 --- a/frontend-web/src/roundtable-planning/components/Step2Panel.tsx +++ b/frontend-web/src/roundtable-planning/components/Step2Panel.tsx @@ -57,6 +57,8 @@ import { StreamingCursor, } from "@/roundtable-planning/components/MessageBubble"; import { MessageStepsCard } from "@/roundtable-planning/components/MessageStepsCard"; +import { SubtaskCard } from "@/components/workspace/messages/subtask-card"; +import { useSubtask } from "@/core/tasks/context"; import { ParallelSeatStrip } from "@/roundtable-planning/components/ParallelSeatStrip"; import { DagFlowMiniPanel } from "@/roundtable-planning/components/DagFlowMiniPanel"; import { useArtifacts } from "@/components/workspace/artifacts"; @@ -909,6 +911,15 @@ export function Step2Message({ resolveAgentName={resolveAgentName} /> )} + {/* 「多智能体问答」模式:本席位派生的子智能体任务卡片(复用主聊天 SubtaskCard, + 从共享 SubtaskContext 取状态)。同时发起多个 task → 多张卡片纵向堆叠。 */} + {Array.isArray(msg.subtaskIds) && msg.subtaskIds.length > 0 && ( +
+ {msg.subtaskIds.map((taskId) => ( + + ))} +
+ )} {reasoning && ( {reasoning} )} @@ -932,6 +943,23 @@ export function Step2Message({ ); } +/** + * 子智能体任务卡片包裹层:子任务内容是**实时态**,存放在共享 SubtaskContext(主聊天同一份); + * 草稿回显 / 历史记录等场景气泡上虽留有 subtaskId,但 context 里已无对应 Subtask —— 此时 + * 返回 null 不渲染(而非让 SubtaskCard 的 `useSubtask(id)!` 取到 undefined 崩溃)。 + */ +function RoundtableSubtaskCard({ + taskId, + isLoading, +}: { + taskId: string; + isLoading: boolean; +}) { + const task = useSubtask(taskId); + if (!task) return null; + return ; +} + /** * 收集气泡里所有"写了文件"的产出路径,按出现顺序去重,聚合到一行文件卡片。 * diff --git a/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts b/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts index 84f98a36..70bfb38f 100644 --- a/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts +++ b/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts @@ -27,6 +27,7 @@ import { type ThreadIdMap, } from "@/roundtable-planning/api/multi-agent"; import { uploadFiles } from "@/core/uploads/api"; +import { useUpdateSubtask } from "@/core/tasks/context"; /** 干预消息携带的上传文件引用(与 streamMultiAgent 的 files 字段同形)。 */ type RunFileRef = NonNullable[number]; @@ -37,12 +38,56 @@ function takeOnce(ref: { current: T | null }): T | undefined { ref.current = null; return v; } + +/** + * 从一帧里识别子智能体 custom 事件(task_started/running/completed/failed/cancelled/ + * timed_out)。custom 流帧通常是裸 payload 对象 `{type:"task_*", task_id, ...}`; + * 开启 stream_subgraphs 时也可能被包成 `[namespace, payload]`,这里两种都兼容。 + */ +function asSubagentTaskEvent(frame: unknown): { + type: string; + task_id: string; + message?: unknown; + result?: unknown; + error?: unknown; + description?: unknown; +} | null { + const cand = + frame && typeof frame === "object" && !Array.isArray(frame) + ? (frame as Record) + : Array.isArray(frame) && frame.length >= 2 && frame[1] && typeof frame[1] === "object" + ? (frame[1] as Record) + : null; + if (!cand) return null; + const type = typeof cand.type === "string" ? cand.type : ""; + const taskId = typeof cand.task_id === "string" ? cand.task_id : ""; + if (!type.startsWith("task_") || !taskId) return null; + return { type, task_id: taskId, message: cand.message, result: cand.result, error: cand.error, description: cand.description }; +} + +/** 从 LangGraph 消息 chunk 的 content(string 或结构化 block 数组)里抽纯文本。 */ +function extractFrameText(content: unknown): string { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + let out = ""; + for (const item of content) { + if (typeof item === "string") out += item; + else if (item && typeof item === "object") { + const block = item as Record; + if (typeof block.text === "string") out += block.text; + } + } + return out; + } + return ""; +} import { buildDagStageLeaderTask, pickDagSeatTask, type OrchestrationPlan, } from "@/roundtable-planning/lib/dag-types"; import type { ChainNodeState } from "@/roundtable-planning/lib/job-types"; +import { seatModeToRequestFields, type SeatMode } from "@/roundtable-planning/lib/seat-mode"; import { DEFAULT_SELECTED_AGENTS, MAX_CYCLES, @@ -65,6 +110,12 @@ export type Step2Dialogue = { streaming?: boolean; /** special 席位气泡对应的 LangGraph thread,用于沙箱预览。 */ agentThreadId?: string; + /** + * 「多智能体问答」模式下,本席位用 `task` 工具派生的子智能体任务 id 列表(=task 工具 + * 调用的 tool_call_id)。渲染时据此从共享 SubtaskContext 取每个 Subtask 渲染任务卡片 + * (复用主聊天 `SubtaskCard`,见 Step2Panel.Step2Message)。非 ultra 模式恒为空。 + */ + subtaskIds?: string[]; steps?: Array<{ id: string; key: string; @@ -222,6 +273,12 @@ export interface UseStep2OrchestrationOptions { currentStep: number; /** 全局选中模型(顶栏「推理模型」下拉)。 */ selectedModel: string; + /** + * 席位执行模式(快速/思考/专业问答/多智能体问答)。决定第二步**每个子智能体席位** + * 的推理深度(thinking/reasoning_effort/subagent);总控派活不受影响。由「选择参会 + * 智能体」弹窗确认时的 `SeatSelectionResult.seatMode` 决定。缺省 `pro`。 + */ + selectedMode: SeatMode; /** * 编排模式: * - `recommend`(默认)—— 总控自由派活循环(`runOrchestration`); @@ -376,6 +433,7 @@ export function useStep2Orchestration( const { currentStep, selectedModel, + selectedMode, orchestrationMode, orchestrationPlan, dagExecMode = "fast", @@ -507,6 +565,8 @@ export function useStep2Orchestration( // runOrchestration 的 useCallback deps 大幅收窄,避免每次模型切换、agentConfigs // 改写都重建一遍循环函数 —— 之前没收窄时 useEffect 会因 deps 变化重复 init。 const selectedModelRef = useRef(selectedModel); + // 席位执行模式同样打到 ref,让席位调用在编排循环里读到最新值而不重建回调。 + const selectedModeRef = useRef(selectedMode); const agentConfigsRef = useRef>(agentConfigs); const agentNameToRoleRef = useRef(agentNameToRole); const threadIdsRef = useRef(threadIds); @@ -543,6 +603,9 @@ export function useStep2Orchestration( useEffect(() => { selectedModelRef.current = selectedModel; }, [selectedModel]); + useEffect(() => { + selectedModeRef.current = selectedMode; + }, [selectedMode]); useEffect(() => { agentConfigsRef.current = agentConfigs; }, [agentConfigs]); @@ -945,6 +1008,137 @@ export function useStep2Orchestration( [upsertSeatToolCall], ); + // ── 「多智能体问答」子智能体任务卡片(参照主聊天 message-list 的 task 识别)────────── + // ultra 模式下席位用 `task` 工具派生子智能体。从席位流的 messages-tuple 帧里: + // (1) AI chunk 的 tool_calls 里 name==="task" → 建/更新一条 Subtask(in_progress); + // (2) type==="tool" 且 tool_call_id 命中已知 task id → 据「Task Succeeded./failed./ + // timed out」前缀更新为 completed/failed(与主聊天 message-list.tsx 同源解析)。 + // Subtask 存进共享 SubtaskContext(主聊天同一份),气泡只记 id 列表并渲染 。 + const updateSubtask = useUpdateSubtask(); + // 已识别的 task tool_call_id —— 用于在收到 tool 结果帧时只认领「子智能体任务」结果, + // 不误把 write_file 等其它工具的结果当成子任务处理。 + const knownTaskIdsRef = useRef>(new Set()); + + /** 把某个子任务 id 记到对应席位气泡上(新引用 → 触发气泡重渲染,卡片随之刷新状态)。 */ + const touchBubbleSubtaskId = useCallback((bubbleId: number, taskId: string) => { + setStep2RoundtableDialogues((prev) => + prev.map((d) => { + if (d.id !== bubbleId) return d; + const ids = d.subtaskIds ?? []; + // 每次都换新数组引用,确保状态(in_progress→completed/failed)变化时卡片重渲染。 + return { ...d, subtaskIds: ids.includes(taskId) ? [...ids] : [...ids, taskId] }; + }), + ); + }, []); + + /** 处理一条消息(values 快照里的整条 message,或 messages-tuple 的 chunk)的子任务语义。 */ + const ingestSubagentMessage = useCallback( + (msg: Record, bubbleId: number) => { + // (1) AI 消息:抓 task 工具调用 → 建/更新 in_progress 子任务。 + const toolCalls = msg.tool_calls; + if (Array.isArray(toolCalls)) { + for (const tc of toolCalls) { + if (!tc || typeof tc !== "object") continue; + const t = tc as Record; + if (t.name !== "task") continue; + const id = typeof t.id === "string" ? t.id : ""; + if (!id) continue; + const a = (t.args && typeof t.args === "object" ? t.args : {}) as Record; + updateSubtask({ + id, + status: "in_progress", + subagent_type: typeof a.subagent_type === "string" ? a.subagent_type : "", + description: typeof a.description === "string" ? a.description : "", + prompt: typeof a.prompt === "string" ? a.prompt : "", + }); + knownTaskIdsRef.current.add(id); + touchBubbleSubtaskId(bubbleId, id); + } + } + // (2) tool 结果消息:命中已知 task id 时按前缀判完成/失败(与主聊天解析一致)。 + const type = typeof msg.type === "string" ? msg.type.toLowerCase() : ""; + if (type === "tool") { + const taskId = typeof msg.tool_call_id === "string" ? msg.tool_call_id : ""; + if (taskId && knownTaskIdsRef.current.has(taskId)) { + const result = extractFrameText(msg.content); + if (result.startsWith("Task Succeeded. Result:")) { + updateSubtask({ + id: taskId, + status: "completed", + result: result.split("Task Succeeded. Result:")[1]?.trim(), + }); + } else if (result.startsWith("Task failed.")) { + updateSubtask({ + id: taskId, + status: "failed", + error: result.split("Task failed.")[1]?.trim(), + }); + } else if (result.startsWith("Task timed out")) { + updateSubtask({ id: taskId, status: "failed", error: result }); + } + touchBubbleSubtaskId(bubbleId, taskId); + } + } + }, + [updateSubtask, touchBubbleSubtaskId], + ); + + const ingestSubagentTaskFrame = useCallback( + (frame: unknown, bubbleId: number) => { + // custom 事件帧(ultra 席位子智能体进度):task_running 带子智能体最新消息 → 回填 + // latestMessage(卡片实时显示「正在调用 X 工具」);完成/失败即时定状态(比 values 更快)。 + const ev = asSubagentTaskEvent(frame); + if (ev) { + knownTaskIdsRef.current.add(ev.task_id); + if (ev.type === "task_running") { + // latestMessage 非空才会触发 SubtaskContext setTasks 重渲染(见 useUpdateSubtask)。 + updateSubtask({ id: ev.task_id, status: "in_progress", latestMessage: ev.message as never }); + } else if (ev.type === "task_started") { + updateSubtask({ + id: ev.task_id, + status: "in_progress", + description: typeof ev.description === "string" ? ev.description : undefined, + }); + } else if (ev.type === "task_completed") { + updateSubtask({ + id: ev.task_id, + status: "completed", + result: typeof ev.result === "string" ? ev.result : extractFrameText(ev.result), + }); + } else if ( + ev.type === "task_failed" || + ev.type === "task_cancelled" || + ev.type === "task_timed_out" + ) { + updateSubtask({ + id: ev.task_id, + status: "failed", + error: typeof ev.error === "string" ? ev.error : "子智能体任务失败", + }); + } + touchBubbleSubtaskId(bubbleId, ev.task_id); + return; + } + + // values 快照帧:{messages:[...]} —— 权威来源,按整条消息列表对账(子任务完成/失败)。 + if (frame && typeof frame === "object" && !Array.isArray(frame)) { + const msgs = (frame as Record).messages; + if (Array.isArray(msgs)) { + for (const m of msgs) { + if (m && typeof m === "object") ingestSubagentMessage(m as Record, bubbleId); + } + } + return; + } + // messages-tuple 帧:[chunk, metadata] —— 增量,派活时即时建卡。 + if (!Array.isArray(frame) || frame.length === 0) return; + const chunk = frame[0]; + if (!chunk || typeof chunk !== "object") return; + ingestSubagentMessage(chunk as Record, bubbleId); + }, + [ingestSubagentMessage, updateSubtask, touchBubbleSubtaskId], + ); + // ─── Dialogue / Speaker helpers ──────────────────────────────────── const appendDialogue = useCallback( (card: { @@ -1406,6 +1600,8 @@ export function useStep2Orchestration( newMessage: fullTask, peerDeliveries: priorDeliveries, model: perSeatModel || selectedModelRef.current || undefined, + // 席位执行模式(快速/思考/专业问答/多智能体问答)→ thinking/reasoning/subagent。 + ...seatModeToRequestFields(selectedModeRef.current), signal: subAborter.signal, onTextDelta: (text) => a.isParallel ? ingestParallelDelta(a.bubbleId, text) : appendToDialogue(a.bubbleId, text), @@ -1415,7 +1611,10 @@ export function useStep2Orchestration( appendStepIfNew(a.bubbleId, label, `${phase}:${toolName ?? ""}`, args); handleArtifactToolPhase(phase, toolName, args, a.seatThreadId); }, - onUpstreamFrame: (frame) => handleUpstreamFrame(frame, a.seatThreadId), + onUpstreamFrame: (frame) => { + handleUpstreamFrame(frame, a.seatThreadId); + ingestSubagentTaskFrame(frame, a.bubbleId); + }, }); activeAbortersRef.current.delete(subAborter); if (isStale()) { @@ -1690,6 +1889,7 @@ export function useStep2Orchestration( triggerToast, handleArtifactToolPhase, handleUpstreamFrame, + ingestSubagentTaskFrame, resolveSeatThreadId, ], ); @@ -1829,6 +2029,8 @@ export function useStep2Orchestration( displayName: seatName, newMessage: task, model: perSeatModel || selectedModelRef.current || undefined, + // 席位执行模式 → thinking/reasoning/subagent(与推荐/DAG 路径一致)。 + ...seatModeToRequestFields(selectedModeRef.current), signal: subAborter.signal, onTextDelta: (text) => appendToDialogue(subBubbleId, text), onPhaseChange: (phase, toolName, args) => { @@ -1837,7 +2039,10 @@ export function useStep2Orchestration( appendStepIfNew(subBubbleId, label, `${phase}:${toolName ?? ""}`, args); handleArtifactToolPhase(phase, toolName, args, seatThreadId); }, - onUpstreamFrame: (frame) => handleUpstreamFrame(frame, seatThreadId), + onUpstreamFrame: (frame) => { + handleUpstreamFrame(frame, seatThreadId); + ingestSubagentTaskFrame(frame, subBubbleId); + }, }); if (isStale()) { finalizeDialogue(subBubbleId, specRes.content || "(已停止)", "已停止"); @@ -1936,6 +2141,7 @@ export function useStep2Orchestration( triggerToast, handleArtifactToolPhase, handleUpstreamFrame, + ingestSubagentTaskFrame, resolveSeatThreadId, ], ); @@ -2163,6 +2369,8 @@ export function useStep2Orchestration( // 前序各阶段的完整交付 → 供本席位 read_peer_delivery 按需取全文(§12.9)。 peerDeliveries: priorDeliveries, model: perSeatModel || selectedModelRef.current || undefined, + // 席位执行模式 → thinking/reasoning/subagent(与推荐/链条路径一致)。 + ...seatModeToRequestFields(selectedModeRef.current), signal: subAborter.signal, // §6:并行席位走 ref 全量累积 + 节流 flush(只渲染选中的);单节点 // stage 实际串行,1 路流直接 setState 不卡,沿用 appendToDialogue。 @@ -2174,7 +2382,10 @@ export function useStep2Orchestration( appendStepIfNew(bubbleId, label, `${phase}:${toolName ?? ""}`, args); handleArtifactToolPhase(phase, toolName, args, seatThreadId); }, - onUpstreamFrame: (frame) => handleUpstreamFrame(frame, seatThreadId), + onUpstreamFrame: (frame) => { + handleUpstreamFrame(frame, seatThreadId); + ingestSubagentTaskFrame(frame, bubbleId); + }, }); if (isStale()) { finalizeDialogue(bubbleId, specRes.content || "(已停止)", "已停止"); @@ -2276,6 +2487,7 @@ export function useStep2Orchestration( triggerToast, handleArtifactToolPhase, handleUpstreamFrame, + ingestSubagentTaskFrame, resolveSeatThreadId, ingestParallelDelta, resetParallelStreams, diff --git a/frontend-web/src/roundtable-planning/lib/business-chain.ts b/frontend-web/src/roundtable-planning/lib/business-chain.ts index 414ea3cf..a8a824f4 100644 --- a/frontend-web/src/roundtable-planning/lib/business-chain.ts +++ b/frontend-web/src/roundtable-planning/lib/business-chain.ts @@ -10,6 +10,7 @@ */ import { buildPlanFromStages } from "./dag-types"; +import type { SeatMode } from "./seat-mode"; /** 链条里的一个有序席位。`seats[0]` 是第一个发言者。 */ export interface ChainSeat { @@ -48,6 +49,11 @@ export interface BusinessChain { * 空 / null = 不注入。 */ coordinatorPrompt?: string | null; + /** + * 默认席位执行模式(快速/思考/专业问答/多智能体)。选入研讨时弹窗默认用它(仍可临时改)。 + * 缺省 = 未设,调用方回退 `DEFAULT_SEAT_MODE`(pro)。 + */ + seatMode?: SeatMode; /** 是否已发布为公共(所有账号可在「公共」筛选查看 / 选入研讨)。 */ isPublic?: boolean; /** 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条为 false)。 */ @@ -85,6 +91,12 @@ export interface SeatSelectionResult { * 缺省 `fast`。 */ dagExecMode?: "fast" | "sandbox"; + /** + * 席位执行模式(快速/思考/专业问答/多智能体问答)——决定第二步**每个子智能体席位** + * 的推理深度(thinking/reasoning_effort/subagent)。三种编排(recommend/chain/dag)通用。 + * 缺省由调用方按 `DEFAULT_SEAT_MODE`(pro)处理。 + */ + seatMode?: SeatMode; } /** 链条席位数硬约束,与圆桌推荐弹窗的 `MIN_SELECTED`/`MAX_SELECTED` 对齐。 */ @@ -132,12 +144,12 @@ export function buildChainSeatSelection( const agents = plan.stages .flatMap((st) => st.agentIds) .map((id) => ({ agent_id: id, name: nameOf(id) })); - return { agents, mode: "dag", chain: chainMeta, plan, dagExecMode: "fast" }; + 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) })); - return { agents, mode: "chain", chain: chainMeta }; + return { agents, mode: "chain", chain: chainMeta, seatMode: chain.seatMode }; } /** diff --git a/frontend-web/src/roundtable-planning/lib/job-types.ts b/frontend-web/src/roundtable-planning/lib/job-types.ts index 9d995c34..d86c286b 100644 --- a/frontend-web/src/roundtable-planning/lib/job-types.ts +++ b/frontend-web/src/roundtable-planning/lib/job-types.ts @@ -9,6 +9,7 @@ */ import type { OrchestrationPlan } from "./dag-types"; +import type { SeatMode } from "./seat-mode"; /** 作业状态机(§2.1)。 */ export type JobStatus = @@ -120,6 +121,12 @@ export interface StartJobPayload { /** 预建好的 thread 映射(Phase 3b 真网关用;模拟网关忽略)。 */ threadIds?: Record | null; coordinatorName?: string; + /** + * 席位执行模式(快速/思考/专业问答/多智能体)。后台续跑时席位按此模式执行(与前端实时 + * 路径一致);缺省由后端按 policy 默认(≈pro)。在 `toStartBody` 里派生成 thinking/ + * reasoning/subagent 三字段发给后端。 + */ + seatMode?: SeatMode; } /** 终态集合——SSE/轮询判定作业是否结束。 */ diff --git a/frontend-web/src/roundtable-planning/lib/seat-mode.ts b/frontend-web/src/roundtable-planning/lib/seat-mode.ts new file mode 100644 index 00000000..78d75aae --- /dev/null +++ b/frontend-web/src/roundtable-planning/lib/seat-mode.ts @@ -0,0 +1,77 @@ +/** + * 圆桌第二步「席位执行模式」共享定义。 + * + * 与主聊天输入框(`components/workspace/input-box.tsx`)的四档模式同源: + * 用户在「选择参会智能体」弹窗里选一档,第二步派活的**每个子智能体(special 席位)** + * 都按这一档的推理深度执行。模式只决定 `thinking_enabled / reasoning_effort / + * subagent_enabled` 三个运行参数;席位的工具裁剪仍由后端 `roundtable_run_policy` + * 决定(见 `app/gateway/roundtable_run_policy.py`)。 + * + * 总控(leader)派活是短决策任务,**不**受本模式影响(沿用其快速派活策略)。 + * + * 映射逻辑刻意对齐主聊天 `core/threads/hooks.ts` 的 mode → context 转换,保持一致体验: + * - flash 快速 —— 不思考、最快 + * - thinking 思考 —— 思考 + low + * - pro 专业问答 —— 思考 + medium(= 现有席位默认行为,向后兼容) + * - ultra 多智能体 —— 思考 + high + 席位可派子代理(task 工具) + */ + +export type SeatMode = "flash" | "thinking" | "pro" | "ultra"; + +/** 默认档位:专业问答(pro)—— 与改造前席位的固定行为(thinking + medium)一致。 */ +export const DEFAULT_SEAT_MODE: SeatMode = "pro"; + +/** 席位单轮 run 的派生参数(透传给 `/api/multi-agent/run/stream`,覆盖后端 policy 默认)。 */ +export interface SeatRunParams { + thinking_enabled: boolean; + reasoning_effort: "minimal" | "low" | "medium" | "high"; + subagent_enabled: boolean; +} + +/** 把一个席位模式映射成具体的运行参数。 */ +export function seatModeToRunParams(mode: SeatMode): SeatRunParams { + switch (mode) { + case "flash": + return { thinking_enabled: false, reasoning_effort: "minimal", subagent_enabled: false }; + case "thinking": + return { thinking_enabled: true, reasoning_effort: "low", subagent_enabled: false }; + case "ultra": + return { thinking_enabled: true, reasoning_effort: "high", subagent_enabled: true }; + case "pro": + default: + return { thinking_enabled: true, reasoning_effort: "medium", subagent_enabled: false }; + } +} + +/** + * 把席位模式映射成 `streamMultiAgent` 请求字段(camelCase),方便在席位调用处直接 spread。 + * 与 `seatModeToRunParams` 同一份逻辑,只是改成 API 客户端期望的字段名。 + */ +export function seatModeToRequestFields(mode: SeatMode): { + thinkingEnabled: boolean; + reasoningEffort: SeatRunParams["reasoning_effort"]; + subagentEnabled: boolean; +} { + const p = seatModeToRunParams(mode); + return { + thinkingEnabled: p.thinking_enabled, + reasoningEffort: p.reasoning_effort, + subagentEnabled: p.subagent_enabled, + }; +} + +/** 弹窗模式选择器用的展示元数据(标签 + 一句话说明),顺序即展示顺序。 */ +export const SEAT_MODE_OPTIONS: ReadonlyArray<{ + value: SeatMode; + label: string; + description: string; +}> = [ + { value: "flash", label: "快速", description: "快速且高效地完成任务,但可能不够精准" }, + { value: "thinking", label: "思考", description: "思考后再行动,在时间与准确性之间取得平衡" }, + { value: "pro", label: "专业问答", description: "思考、计划再执行,获得更精准的结果,可能需要更多时间" }, + { + value: "ultra", + label: "多智能体问答", + description: "在专业问答基础上,每个席位可调用子代理分工协作,能力最强、更慢更耗时", + }, +]; diff --git a/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx b/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx index 5587e1fa..4eabfe46 100644 --- a/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx @@ -85,6 +85,11 @@ import { type BusinessChain, type ChainSeat, } from "@/roundtable-planning/lib/business-chain"; +import { + DEFAULT_SEAT_MODE, + SEAT_MODE_OPTIONS, + type SeatMode, +} from "@/roundtable-planning/lib/seat-mode"; import { addEmptyStage, addSeatAsNewStage, @@ -153,6 +158,8 @@ export default function BusinessChainEditorPage() { const [description, setDescription] = useState(""); // 写给总控的「编排提示」:告诉总控该怎么派活、做什么(仅分层 DAG 模式注入)。 const [coordinatorPrompt, setCoordinatorPrompt] = useState(""); + // 默认席位执行模式(快速/思考/专业问答/多智能体)—— 选入研讨时弹窗默认用它。 + const [seatMode, setSeatMode] = useState(DEFAULT_SEAT_MODE); // 分层编排是唯一数据源;落库时展平为 seats + 折叠为 stages。 const [stageGroups, setStageGroups] = useState([]); const [saving, setSaving] = useState(false); @@ -277,6 +284,7 @@ export default function BusinessChainEditorPage() { setTitle(chain.title); setDescription(chain.description ?? ""); setCoordinatorPrompt(chain.coordinatorPrompt ?? ""); + setSeatMode(chain.seatMode ?? DEFAULT_SEAT_MODE); setStageGroups(chainToStageGroups(chain)); setStep(1); }, []); @@ -288,6 +296,7 @@ export default function BusinessChainEditorPage() { setTitle(""); setDescription(""); setCoordinatorPrompt(""); + setSeatMode(DEFAULT_SEAT_MODE); setStageGroups([]); setStep(1); }, []); @@ -560,6 +569,7 @@ export default function BusinessChainEditorPage() { stages, stageGoals, coordinatorPrompt: coordPrompt, + seatMode, }); if (!updated) { toast.error("链条已不存在,可能被删除"); @@ -578,6 +588,7 @@ export default function BusinessChainEditorPage() { stages, stageGoals, coordinatorPrompt: coordPrompt, + seatMode, }); setCurrentChainId(created.id); toast.success("已创建业务链条"); @@ -592,6 +603,7 @@ export default function BusinessChainEditorPage() { title, description, coordinatorPrompt, + seatMode, stageGroups, seatCountInvalid, currentChainId, @@ -699,6 +711,37 @@ export default function BusinessChainEditorPage() { /> + {/* 默认执行模式:选入研讨时弹窗默认用它(仍可临时改)。 */} +
+ +
+ {SEAT_MODE_OPTIONS.map((opt) => ( + + ))} +
+

+ {SEAT_MODE_OPTIONS.find((o) => o.value === seatMode)?.description} +

+
+ {/* 总控提示词:label + AI 优化(配色沿用现有 primary)+ 大号等宽编辑区 */}
diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index 1ef6d6ac..4f168b1d 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -100,6 +100,7 @@ import { buildChainSeatSelection, type SeatSelectionResult, } from "@/roundtable-planning/lib/business-chain"; +import { DEFAULT_SEAT_MODE, type SeatMode } from "@/roundtable-planning/lib/seat-mode"; import type { OrchestrationPlan } from "@/roundtable-planning/lib/dag-types"; import type { IntentSummary } from "@/roundtable-planning/api/intent"; import { @@ -108,6 +109,7 @@ import { startJob, startJobKeepalive, } from "@/roundtable-planning/api/roundtable-jobs"; +import { useRoundtableJobNotifier } from "@/roundtable-planning/components/RoundtableJobNotifier"; import { getChain } from "@/roundtable-planning/api/chains"; import { filterRecommendCandidates } from "@/roundtable-planning/api/recommend"; import { listAgents } from "@/core/agents"; @@ -169,6 +171,8 @@ function readEmbedHashQueryPrefill(): RoundtablePrefill | null { export default function RoundtablePlanningPage() { const { theme } = useTheme(); + // 全工作区跨页的后台作业完成通知器(Provider 挂在 PageLayout 常驻层)。 + const { watchJob } = useRoundtableJobNotifier(); const isDark = theme === "dark"; const navigate = useNavigate(); const location = useLocation(); @@ -214,6 +218,9 @@ export default function RoundtablePlanningPage() { // 「导入会商」弹窗(输入分享码把别人分享的会商复制到本账号)。 const [isImportOpen, setIsImportOpen] = useState(false); const [selectedModel, setSelectedModel] = useState(""); + // Step 2 席位执行模式(快速/思考/专业问答/多智能体问答)——决定每个子智能体席位的 + // 推理深度。由「选择参会智能体」弹窗确认时的 SeatSelectionResult.seatMode 决定。 + const [seatMode, setSeatMode] = useState(DEFAULT_SEAT_MODE); // Step 2 编排模式:recommend = 总控自由派活;chain = 总控按业务链条顺序派活; // dag = 分层 DAG(stage 内并行、stage 间串行,配合 orchestrationPlan)。 // 由推荐弹窗确认时的 SeatSelectionResult.mode 决定(见 handleConfirmAgents)。 @@ -329,6 +336,7 @@ export default function RoundtablePlanningPage() { const step2 = useStep2Orchestration({ currentStep, selectedModel, + selectedMode: seatMode, orchestrationMode, orchestrationPlan, dagExecMode, @@ -739,6 +747,8 @@ export default function RoundtablePlanningPage() { // result.mode 决定 Step 2 编排:recommend = 总控自由派活;chain = 按链条顺序派活; // dag = 分层并行(来源链条带 stages,携带 result.plan)。 setOrchestrationMode(result.mode); + // 席位执行模式:弹窗未带(旧调用)时回退默认 pro,保证行为可预期。 + setSeatMode(result.seatMode ?? DEFAULT_SEAT_MODE); setChainMeta(result.mode === "recommend" ? null : result.chain ?? null); setOrchestrationPlan(result.mode === "dag" ? result.plan ?? null : null); setDagExecMode(result.mode === "dag" ? result.dagExecMode ?? "fast" : "fast"); @@ -1118,9 +1128,11 @@ export default function RoundtablePlanningPage() { // 「后台挂起」核心(手动按钮 / 离开页面自动挂起共用):幂等保证草稿存在 → // 同草稿已有在跑作业则跳过 → 启动后台作业。后端另有「同草稿未终态作业幂等返回」 // 兜底(覆盖跨标签页 / beforeunload 与本函数并发触发),绝不重复建作业。 - const startBackgroundJob = useCallback(async (): Promise => { + // 返回本次后台作业的 jobId(已在跑则返回既有作业 id),供「完成时提示」监听; + // 未发起(无席位)返回 null。 + const startBackgroundJob = useCallback(async (): Promise => { const agents = step2.selectedAgents.map((a) => ({ agentId: a.agent_id, name: a.name })); - if (agents.length === 0) return false; + if (agents.length === 0) return null; // 后台挂起发起前**立刻强制存一次**当前聊天(保住发起时刻的本地研讨记录,防丢)。 const draftId = await drafts.saveDraftNow(2); // 防重复:该草稿已有在跑作业就不再新建(避免重复作业 / 前后端双跑)。 @@ -1130,28 +1142,31 @@ export default function RoundtablePlanningPage() { (existing.status === "running" || existing.status === "awaiting_input" || existing.status === "queued"); - if (!alreadyRunning) { - await startJob({ - draftId: draftId ?? undefined, - intent: buildInitialIntent(), - agents, - model: selectedModel || undefined, // 模型沿用第二步选择 - // 后台作业引擎支持 dag(后端 _run_dag:stage 串行 / stage 内并行)—— - // 直接把当前编排模式透传,业务链的分层 / 并行 / 阶段目标 / 总控提示词全部保留。 - orchestrationMode, - chain: chainMeta, - // dag 模式:把整份分层计划带给后端(含 stages / 阶段目标 / 总控提示词 / finalSynthesis)。 - // 非 dag 时为 null(后端按 mode 走 chain/recommend)。 - orchestrationPlan: orchestrationMode === "dag" ? orchestrationPlan : null, - // 不传 threadIds:后台作业由真网关自建全新线程,绝不复用前端本地研讨的线程 - // (那些线程可能有活跃 run,复用会撞 409)。 - }); + if (alreadyRunning) { + return existing?.id ?? null; } - return true; + const job = await startJob({ + draftId: draftId ?? undefined, + intent: buildInitialIntent(), + agents, + model: selectedModel || undefined, // 模型沿用第二步选择 + seatMode, // 席位执行模式沿用第二步选择(后台续跑同样按此模式跑席位) + // 后台作业引擎支持 dag(后端 _run_dag:stage 串行 / stage 内并行)—— + // 直接把当前编排模式透传,业务链的分层 / 并行 / 阶段目标 / 总控提示词全部保留。 + orchestrationMode, + chain: chainMeta, + // dag 模式:把整份分层计划带给后端(含 stages / 阶段目标 / 总控提示词 / finalSynthesis)。 + // 非 dag 时为 null(后端按 mode 走 chain/recommend)。 + orchestrationPlan: orchestrationMode === "dag" ? orchestrationPlan : null, + // 不传 threadIds:后台作业由真网关自建全新线程,绝不复用前端本地研讨的线程 + // (那些线程可能有活跃 run,复用会撞 409)。 + }); + return job.id; }, [ step2, drafts, selectedModel, + seatMode, orchestrationMode, orchestrationPlan, chainMeta, @@ -1165,17 +1180,20 @@ export default function RoundtablePlanningPage() { triggerToast("⚠️ 请先选择研讨席位再后台挂起"); return; } + let jobId: string | null = null; try { - await startBackgroundJob(); + jobId = await startBackgroundJob(); } catch (err) { triggerToast(`❌ 后台挂起失败:${(err as Error).message}`); return; } + // 注册到全工作区通知器,作业完成时**跨页**弹「达成共识同款」提示。 + if (jobId) watchJob(jobId); // 重置回第一步(同新建任务);后台作业在历史记录里继续跑、可点开看进度。 resetToStep1(); triggerToast("🚀 已转入后台,任务会自动跑完研讨、总结报告与结果绘制;可在「历史记录」查看进度"); void drafts.refresh(); - }, [step2, drafts, startBackgroundJob, resetToStep1, triggerToast]); + }, [step2, drafts, startBackgroundJob, resetToStep1, triggerToast, watchJob]); // ── 自动后台挂起:研讨进行中离开本页面(路由卸载)或刷新 / 关标签页时,任务自动转后台 ── // 最新状态进 ref:卸载 cleanup 与 beforeunload 都是「拍快照时刻」的同步回调, @@ -1205,6 +1223,7 @@ export default function RoundtablePlanningPage() { intent: buildInitialIntent(), agents, model: selectedModel || undefined, + seatMode, orchestrationMode, chain: chainMeta, orchestrationPlan: orchestrationMode === "dag" ? orchestrationPlan : null, diff --git a/offline-backend-20260512/backend/app/gateway/deps.py b/offline-backend-20260512/backend/app/gateway/deps.py index 109d32d2..7813ffc2 100644 --- a/offline-backend-20260512/backend/app/gateway/deps.py +++ b/offline-backend-20260512/backend/app/gateway/deps.py @@ -259,7 +259,14 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]: _app = app # 闭包捕获 app,供网关访问 app.state(checkpointer/thread_store) gateway_factory = lambda p: InProcessRoundtableGateway( # noqa: E731 - _app, user_id=p.user_id, agents=p.agents, model=p.model + _app, + user_id=p.user_id, + agents=p.agents, + model=p.model, + # 席位执行模式沿用 Step2 选择,后台席位 run 据此覆盖 policy 的 thinking/reasoning + 开子代理。 + seat_thinking_enabled=p.seat_thinking_enabled, + seat_reasoning_effort=p.seat_reasoning_effort, + seat_subagent_enabled=p.seat_subagent_enabled, ) logger.info("[roundtable-jobs] gateway = inprocess (real models, default)") diff --git a/offline-backend-20260512/backend/app/gateway/roundtable_inprocess_gateway.py b/offline-backend-20260512/backend/app/gateway/roundtable_inprocess_gateway.py index 948075c8..b0c494d4 100644 --- a/offline-backend-20260512/backend/app/gateway/roundtable_inprocess_gateway.py +++ b/offline-backend-20260512/backend/app/gateway/roundtable_inprocess_gateway.py @@ -164,6 +164,7 @@ async def _run_agent_turn( skill_stop_names: list[str], thinking_enabled: bool, reasoning_effort: str, + subagent_enabled: bool = False, ) -> list[dict[str, Any]]: """进程内驱动 agent_name 在 thread_id 上跑一轮(发 message),阻塞到完成,返回最终 messages。 @@ -184,7 +185,8 @@ async def _run_agent_turn( "reasoning_effort": reasoning_effort, "thinking_enabled": thinking_enabled, "is_plan_mode": False, - "subagent_enabled": False, + # ultra 席位才置真 → 解锁 task 子代理工具(席位 policy 未禁 task)。leader 恒 False。 + "subagent_enabled": subagent_enabled, "thread_id": thread_id, "memory_recall_disabled": True, # 与 multi_agent._run_payload 对齐:关 builtin 记忆注入,避免单例总控/席位 @@ -262,7 +264,13 @@ def _read_summary_md(app, thread_id: str, user_id: str) -> str | None: # it all at once」后发出超长 write_file,单次输出被 max_tokens 截断 → 工具调用从未执行、 # run 以 success 收尾但 outputs 下没有 .html → step3.html 回退成开场白文字。 # 自愈:检测产物缺失/截断后,同线程追加纠偏消息(要求 write_file 分段 + append 续写)重试。 -_ARTIFACT_RETRY_LIMIT = 2 +# +# 取值依据(2026-06-15 修复「后台 HTML 看板只生成一半、概率渲染失败」):模型单轮输出受 +# ``config.yaml`` 的 ``max_tokens``(deepseek-chat = 4096)硬截。一份完整看板 HTML 几十 KB +# ≈ 上万 token,单轮写不完;旧值 2(最多 3 轮 ≈ 12k token)对长报告不够 → 落库半截 HTML → +# 渲染失败。调到 5(最多 6 轮 append ≈ 24k token,足够绝大多数看板)显著降低截断概率。 +# 循环命中完整产物(```` / 非空 md / 合法 json)即 break,短报告不会白跑额外轮次。 +_ARTIFACT_RETRY_LIMIT = 5 _REPORT_RETRY_MISSING = ( f"检测到 /mnt/user-data/outputs/{_REPORT_FILE} 还没有生成——你上一轮的 write_file 没有执行成功" @@ -332,11 +340,26 @@ def _build_seat_intro(agents: list[SeatRef]) -> str: class InProcessRoundtableGateway: """进程内真网关。executor 先调 ``init_threads`` 建好线程,再跑编排引擎。""" - def __init__(self, app, *, user_id: str, agents: list[SeatRef], model: str | None = None) -> None: + def __init__( + self, + app, + *, + user_id: str, + agents: list[SeatRef], + model: str | None = None, + seat_thinking_enabled: bool | None = None, + seat_reasoning_effort: str | None = None, + seat_subagent_enabled: bool = False, + ) -> None: self._app = app self._user_id = user_id self._agents = list(agents) self._model = model + # 席位执行模式(沿用 Step2 选择):覆盖 seat policy 的 thinking/reasoning(None → policy 默认), + # subagent 仅 ultra 档为真。只作用 special 席位;leader 派活不受影响。 + self._seat_thinking_enabled = seat_thinking_enabled + self._seat_reasoning_effort = seat_reasoning_effort + self._seat_subagent_enabled = seat_subagent_enabled self._seat_intro = "" self._first_leader = True @@ -400,6 +423,7 @@ class InProcessRoundtableGateway: async def run_seat(self, *, thread_ids, agent_id, task, model) -> SeatTurn: policy = roundtable_run_policy("seat") + # 席位模式覆盖 policy 的 thinking/reasoning(None → policy 默认),与前端 _special_run 一致。 messages = await _run_agent_turn( self._app, thread_id=thread_ids[agent_id], @@ -408,8 +432,17 @@ class InProcessRoundtableGateway: model=model or self._model, excluded_tools=policy.excluded_tools, skill_stop_names=policy.skill_stop_names, - thinking_enabled=policy.thinking_enabled, - reasoning_effort=policy.reasoning_effort, + thinking_enabled=( + policy.thinking_enabled + if self._seat_thinking_enabled is None + else self._seat_thinking_enabled + ), + reasoning_effort=( + policy.reasoning_effort + if self._seat_reasoning_effort is None + else self._seat_reasoning_effort + ), + subagent_enabled=self._seat_subagent_enabled, ) return SeatTurn(final_text=_latest_ai_text(messages), tool_calls=_tool_calls_since_last_human(messages)) diff --git a/offline-backend-20260512/backend/app/gateway/roundtable_job_executor.py b/offline-backend-20260512/backend/app/gateway/roundtable_job_executor.py index c21d6aec..2fb39e8f 100644 --- a/offline-backend-20260512/backend/app/gateway/roundtable_job_executor.py +++ b/offline-backend-20260512/backend/app/gateway/roundtable_job_executor.py @@ -179,6 +179,12 @@ class StartParams: # 是否自动产出 Step3(方案总结报告 + 结果绘制)。默认沿用全局开关(现已恢复 True, # 见 ROUNDTABLE_BACKGROUND_REPORT_ENABLED)。测试可显式覆盖。 enable_report: bool = ROUNDTABLE_BACKGROUND_REPORT_ENABLED + # 席位执行模式(快速/思考/专业问答/多智能体)派生参数,沿用 Step2 选择。仅 special 席位用: + # 覆盖 roundtable_run_policy("seat") 的 thinking/reasoning(None → policy 默认); + # subagent 仅 ultra 档为真。透传给进程内网关(InProcessRoundtableGateway)的席位 run。 + seat_thinking_enabled: bool | None = None + seat_reasoning_effort: str | None = None + seat_subagent_enabled: bool = False # 网关工厂:给定一次启动参数,返回一个 RoundtableGateway 实例。 diff --git a/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py b/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py index a26f5742..ee12623f 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py +++ b/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py @@ -637,6 +637,8 @@ def _run_payload( skill_stop_names: list[str], thinking_enabled: bool = True, reasoning_effort: str = "medium", + subagent_enabled: bool = False, + stream_custom: bool = False, multitask_strategy: str = "reject", extra_context: dict[str, Any] | None = None, files: list[dict[str, Any]] | None = None, @@ -676,7 +678,9 @@ def _run_payload( "reasoning_effort": reasoning_effort, "thinking_enabled": thinking_enabled, "is_plan_mode": False, - "subagent_enabled": False, + # 默认 False;圆桌「多智能体问答(ultra)」档会让席位 _special_run 传 True, + # 给席位解锁 task 子代理工具(席位 policy 未禁 task,故可用)。 + "subagent_enabled": subagent_enabled, "thread_id": thread_id, # 圆桌四个 router(intent / recommend / leader / special)都是临时任务, # 用户个人记忆与本次研讨无关。MemoryMiddleware 的 Hindsight recall 是 @@ -691,7 +695,12 @@ def _run_payload( # 圆桌是一次性研讨,本就不该吃历史会话记忆,这里统一关注入根治该污染。 "memory_injection_enabled": False, }, - "stream_mode": ["messages-tuple", "values"], + # ultra 席位会派子智能体(task 工具),其 task_started/running/completed 进度走 + # **custom** 流(StreamWriter)。仅这种场景加 "custom",让前端任务卡片能显示子智能体 + # 实时进度;其余角色不加,避免无谓帧。 + "stream_mode": ( + ["messages-tuple", "values", "custom"] if stream_custom else ["messages-tuple", "values"] + ), "stream_subgraphs": True, "stream_resumable": True, "assistant_id": "lead_agent", @@ -1570,6 +1579,9 @@ async def _special_run( no_file: bool = False, peer_deliveries: list[dict[str, Any]] | None = None, files: list[dict[str, Any]] | None = None, + thinking_enabled: bool | None = None, + reasoning_effort: str | None = None, + subagent_enabled: bool = False, ) -> AsyncIterator[str | dict[str, Any]]: """子智能体(special)单轮:执行总控下发的 task 文本,交付 content。 @@ -1584,6 +1596,12 @@ async def _special_run( ``peer_deliveries``:前序席位的**完整交付** ``[{name, content}, ...]``。注入到 run context 的 ``roundtable_peer_deliveries``,供席位用 ``read_peer_delivery`` 工具 **按需**取全文(平时只看广播摘要,§12.9 传摘要按需传全文)。不进 prompt,不耗 token。 + + ``thinking_enabled`` / ``reasoning_effort`` / ``subagent_enabled``:圆桌第二步「席位 + 执行模式」(快速/思考/专业问答/多智能体问答)派生的覆盖值。**仅 special 席位**接收: + 前两者为 None 时回退到 policy 默认(向后兼容,旧前端不传 → 维持 thinking + medium); + ``subagent_enabled`` 仅 ultra 档为 True,给席位解锁 ``task`` 子代理工具。**总控 + (leader)不受影响**,始终用其快速派活策略。 """ t0 = time.perf_counter() thread_id = agent_threads[agent_name] @@ -1613,8 +1631,13 @@ async def _special_run( new_message=new_message, excluded_tools=seat_excluded, skill_stop_names=merge_skill_stop_names(policy.skill_stop_names, skill_stop_names), - thinking_enabled=policy.thinking_enabled, - reasoning_effort=policy.reasoning_effort, + # 席位执行模式覆盖 policy 的 thinking/reasoning(None → 回退 policy 默认); + # subagent 仅 ultra 档置真。工具裁剪(excluded_tools)仍完全由 policy 决定。 + thinking_enabled=(policy.thinking_enabled if thinking_enabled is None else thinking_enabled), + reasoning_effort=(policy.reasoning_effort if reasoning_effort is None else reasoning_effort), + subagent_enabled=subagent_enabled, + # ultra 席位才会派子智能体 → 才需要 custom 流回传子任务实时进度(task_running 等)。 + stream_custom=subagent_enabled, # 干预/重新派活会把同一席位 thread 再跑一次;抢占掉没结束的旧 run,避免 409。 multitask_strategy="rollback", # 前序席位完整交付 → run context,供 read_peer_delivery 工具按需取全文(§12.9)。 @@ -1756,6 +1779,15 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) -> # 不写死任何默认模型;空串会让 lead_agent._resolve_model_name() 回退到 # config.yaml 的 models[0],与 /api/ai-writing 等其它路由一致。 model_name: str = str(payload.get("model") or "") + # 圆桌第二步「席位执行模式」(快速/思考/专业问答/多智能体问答)派生参数。**仅 special + # 席位消费**:覆盖 seat policy 的 thinking/reasoning。thinking_enabled/reasoning_effort + # 缺省(None/空)→ 后端按 policy 默认执行(向后兼容,旧前端不传维持 thinking + medium); + # subagent_enabled 仅 ultra 档为真,给席位解锁 task 子代理工具。leader 不消费这些字段。 + _te_raw = payload.get("thinking_enabled") + seat_thinking_enabled: bool | None = bool(_te_raw) if _te_raw is not None else None + _re_raw = payload.get("reasoning_effort") + seat_reasoning_effort: str | None = _re_raw if isinstance(_re_raw, str) and _re_raw else None + seat_subagent_enabled: bool = bool(payload.get("subagent_enabled") or False) if not agent_name or agent_name not in agent_threads: raise HTTPException( @@ -1803,6 +1835,9 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) -> no_file=no_file, peer_deliveries=peer_deliveries, files=files, + thinking_enabled=seat_thinking_enabled, + reasoning_effort=seat_reasoning_effort, + subagent_enabled=seat_subagent_enabled, ) async for chunk in gen: if isinstance(chunk, dict): diff --git a/offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py b/offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py index 2f2db415..2f6a6363 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py +++ b/offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py @@ -57,6 +57,8 @@ class ChainResponse(BaseModel): stage_goals: list[str | None] | None = None # 写给总控的「编排提示」(dag-only):告诉总控怎么派活、做什么。None/空 = 不注入。 coordinator_prompt: str | None = None + # 默认席位执行模式(flash/thinking/pro/ultra);None = 未设,选入研讨时回退默认(pro)。 + seat_mode: str | None = None # 发布状态:True = 公共(所有账号可在「公共」筛选查看 / 选用)。 is_public: bool = False # 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条 is_mine=False)。 @@ -83,6 +85,7 @@ class ChainCreateRequest(BaseModel): stages: list[list[str]] | None = None stage_goals: list[str | None] | None = None coordinator_prompt: str | None = Field(default=None, max_length=4000) + seat_mode: str | None = Field(default=None, max_length=16) class ChainUpdateRequest(BaseModel): @@ -93,6 +96,7 @@ class ChainUpdateRequest(BaseModel): stages: list[list[str]] | None = None stage_goals: list[str | None] | None = None coordinator_prompt: str | None = Field(default=None, max_length=4000) + seat_mode: str | None = Field(default=None, max_length=16) # 发布开关(True 发布为公共 / False 取消发布);仅 owner 可改(update 本就 owner 限定)。 is_public: bool | None = None @@ -224,6 +228,18 @@ def _validate_stage_goals( ) +_VALID_SEAT_MODES = {"flash", "thinking", "pro", "ultra"} + + +def _validate_seat_mode(seat_mode: str | None) -> None: + """席位执行模式只能是 flash/thinking/pro/ultra 或 None(未设)。""" + if seat_mode is not None and seat_mode not in _VALID_SEAT_MODES: + raise HTTPException( + status_code=400, + detail=f"非法席位执行模式: {seat_mode}(应为 {'/'.join(sorted(_VALID_SEAT_MODES))} 之一)", + ) + + # ── routes ─────────────────────────────────────────────────────────────── @@ -249,6 +265,7 @@ async def create_chain(request: Request, body: ChainCreateRequest) -> ChainRespo _validate_seats(body.seats) _validate_stages(body.seats, body.stages) _validate_stage_goals(body.stages, body.stage_goals) + _validate_seat_mode(body.seat_mode) store = _get_store(request) user_id = _current_user_id(request) row = await store.create_chain( @@ -261,6 +278,7 @@ async def create_chain(request: Request, body: ChainCreateRequest) -> ChainRespo "stages": body.stages, "stage_goals": body.stage_goals, "coordinator_prompt": body.coordinator_prompt, + "seat_mode": body.seat_mode, }, ) await _enrich_owner_names([row]) @@ -290,6 +308,7 @@ async def update_chain(request: Request, chain_id: str, body: ChainUpdateRequest _validate_stages(body.seats, body.stages) # Goals align to stages; reject goals sent without a layering, enforce length/size. _validate_stage_goals(body.stages, body.stage_goals) + _validate_seat_mode(body.seat_mode) store = _get_store(request) user_id = _current_user_id(request) # Distinguish "omitted" from "explicitly sent"; only forward provided fields. diff --git a/offline-backend-20260512/backend/app/gateway/routers/roundtable_jobs.py b/offline-backend-20260512/backend/app/gateway/routers/roundtable_jobs.py index 5c344e7c..e5b37c27 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/roundtable_jobs.py +++ b/offline-backend-20260512/backend/app/gateway/routers/roundtable_jobs.py @@ -68,6 +68,12 @@ class StartJobRequest(BaseModel): # 预建好的 thread 映射(Phase 3b 真网关用;模拟网关忽略)。 threadIds: dict[str, str] | None = None coordinatorName: str = "roundtable-coordinator" + # 席位执行模式(快速/思考/专业问答/多智能体)派生参数,由前端 toStartBody 下发。 + # 仅 special 席位用:覆盖 roundtable_run_policy("seat") 的 thinking/reasoning; + # None/缺省 → 后端按 policy 默认(≈pro),向后兼容旧前端。subagent 仅 ultra 档为真。 + thinking_enabled: bool | None = None + reasoning_effort: str | None = None + subagent_enabled: bool = False class ResumeJobRequest(BaseModel): @@ -219,6 +225,9 @@ async def start_job(request: Request, body: StartJobRequest) -> RoundtableJobRes draft_id=body.draftId, chain=body.chain, orchestration_plan=body.orchestrationPlan, + seat_thinking_enabled=body.thinking_enabled, + seat_reasoning_effort=body.reasoning_effort, + seat_subagent_enabled=body.subagent_enabled, ) ) return _to_response(row) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260615_01_chain_seat_mode.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260615_01_chain_seat_mode.py new file mode 100644 index 00000000..f833ff75 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260615_01_chain_seat_mode.py @@ -0,0 +1,50 @@ +"""Add seat_mode column to roundtable_chains. + +Per-chain「默认席位执行模式」: an optional short string (flash/thinking/pro/ultra, see +frontend ``lib/seat-mode.ts``) the user picks in the chain editor. When the chain is +selected into a roundtable discussion the picker defaults to it (still overridable per +run). NULL = unset → callers fall back to the default (pro). VARCHAR(16) on MySQL, plain +String elsewhere — matching ``String(16)`` on ``RoundtableChainRow.seat_mode``. + +``create_all`` adds the column for brand-new DBs; the startup ``_ensure_orm_columns_sync`` +sweep adds it to already-existing tables (sqlite/mysql/postgres). This revision keeps the +alembic graph consistent with the rest of the chain columns and documents the change. + +Revision ID: 20260615_01 +Revises: 20260612_01 +Create Date: 2026-06-15 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect + + +revision = "20260615_01" +down_revision = "20260612_01" +branch_labels = None +depends_on = None + + +def _has_table(table_name: str) -> bool: + inspector = inspect(op.get_bind()) + return table_name in inspector.get_table_names() + + +def _has_column(table_name: str, column_name: str) -> bool: + if not _has_table(table_name): + return False + inspector = inspect(op.get_bind()) + return any(col["name"] == column_name for col in inspector.get_columns(table_name)) + + +def upgrade() -> None: + if _has_table("roundtable_chains") and not _has_column("roundtable_chains", "seat_mode"): + op.add_column("roundtable_chains", sa.Column("seat_mode", sa.String(length=16), nullable=True)) + + +def downgrade() -> None: + if _has_column("roundtable_chains", "seat_mode"): + op.drop_column("roundtable_chains", "seat_mode") diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/model.py index 36d54713..eb2d5baf 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/model.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/model.py @@ -49,6 +49,9 @@ class RoundtableChainRow(Base): # 怎么派活、做什么(整体编排策略 / 风格 / 约束)。**仅分层 DAG 模式生效**:注入到总控 # 每阶段派活提示(``buildDagStageLeaderTask``)。NULL = 不注入。纯文本,存为 LONGTEXT。 coordinator_prompt: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) + # 可选「默认席位执行模式」:flash/thinking/pro/ultra(见前端 lib/seat-mode.ts)。选入研讨时 + # 弹窗默认用它(仍可临时改)。NULL = 未设,调用方回退默认(pro)。短串,VARCHAR(16)。 + seat_mode: Mapped[str | None] = mapped_column(String(16), nullable=True) # 发布开关:True = 公共(所有账号可在「公共」筛选里查看 / 选用);False = 仅本人可见。 # 编辑 / 删除始终只限 owner;发布只是放开「查看 + 选入研讨」。索引以支持「公共」列表筛选。 is_public: Mapped[bool] = mapped_column( diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/sql.py index 1b86b9cd..f3506589 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/sql.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/sql.py @@ -56,6 +56,8 @@ class RoundtableChainRepository: "stage_goals": _loads(getattr(row, "stage_goals", None)), # 总控编排提示(写给总控的派活指引,dag-only);None/空 = 不注入。纯文本,不 json。 "coordinator_prompt": getattr(row, "coordinator_prompt", None), + # 默认席位执行模式(flash/thinking/pro/ultra);None = 未设。 + "seat_mode": getattr(row, "seat_mode", None), # 发布状态 + 是否本人所有(前端据此显示「公共」徽标、决定能否编辑 / 发布)。 "is_public": bool(getattr(row, "is_public", False)), "is_mine": (current_user_id is None) or (row.user_id == current_user_id), @@ -110,6 +112,7 @@ class RoundtableChainRepository: stages=_dumps(data.get("stages")), stage_goals=_dumps(data.get("stage_goals")), coordinator_prompt=(data.get("coordinator_prompt") or None), + seat_mode=(data.get("seat_mode") or None), is_public=bool(data.get("is_public") or False), created_at=now, updated_at=now, @@ -131,6 +134,7 @@ class RoundtableChainRepository: stages=_UNSET, stage_goals=_UNSET, coordinator_prompt=_UNSET, + seat_mode=_UNSET, is_public=_UNSET, ) -> dict[str, Any] | None: async with self._sf() as session: @@ -149,6 +153,8 @@ class RoundtableChainRepository: row.stage_goals = _dumps(stage_goals) if coordinator_prompt is not _UNSET: row.coordinator_prompt = coordinator_prompt or None + if seat_mode is not _UNSET: + row.seat_mode = seat_mode or None if is_public is not _UNSET and is_public is not None: row.is_public = bool(is_public) row.updated_at = datetime.now(UTC) diff --git a/offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql b/offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql index 330b6c41..ae02f476 100644 --- a/offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql +++ b/offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql @@ -82,6 +82,10 @@ CALL _add_column_if_missing('roundtable_drafts', 'task_id', 'VARCHAR(128) NULL D -- ORM: RoundtableJobRow.orchestration_plan = PortableLongText(LONGTEXT), nullable。 CALL _add_column_if_missing('roundtable_jobs', 'orchestration_plan', 'LONGTEXT NULL DEFAULT NULL'); +-- seat_mode: 业务链条「默认席位执行模式」(flash/thinking/pro/ultra),对应 migration 20260615_01。 +-- 选入研讨时弹窗默认用它(仍可临时改)。ORM: RoundtableChainRow.seat_mode = String(16), nullable。 +CALL _add_column_if_missing('roundtable_chains', 'seat_mode', 'VARCHAR(16) NULL DEFAULT NULL'); + -- ---- 清理临时存储过程 -------------------------------------------------------- DROP PROCEDURE IF EXISTS _add_column_if_missing; From 88b1a330a6ca3e7c408402894cb9084ec848c08a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Mon, 15 Jun 2026 18:31:50 +0800 Subject: [PATCH 2/9] =?UTF-8?q?fix(roundtable):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=BC=B9=E7=AA=97=E8=A2=AB=E9=A1=B6=E9=83=A8=E5=AF=BC=E8=88=AA?= =?UTF-8?q?=E6=A0=8F=E9=81=AE=E6=8C=A1=E7=9A=84=20z-index=20=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将全局 Header z-[2101] 降为 z-[45]、圆桌页内部 header z-[2000] 降为 z-[40], 使两者低于 Shadcn Dialog 遮罩层 z-50,弹窗打开时可正常覆盖导航栏。 Co-Authored-By: Claude Sonnet 4.6 --- .../src/roundtable-planning/pages/RoundtablePlanningPage.tsx | 2 +- frontend-web/src/strategy-components/Header.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index 9163c070..c0b01eb6 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -1366,7 +1366,7 @@ export default function RoundtablePlanningPage() { )} {/* HEADER */} -
+
{ open={notificationOpen} onOpenChange={setNotificationOpen} /> -
+
{showTopNav ? ( <> {/* 左分区(flex-1):菜单靠右贴近中间基座 */} From 69c8c14c91ad5f56dbccd890ad2ef5b2c46e8e8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Mon, 15 Jun 2026 20:50:19 +0800 Subject: [PATCH 3/9] =?UTF-8?q?fix(nav):=20=E4=BF=AE=E5=A4=8D=E9=97=AE?= =?UTF-8?q?=E7=AD=94=E6=A2=B3=E7=90=86=E7=AD=89=E5=B8=A6=E5=AD=90=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E9=A1=B5=E9=9D=A2=E7=9A=84=E5=AF=BC=E8=88=AA=E9=AB=98?= =?UTF-8?q?=E4=BA=AE=E4=B8=A2=E5=A4=B1=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KnowledgeBasePage 加载后自动跳转至 /knowledge/:noteId,原精确路径匹配 导致顶栏和侧边栏选中态丢失。将 hasActiveChild / isItemActive / menuItemContainsPath 等 8 处判断统一改为前缀匹配 (pathname === path || pathname.startsWith(path + "/")), 覆盖浅色侧边栏、深色侧边栏、顶部导航栏三套组件。 Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/page-sidebar-dark.tsx | 4 +- frontend-web/src/components/page-sidebar.tsx | 8 +- .../workspace/messages/message-list.tsx | 36 +++- .../workspace/workspace-nav-menu.tsx | 11 +- .../workspace/workspace-sidebar.tsx | 2 +- .../src/core/page-layout/sidebar-menu.ts | 164 ++++++++++-------- .../src/pages/LightAppManagementPage.tsx | 2 +- frontend-web/src/pages/StrategyRoutes.tsx | 2 + frontend-web/src/pages/WorkspaceRoutes.tsx | 10 +- .../src/strategy-components/Header.tsx | 23 +-- 10 files changed, 161 insertions(+), 101 deletions(-) diff --git a/frontend-web/src/components/page-sidebar-dark.tsx b/frontend-web/src/components/page-sidebar-dark.tsx index 15b6f8c8..af274aff 100644 --- a/frontend-web/src/components/page-sidebar-dark.tsx +++ b/frontend-web/src/components/page-sidebar-dark.tsx @@ -54,7 +54,7 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar }; const hasActiveChild = (item: MenuItem): boolean => { - if (item.path && pathname === item.path) { + if (item.path && (pathname === item.path || pathname.startsWith(item.path + "/"))) { return true; } @@ -153,7 +153,7 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar inWujieEnv ? item.id === activeTopId : item.path - ? pathname === item.path + ? pathname === item.path || pathname.startsWith(item.path + "/") : hasActiveChild(item); const handleSidebarItemClick = (item: MenuItem) => { if (inWujieEnv) { diff --git a/frontend-web/src/components/page-sidebar.tsx b/frontend-web/src/components/page-sidebar.tsx index 42b556fe..4ad89043 100644 --- a/frontend-web/src/components/page-sidebar.tsx +++ b/frontend-web/src/components/page-sidebar.tsx @@ -42,7 +42,7 @@ type PageSidebarProps = { type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about"; const hasActiveChild = (item: MenuItem, pathname: string): boolean => { - if (item.path && pathname === item.path) { + if (item.path && (pathname === item.path || pathname.startsWith(item.path + "/"))) { return true; } return item.children?.some((child) => hasActiveChild(child, pathname)) ?? false; @@ -116,7 +116,9 @@ function SecondaryNavSidebar() { const secondaryItems = activeTopItem?.children ?? []; const isItemActive = (item: MenuItem) => - item.path ? pathname === item.path : hasActiveChild(item, pathname); + item.path + ? pathname === item.path || pathname.startsWith(item.path + "/") + : hasActiveChild(item, pathname); const handleItemClick = (item: MenuItem) => { const path = item.path ?? findFirstRoutePath(item.children); @@ -210,7 +212,7 @@ function SecondaryNavSidebar() { {thirdItems.map((child) => { const ChildIcon = child.icon; const childActive = child.path - ? pathname === child.path + ? pathname === child.path || pathname.startsWith(child.path + "/") : hasActiveChild(child, pathname); return (
{/* 中:CM助手(落在背景图发光基座上,纯文字无图标) */} -
CM助手
+
CM业务中心
{/* 右分区(flex-1):菜单靠左贴近中间基座,操作区靠最右 */}
@@ -336,7 +337,7 @@ const Header = ({ collapsed }: HeaderProps) => {
- CM助手 + CM业务中心