feat: taskId 深链会商按任务隔离草稿 + 第三步自动结构化流程图

- 深链 ?taskId=&goPath=*-A 透传 taskId 进会商,按 taskId 隔离存取草稿(一个 taskId 多条、可新建);
  后端新增 GET /api/roundtable-drafts/by-task/{id} 列表 + RoundtableJobRepository.status_map_by_drafts 富化。
- 第三步生成 md 报告后自动结构化输出流程图(仅 taskId 模式):有报告基于报告、无报告基于各席位交付,
  整段内联数据来源逐条抽取;层级优先用业务链总控提示词写明的层级,否则用 stages 的 goal。
- 流程图节点加 group(层级名)标签 + detail(条目名称+理由);强 prompt 压制 coordinatorPrompt 报告锚定,
  禁阶段编号节点;FLOW_STRUCTURE_RULE 精确画法(根=任务、每层每条成节点、按对应关系串分支、排除汇总段落)。
- flow-graph 解析容错:JSON.parse 失败退回正则逐字段抽取,兼容裸换行/尾逗号,避免节点被整体丢弃。
- 修:重开第二步后第三步基于新一轮分析重新生成(复位 step3 闸门 + 中断旧三流)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
fengxiangdebaoziandClaude Opus 4.8 committed 2026-06-17 19:45:35 +08:00
1 parent e6a41dbc97
commit fca39eeaee
17 files changed
+472 -37

No files matched your search

+8 -2
View File
@@ -128,10 +128,14 @@ function buildOpeningText(detail: CopTaskDetail): string {
/**
* 据 goPath + 任务详情解析跳转目标。
* - 多智能体会商:返回带 ``roundtablePrefill``(businessCode 让会商页自动选用配置的业务链条)的 state。
* - 多智能体会商:返回带 ``roundtablePrefill``(businessCode 让会商页自动选用配置的业务链条;
* taskId 让会商页按任务隔离存取草稿/聊天记录)的 state。
* - 单智能体问答:解析「配置管理」里该业务配置的智能体;把开场信息写入
* ``sessionStorage.pendingAgentMessage``(AgentChatPage 自动发出)。
* 无法识别 goPath / 未配置智能体时返回 null(调用方回退到默认首页)。
*
* @param taskId 深链 URL 的原始 ``?taskId=``(外部任务 id,用于按任务隔离草稿;与
* consumer 返回的 detail.id 区分——后者不保证等于外部任务 id)。
*/
export async function resolveTaskDeeplinkTarget(
goPath: string,
@@ -147,7 +151,8 @@ export async function resolveTaskDeeplinkTarget(
clearTaskContext();
if (parsed.roundtable) {
// 多智能体会商:嵌进对应业务的「任务管理」布局;businessCode 让会商页自动选用配置的业务链条。
// 多智能体会商:嵌进对应业务的「任务管理」布局;businessCode 让会商页自动选用配置的业务链条;
// taskId 让会商页按任务隔离存取草稿(一个 taskId 多条记录),并触发第三步自动结构化输出。
return {
path: `${taskBase}/roundtable`,
state: {
@@ -155,6 +160,7 @@ export async function resolveTaskDeeplinkTarget(
taskTitle: detail.taskName,
taskGoal: detail.taskContent,
businessCode: parsed.businessCode,
taskId: taskId || undefined,
autoStart: true,
},
},
@@ -309,6 +309,16 @@ export async function getLatestDraftByTask(taskId: string): Promise<DraftRecord
return toRecord(data);
}
/**
* 按外部任务 id 列出该任务全部草稿(轻量 meta,newest first;后端忽略 user,不分权)。
* taskId 深链模式下历史下拉用:一个 taskId 可有多条会商记录。
*/
export async function listDraftsByTask(taskId: string): Promise<DraftMeta[]> {
const res = await apiFetch(`${BASE()}/by-task/${encodeURIComponent(taskId)}`, { method: "GET" });
const data = await readJson<{ drafts: ServerDraftMeta[] }>(res, "list drafts by task");
return (data.drafts ?? []).map(toMeta);
}
export async function createDraft(payload: DraftCreatePayload): Promise<DraftRecord> {
const res = await apiFetch(BASE(), {
method: "POST",
@@ -85,7 +85,7 @@ function FlowCardNode({ data }: NodeProps) {
<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" style={{ fontSize: `${9 * scale}px` }}>
{meta.label}
{node.group?.trim() || meta.label}
</span>
</div>
<div className="break-words font-bold leading-snug" style={{ fontSize: `${11.5 * scale}px` }}>{node.label}</div>
@@ -347,7 +347,7 @@ function NodeDetailModal({
className="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-bold"
style={{ background: "#ffffffaa", color: meta.text }}
>
{meta.label}
{node.group?.trim() || meta.label}
</span>
<h3 className="min-w-0 truncate text-sm font-bold" style={{ color: meta.text }}>
{node.label}
@@ -19,6 +19,7 @@ import type { ReportIntent, UseStep3ReportReturn } from "@/roundtable-planning/h
import type { UseStep3SummaryReturn } from "@/roundtable-planning/hooks/useStep3Summary";
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
import { stripReasoning } from "@/roundtable-planning/lib/reasoning";
import { useMemo } from "react";
export interface Step3PanelProps {
threadIds: ThreadIdMap | null;
@@ -81,18 +82,25 @@ export function Step3Panel(props: Step3PanelProps) {
seatStubMessages,
} = props;
const seatDeliveries = step2RoundtableDialogues
.filter((d) => d.confidence === "子智能体交付")
.map((d) => ({
sender: String(d.sender ?? ""),
avatarType: String(d.avatarType ?? "blue"),
// 交付内容只保留模型生成的主体输出,剥离 `<think>...</think>` 推理过程
// —— 既用于渲染各席位交付卡片,也会喂给总结/报告模型,避免推理噪声污染。
content: stripReasoning(String(d.content ?? "")),
}));
const seatDeliveries = useMemo(
() =>
step2RoundtableDialogues
.filter((d) => d.confidence === "子智能体交付")
.map((d) => ({
sender: String(d.sender ?? ""),
avatarType: String(d.avatarType ?? "blue"),
// 交付内容只保留模型生成的主体输出,剥离 `<think>...</think>` 推理过程
// —— 既用于渲染各席位交付卡片,也会喂给总结/报告模型,避免推理噪声污染。
content: stripReasoning(String(d.content ?? "")),
})),
[step2RoundtableDialogues],
);
const seatNameMap = Object.fromEntries(
Object.entries(agentNameToRole).map(([k, v]) => [k, v.displayName]),
const seatNameMap = useMemo(
() => Object.fromEntries(
Object.entries(agentNameToRole).map(([k, v]) => [k, v.displayName]),
),
[agentNameToRole],
);
return (
@@ -159,9 +159,11 @@ export function Step3SummaryMode({
if (!flowGraph || flowGraph.graph.nodes.length === 0) return;
if (flowAutoOpenedRef.current) return;
flowAutoOpenedRef.current = true;
// 没开沙箱 → 自动打开流程图画布;沙箱开着 → 不抢占,只露出切换按钮。
if (!artifactsOpen) setFlowView(true);
}, [flowGraph, artifactsOpen]);
// taskId/深链模式:结构化输出是本模式的主交付 → 流程图一出现就自动聚焦画布
// (即便此前 md 沙箱已自动打开,也切到流程图)。
// 普通模式:没开沙箱才自动打开;沙箱开着不抢占,只露出切换按钮。
if (summary.autoFlow || !artifactsOpen) setFlowView(true);
}, [flowGraph, artifactsOpen, summary.autoFlow]);
// 沙箱里已写出的 md 报告(流程图画布上提供「MD 报告」切回按钮的锚点)。
const mdArtifact = useMemo(() => {
@@ -575,6 +577,16 @@ function AgentProgressPanel({
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]);
// 新一轮气泡出现(如:md 报告完成后自动续跑的结构化输出轮、用户发问)→ 强制滚到底,
// 让用户看到新阶段(修「调用结构化输出时不自动下滑」)。仅在气泡**条数**增加时触发,
// 流式增量内容仍由 useAutoScroll 的「在底部才跟随」逻辑处理,不抢用户向上翻阅。
const prevDialogueCountRef = useRef(dialogues.length);
useEffect(() => {
if (dialogues.length > prevDialogueCountRef.current) {
scrollToBottom();
}
prevDialogueCountRef.current = dialogues.length;
}, [dialogues.length, scrollToBottom]);
// 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。
const [localSettings] = useLocalSettings();
const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled;
@@ -26,6 +26,7 @@ import {
getDraft,
getLatestDraftByTask,
listDrafts,
listDraftsByTask,
updateDraft,
type DraftRecord,
type DraftStep2Snapshot,
@@ -190,7 +191,10 @@ export function useDraftPersistence(
const refreshList = useCallback(async () => {
try {
const metas = await listDrafts();
// taskId 模式:历史下拉只列该任务的草稿(一个 taskId 多条记录、跨用户共享);
// 普通模式:列当前用户全部草稿。
const tid = taskIdRef.current;
const metas = tid ? await listDraftsByTask(tid) : await listDrafts();
setDrafts(
metas.map((m) => ({
id: m.id,
@@ -11,25 +11,30 @@ import { useCallback, useState } from "react";
import {
deleteDraft,
listDrafts,
listDraftsByTask,
updateDraft,
type DraftMeta,
} from "@/roundtable-planning/api/drafts";
export function useRoundtableDrafts() {
/**
* @param taskId 外部任务 id(深链 taskId 模式)。非空时只列该任务的草稿
* (一个 taskId 多条、跨用户共享);否则列当前用户全部草稿。
*/
export function useRoundtableDrafts(taskId?: string | null) {
const [drafts, setDrafts] = useState<DraftMeta[]>([]);
const [loading, setLoading] = useState(false);
const refresh = useCallback(async () => {
setLoading(true);
try {
const data = await listDrafts();
const data = taskId ? await listDraftsByTask(taskId) : await listDrafts();
setDrafts(data);
} catch {
// 历史记录非关键,静默忽略
} finally {
setLoading(false);
}
}, []);
}, [taskId]);
const rename = useCallback(async (id: string, title: string) => {
// 只发 title —— 与页面自动保存的「不带 title」互补,是改标题的唯一入口。
@@ -57,6 +57,17 @@ interface UseStep3SummaryOptions {
selectedModel: string;
/** 业务链写给总控的编排提示 —— 作为最高优先级口径注入总结智能体。 */
coordinatorPrompt?: string | null;
/**
* 业务链条层级(取自 stages 的「本阶段目标」goal,有序)—— taskId 深链模式下
* 自动结构化输出时,作流程图的层级骨架(任务 → 层级1 → 层级2 → …)。
* 例:链层级 目的→行为体→驱动因素 → 流程图 任务→目的→行为体→驱动因素。
*/
chainLevels?: string[];
/**
* taskId/深链模式:生成 md 报告后**自动**续跑一轮结构化输出(流程图)。
* 普通手动会商为 false —— 结构化输出仍按需(用户输入框打「流程图」才出)。
*/
autoFlow?: boolean;
/** 草稿里已存的上次总结结果 —— 有则进入时恢复展示,不重新生成。 */
savedSummary?: DraftStep3SummarySnapshot | null;
/** 一次总结成功完成时回调(传出 md/thread/model/时间/收尾语),供上层存草稿。 */
@@ -66,6 +77,8 @@ interface UseStep3SummaryOptions {
export interface UseStep3SummaryReturn {
phase: SummaryPhase;
error: string | null;
/** taskId/深链模式:md 报告生成后自动续跑流程图(组件据此让流程图画布自动聚焦)。 */
autoFlow: boolean;
summaryThreadId: string | null;
dialogues: Step2Dialogue[];
seatStubMessages: Record<string, StubAiMessage[]>;
@@ -152,6 +165,117 @@ function buildMaterials(opts: {
return parts.join("\n");
}
/**
* flow-json 形状契约(内联)。roundtable-summary 的 runtime SOUL 为空,模型拿不到契约,
* 所有要画流程图的 prompt 都得带上它,否则模型产出的 JSON 可能解析不出节点。
*/
const FLOW_JSON_SHAPE =
"flow-json 严格用下面这个形状(一个 JSON 对象,`nodes` 是节点数组;示例仅示意结构、勿照抄内容):\n" +
"```flow-json\n" +
'{"title":"流程图标题","nodes":[\n' +
' {"id":"n1","label":"任务简短名","type":"start","group":"任务","detail":"任务目标的完整描述"},\n' +
' {"id":"n2","label":"目的核心短语","type":"task","group":"目的","detail":"该目的的完整内容:名称 + 理由全文","from":["n1"]},\n' +
' {"id":"n3","label":"行为体核心短语","type":"role","group":"行为体","detail":"该行为体的完整内容:名称 + 理由全文","from":["n2"]}\n' +
"]}\n" +
"```\n" +
"字段:`id`(n1/n2… 唯一短串)、`label`(≤12 字,节点标题,取条目核心短语)、" +
"`type`(取 start/end/stage/task/decision/risk/deliverable/role 之一,仅决定配色)、" +
"**`group`(该节点所在的层级名称,如 任务 / 目的 / 行为体 / 预期行为 / 驱动因素 / 叙事——画布按它给节点打标签,必须填)**、" +
"**`detail`(该条目的名称 + 理由 / 说明,点击节点时展示——要把要点表达完整,但控制在约 2–4 句,**不必**逐字照抄报告整段)**、" +
"`from`(前驱节点 id 列表,只引用**前面已出现**的节点)。" +
"节点按先后排列、输出**全量**数据、全程简体中文。";
/**
* 「本轮只画流程图、不写报告」的强约束。关键:有的业务链 coordinatorPrompt 自身写着
* 「输出结构化结论 / 按阶段产出报告 / 最终方案总结报告按层级结构输出」,模型在同一线程里
* 会被它锚定,把用户的「结构化输出」当成「继续写结构化的文字报告」→ 产出 Markdown 而非
* flow-json(实测 deepseek-chat 在这类链上必现)。所以必须显式声明本轮压过那些报告措辞。
*/
const FLOW_ONLY_RULE =
"【硬性·本轮只画流程图】本轮唯一产物是一张**流程图**(由节点 + 连线组成的图),不是文字、不是报告、" +
"不是章节、不是结论清单、不是 Markdown:①**不要**调用 write_file、**不要**写或改任何 .md 文件;" +
"②回复里**不要**再出现任何报告正文 / 章节标题(如「## 四…」)/ 编号结论,正文最多一句话说明;" +
"③**忽略**此前关于「方案总结报告」「按层级结构输出报告」「输出结构化结论」「分阶段产出」的一切措辞——" +
"那些是给上一轮文字报告用的,本轮一律不适用;④在回复**最末尾**输出一个**完整**的 ```flow-json 代码块。";
/**
* 流程图的层级画法(修「结构画错」):直接从已有报告/席位交付里**提取实体**,
* 根=任务名称,每层取具体条目当节点,按报告里的对应关系串成分支,排除汇总/阶段名等无关段落。
*/
const FLOW_STRUCTURE_RULE =
"【流程图画法·严格遵守】\n" +
"① 根节点(n1) = 本次任务本身(即报告「任务总览」里的任务名称 / 目标),label 用任务的简短名称(≤12 字)," +
"**不要**用「任务」「目标」这种泛称当 label;其 `group` 填「任务」;\n" +
"② 从第二层起按层级顺序逐层展开:每个层级在报告/交付里通常列了**多条具体条目**" +
"(如『目的』层有 目的1 / 目的2 / 目的3,『行为体』层有各自对应的行为体,后面还有 预期行为·驱动因素、叙事…)," +
"把**每一条具体条目各画成一个节点**,label 取该条目核心短语(≤12 字);**不要**把一整层合并成一个笼统节点;\n" +
"③ **每个节点的 `group` 必须填它所在层级的名称**(任务 / 目的 / 行为体 / 预期行为 / 驱动因素 / 叙事…,取自报告里该层级的真实名称)——画布据此给节点打分类标签;\n" +
"④ **每个节点的 `detail`** 放该条目的名称 + 理由 / 说明,把要点表达完整、约 2–4 句即可(不必逐字照抄报告整段);" +
"**`detail` 必须写成一行**——内部不要出现真实换行,需要分隔就用「;」或「/」,避免 JSON 里出现裸换行;\n" +
"⑤ 连边严格按报告里的**对应关系**串成分支:根任务 → 各『目的』节点;每个『目的』→ 它对应的『行为体』节点;" +
"每个『行为体』→ 它对应的『预期行为·驱动因素』节点 → 对应的『叙事』节点……每一组各成一条自上而下的分支" +
"(报告里有几组就有几条分支,组与组之间不要混连);\n" +
"⑦ **务必把每个层级的每一条都输出成节点、不要漏层、不要只输出开头一两个节点就停**——" +
"典型规模约 13–16 个节点(如 任务1 + 目的3 + 行为体3 + 预期行为·驱动因素3 + 叙事3);\n" +
"⑥ **只画**这些层级实体节点;**不要**把『综合分析』『核心洞察』『分层传播策略 / 执行路线 / 建议』『结论』" +
"以及『阶段①②③④』等章节名 / 阶段名 / 汇总段落画成节点。";
/** 把各席位交付拼成一段内联材料(无报告时作流程图的数据来源)。 */
function inlineSeatDeliveries(seatDeliveries?: { sender: string; content: string }[]): string {
const list = (seatDeliveries ?? []).filter((s) => (s.content ?? "").trim());
if (list.length === 0) return "";
return list
.map((s, i) => `## 席位${i + 1}:${s.sender || "(未具名席位)"}\n${s.content.trim()}`)
.join("\n\n");
}
/**
* taskId/深链模式:md 报告生成完后自动续跑的「结构化输出」prompt。
*
* 数据基准(用户拍板):**有报告就基于报告**(报告已把各席位交付清晰总结过,直接提取实体、
* 不要再自己重新总结一遍);没有报告则基于**各席位交付产物**。两种来源都**整段内联**进 prompt
* (不依赖线程上下文),让模型逐条对着抽取节点。层级顺序优先用总控提示词里写明的层级,否则 chainLevels。
*/
function buildAutoFlowPrompt(opts: {
chainLevels?: string[];
coordinatorPrompt?: string | null;
reportMd?: string;
seatDeliveries?: { sender: string; content: string }[];
}): string {
const levels = (opts.chainLevels ?? []).map((s) => s.trim()).filter(Boolean);
const cp = opts.coordinatorPrompt?.trim();
const report = opts.reportMd?.trim() ?? "";
const seatMaterial = report ? "" : inlineSeatDeliveries(opts.seatDeliveries);
const parts: string[] = [];
if (report) {
parts.push(
"本轮只画一张流程图。**数据来源 = 下面这份你已写好的《方案总结报告》**(它已把各席位交付清晰总结过)——" +
"请**逐条对着报告抽取**已有的层级条目作为流程图节点,**不要再自己重新总结 / 概括 / 提炼**,也不要凭空新增报告里没有的内容。",
);
} else {
parts.push(
"本轮只画一张流程图。**数据来源 = 下面各席位的交付产物**——请**逐条对着这些交付抽取**已有的层级条目作为流程图节点,不要自己另起总结。",
);
}
parts.push(FLOW_ONLY_RULE);
// 整段内联数据来源(报告全文 / 席位交付),用分隔线包起来便于模型对照抽取。
if (report) {
parts.push("===== 方案总结报告全文(数据来源,逐条抽取,勿改写)=====\n" + report + "\n===== 报告结束 =====");
} else if (seatMaterial) {
parts.push("===== 各席位交付产物(数据来源,逐条抽取,勿改写)=====\n" + seatMaterial + "\n===== 交付结束 =====");
}
parts.push(FLOW_JSON_SHAPE);
parts.push(FLOW_STRUCTURE_RULE);
if (cp) {
parts.push("层级顺序优先采用「业务链总控编排提示」里写明的层级链(如它写的 任务 → 目的 → 行为体 → …)。");
} else if (levels.length > 0) {
parts.push(`层级顺序(自上而下):任务 → ${levels.join(" → ")}。`);
}
// 收尾再强调(首尾夹击,对抗线程里 coordinatorPrompt 的报告锚定)。
parts.push("再次强调:本轮不写任何 Markdown 报告正文 / 章节,整个回复除一句话说明外,只有一个完整的 ```flow-json 代码块。");
return parts.join("\n");
}
/** 首轮总结 prompt。`flowMode`=编排提示里明确要求了结构化输出/流程图 → 跳过 md 文件,直出流程图。 */
function buildSummaryPrompt(opts: {
intentReady: ReportIntent | null;
@@ -203,10 +327,15 @@ function buildSummaryPrompt(opts: {
* 强调「全量 flow-json + 不写文件」——画图数据必须完整自洽,否则前端增量解析会画出半张图。
*/
const FLOW_ASK_INSTRUCTION =
"(系统指令:用户要求结构化输出/流程图——本轮不要写或修改任何 .md 报告文件," +
"在回复正文给出简短的文字说明/总结(用户未指定结构时,默认对各席位交付内容逐席位格式化输出:" +
"席位名→核心结论→关键交付要点),并在回复最末尾输出一个**完整**的 ```flow-json 代码块" +
"(按你职责设定的「结构化流程数据」契约;输出全量数据而非增量)。)";
"(系统指令:用户要求结构化输出 / 流程图——这里的「结构化输出」指的是画一张**流程图**," +
"不是写结构化的文字报告。**有报告就直接基于报告提取层级条目**(报告已总结过各席位交付,不要重复总结);" +
"没有报告则基于各席位交付。\n" +
FLOW_ONLY_RULE +
"\n" +
FLOW_STRUCTURE_RULE +
"\n" +
FLOW_JSON_SHAPE +
")";
/**
* 上一轮主产物是流程图、但本条消息没显式提「结构化/流程图」时的意图引导指令。
@@ -299,6 +428,8 @@ export function useStep3Summary(
consensusText,
selectedModel,
coordinatorPrompt,
chainLevels,
autoFlow,
savedSummary,
onSummarySaved,
} = options;
@@ -901,7 +1032,8 @@ export function useStep3Summary(
try {
// 编排提示里明确要求了结构化输出/流程图 → 首轮就走流程图模式(不写 md,直出 flow-json)。
const flowMode = detectFlowIntent(coordinatorPrompt);
// 但 autoFlow(taskId/深链)模式要「先 md 再结构化输出」,故首轮强制走 md(忽略 detectFlowIntent)。
const flowMode = !autoFlow && detectFlowIntent(coordinatorPrompt);
flowActiveRef.current = flowMode; // 流式结束后会按真实产物校正
const prompt = buildSummaryPrompt({
intentReady,
@@ -911,6 +1043,35 @@ export function useStep3Summary(
flowMode,
});
await streamOnce({ bubbleId, prompt, aborter, flowRound: flowMode });
// taskId/深链模式:报告生成完,**自动**续跑一轮结构化输出(流程图)。
// 数据来源:有报告则内联报告全文、否则内联各席位交付(buildAutoFlowPrompt 处理)。
// 故触发条件放宽为「有报告 **或** 有席位交付」——保证「无报告也能据席位交付出图」。
// 复用同一真线程,与手动「结构化输出」走同一渲染链路。
if (
autoFlow &&
!aborter.signal.aborted &&
liveThreadRef.current &&
(currentMdRef.current.trim() || seatDeliveries.length > 0)
) {
const flowBubbleId = appendStreamingDialogue({
sender: "方案总结报告智能体",
avatarType: "blue",
});
const flowPrompt = buildAutoFlowPrompt({
chainLevels,
coordinatorPrompt,
reportMd: currentMdRef.current,
seatDeliveries,
});
await streamOnce({
bubbleId: flowBubbleId,
prompt: flowPrompt,
aborter,
seedThreadId: liveThreadRef.current,
flowRound: true,
});
}
} catch (err) {
if (aborter.signal.aborted) return;
const msg = err instanceof Error ? err.message : String(err);
@@ -926,6 +1087,8 @@ export function useStep3Summary(
seatDeliveries,
consensusText,
coordinatorPrompt,
chainLevels,
autoFlow,
appendStreamingDialogue,
finalizeDialogue,
streamOnce,
@@ -1166,6 +1329,7 @@ export function useStep3Summary(
return {
phase,
error,
autoFlow: !!autoFlow,
summaryThreadId,
dialogues,
seatStubMessages,
@@ -77,6 +77,62 @@ test("extractFlowGraph:无 fence / 无完整节点返回 null", () => {
assert.equal(extractFlowGraph('```flow-json\n{"nodes":[{"id":"a"'), null);
});
test("extractFlowGraph:group 层级名解析(兼容 group/level)", () => {
const text = [
"```flow-json",
'{"nodes":[',
'{"id":"n1","label":"暑期文旅","type":"start","group":"任务"},',
'{"id":"n2","label":"情绪管理","type":"task","level":"目的","from":["n1"]}',
"]}",
"```",
].join("\n");
const g = extractFlowGraph(text);
assert.ok(g);
assert.equal(g.nodes[0].group, "任务");
assert.equal(g.nodes[1].group, "目的"); // level 别名也认
});
test("extractFlowGraph:容错——detail 含裸换行的非法 JSON 节点不被整个丢弃", () => {
// 模型常在 JSON 字符串里吐真实换行(非法 JSON):n2 的 detail 跨行,
// 旧逻辑会 JSON.parse 失败把 n2/n3 整个丢掉,只剩 n1(即「只出两个节点」bug)。
const text = [
"```flow-json",
'{"title":"T","nodes":[',
'{"id":"n1","label":"任务X","type":"start","group":"任务"},',
'{"id":"n2","label":"目的A","type":"task","group":"目的","detail":"名称:目的A',
'理由:跨了行的说明文字","from":["n1"]},',
'{"id":"n3","label":"行为体A","type":"role","group":"行为体","detail":"名称:行为体A;理由:单行说明","from":["n2"]}',
"]}",
"```",
].join("\n");
const g = extractFlowGraph(text);
assert.ok(g);
// 三个节点都救回来(含裸换行的 n2 不再丢失)。
assert.equal(g.nodes.length, 3);
assert.equal(g.nodes[1].group, "目的");
assert.ok((g.nodes[1].detail ?? "").includes("目的A"));
// 连线也按 from 串起来。
assert.deepEqual(g.edges, [
{ from: "n1", to: "n2" },
{ from: "n2", to: "n3" },
]);
});
test("extractFlowGraph:容错——尾逗号的非法 JSON 节点仍解析", () => {
const text = [
"```flow-json",
'{"nodes":[',
'{"id":"a","label":"A","type":"task","group":"目的",},', // 尾逗号 → JSON.parse 失败
'{"id":"b","label":"B","type":"task","from":["a"]}',
"]}",
"```",
].join("\n");
const g = extractFlowGraph(text);
assert.ok(g);
assert.equal(g.nodes.length, 2);
assert.equal(g.nodes[0].group, "目的");
});
test("extractFlowGraph:兼容独立 edges 数组(source/target 别名)", () => {
const text = [
"```flow-json",
@@ -33,6 +33,12 @@ export interface FlowGraphNode {
label: string;
type: FlowNodeType;
detail?: string;
/**
* 该节点所在的**层级名称**(如 任务 / 目的 / 行为体 / 预期行为 / 驱动因素 / 叙事)。
* 由模型在 flow-json 里给出,画布的分类标签优先显示它(缺省回退到 type 的固定中文名)。
* 让流程图按业务链层级标注,而非只显示通用的 开始/任务/角色 等 type 名。
*/
group?: string;
}
export interface FlowGraphEdge {
@@ -66,10 +72,66 @@ const VALID_TYPES = new Set<string>(Object.keys(FLOW_NODE_TYPE_META));
export const FLOW_FENCE_OPEN = "```flow-json";
/**
* 容错解析单个对象切片(JSON.parse 失败时回退)——模型输出不可控,常在 JSON 字符串里吐
* **裸换行 / 尾逗号 / 中文标点**等非法内容,导致整对象 JSON.parse 失败、节点被整个丢弃
* (表现为「流程图只剩前两个节点」)。这里用正则逐字段抽取,最大限度把节点 / 边救回来。
* 字符串字段正则 `(?:\\.|[^"\\])*` 能跨裸换行匹配(换行既不是 `"` 也不是 `\`)。
*/
function looseParseObject(slice: string): Record<string, unknown> | null {
const pickStr = (...keys: string[]): string | undefined => {
for (const k of keys) {
const m = new RegExp(`"${k}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`).exec(slice);
if (m) {
const raw = m[1];
try {
return JSON.parse(`"${raw.replace(/\r?\n/g, "\\n")}"`) as string;
} catch {
return raw.replace(/\\n/g, "\n").replace(/\\"/g, '"');
}
}
}
return undefined;
};
const obj: Record<string, unknown> = {};
const id = pickStr("id");
if (id) obj.id = id;
const label = pickStr("label", "name");
if (label) obj.label = label;
const type = pickStr("type");
if (type) obj.type = type;
const group = pickStr("group", "level", "tier", "layer");
if (group) obj.group = group;
const detail = pickStr("detail", "description");
if (detail) obj.detail = detail;
// 边的简单形态(独立 edges 数组里):from/to 为字符串。
const toStr = pickStr("to", "target");
if (toStr) obj.to = toStr;
// 节点的 from 为数组(["n1"] 或 [{"id":"n1","label":"是"}]);先按数组抽,抽不到再当字符串。
const fm = /"(?:from|prev)"\s*:\s*\[([\s\S]*?)\]/.exec(slice);
if (fm) {
const inner = fm[1];
const ids: unknown[] = [];
const objRe = /\{[^{}]*?"id"\s*:\s*"([^"]+)"[^{}]*?\}/g;
let om: RegExpExecArray | null;
while ((om = objRe.exec(inner)) !== null) ids.push({ id: om[1] });
if (ids.length === 0) {
const sRe = /"([^"]+)"/g;
let sm: RegExpExecArray | null;
while ((sm = sRe.exec(inner)) !== null) ids.push(sm[1]);
}
if (ids.length > 0) obj.from = ids;
} else {
const fromStr = pickStr("from", "source");
if (fromStr) obj.from = fromStr;
}
return Object.keys(obj).length > 0 ? obj : null;
}
/**
* 平衡括号扫描:从 `key`(如 `"nodes"`)后面的 `[` 开始,把数组里**已完整闭合**的
* 顶层 `{…}` 逐个 JSON.parse 收集;跳过字符串内的括号与转义;数组未闭合(流式半截)
* 时收集到哪算哪,截断的尾对象忽略。
* 顶层 `{…}` 逐个解析收集(JSON.parse 失败则用 looseParseObject 容错回退);跳过字符串内的
* 括号与转义;数组未闭合(流式半截)时收集到哪算哪,截断的尾对象忽略。
*/
function collectObjects(text: string, key: string): Record<string, unknown>[] {
const keyIdx = text.indexOf(key);
@@ -101,14 +163,18 @@ function collectObjects(text: string, key: string): Record<string, unknown>[] {
if (ch === "}") {
depth--;
if (depth === 0 && objStart >= 0) {
const objSlice = text.slice(objStart, i + 1);
let parsed: Record<string, unknown> | null = null;
try {
const parsed = JSON.parse(text.slice(objStart, i + 1)) as unknown;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
out.push(parsed as Record<string, unknown>);
const j = JSON.parse(objSlice) as unknown;
if (j && typeof j === "object" && !Array.isArray(j)) {
parsed = j as Record<string, unknown>;
}
} catch {
/* 截断/非法对象:跳过 */
// 非法 JSON(裸换行/尾逗号等)→ 容错正则抽取,别整节点丢掉。
parsed = looseParseObject(objSlice);
}
if (parsed) out.push(parsed);
objStart = -1;
}
continue;
@@ -130,7 +196,10 @@ function toNode(raw: Record<string, unknown>): FlowGraphNode | null {
const rawType = asString(raw.type).toLowerCase();
const type = (VALID_TYPES.has(rawType) ? rawType : "task") as FlowNodeType;
const detail = asString(raw.detail) || asString(raw.description) || undefined;
return { id, label, type, detail };
// 层级名(任务/目的/行为体/…):兼容 group / level / tier / layer 几种键名。
const group =
asString(raw.group) || asString(raw.level) || asString(raw.tier) || asString(raw.layer) || undefined;
return { id, label, type, detail, group };
}
/** 节点 `from` 字段(前驱列表,元素是 "n1" 或 {id:"n1",label:"是"})→ 入边列表。 */
@@ -751,6 +751,12 @@ export default function RoundtablePlanningPage() {
setStep2Runs([...syncedRuns, newRun]);
setActiveStep2RunId(newRunId);
setStep3Report(null);
// 开新一轮第二步 → 第三步必须基于这轮重新生成(含总结/流程图)。复位「已进入第三步」
// 闸门 + 中断上一轮第三步在途三流,否则再进第三步会沿用上一轮旧结果(bug:第三步与第二步对不上)。
step3EnteredRef.current = false;
summaryFlow.abort();
reportFlow.abort();
dashboardFlow.abort();
step2.resetStep2State();
step2.setSelectedAgents(normalized);
step2.resetInitGate();
@@ -997,12 +1003,26 @@ export default function RoundtablePlanningPage() {
setStep3SavedTick((t) => t + 1);
}, []);
// 业务链条层级(取 stages 的「本阶段目标」goal,有序)——taskId 深链模式自动结构化输出时
// 作流程图层级骨架(任务 → 层级1 → …)。memo 稳定数组身份,避免 run useCallback 频繁失效。
const chainLevels = useMemo(
() =>
(orchestrationPlan?.stages ?? [])
.map((s) => s.goal?.trim() ?? "")
.filter((g) => g.length > 0),
[orchestrationPlan],
);
// taskId/深链模式:进第三步生成 md 后自动续跑结构化输出(流程图)。普通手动会商不自动。
const autoSummaryFlow = !!prefillRef.current?.taskId;
const summaryFlow = useStep3Summary({
intentReady: step1.intentReady,
seatDeliveries: step3SeatDeliveries,
consensusText: step2.lastLeaderContent,
selectedModel,
coordinatorPrompt: orchestrationPlan?.coordinatorPrompt ?? null,
chainLevels,
autoFlow: autoSummaryFlow,
savedSummary: step3Report?.summaryReport ?? null,
onSummarySaved: handleSummarySaved,
});
@@ -1596,6 +1616,7 @@ export default function RoundtablePlanningPage() {
onSelectChain={handleSelectChainFromSidebar}
onOpenDraft={drafts.handleLoadDraft}
activeDraftId={drafts.currentDraftId}
entryRailTaskId={prefillRef.current?.taskId ?? null}
/>
{/* MIDDLE VIEWPORT */}
@@ -2103,6 +2124,8 @@ interface LeftSidebarProps {
onOpenDraft: (draftId: string) => void;
/** 当前已加载草稿 id(分析记录选中高亮用),来自 drafts.currentDraftId。 */
activeDraftId: string | null;
/** 深链 taskId 模式:Step1 落地左栏「分析记录」只列该任务的草稿。 */
entryRailTaskId?: string | null;
}
function Step2SandboxSidebarSync({
@@ -2148,6 +2171,7 @@ function LeftSidebar({
onSelectChain,
onOpenDraft,
activeDraftId,
entryRailTaskId,
}: LeftSidebarProps) {
// 整片脱敏:左侧面板(任务概览/参与角色/人工参与等)的所有文本——标题、动态内容、
// 嵌套子组件文本一并脱敏。消息列表与沙箱是另外的容器,不挂此 Hook,保持原文。
@@ -2284,6 +2308,7 @@ function LeftSidebar({
onSelectChain={onSelectChain}
onOpenDraft={onOpenDraft}
activeDraftId={activeDraftId}
taskId={entryRailTaskId ?? null}
/>
)}
</section>
@@ -2329,15 +2354,18 @@ function RoundtableEntryRail({
onSelectChain,
onOpenDraft,
activeDraftId,
taskId,
}: {
selectedChainId: string | null;
onSelectChain: (chain: BusinessChain) => void;
onOpenDraft: (draftId: string) => void;
activeDraftId: string | null;
/** 深链 taskId 模式:分析记录只列该任务的草稿。 */
taskId?: string | null;
}) {
const [chains, setChains] = useState<BusinessChain[]>([]);
const [expandedChainId, setExpandedChainId] = useState<string | null>(null);
const { drafts: records, refresh } = useRoundtableDrafts();
const { drafts: records, refresh } = useRoundtableDrafts(taskId);
useEffect(() => {
let cancelled = false;