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:
1 parent
e6a41dbc97
commit
fca39eeaee
17 files changed
+472
-37
No files matched your search
@@ -86,7 +86,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
|
||||
- **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE
|
||||
- **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/`
|
||||
- **Shims**: `src/shims/` provides `next/link` and `next/navigation` polyfills (original code was Next.js)
|
||||
- **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent`) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A (resolved via `/api/business-mapping`, opening message in `sessionStorage.pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management/<business-path>/{roundtable,agent/:agent_id/chats/:thread_id}` in `StrategyRoutes.tsx` (`<business-path>` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. The agent chat is rendered via `TaskEmbeddedAgentChat`, which passes `AgentChatPage`'s `chatBasePath` prop so the post-send URL rewrite + new-chat button stay in the task-management context (instead of the standalone `/agents/...` route). Any failure (no consumer URL / fetch error / unmapped goPath / no configured agent) silently falls back to the default home. Helpers: `core/auth/task-deeplink.ts`.
|
||||
- **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent`) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A (resolved via `/api/business-mapping`, opening message in `sessionStorage.pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. The roundtable target also threads the URL `taskId` into `roundtablePrefill.taskId` (`resolveTaskDeeplinkTarget(goPath, detail, taskId)`), which turns on two taskId-scoped behaviors: (1) **per-task draft isolation** — drafts save/list/restore by `task_id` (one taskId = many records, shared across users; backend `GET /api/roundtable-drafts/by-task/{id}` + `/latest`); the history dropdown filters to the task and 新建 keeps the binding; (2) **Step-3 auto structured output** — after the `roundtable-summary` agent writes the Markdown report it automatically runs a second round emitting a `flow-json` flowchart (`useStep3Summary` `autoFlow`+`chainLevels`), whose hierarchy follows the business chain's stage goals (任务 → 目的 → 行为体 → …; coordinatorPrompt-specified hierarchy wins) and whose nodes never carry 阶段编号/阶段名 (the prior bug). **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management/<business-path>/{roundtable,agent/:agent_id/chats/:thread_id}` in `StrategyRoutes.tsx` (`<business-path>` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. The agent chat is rendered via `TaskEmbeddedAgentChat`, which passes `AgentChatPage`'s `chatBasePath` prop so the post-send URL rewrite + new-chat button stay in the task-management context (instead of the standalone `/agents/...` route). Any failure (no consumer URL / fetch error / unmapped goPath / no configured agent) silently falls back to the default home. Helpers: `core/auth/task-deeplink.ts`.
|
||||
|
||||
### Configuration
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -245,7 +245,7 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
|
||||
| **Runs** (`/api/runs`) | `POST /stream` - stateless run + SSE; `POST /wait` - stateless run + block; `GET /{rid}/messages` - paginated messages by run_id `{data, has_more}` (cursor: `after_seq`/`before_seq`); `GET /{rid}/feedback` - list feedback by run_id |
|
||||
| **Thread Shares** (`/api/thread-shares`) | Per-conversation sharing with two modes (`thread_shares.mode`). **`import`**: `POST /` `{thread_id, mode}` - create (or reuse) a revocable share code; `GET /{code}/preview` + `POST /{code}/import` - copy that conversation into the caller's account (fresh `thread_id` via `app/gateway/thread_copy.py` + new `threads_meta` row + `user-data` copy; dedup on `metadata.shared_origin`). **`view`**: a public, read-only static page ? see Public Shares below. Both modes: `GET /` - list my codes; `DELETE /{code}` - revoke. |
|
||||
| **Public Shares** (`/api/public/shares`) | **Unauthenticated** (the `/api/public/` prefix is whitelisted in `auth_middleware._PUBLIC_PATH_PREFIXES`). `GET /{code}` - read-only conversation snapshot (title + serialized messages + artifacts) for a `view`-mode share; messages are read straight from the latest checkpoint. `GET /{code}/files/{path}` - serves a file referenced by that conversation (images, Markdown, text, PDF, ?) so the shared page's links are openable; path resolution is confined to the owner's thread directory. Active content (`text/html`, `application/xhtml+xml`, `image/svg+xml`) is forced to download (`_public_share_disposition`); everything else is served inline. Missing/revoked/non-`view` codes all return 404. |
|
||||
| **Roundtable Drafts** (`/api/roundtable-drafts`) | Per-user persistence for the roundtable-planning page (replaces the old browser `localStorage`). `GET /` - list drafts (lightweight meta); `POST /` - create; `GET/PUT/DELETE /{id}` - full read / partial update / delete. A draft = one full session (Step 1 intent + Step 2 roundtable), step snapshots stored as JSON in `PortableLongText`. Recommendation history bound to a draft: `GET /{id}/recommendations` - list newest-first; `POST /{id}/recommendations` - append one run (objective/status/rationale/picks/candidates). Backed by `deerflow.persistence.roundtable_drafts` (tables `roundtable_drafts`, `roundtable_recommend_history`); store wired in `deps.py` as `app.state.roundtable_draft_store`. |
|
||||
| **Roundtable Drafts** (`/api/roundtable-drafts`) | Per-user persistence for the roundtable-planning page (replaces the old browser `localStorage`). `GET /` - list drafts (lightweight meta); `POST /` - create; `GET/PUT/DELETE /{id}` - full read / partial update / delete. **External-task binding (`task_id`)**: drafts carry an optional `task_id` (deep-link / 无界 embed); task-bound drafts are **shared** (the store ignores `user_id` for them, so a task's drafts span users). `GET /by-task/{task_id}/latest` - newest draft for a task (回显最新一条); `GET /by-task/{task_id}` - **all** drafts for a task, newest-first (taskId-mode history dropdown: 一个 taskId 多条记录), enriched with the latest background-job status per draft via `RoundtableJobRepository.status_map_by_drafts`. A draft = one full session (Step 1 intent + Step 2 roundtable), step snapshots stored as JSON in `PortableLongText`. Recommendation history bound to a draft: `GET /{id}/recommendations` - list newest-first; `POST /{id}/recommendations` - append one run (objective/status/rationale/picks/candidates). Backed by `deerflow.persistence.roundtable_drafts` (tables `roundtable_drafts`, `roundtable_recommend_history`); store wired in `deps.py` as `app.state.roundtable_draft_store`. Tests: `tests/test_roundtable_drafts_by_task.py`. |
|
||||
| **Roundtable Draft Shares** (`/api/roundtable-draft-shares`) | Per-draft sharing for the roundtable-planning page, mirroring Thread Shares but the shared resource is a `roundtable_drafts` row. Two modes (`roundtable_draft_shares.mode`). **`import`**: `POST /` `{draft_id, mode}` - create (or reuse) a revocable code; `GET /{code}/preview` + `POST /{code}/import` - copy that draft (step1/2/3) into the caller's account under a deterministic per-(code,recipient) id (`imp_<sha1>` ? re-import is a no-op). **`view`**: a public, read-only result page ? see Public Roundtable Shares. Both: `GET /` - list my codes; `DELETE /{code}` - revoke. Backed by `deerflow.persistence.roundtable_draft_shares` (table `roundtable_draft_shares`, migration `20260606_01`); store wired in `deps.py` as `app.state.roundtable_draft_share_store`. Tests: `tests/test_roundtable_draft_shares.py`. |
|
||||
| **Public Roundtable Shares** (`/api/public/roundtable-shares`) | **Unauthenticated** (`/api/public/` whitelisted). `GET /{code}` - read-only snapshot for a `view`-mode draft share: returns the Step 3??????report HTML (`step3.html`, a self-contained document) + title/summary/`has_report`. The frontend renders it in a sandboxed `iframe` (no `allow-scripts`) at `#/roundtable-share/<code>`. Missing/revoked/non-`view` codes all return 404. |
|
||||
| **Scheduled Tasks** (`/api/scheduled-tasks`) | Per-user cron-like agent tasks. `GET/POST /` - list/create owned tasks; `PATCH/DELETE /{id}` - update/delete (owner); `POST /{id}/{pause,resume,trigger}`; `GET /{id}/runs`, `GET /runs/{rid}`, run-file preview/edit/download; `GET /runs/{rid}/messages` - agent messages for the run's underlying agent run (used to inspect a stuck/running run; authorized via `_load_run`); `GET /runs/{rid}/html` - generated HTML document for an `html_page` run; `DELETE /runs/{rid}` - owner-scoped, with an admin fallback so admins can delete any user's run; publish/subscribe under `/{id}/{publish,unpublish,subscribe}` and `GET /published`. **Admin (cross-user):** `GET /admin/all` - every task; `PATCH/DELETE /admin/{id}` - edit/delete any task; `POST /admin/{id}/trigger`; `GET /admin/{id}/runs`. Admin routes require `system_role == "admin"`. |
|
||||
|
||||
+2
@@ -74,6 +74,8 @@
|
||||
- 每个节点字段:`id`(短唯一串 n1/n2…)、`label`(≤12 字)、`type`、`detail`(一两句说明,弹窗展示)、`from`(**前驱节点列表**,可空;分支边可写成 `{"id":"n3","label":"可行"}` 给边加标签);
|
||||
- `type` 只能取:`start`(开始)/ `end`(结束)/ `stage`(阶段)/ `task`(任务动作)/ `decision`(判断分支)/ `risk`(风险点)/ `deliverable`(交付物)/ `role`(角色席位)——前端按类型着不同颜色;
|
||||
- **`nodes` 必须按流程先后顺序排列**,且每个节点的 `from` 只引用**前面已出现**的节点 id——前端解析到一个节点就画一个节点和它的连线;
|
||||
- **节点 label 命名约束(重要)**:label 必须是该层级 / 步骤对应的**具体概念或实体名**(如 目的 / 行为体 / 驱动因素,或它们下面的具体条目),**绝不能**写成「阶段1 / 阶段2 / 第N阶段 / 阶段几」这类阶段编号或泛称,也不要把「阶段」「环节」本身当成节点;不要在节点里塞阶段名称等与层级无关的内容;
|
||||
- **按业务链条层级组织**:当本轮 prompt 给了「业务链层级」(如 任务→目的→行为体→驱动因素)时,以「任务」为根节点按该层级自上而下逐层展开(每层放该层级下提炼出的具体节点);总控编排提示里明确写了层级 / 结构时,以总控提示词为准(最高优先级);
|
||||
- 节点 6–20 个为宜;flow-json 块**只放对话回复末尾**,不要写进 .md 报告文件;
|
||||
- 用户没有要求结构化 / 流程图时,**不要**输出该块。
|
||||
|
||||
|
||||
@@ -194,6 +194,32 @@ async def get_latest_draft_by_task(request: Request, task_id: str) -> DraftRespo
|
||||
return DraftResponse(**row)
|
||||
|
||||
|
||||
@router.get("/by-task/{task_id}", response_model=DraftListResponse)
|
||||
async def list_drafts_by_task(request: Request, task_id: str) -> DraftListResponse:
|
||||
"""按外部任务 id 列出该任务全部草稿(轻量 meta,newest first,**不按 user 分权**)。
|
||||
|
||||
taskId 深链模式下历史下拉用:一个 taskId 可有多条会商记录;同 `list_drafts`
|
||||
一样用最近后台作业状态富化(job store 缺失/失败时静默退化为无状态)。
|
||||
"""
|
||||
store = _get_store(request)
|
||||
rows = await store.list_drafts_by_task(task_id)
|
||||
job_store = getattr(request.app.state, "roundtable_job_store", None)
|
||||
if job_store is not None and rows:
|
||||
try:
|
||||
status_map = await job_store.status_map_by_drafts([r.get("id") for r in rows])
|
||||
except AttributeError:
|
||||
status_map = {}
|
||||
except Exception: # noqa: BLE001 — 富化失败不能拖垮列表
|
||||
logger.warning("roundtable draft by-task list: job status enrichment failed", exc_info=True)
|
||||
status_map = {}
|
||||
for r in rows:
|
||||
info = status_map.get(r.get("id"))
|
||||
if info:
|
||||
r["status"] = info.get("status")
|
||||
r["job_id"] = info.get("jobId")
|
||||
return DraftListResponse(drafts=[DraftMetaResponse(**r) for r in rows])
|
||||
|
||||
|
||||
@router.get("/{draft_id}", response_model=DraftResponse)
|
||||
async def get_draft(request: Request, draft_id: str) -> DraftResponse:
|
||||
store = _get_store(request)
|
||||
|
||||
+15
@@ -100,6 +100,21 @@ class RoundtableDraftRepository:
|
||||
row = result.scalars().first()
|
||||
return self._draft_to_dict(row) if row is not None else None
|
||||
|
||||
async def list_drafts_by_task(self, task_id: str, *, limit: int = 100) -> list[dict[str, Any]]:
|
||||
"""按外部任务 id 列出全部草稿(轻量 meta,newest first,**忽略 user_id** 不分权)。
|
||||
|
||||
taskId 深链模式下历史下拉用:一个 taskId 可有多条会商记录。
|
||||
"""
|
||||
stmt = (
|
||||
select(RoundtableDraftRow)
|
||||
.where(RoundtableDraftRow.task_id == task_id)
|
||||
.order_by(RoundtableDraftRow.updated_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
return [self._draft_to_meta(row) for row in result.scalars()]
|
||||
|
||||
async def create_draft(self, user_id: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||
now = datetime.now(UTC)
|
||||
row = RoundtableDraftRow(
|
||||
|
||||
+30
@@ -236,6 +236,36 @@ class RoundtableJobRepository:
|
||||
out[draft_id] = {"jobId": job_id, "status": status}
|
||||
return out
|
||||
|
||||
async def status_map_by_drafts(
|
||||
self,
|
||||
draft_ids: list[str],
|
||||
*,
|
||||
limit: int = 400,
|
||||
) -> dict[str, dict[str, str]]:
|
||||
"""``{draft_id: {"jobId": ..., "status": ...}}`` for the latest job per draft.
|
||||
|
||||
Like :meth:`status_map_by_user` but filtered by an explicit draft-id list
|
||||
instead of by owner — used by the taskId-mode history dropdown, where a
|
||||
task's drafts may span multiple users (task-bound drafts are shared).
|
||||
"""
|
||||
ids = [d for d in draft_ids if d]
|
||||
if not ids:
|
||||
return {}
|
||||
stmt = (
|
||||
select(RoundtableJobRow.id, RoundtableJobRow.draft_id, RoundtableJobRow.status)
|
||||
.where(RoundtableJobRow.draft_id.in_(ids))
|
||||
.order_by(RoundtableJobRow.updated_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
out: dict[str, dict[str, str]] = {}
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
for job_id, draft_id, status in result.all():
|
||||
if draft_id is None or draft_id in out:
|
||||
continue
|
||||
out[draft_id] = {"jobId": job_id, "status": status}
|
||||
return out
|
||||
|
||||
# ── delete / cleanup ─────────────────────────────────────────────────────
|
||||
|
||||
async def delete(
|
||||
|
||||
Reference in new issue
Block a user