feat(roundtable): 第二步席位执行模式(快速/思考/专业问答/多智能体)+ 子智能体任务卡片 + 后台完成跨页通知 + 链条默认模式持久化
- 席位执行模式:选择参会智能体弹窗加模式选择器,映射 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) <noreply@anthropic.com>
This commit is contained in:
1 parent
fbd237531e
commit
3907034a1d
25 files changed
+834
-40
No files matched your search
@@ -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 (
|
||||
<RoundtableJobNotifier>
|
||||
<div className="h-full flex flex-col bg-muted/20">
|
||||
{pageSidebarAllowed && <Header collapsed={collapsed} />}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
@@ -86,6 +88,7 @@ function PageLayout() {
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</RoundtableJobNotifier>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, unknown>): 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,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<BusinessChain[]>([]);
|
||||
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],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<SeatMode>(DEFAULT_SEAT_MODE);
|
||||
const [state, setState] = useState<InternalState>(INITIAL_STATE);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(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({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 席位执行模式:快速 / 思考 / 专业问答 / 多智能体问答。推荐 & 业务链条通用,
|
||||
决定第二步每个子智能体席位的推理深度(thinking/reasoning/subagent)。 */}
|
||||
<div className="shrink-0 space-y-1.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">执行模式</span>
|
||||
<div className="inline-flex rounded-lg border border-border bg-muted/40 p-0.5 text-xs font-medium">
|
||||
{SEAT_MODE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setSeatMode(opt.value)}
|
||||
title={opt.description}
|
||||
className={cn(
|
||||
"rounded-md px-2.5 py-1 transition-colors",
|
||||
seatMode === opt.value
|
||||
? "bg-card text-primary shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] leading-relaxed text-muted-foreground">
|
||||
{SEAT_MODE_OPTIONS.find((o) => o.value === seatMode)?.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ===== 智能推荐模式(现状,零行为变更)===== */}
|
||||
{mode === "recommend" && (
|
||||
<>
|
||||
@@ -750,9 +795,13 @@ export function RecommendAgentsDialog({
|
||||
{/* ===== 业务链条模式 ===== */}
|
||||
{mode === "chain" && (
|
||||
<BusinessChainPicker
|
||||
onConfirm={onConfirm}
|
||||
onConfirm={handleConfirmWithMode}
|
||||
onCancel={onCancel}
|
||||
onGoConfig={handleGoConfig}
|
||||
onSelectedChainChange={(chain) => {
|
||||
// 选中链条若配了默认执行模式 → 把顶部模式选择器默认设为它(用户仍可临时改)。
|
||||
if (chain?.seatMode) setSeatMode(chain.seatMode);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 圆桌「后台挂起作业」全工作区跨页完成通知器。
|
||||
*
|
||||
* 挂在 `PageLayout`(`/page/*` 的常驻层,导航切页不卸载)里:圆桌页点「后台挂起」后
|
||||
* 调 `watchJob(jobId)` 注册监听,本 Provider 用 `streamJob` 订阅该作业 SSE;作业跑到
|
||||
* 终态时弹一个**与「达成共识」同款**的顶部浮窗(bg-primary + Sparkles + bounce),
|
||||
* 因此用户切到聊天/任务/其它工作区页面也能收到完成提示。
|
||||
*
|
||||
* 自带浮窗(不走 sonner)—— 各工作区页各自挂了一个 sonner `<Toaster>`,若这里也用
|
||||
* 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<RoundtableJobNotifierValue>({
|
||||
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<string[]>([]);
|
||||
const [notice, setNotice] = useState<string | null>(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 (
|
||||
<RoundtableJobNotifierContext.Provider value={{ watchJob }}>
|
||||
{children}
|
||||
{watchedJobIds.map((id) => (
|
||||
<JobWatcher
|
||||
key={id}
|
||||
jobId={id}
|
||||
onTerminal={handleTerminal}
|
||||
onStreamError={handleStreamError}
|
||||
/>
|
||||
))}
|
||||
{notice && (
|
||||
<div
|
||||
className="fixed top-4 left-1/2 z-[100] flex -translate-x-1/2 transform items-center gap-2 rounded-xl border border-primary/40 bg-primary px-4 py-2.5 text-xs font-medium text-white shadow-lg animate-bounce"
|
||||
role="status"
|
||||
>
|
||||
<Sparkles className="h-4 w-4 shrink-0" />
|
||||
<span>{notice}</span>
|
||||
</div>
|
||||
)}
|
||||
</RoundtableJobNotifierContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
{msg.subtaskIds.map((taskId) => (
|
||||
<RoundtableSubtaskCard key={taskId} taskId={taskId} isLoading={isStreaming} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{reasoning && (
|
||||
<ReasoningBlock streaming={isStreaming}>{reasoning}</ReasoningBlock>
|
||||
)}
|
||||
@@ -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 <SubtaskCard taskId={taskId} isLoading={isLoading} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集气泡里所有"写了文件"的产出路径,按出现顺序去重,聚合到一行文件卡片。
|
||||
*
|
||||
|
||||
@@ -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<StreamMultiAgentRequest["files"]>[number];
|
||||
@@ -37,12 +38,56 @@ function takeOnce<T>(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<string, unknown>)
|
||||
: Array.isArray(frame) && frame.length >= 2 && frame[1] && typeof frame[1] === "object"
|
||||
? (frame[1] as Record<string, unknown>)
|
||||
: 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<string, unknown>;
|
||||
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<string>(selectedModel);
|
||||
// 席位执行模式同样打到 ref,让席位调用在编排循环里读到最新值而不重建回调。
|
||||
const selectedModeRef = useRef<SeatMode>(selectedMode);
|
||||
const agentConfigsRef = useRef<Record<number, AgentConfig>>(agentConfigs);
|
||||
const agentNameToRoleRef = useRef(agentNameToRole);
|
||||
const threadIdsRef = useRef<ThreadIdMap | null>(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 列表并渲染 <SubtaskCard>。
|
||||
const updateSubtask = useUpdateSubtask();
|
||||
// 已识别的 task tool_call_id —— 用于在收到 tool 结果帧时只认领「子智能体任务」结果,
|
||||
// 不误把 write_file 等其它工具的结果当成子任务处理。
|
||||
const knownTaskIdsRef = useRef<Set<string>>(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<string, unknown>, 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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>).messages;
|
||||
if (Array.isArray(msgs)) {
|
||||
for (const m of msgs) {
|
||||
if (m && typeof m === "object") ingestSubagentMessage(m as Record<string, unknown>, 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<string, unknown>, 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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string, string> | null;
|
||||
coordinatorName?: string;
|
||||
/**
|
||||
* 席位执行模式(快速/思考/专业问答/多智能体)。后台续跑时席位按此模式执行(与前端实时
|
||||
* 路径一致);缺省由后端按 policy 默认(≈pro)。在 `toStartBody` 里派生成 thinking/
|
||||
* reasoning/subagent 三字段发给后端。
|
||||
*/
|
||||
seatMode?: SeatMode;
|
||||
}
|
||||
|
||||
/** 终态集合——SSE/轮询判定作业是否结束。 */
|
||||
|
||||
@@ -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: "在专业问答基础上,每个席位可调用子代理分工协作,能力最强、更慢更耗时",
|
||||
},
|
||||
];
|
||||
@@ -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<SeatMode>(DEFAULT_SEAT_MODE);
|
||||
// 分层编排是唯一数据源;落库时展平为 seats + 折叠为 stages。
|
||||
const [stageGroups, setStageGroups] = useState<StageGroups>([]);
|
||||
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() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 默认执行模式:选入研讨时弹窗默认用它(仍可临时改)。 */}
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-1 text-xs font-semibold text-foreground">
|
||||
默认执行模式
|
||||
<span className="text-[10px] font-normal text-muted-foreground">
|
||||
(选入研讨时默认用它,进研讨前仍可临时切换)
|
||||
</span>
|
||||
</label>
|
||||
<div className="inline-flex flex-wrap gap-0.5 rounded-lg border border-border bg-muted/40 p-0.5 text-xs font-medium">
|
||||
{SEAT_MODE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setSeatMode(opt.value)}
|
||||
title={opt.description}
|
||||
className={cn(
|
||||
"rounded-md px-2.5 py-1 transition-colors",
|
||||
seatMode === opt.value
|
||||
? "bg-card text-primary shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[11px] leading-relaxed text-muted-foreground">
|
||||
{SEAT_MODE_OPTIONS.find((o) => o.value === seatMode)?.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 总控提示词:label + AI 优化(配色沿用现有 primary)+ 大号等宽编辑区 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
|
||||
@@ -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<boolean>(false);
|
||||
const [selectedModel, setSelectedModel] = useState<string>("");
|
||||
// Step 2 席位执行模式(快速/思考/专业问答/多智能体问答)——决定每个子智能体席位的
|
||||
// 推理深度。由「选择参会智能体」弹窗确认时的 SeatSelectionResult.seatMode 决定。
|
||||
const [seatMode, setSeatMode] = useState<SeatMode>(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<boolean> => {
|
||||
// 返回本次后台作业的 jobId(已在跑则返回既有作业 id),供「完成时提示」监听;
|
||||
// 未发起(无席位)返回 null。
|
||||
const startBackgroundJob = useCallback(async (): Promise<string | null> => {
|
||||
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,
|
||||
|
||||
Reference in new issue
Block a user