圆桌第三步流程图模式 + 后台 DAG 任务编排
- 方案总结报告:用户要求结构化输出/流程图时跳过 md 直出 flow-json 流程图, 报告用文字展示;报告与流程图均可多轮修改、互相切换(detectFlowIntent 识别意图) - 流程图画布放右侧沙箱位(sidePanel 插槽)不覆盖消息列表,可与 md 预览切换; 节点随容器宽度自适应缩放并 fitView 居中 - 后台挂起支持真 DAG 编排(分层并行/阶段目标/总控提示词),新增 orchestration_plan 持久化列 + 迁移;大弹窗流程图按 DAG 分层渲染 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b33426416b
commit
086c280e5d
25 files changed
+880
-149
No files matched your search
@@ -65,6 +65,7 @@ function mapJob(raw: Record<string, unknown>): RoundtableJob {
|
||||
pendingClarification: (raw.pendingClarification as string | null) ?? null,
|
||||
orchestrationMode: (raw.orchestrationMode as RoundtableJob["orchestrationMode"]) ?? "recommend",
|
||||
chain: (raw.chain as RoundtableJob["chain"]) ?? null,
|
||||
orchestrationPlan: (raw.orchestrationPlan as RoundtableJob["orchestrationPlan"]) ?? null,
|
||||
error: (raw.error as string | null) ?? null,
|
||||
dialogues: Array.isArray(raw.dialogues) ? (raw.dialogues as JobDialogue[]) : [],
|
||||
step3: (raw.step3 as RoundtableJob["step3"]) ?? null,
|
||||
@@ -81,6 +82,7 @@ function toStartBody(p: StartJobPayload) {
|
||||
model: p.model,
|
||||
orchestrationMode: p.orchestrationMode ?? "recommend",
|
||||
chain: p.chain ?? null,
|
||||
orchestrationPlan: p.orchestrationPlan ?? null,
|
||||
seedMessage: p.seedMessage,
|
||||
threadIds: p.threadIds ?? null,
|
||||
coordinatorName: p.coordinatorName ?? "roundtable-coordinator",
|
||||
|
||||
@@ -68,6 +68,8 @@ export function DispatchChainJobModal({
|
||||
onView={job && onView ? () => onView(job) : undefined}
|
||||
error={job?.error}
|
||||
dialogues={job?.dialogues}
|
||||
orchestrationMode={job?.orchestrationMode}
|
||||
stages={job?.orchestrationPlan?.stages ?? null}
|
||||
pendingClarification={job?.pendingClarification}
|
||||
onResume={
|
||||
jobId
|
||||
|
||||
@@ -29,8 +29,10 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { getAvatar } from "./Avatars";
|
||||
import { DagFlowChart } from "./DagFlowChart";
|
||||
import { MarkdownContent } from "./MessageBubble";
|
||||
import { chainTagClasses } from "../lib/business-chain";
|
||||
import type { OrchestrationStage } from "../lib/dag-types";
|
||||
import { getAvatarTypeFor } from "../lib/roundtable-constants";
|
||||
import type {
|
||||
ChainNodeState,
|
||||
@@ -67,6 +69,10 @@ export interface DispatchChainModalProps {
|
||||
error?: string | null;
|
||||
/** 研讨对话记录(leader 说明 + 各席位交付)。 */
|
||||
dialogues?: JobDialogue[];
|
||||
/** 编排模式;==="dag" 且 stages 非空时,流程图按 stage 分层(DAG)绘制,否则线性。 */
|
||||
orchestrationMode?: "recommend" | "chain" | "dag";
|
||||
/** dag 模式的分层 stages(按 stage 分列画流程图,stage 内并行席位纵向并列)。 */
|
||||
stages?: ReadonlyArray<OrchestrationStage> | null;
|
||||
}
|
||||
|
||||
const _NON_TERMINAL: ReadonlySet<JobStatus> = new Set<JobStatus>(["queued", "running", "awaiting_input"]);
|
||||
@@ -105,6 +111,8 @@ export function DispatchChainModal({
|
||||
onCancel,
|
||||
error,
|
||||
dialogues,
|
||||
orchestrationMode,
|
||||
stages,
|
||||
}: DispatchChainModalProps) {
|
||||
const [answer, setAnswer] = useState("");
|
||||
const convoRef = useRef<HTMLDivElement>(null);
|
||||
@@ -139,6 +147,28 @@ export function DispatchChainModal({
|
||||
const reportActive = phase === "report_drawing";
|
||||
const reportDone = phase === "report_done" || status === "done";
|
||||
|
||||
// ── DAG(分层)流程图:mode==="dag" 且有 stages 时按 stage 分列绘制 ──────────────
|
||||
const showDag = orchestrationMode === "dag" && (stages?.length ?? 0) > 0;
|
||||
// 节点状态来自 dispatch_chain(agentId 小写 → state);resolveSeat 取名称 + 头像配色。
|
||||
const dagSeatStates: Record<string, ChainNodeState> = {};
|
||||
const nodeByAgent = new Map<string, DispatchChainNode>();
|
||||
for (const n of nodes) {
|
||||
dagSeatStates[n.agentId.toLowerCase()] = n.state;
|
||||
nodeByAgent.set(n.agentId.toLowerCase(), n);
|
||||
}
|
||||
const resolveDagSeat = (agentId: string, order: number) => {
|
||||
const node = nodeByAgent.get(agentId.toLowerCase());
|
||||
return {
|
||||
name: node?.name || agentId,
|
||||
avatarType: node?.avatarType || getAvatarTypeFor(agentId, order),
|
||||
};
|
||||
};
|
||||
const dagConsensusState: ChainNodeState = consensusDone
|
||||
? "done"
|
||||
: consensusActive
|
||||
? "active"
|
||||
: "pending";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[90vh] w-[94vw] flex-col gap-0 sm:max-w-[1240px]">
|
||||
@@ -167,51 +197,65 @@ export function DispatchChainModal({
|
||||
<DialogDescription>{PHASE_LABEL[phase]}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 横向流程图(横向滚动) */}
|
||||
<div className="custom-scrollbar shrink-0 overflow-x-auto overflow-y-hidden py-8">
|
||||
<div className="flex min-w-max items-start gap-0 px-6">
|
||||
{/* 总控节点 */}
|
||||
<FlowNode
|
||||
label="总控协调"
|
||||
avatarType="human"
|
||||
state={coordinatorState}
|
||||
/>
|
||||
{/* 流程图:dag 模式按 stage 分层(DAG);否则线性总控→席位→共识→报告 */}
|
||||
{showDag ? (
|
||||
// 分层 DAG 流程图:总控 → ┌Stage┐(并行席位纵向并列) → … → 🤝共识。
|
||||
// 后台本期不绘报告(Step3 禁用) → 不显示「结果绘制」里程碑。
|
||||
<DagFlowChart
|
||||
stages={stages!}
|
||||
resolveSeat={resolveDagSeat}
|
||||
seatStates={dagSeatStates}
|
||||
coordinatorState={coordinatorState}
|
||||
consensusState={dagConsensusState}
|
||||
showReportMilestone={false}
|
||||
className="shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="custom-scrollbar shrink-0 overflow-x-auto overflow-y-hidden py-8">
|
||||
<div className="flex min-w-max items-start gap-0 px-6">
|
||||
{/* 总控节点 */}
|
||||
<FlowNode
|
||||
label="总控协调"
|
||||
avatarType="human"
|
||||
state={coordinatorState}
|
||||
/>
|
||||
|
||||
{/* 席位节点 */}
|
||||
{nodes.map((node) => (
|
||||
<Fragment key={node.agentId}>
|
||||
<Connector
|
||||
active={node.state === "active"}
|
||||
done={node.state === "done"}
|
||||
/>
|
||||
<FlowNode
|
||||
label={node.name}
|
||||
avatarType={node.avatarType}
|
||||
state={node.state}
|
||||
order={node.order}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
{/* 席位节点 */}
|
||||
{nodes.map((node) => (
|
||||
<Fragment key={node.agentId}>
|
||||
<Connector
|
||||
active={node.state === "active"}
|
||||
done={node.state === "done"}
|
||||
/>
|
||||
<FlowNode
|
||||
label={node.name}
|
||||
avatarType={node.avatarType}
|
||||
state={node.state}
|
||||
order={node.order}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
{/* 共识里程碑 */}
|
||||
<Connector active={consensusActive} done={consensusDone} />
|
||||
<MilestoneNode
|
||||
label="共识达成"
|
||||
emoji="🤝"
|
||||
state={
|
||||
consensusActive ? "active" : consensusDone ? "done" : "pending"
|
||||
}
|
||||
/>
|
||||
{/* 共识里程碑 */}
|
||||
<Connector active={consensusActive} done={consensusDone} />
|
||||
<MilestoneNode
|
||||
label="共识达成"
|
||||
emoji="🤝"
|
||||
state={
|
||||
consensusActive ? "active" : consensusDone ? "done" : "pending"
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 报告绘制里程碑 */}
|
||||
<Connector active={reportActive} done={reportDone} />
|
||||
<MilestoneNode
|
||||
label="结果绘制"
|
||||
emoji="📊"
|
||||
state={reportActive ? "active" : reportDone ? "done" : "pending"}
|
||||
/>
|
||||
{/* 报告绘制里程碑 */}
|
||||
<Connector active={reportActive} done={reportDone} />
|
||||
<MilestoneNode
|
||||
label="结果绘制"
|
||||
emoji="📊"
|
||||
state={reportActive ? "active" : reportDone ? "done" : "pending"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 研讨对话(leader 说明 + 各席位交付,真网关下即真模型真实对话) */}
|
||||
{dialogues && dialogues.length > 0 && (
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import {
|
||||
FileText,
|
||||
Loader2,
|
||||
LocateFixed,
|
||||
Maximize2,
|
||||
@@ -56,21 +57,26 @@ interface FlowGraphPanelProps {
|
||||
/** 流式绘制中(决定蚂蚁线 + 自动 fitView 跟随)。 */
|
||||
streaming: boolean;
|
||||
onClose: () => void;
|
||||
/** 沙箱里有 md 报告时提供:工具栏显示「MD 报告」按钮,点击切回 Markdown 预览。 */
|
||||
onSwitchToReport?: () => void;
|
||||
}
|
||||
|
||||
// ── 自定义节点(按 type 着色的卡片)────────────────────────────────────────
|
||||
|
||||
function FlowCardNode({ data }: NodeProps) {
|
||||
const node = (data as { node?: FlowGraphNode }).node;
|
||||
const node = (data as { node?: FlowGraphNode; scale?: number }).node;
|
||||
const scale = (data as { node?: FlowGraphNode; scale?: number }).scale ?? 1;
|
||||
if (!node) return null;
|
||||
const meta = FLOW_NODE_TYPE_META[node.type];
|
||||
// 节点宽度随容器宽度缩放(宽时放大、窄时缩小)。
|
||||
const scaledWidth = FLOW_NODE_W * scale;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: meta.bg,
|
||||
borderColor: meta.border,
|
||||
color: meta.text,
|
||||
width: FLOW_NODE_W,
|
||||
width: scaledWidth,
|
||||
}}
|
||||
className="cursor-pointer rounded-xl border-2 px-3 py-2 shadow-sm transition hover:shadow-md"
|
||||
title="点击查看详情"
|
||||
@@ -78,11 +84,11 @@ function FlowCardNode({ data }: NodeProps) {
|
||||
<Handle type="target" position={Position.Top} className="!h-1.5 !w-1.5 !bg-slate-400" />
|
||||
<div className="mb-0.5 flex items-center gap-1.5">
|
||||
<span style={{ background: meta.dot }} className="size-2 shrink-0 rounded-full" />
|
||||
<span className="text-[9px] font-bold uppercase tracking-wider opacity-70">
|
||||
<span className="text-[9px] font-bold uppercase tracking-wider opacity-70" style={{ fontSize: `${9 * scale}px` }}>
|
||||
{meta.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="break-words text-[11.5px] font-bold leading-snug">{node.label}</div>
|
||||
<div className="break-words font-bold leading-snug" style={{ fontSize: `${11.5 * scale}px` }}>{node.label}</div>
|
||||
<Handle type="source" position={Position.Bottom} className="!h-1.5 !w-1.5 !bg-slate-400" />
|
||||
</div>
|
||||
);
|
||||
@@ -99,22 +105,48 @@ function FlowCanvasInner({
|
||||
fullscreen,
|
||||
onToggleFullscreen,
|
||||
onClose,
|
||||
onSwitchToReport,
|
||||
}: FlowGraphPanelProps & { fullscreen: boolean; onToggleFullscreen: () => void }) {
|
||||
const rf = useReactFlow();
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [scale, setScale] = useState(1);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const fitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// 用户手动拖过的节点位置 —— 数据增量更新重新布局时保留,不被布局覆盖。
|
||||
const manualPosRef = useRef<Map<string, { x: number; y: number }>>(new Map());
|
||||
|
||||
// 监听容器宽度:宽度变化时按比例缩放节点(变宽→放大、变窄→缩小),并重新 fitView 居中。
|
||||
// 700px 基准 1.0,clamp 到 [0.7, 1.4]:拖宽到 ~1000px 节点放大、窄屏自动缩小。
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const resizeObs = new ResizeObserver(([entry]) => {
|
||||
const width = entry.contentRect.width;
|
||||
if (width <= 0) return;
|
||||
setScale(Math.min(1.4, Math.max(0.7, width / 700)));
|
||||
if (fitTimerRef.current) clearTimeout(fitTimerRef.current);
|
||||
fitTimerRef.current = setTimeout(() => {
|
||||
void rf.fitView({ duration: 200, padding: 0.15 });
|
||||
}, 120);
|
||||
});
|
||||
resizeObs.observe(el);
|
||||
return () => {
|
||||
resizeObs.disconnect();
|
||||
if (fitTimerRef.current) clearTimeout(fitTimerRef.current);
|
||||
};
|
||||
}, [rf]);
|
||||
|
||||
const layoutNodes = useMemo(() => layoutFlowGraph(data), [data]);
|
||||
const derivedNodes: Node[] = useMemo(
|
||||
() =>
|
||||
layoutNodes.map((n) => ({
|
||||
id: n.id,
|
||||
type: "flowCard",
|
||||
position: manualPosRef.current.get(n.id) ?? { x: n.x, y: n.y },
|
||||
data: { node: n },
|
||||
// 节点随 scale 放大 → 布局坐标同步乘 scale,间距一起拉开,避免同层重叠。
|
||||
position: manualPosRef.current.get(n.id) ?? { x: n.x * scale, y: n.y * scale },
|
||||
data: { node: n, scale },
|
||||
})),
|
||||
[layoutNodes],
|
||||
[layoutNodes, scale],
|
||||
);
|
||||
|
||||
const [nodes, setNodes] = useState<Node[]>(derivedNodes);
|
||||
@@ -180,6 +212,17 @@ function FlowCanvasInner({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{onSwitchToReport && (
|
||||
<button
|
||||
type="button"
|
||||
title="切回 Markdown 报告预览(流程图可随时再打开)"
|
||||
onClick={onSwitchToReport}
|
||||
className="mr-1 inline-flex h-7 items-center gap-1 rounded-lg border border-slate-200/70 bg-white px-2 text-[10.5px] font-bold text-slate-600 transition hover:border-primary/40 hover:text-primary"
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
MD 报告
|
||||
</button>
|
||||
)}
|
||||
<ToolButton title="放大" onClick={() => void rf.zoomIn({ duration: 150 })}>
|
||||
<ZoomIn className="h-3.5 w-3.5" />
|
||||
</ToolButton>
|
||||
@@ -202,7 +245,7 @@ function FlowCanvasInner({
|
||||
</div>
|
||||
|
||||
{/* 画布 */}
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<div ref={containerRef} className="relative min-h-0 flex-1">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
@@ -377,13 +420,14 @@ function ConnectionList({
|
||||
|
||||
// ── 外壳:覆盖右栏 / 全屏切换 ───────────────────────────────────────────────
|
||||
|
||||
export function FlowGraphPanel({ data, streaming, onClose }: FlowGraphPanelProps) {
|
||||
export function FlowGraphPanel({ data, streaming, onClose, onSwitchToReport }: FlowGraphPanelProps) {
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col bg-white",
|
||||
fullscreen ? "fixed inset-0 z-[80]" : "absolute inset-0 z-20 rounded-2xl",
|
||||
// 非全屏=填满所在面板(Step3 放在右侧沙箱位,不覆盖左侧消息列表);全屏才整页覆盖。
|
||||
fullscreen ? "fixed inset-0 z-[80]" : "relative h-full min-h-0 w-full",
|
||||
)}
|
||||
>
|
||||
<ReactFlowProvider>
|
||||
@@ -393,6 +437,7 @@ export function FlowGraphPanel({ data, streaming, onClose }: FlowGraphPanelProps
|
||||
fullscreen={fullscreen}
|
||||
onToggleFullscreen={() => setFullscreen((f) => !f)}
|
||||
onClose={onClose}
|
||||
onSwitchToReport={onSwitchToReport}
|
||||
/>
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
|
||||
@@ -44,8 +44,8 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
/** 后台挂起入口 —— 暂时隐藏,恢复时改为 true。 */
|
||||
const SHOW_BACKGROUND_SUSPEND = false;
|
||||
/** 后台挂起入口 —— 后端 _run_dag 已支持业务链分层并行,放出该入口。 */
|
||||
const SHOW_BACKGROUND_SUSPEND = true;
|
||||
|
||||
import { PersonaDrawer } from "@/roundtable-planning/components/PersonaDrawer";
|
||||
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
|
||||
|
||||
@@ -43,6 +43,11 @@ interface Step2SandboxLayoutProps {
|
||||
* 对齐主聊天 chats/:threadId 的实时预览体验。
|
||||
*/
|
||||
seatMessages?: unknown[];
|
||||
/**
|
||||
* 右侧面板的替代内容(如 Step3 流程图画布)——非空时强制展开右栏并替换沙箱预览,
|
||||
* 左侧消息列表保持可见;置空即恢复沙箱(文件预览/列表)。
|
||||
*/
|
||||
sidePanel?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Step2SandboxLayout({
|
||||
@@ -52,6 +57,7 @@ export function Step2SandboxLayout({
|
||||
seatMessages,
|
||||
artifactsFill = false,
|
||||
onArtifactsClose,
|
||||
sidePanel,
|
||||
}: Step2SandboxLayoutProps) {
|
||||
const layoutRef = useRef<GroupImperativeHandle>(null);
|
||||
const {
|
||||
@@ -64,11 +70,13 @@ export function Step2SandboxLayout({
|
||||
const artifacts = rawArtifacts ?? [];
|
||||
|
||||
const artifactPanelOpen = artifactsOpen;
|
||||
// sidePanel(流程图等)非空时即使沙箱没开也要把右栏撑开。
|
||||
const panelOpen = artifactPanelOpen || !!sidePanel;
|
||||
|
||||
useEffect(() => {
|
||||
if (artifactsFill || !layoutRef.current) return;
|
||||
layoutRef.current.setLayout(artifactPanelOpen ? OPEN_MODE : CLOSE_MODE);
|
||||
}, [artifactPanelOpen, artifactsFill]);
|
||||
layoutRef.current.setLayout(panelOpen ? OPEN_MODE : CLOSE_MODE);
|
||||
}, [panelOpen, artifactsFill]);
|
||||
|
||||
const handleArtifactsClose = () => {
|
||||
deselect();
|
||||
@@ -151,7 +159,7 @@ export function Step2SandboxLayout({
|
||||
if (artifactsFill) {
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread: threadStub, isMock: false }}>
|
||||
<div className="h-full min-h-0 flex-1 bg-[#fffdfa]">{artifactPanelBody}</div>
|
||||
<div className="h-full min-h-0 flex-1 bg-[#fffdfa]">{sidePanel ?? artifactPanelBody}</div>
|
||||
</ThreadContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -172,17 +180,17 @@ export function Step2SandboxLayout({
|
||||
id="roundtable-step2-separator"
|
||||
className={cn(
|
||||
"opacity-33 hover:opacity-100",
|
||||
!artifactPanelOpen && "pointer-events-none opacity-0",
|
||||
!panelOpen && "pointer-events-none opacity-0",
|
||||
)}
|
||||
/>
|
||||
<ResizablePanel
|
||||
className={cn(
|
||||
"bg-[#fffdfa] transition-all duration-300 ease-in-out",
|
||||
!artifactsOpen && "opacity-0",
|
||||
!panelOpen && "opacity-0",
|
||||
)}
|
||||
id="artifacts"
|
||||
>
|
||||
{artifactPanelBody}
|
||||
{sidePanel ?? artifactPanelBody}
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ThreadContext.Provider>
|
||||
|
||||
@@ -329,7 +329,7 @@ export function Step3ReportMode({
|
||||
dialogues={dialogues}
|
||||
error={error}
|
||||
themeMismatch={themeMismatch}
|
||||
onRetry={redraw}
|
||||
onRetry={() => redraw()}
|
||||
onRevisionSubmit={interject}
|
||||
onArtifactStepClick={openArtifactFromStep}
|
||||
onArtifactPreview={openArtifactPreview}
|
||||
|
||||
@@ -42,6 +42,7 @@ import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactP
|
||||
import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton";
|
||||
import { FlowGraphPanel } from "@/roundtable-planning/components/FlowGraphPanel";
|
||||
import {
|
||||
detectFlowIntent,
|
||||
extractFlowGraph,
|
||||
stripFlowFence,
|
||||
FLOW_FENCE_OPEN,
|
||||
@@ -169,6 +170,37 @@ export function Step3SummaryMode({
|
||||
if (!artifactsOpen) setFlowView(true);
|
||||
}, [flowGraph, artifactsOpen]);
|
||||
|
||||
// 沙箱里已写出的 md 报告(流程图画布上提供「MD 报告」切回按钮的锚点)。
|
||||
const mdArtifact = useMemo(() => {
|
||||
for (const [threadId, msgs] of Object.entries(seatStubMessages)) {
|
||||
const msg = msgs?.[0];
|
||||
if (!msg) continue;
|
||||
for (const tc of msg.tool_calls) {
|
||||
if (tc.name !== "write_file") continue;
|
||||
const path =
|
||||
typeof (tc.args as { path?: unknown })?.path === "string"
|
||||
? ((tc.args as { path?: string }).path as string)
|
||||
: "";
|
||||
const content =
|
||||
typeof (tc.args as { content?: unknown })?.content === "string"
|
||||
? ((tc.args as { content?: string }).content as string)
|
||||
: "";
|
||||
if (/\.md$/i.test(path) && content.trim()) return { threadId, path };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [seatStubMessages]);
|
||||
|
||||
// 用户在输入框里明确要求结构化输出/流程图 → 立即切到流程图画布(旧图先顶着,
|
||||
// 新 flow-json 流出来就增量重绘;首次还没有图时画布在数据到达后自动渲染)。
|
||||
const handleAsk = (message: string, files?: File[]) => {
|
||||
if (detectFlowIntent(message)) {
|
||||
flowAutoOpenedRef.current = true;
|
||||
setFlowView(true);
|
||||
}
|
||||
ask(message, files);
|
||||
};
|
||||
|
||||
// 气泡里不展示生 JSON:把 flow-json 块替换成一句引导语。
|
||||
const displayDialogues = useMemo(
|
||||
() =>
|
||||
@@ -180,17 +212,20 @@ export function Step3SummaryMode({
|
||||
[dialogues],
|
||||
);
|
||||
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
||||
const prevArtifactsOpenRef = useRef(artifactsOpen);
|
||||
// 右侧面板(沙箱预览或流程图画布)露出时自动收起左栏,关闭时复原——边沿触发,手动切换不回弹。
|
||||
const rightPanelVisible = artifactsOpen || (!!flowGraph && flowView);
|
||||
const prevRightPanelVisibleRef = useRef(rightPanelVisible);
|
||||
useEffect(() => {
|
||||
if (artifactsOpen !== prevArtifactsOpenRef.current) {
|
||||
prevArtifactsOpenRef.current = artifactsOpen;
|
||||
setLeftCollapsed(artifactsOpen);
|
||||
if (rightPanelVisible !== prevRightPanelVisibleRef.current) {
|
||||
prevRightPanelVisibleRef.current = rightPanelVisible;
|
||||
setLeftCollapsed(rightPanelVisible);
|
||||
}
|
||||
}, [artifactsOpen]);
|
||||
}, [rightPanelVisible]);
|
||||
|
||||
// 进入即自动开跑;草稿里已存结果则恢复展示。卸载中断在途请求(理由同 Step3ReportMode)。
|
||||
// 进入即自动开跑;草稿里已存结果则恢复展示(流程图模式的快照可能只有 closing 没有 md)。
|
||||
// 卸载中断在途请求(理由同 Step3ReportMode)。
|
||||
useEffect(() => {
|
||||
if (savedSummary?.md) {
|
||||
if (savedSummary && (savedSummary.md.trim() || savedSummary.closing?.trim())) {
|
||||
hydrateSaved(savedSummary);
|
||||
} else {
|
||||
start();
|
||||
@@ -337,20 +372,39 @@ export function Step3SummaryMode({
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{/* 右栏:智能体进度 + 沙箱(复用第 2 步)。流程图画布作为覆盖层叠在其上。 */}
|
||||
{/* 右栏:智能体进度 + 沙箱(复用第 2 步)。流程图画布放在右侧沙箱位
|
||||
(sidePanel 插槽),消息列表保持可见,可与 md 沙箱预览互相切换。 */}
|
||||
<section className="relative flex-1 min-w-0 rounded-2xl border border-slate-200/60 bg-white overflow-hidden">
|
||||
<Step2SandboxLayout
|
||||
activeThreadId={activeArtifactThreadId ?? summaryThreadId}
|
||||
isStreaming={isStreaming}
|
||||
seatMessages={activeSeatMessages}
|
||||
sidePanel={
|
||||
flowGraph && flowView ? (
|
||||
<FlowGraphPanel
|
||||
data={flowGraph.graph}
|
||||
streaming={flowGraph.streaming}
|
||||
onClose={() => setFlowView(false)}
|
||||
onSwitchToReport={
|
||||
mdArtifact
|
||||
? () => {
|
||||
setFlowView(false);
|
||||
openArtifactPreview(mdArtifact.threadId, mdArtifact.path);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<AgentProgressPanel
|
||||
phase={phase}
|
||||
dialogues={displayDialogues}
|
||||
error={error}
|
||||
busy={busy}
|
||||
mdAvailable={!!mdArtifact}
|
||||
onRetry={redraw}
|
||||
onAsk={ask}
|
||||
onAsk={handleAsk}
|
||||
onPause={pause}
|
||||
onArtifactStepClick={openArtifactFromStep}
|
||||
onArtifactPreview={openArtifactPreview}
|
||||
@@ -381,14 +435,6 @@ export function Step3SummaryMode({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 流程图画布(覆盖右栏;全屏在组件内部切换) */}
|
||||
{flowGraph && flowView && (
|
||||
<FlowGraphPanel
|
||||
data={flowGraph.graph}
|
||||
streaming={flowGraph.streaming}
|
||||
onClose={() => setFlowView(false)}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -498,6 +544,7 @@ function AgentProgressPanel({
|
||||
dialogues,
|
||||
error,
|
||||
busy,
|
||||
mdAvailable,
|
||||
onRetry,
|
||||
onAsk,
|
||||
onPause,
|
||||
@@ -512,6 +559,8 @@ function AgentProgressPanel({
|
||||
dialogues: ReturnType<typeof useStep3Summary>["dialogues"];
|
||||
error: string | null;
|
||||
busy: boolean;
|
||||
/** 沙箱里是否已有 md 报告 —— 决定提示语(纯流程图轮没有 .md 可预览/下载)。 */
|
||||
mdAvailable: boolean;
|
||||
onRetry: () => void;
|
||||
onAsk: (message: string, files?: File[]) => void;
|
||||
onPause: () => void;
|
||||
@@ -616,7 +665,9 @@ function AgentProgressPanel({
|
||||
{busy && (
|
||||
<div className="inline-flex items-center gap-2 rounded-full border bg-white px-2.5 py-1 text-[11px] text-slate-500">
|
||||
<span className="bg-primary size-1.5 animate-pulse rounded-full" />
|
||||
报告正文实时输出,右侧沙箱同步预览 Markdown
|
||||
{mdAvailable
|
||||
? "报告正文实时输出,右侧沙箱同步预览 Markdown"
|
||||
: "报告正文实时输出;输出结构化数据时流程图画布同步绘制"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -638,7 +689,9 @@ function AgentProgressPanel({
|
||||
{phase === "done" && (
|
||||
<div className="flex items-center gap-2 text-xs font-bold text-emerald-600">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
已生成方案总结报告,右侧可预览 / 下载 .md;可在下方继续提修改意见或提问。
|
||||
{mdAvailable
|
||||
? "已生成方案总结报告,右侧可预览 / 下载 .md;可在下方继续修改报告 / 流程图或提问。"
|
||||
: "已生成方案总结,流程图可在画布查看;可在下方继续修改总结 / 流程图或提问。"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -683,7 +736,7 @@ function AgentProgressPanel({
|
||||
placeholder={
|
||||
busy
|
||||
? "生成中也可随时插话干预:发送将打断当前轮并据此继续…"
|
||||
: "继续对话:提修改意见(如「执行路线写细些」)/ 提问 / 上传参考文件"
|
||||
: "继续对话:改报告(如「执行路线写细些」)/ 改流程图(如「总结成结构化输出」「流程图加上验收环节」)/ 提问"
|
||||
}
|
||||
className="min-h-8 max-h-28 flex-1 resize-none bg-transparent py-1.5 text-xs leading-relaxed text-slate-800 placeholder:text-slate-400 focus:outline-none"
|
||||
/>
|
||||
|
||||
@@ -40,6 +40,7 @@ import type {
|
||||
Step2Dialogue,
|
||||
StubAiMessage,
|
||||
} from "@/roundtable-planning/hooks/useStep2Orchestration";
|
||||
import { detectFlowIntent } from "@/roundtable-planning/lib/flow-graph";
|
||||
import { nowTime } from "@/roundtable-planning/lib/roundtable-constants";
|
||||
|
||||
/** 报告输出文件的固定虚拟路径(恢复历史快照时用它重建 stub)。 */
|
||||
@@ -149,31 +150,60 @@ function buildMaterials(opts: {
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
/** 首轮总结 prompt。 */
|
||||
/** 首轮总结 prompt。`flowMode`=编排提示里明确要求了结构化输出/流程图 → 跳过 md 文件,直出流程图。 */
|
||||
function buildSummaryPrompt(opts: {
|
||||
intentReady: ReportIntent | null;
|
||||
seatDeliveries: ReportSeatDelivery[];
|
||||
consensusText: string;
|
||||
coordinatorPrompt?: string | null;
|
||||
flowMode?: boolean;
|
||||
}): string {
|
||||
const parts: string[] = [];
|
||||
parts.push(
|
||||
"请基于以下圆桌多智能体研讨的成果,撰写一份以文字叙述为主线的 Markdown「方案总结报告」," +
|
||||
"用 write_file 工具写入 /mnt/user-data/outputs/方案总结报告.md," +
|
||||
"并把报告完整正文同时输出到对话回复里供用户直接阅读。",
|
||||
);
|
||||
if (opts.flowMode) {
|
||||
parts.push(
|
||||
"请基于以下圆桌多智能体研讨的成果,产出一份**结构化的方案总结**。" +
|
||||
"用户已明确要求结构化输出/流程图,因此本轮:" +
|
||||
"①**不要**调用 write_file,**不要**生成任何 .md 文件;" +
|
||||
"②把精炼的文字版总结报告直接写在对话回复里(供用户直接阅读,结构遵循编排提示的要求);" +
|
||||
"③在回复最末尾**必须**输出一个完整的 ```flow-json 代码块(前端据此实时绘制可交互流程图)。",
|
||||
);
|
||||
} else {
|
||||
parts.push(
|
||||
"请基于以下圆桌多智能体研讨的成果,撰写一份以文字叙述为主线的 Markdown「方案总结报告」," +
|
||||
"用 write_file 工具写入 /mnt/user-data/outputs/方案总结报告.md," +
|
||||
"并把报告完整正文同时输出到对话回复里供用户直接阅读。",
|
||||
);
|
||||
}
|
||||
parts.push("");
|
||||
parts.push(buildMaterials(opts));
|
||||
parts.push(
|
||||
"请严格遵循你的职责设定:纯 Markdown 文本(不画 HTML / 图表)、全程简体中文。" +
|
||||
"报告结构:默认用叙事型结构(一句话结论 → 背景目标 → 各席位观点 → 综合分析 → 最终共识方案 → 执行路线);" +
|
||||
"但若上面的「业务链总控编排提示」对最终总结提出了要求(输出结构/格式、重点席位、口径、详略)," +
|
||||
"那些要求优先于默认结构——用户指定的结构可以完全替换默认章节。" +
|
||||
"若用户要求了结构化输出/流程图,按你职责设定在对话回复末尾追加 ```flow-json 代码块(前端据此实时绘制流程图)。",
|
||||
);
|
||||
if (opts.flowMode) {
|
||||
parts.push(
|
||||
"请严格遵循你的职责设定:全程简体中文。" +
|
||||
"flow-json 按你职责设定的「结构化流程数据」契约输出:节点按流程先后排列、" +
|
||||
"每个节点的 from 只引用前面已出现的节点、6–20 个节点为宜、类型用 start/end/stage/task/decision/risk/deliverable/role。" +
|
||||
"正文文字报告以「业务链总控编排提示」的要求为骨架,简明、重点突出。",
|
||||
);
|
||||
} else {
|
||||
parts.push(
|
||||
"请严格遵循你的职责设定:纯 Markdown 文本(不画 HTML / 图表)、全程简体中文。" +
|
||||
"报告结构:默认用叙事型结构(一句话结论 → 背景目标 → 各席位观点 → 综合分析 → 最终共识方案 → 执行路线);" +
|
||||
"但若上面的「业务链总控编排提示」对最终总结提出了要求(输出结构/格式、重点席位、口径、详略)," +
|
||||
"那些要求优先于默认结构——用户指定的结构可以完全替换默认章节。" +
|
||||
"若用户要求了结构化输出/流程图,按你职责设定在对话回复末尾追加 ```flow-json 代码块(前端据此实时绘制流程图)。",
|
||||
);
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户消息命中结构化/流程图意图时追加给智能体的系统指令(live 线程续问轮直接拼在消息后)。
|
||||
* 强调「全量 flow-json + 不写文件」——画图数据必须完整自洽,否则前端增量解析会画出半张图。
|
||||
*/
|
||||
const FLOW_ASK_INSTRUCTION =
|
||||
"(系统指令:用户要求结构化输出/流程图——本轮不要写或修改任何 .md 报告文件," +
|
||||
"在回复正文给出简短的文字说明/总结即可,并在回复最末尾输出一个**完整**的 ```flow-json 代码块" +
|
||||
"(按你职责设定的「结构化流程数据」契约;输出全量数据而非增量)。)";
|
||||
|
||||
/** 恢复草稿后线程已不在内存(无 live thread)时,新建线程续问用的 prompt:带上旧报告做底。 */
|
||||
function buildResumeAskPrompt(opts: {
|
||||
intentReady: ReportIntent | null;
|
||||
@@ -182,8 +212,10 @@ function buildResumeAskPrompt(opts: {
|
||||
coordinatorPrompt?: string | null;
|
||||
previousMd: string;
|
||||
userMessage: string;
|
||||
/** 用户消息命中结构化/流程图意图 → 指示不写文件、直出 flow-json。 */
|
||||
flowIntent?: boolean;
|
||||
}): string {
|
||||
const { previousMd, userMessage, ...materials } = opts;
|
||||
const { previousMd, userMessage, flowIntent, ...materials } = opts;
|
||||
const parts: string[] = [];
|
||||
parts.push(
|
||||
"这是一次对已有「方案总结报告」的继续对话。下面先给出研讨原料与你之前已生成的报告," +
|
||||
@@ -202,6 +234,10 @@ function buildResumeAskPrompt(opts: {
|
||||
}
|
||||
parts.push("# 用户的新消息");
|
||||
parts.push(userMessage.trim());
|
||||
if (flowIntent) {
|
||||
parts.push("");
|
||||
parts.push(FLOW_ASK_INSTRUCTION);
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
@@ -649,15 +685,19 @@ export function useStep3Summary(
|
||||
);
|
||||
}, []);
|
||||
|
||||
/** 把当前报告 md 种进某线程 stub + 开沙箱(续问时给 reconcile/预览一个锚点)。 */
|
||||
/**
|
||||
* 把当前报告 md 种进某线程 stub(续问时给 reconcile/预览一个锚点)。
|
||||
* `open`=false 时只种不开沙箱——流程图轮用户要看的是画布,不要弹 md 预览抢占。
|
||||
*/
|
||||
const seedStubWithCurrentMd = useCallback(
|
||||
(threadId: string) => {
|
||||
(threadId: string, opts?: { open?: boolean }) => {
|
||||
const md = currentMdRef.current;
|
||||
if (!md.trim()) return;
|
||||
const { messageId, toolCallId } = upsertStub(threadId, "write_file", {
|
||||
path: SUMMARY_OUTPUT_PATH,
|
||||
content: md,
|
||||
});
|
||||
if (opts?.open === false) return;
|
||||
const virtualUrl = buildWriteFileVirtualUrl(SUMMARY_OUTPUT_PATH, messageId, toolCallId);
|
||||
openArtifact(threadId, virtualUrl);
|
||||
},
|
||||
@@ -676,8 +716,10 @@ export function useStep3Summary(
|
||||
seedThreadId?: string | null;
|
||||
forceReconcile?: boolean;
|
||||
files?: RunFileRef[];
|
||||
/** 流程图轮:不自动打开 md 沙箱预览(用户要看的是流程图画布)。 */
|
||||
flowRound?: boolean;
|
||||
}): Promise<void> => {
|
||||
const { bubbleId, prompt, aborter, seedThreadId, forceReconcile, files } = args;
|
||||
const { bubbleId, prompt, aborter, seedThreadId, forceReconcile, files, flowRound } = args;
|
||||
|
||||
let threadId = seedThreadId ?? null;
|
||||
let agentName = SUMMARY_AGENT_NAME;
|
||||
@@ -694,7 +736,8 @@ export function useStep3Summary(
|
||||
prev.map((d) => (d.id === bubbleId ? { ...d, agentThreadId: threadId! } : d)),
|
||||
);
|
||||
// 续问/恢复时把当前报告种进本线程 stub(续问 str_replace 被禁、整文件重写,但仍给预览锚点)。
|
||||
if (seedThreadId) seedStubWithCurrentMd(threadId);
|
||||
// 流程图轮只种不开沙箱——md 保留着供用户随时切回去看。
|
||||
if (seedThreadId) seedStubWithCurrentMd(threadId, { open: !flowRound });
|
||||
setPhase("streaming");
|
||||
|
||||
const res = await streamMultiAgent({
|
||||
@@ -718,7 +761,8 @@ export function useStep3Summary(
|
||||
if (aborter.signal.aborted) return;
|
||||
|
||||
const hasStubFile = (stubRef.current[threadId]?.[0]?.tool_calls.length ?? 0) > 0;
|
||||
if (!hasStubFile && !seedThreadId) await openRealArtifactFallback(threadId);
|
||||
// 流程图轮本就不产文件,不要去沙箱兜底翻文件(也避免把别的文件弹出来)。
|
||||
if (!hasStubFile && !seedThreadId && !flowRound) await openRealArtifactFallback(threadId);
|
||||
|
||||
// 以真实 md 回灌 stub,拿到完整报告(ask 强制对账,防增量改后被长度守卫挡掉)。
|
||||
const md = await reconcileStubFromRealFile(threadId, {
|
||||
@@ -731,9 +775,10 @@ export function useStep3Summary(
|
||||
setPhase("done");
|
||||
|
||||
// 报告有更新就存草稿(问答轮不写文件 → md 不变也无妨,仍带最新 closing)。
|
||||
// 流程图模式可能全程没有 md——只要 closing 有内容(含 flow-json)也要存,恢复时才能重画流程图。
|
||||
const finalMd = md.trim() || currentMdRef.current;
|
||||
if (finalMd.trim()) {
|
||||
currentMdRef.current = finalMd;
|
||||
if (finalMd.trim()) currentMdRef.current = finalMd;
|
||||
if (finalMd.trim() || (res.content ?? "").trim()) {
|
||||
onSummarySavedRef.current?.({
|
||||
md: finalMd,
|
||||
threadId,
|
||||
@@ -778,13 +823,16 @@ export function useStep3Summary(
|
||||
});
|
||||
|
||||
try {
|
||||
// 编排提示里明确要求了结构化输出/流程图 → 首轮就走流程图模式(不写 md,直出 flow-json)。
|
||||
const flowMode = detectFlowIntent(coordinatorPrompt);
|
||||
const prompt = buildSummaryPrompt({
|
||||
intentReady,
|
||||
seatDeliveries,
|
||||
consensusText,
|
||||
coordinatorPrompt,
|
||||
flowMode,
|
||||
});
|
||||
await streamOnce({ bubbleId, prompt, aborter });
|
||||
await streamOnce({ bubbleId, prompt, aborter, flowRound: flowMode });
|
||||
} catch (err) {
|
||||
if (aborter.signal.aborted) return;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
@@ -817,6 +865,9 @@ export function useStep3Summary(
|
||||
abort();
|
||||
if (wasBusy) freezeStreaming("已打断");
|
||||
|
||||
// 输入框消息命中结构化/流程图意图 → 本轮直出 flow-json、不写 md(md 留在沙箱可切回)。
|
||||
const flowIntent = detectFlowIntent(text);
|
||||
|
||||
runningRef.current = true;
|
||||
setError(null);
|
||||
const aborter = new AbortController();
|
||||
@@ -860,8 +911,10 @@ export function useStep3Summary(
|
||||
|
||||
if (hadLiveThread) {
|
||||
// 真线程仍在 → 续问,发原始消息,靠线程上下文 + SOUL 自判改/答。
|
||||
const prompt =
|
||||
// 命中流程图意图时附上系统指令(不写文件、末尾输出全量 flow-json)。
|
||||
const base =
|
||||
text || "我上传了参考文件,请先阅读文件内容,再据此更新报告或回答。";
|
||||
const prompt = flowIntent ? `${base}\n\n${FLOW_ASK_INSTRUCTION}` : base;
|
||||
await streamOnce({
|
||||
bubbleId,
|
||||
prompt,
|
||||
@@ -869,6 +922,7 @@ export function useStep3Summary(
|
||||
seedThreadId: liveThreadRef.current,
|
||||
forceReconcile: true,
|
||||
files: fileRefs,
|
||||
flowRound: flowIntent,
|
||||
});
|
||||
} else {
|
||||
// 无 live thread(恢复草稿 / 首轮没建好就被打断)→ prompt 带研讨原料 + 旧报告做底。
|
||||
@@ -881,6 +935,7 @@ export function useStep3Summary(
|
||||
previousMd: currentMdRef.current,
|
||||
userMessage:
|
||||
text || "请阅读我上传的参考文件后,据此更新报告或回答。",
|
||||
flowIntent,
|
||||
});
|
||||
await streamOnce({
|
||||
bubbleId,
|
||||
@@ -889,6 +944,7 @@ export function useStep3Summary(
|
||||
seedThreadId: threadId,
|
||||
forceReconcile: true,
|
||||
files: fileRefs,
|
||||
flowRound: flowIntent,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -933,30 +989,35 @@ export function useStep3Summary(
|
||||
|
||||
// 从草稿快照恢复:不跑 SSE,用保存的 md 重建一条「完成」气泡 + 合成 stub,并打开沙箱预览。
|
||||
// 保留 threadId 到 liveThreadRef —— 若快照带真线程 id,ask() 能直接续问(后端线程仍在)。
|
||||
// 流程图模式的快照可能没有 md(只有含 flow-json 的 closing):跳过 stub/沙箱,只重建气泡,
|
||||
// 流程图由组件从气泡内容里重新解析绘制。
|
||||
const hydrateSaved = useCallback(
|
||||
(snap: DraftStep3SummarySnapshot) => {
|
||||
abort();
|
||||
runningRef.current = false;
|
||||
const hasMd = snap.md.trim().length > 0;
|
||||
const realThreadId = snap.threadId?.trim() || null;
|
||||
const synthThreadId = realThreadId ?? "roundtable-summary-restored";
|
||||
const messageId = `roundtable-summary-stub-${synthThreadId}`;
|
||||
const toolCallId = `tc-${synthThreadId}-0`;
|
||||
const stub: Record<string, StubAiMessage[]> = {
|
||||
[synthThreadId]: [
|
||||
{
|
||||
id: messageId,
|
||||
type: "ai",
|
||||
content: "",
|
||||
tool_calls: [
|
||||
const stub: Record<string, StubAiMessage[]> = hasMd
|
||||
? {
|
||||
[synthThreadId]: [
|
||||
{
|
||||
id: toolCallId,
|
||||
name: "write_file",
|
||||
args: { path: SUMMARY_OUTPUT_PATH, content: snap.md },
|
||||
id: messageId,
|
||||
type: "ai",
|
||||
content: "",
|
||||
tool_calls: [
|
||||
{
|
||||
id: toolCallId,
|
||||
name: "write_file",
|
||||
args: { path: SUMMARY_OUTPUT_PATH, content: snap.md },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
: {};
|
||||
stubRef.current = stub;
|
||||
currentMdRef.current = snap.md;
|
||||
liveThreadRef.current = realThreadId;
|
||||
@@ -975,23 +1036,27 @@ export function useStep3Summary(
|
||||
time: nowTime(),
|
||||
confidence: "历史记录",
|
||||
streaming: false,
|
||||
steps: [
|
||||
{
|
||||
id: `${bubbleId}-0`,
|
||||
key: "tool_calling:write_file",
|
||||
label: "已撰写报告 Markdown",
|
||||
ts: nowTime(),
|
||||
args: { path: SUMMARY_OUTPUT_PATH },
|
||||
},
|
||||
],
|
||||
steps: hasMd
|
||||
? [
|
||||
{
|
||||
id: `${bubbleId}-0`,
|
||||
key: "tool_calling:write_file",
|
||||
label: "已撰写报告 Markdown",
|
||||
ts: nowTime(),
|
||||
args: { path: SUMMARY_OUTPUT_PATH },
|
||||
},
|
||||
]
|
||||
: [],
|
||||
agentThreadId: synthThreadId,
|
||||
},
|
||||
]);
|
||||
setError(null);
|
||||
setPhase("done");
|
||||
|
||||
const virtualUrl = buildWriteFileVirtualUrl(SUMMARY_OUTPUT_PATH, messageId, toolCallId);
|
||||
openArtifact(synthThreadId, virtualUrl);
|
||||
if (hasMd) {
|
||||
const virtualUrl = buildWriteFileVirtualUrl(SUMMARY_OUTPUT_PATH, messageId, toolCallId);
|
||||
openArtifact(synthThreadId, virtualUrl);
|
||||
}
|
||||
},
|
||||
[abort, buildWriteFileVirtualUrl, openArtifact],
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
detectFlowIntent,
|
||||
extractFlowGraph,
|
||||
layoutFlowGraph,
|
||||
stripFlowFence,
|
||||
@@ -109,6 +110,24 @@ test("layoutFlowGraph:按前驱分层(根 0 层,子层递增),保持
|
||||
assert.deepEqual(positioned.map((n) => n.id), ["n1", "n2", "n3"]);
|
||||
});
|
||||
|
||||
test("detectFlowIntent:明确短语命中(编排提示 / 输入框消息)", () => {
|
||||
assert.ok(detectFlowIntent("最终总结请用结构化输出"));
|
||||
assert.ok(detectFlowIntent("总结成格式化输出"));
|
||||
assert.ok(detectFlowIntent("给我画一张流程图"));
|
||||
assert.ok(detectFlowIntent("输出可视化流程"));
|
||||
assert.ok(detectFlowIntent("请按 flow-json 契约输出"));
|
||||
assert.ok(detectFlowIntent("重点总结方案设计席位,最后给结构化数据"));
|
||||
});
|
||||
|
||||
test("detectFlowIntent:普通措辞不误触发", () => {
|
||||
assert.equal(detectFlowIntent("用表格把要点结构化呈现"), false);
|
||||
assert.equal(detectFlowIntent("把执行路线写得更细一些"), false);
|
||||
assert.equal(detectFlowIntent("面向高管汇报,控制在一页内"), false);
|
||||
assert.equal(detectFlowIntent(""), false);
|
||||
assert.equal(detectFlowIntent(null), false);
|
||||
assert.equal(detectFlowIntent(undefined), false);
|
||||
});
|
||||
|
||||
test("layoutFlowGraph:环不死循环", () => {
|
||||
const text = [
|
||||
"```flow-json",
|
||||
|
||||
@@ -212,6 +212,30 @@ export function extractFlowGraph(text: string): FlowGraphData | null {
|
||||
return { title, nodes, edges: validEdges };
|
||||
}
|
||||
|
||||
/**
|
||||
* 识别文本(业务链编排提示 / 用户输入框消息)里是否明确要求了结构化输出 / 流程图。
|
||||
* 命中 → 总结走「流程图模式」:跳过 .md 文件,报告用文字展示 + 末尾输出 flow-json。
|
||||
* 只匹配较明确的短语,避免「用表格结构化呈现」这类普通措辞误触发。
|
||||
*/
|
||||
const FLOW_INTENT_PATTERNS = [
|
||||
"流程图",
|
||||
"可视化流程",
|
||||
"结构化输出",
|
||||
"结构化数据",
|
||||
"结构化流程",
|
||||
"结构化总结",
|
||||
"格式化输出",
|
||||
"格式化总结",
|
||||
"flow-json",
|
||||
"flowchart",
|
||||
];
|
||||
|
||||
export function detectFlowIntent(text: string | null | undefined): boolean {
|
||||
if (!text) return false;
|
||||
const lower = text.toLowerCase();
|
||||
return FLOW_INTENT_PATTERNS.some((p) => lower.includes(p));
|
||||
}
|
||||
|
||||
/**
|
||||
* 把回复里的 flow-json 块替换成一句人话(气泡里不展示生 JSON)。
|
||||
* 块未闭合(流式中)→「正在输出」措辞;已闭合 → 引导去看流程图画布。
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
* `DispatchChainNode`,其余类型为后续阶段预留,先一并定义好避免反复改动。
|
||||
*/
|
||||
|
||||
import type { OrchestrationPlan } from "./dag-types";
|
||||
|
||||
/** 作业状态机(§2.1)。 */
|
||||
export type JobStatus =
|
||||
| "queued" // 已创建,等待 worker 拉起
|
||||
@@ -66,8 +68,10 @@ export interface RoundtableJob {
|
||||
activeAgentId: string | null;
|
||||
consensusPercentage: number;
|
||||
pendingClarification: string | null;
|
||||
orchestrationMode: "recommend" | "chain";
|
||||
orchestrationMode: "recommend" | "chain" | "dag";
|
||||
chain: { id: string; title: string } | null;
|
||||
/** dag 模式分层编排计划(流程图按 stage 分层绘制);非 dag 为 null。 */
|
||||
orchestrationPlan: OrchestrationPlan | null;
|
||||
error: string | null;
|
||||
/** 研讨对话记录(leader 说明 + 各席位交付),跑完 / 进行中都可看真实内容。 */
|
||||
dialogues: JobDialogue[];
|
||||
@@ -85,8 +89,10 @@ export interface StartJobPayload {
|
||||
/** 有序席位(recommend=选择顺序 / chain=链条顺序)。 */
|
||||
agents: Array<{ agentId: string; name: string }>;
|
||||
model?: string;
|
||||
orchestrationMode?: "recommend" | "chain";
|
||||
orchestrationMode?: "recommend" | "chain" | "dag";
|
||||
chain?: { id: string; title: string } | null;
|
||||
/** dag 模式分层编排计划(stages / 阶段目标 / 总控提示词 / finalSynthesis)。非 dag 省略。 */
|
||||
orchestrationPlan?: OrchestrationPlan | null;
|
||||
/** 显式第一轮 leader prompt(覆盖 intent)。 */
|
||||
seedMessage?: string;
|
||||
/** 预建好的 thread 映射(Phase 3b 真网关用;模拟网关忽略)。 */
|
||||
|
||||
@@ -923,10 +923,13 @@ export default function RoundtablePlanningPage() {
|
||||
intent: buildInitialIntent(),
|
||||
agents,
|
||||
model: selectedModel || undefined, // 模型沿用第二步选择
|
||||
// 后台作业引擎暂不支持 dag(分层 DAG 只在前端 runDagOrchestration 跑);
|
||||
// 挂起到后台时降级为 recommend(总控自由派活)。
|
||||
orchestrationMode: orchestrationMode === "dag" ? "recommend" : orchestrationMode,
|
||||
// 后台作业引擎现已支持 dag(后端 _run_dag:stage 串行 / stage 内并行)——
|
||||
// 直接把当前编排模式透传,业务链的分层 / 并行 / 阶段目标 / 总控提示词全部保留。
|
||||
orchestrationMode,
|
||||
chain: chainMeta,
|
||||
// dag 模式:把整份分层计划带给后端(含 stages / 阶段目标 / 总控提示词 / finalSynthesis)。
|
||||
// 非 dag 时为 null(后端按 mode 走 chain/recommend)。
|
||||
orchestrationPlan: orchestrationMode === "dag" ? orchestrationPlan : null,
|
||||
// 不传 threadIds:后台作业由真网关自建全新线程,绝不复用前端本地研讨的线程
|
||||
// (那些线程可能有活跃 run,复用会撞 409)。
|
||||
});
|
||||
@@ -944,6 +947,7 @@ export default function RoundtablePlanningPage() {
|
||||
drafts,
|
||||
selectedModel,
|
||||
orchestrationMode,
|
||||
orchestrationPlan,
|
||||
chainMeta,
|
||||
buildInitialIntent,
|
||||
resetToStep1,
|
||||
|
||||
Reference in new issue
Block a user