fix(会商第三步): 流程图入库限 rwfx-A + 修复跳过报告生成流程图出现双调度中卡片

- 入库功能仅 rwfx-A(businessCode 6BF)深链会商模式提供,其它业务深链生成流程图不再出现入库按钮/询问
- 流程图抽取串行化:auto 自动出图与用户「跳过报告直接生成流程图」手动出图竞态时,先中止上一轮、复用/移除被取代气泡,保证任意时刻只有一张流程图卡片

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
fengxiangdebaoziandClaude Opus 4.8 committed 2026-06-25 12:29:45 +08:00
1 parent 04e6d0aa00
commit 893f1e6933
19 files changed
+559 -51

No files matched your search

+47 -1
View File
@@ -20,6 +20,7 @@ import {
readCopTaskDetail,
resolveTaskDeeplinkTarget,
} from "@/core/auth/task-deeplink";
import { readUserInfo } from "@/strategy-components/lib/light-app";
/** 「大屏绘制」独立页应用内路由(深链「上一步」默认指向它,跳转追加 ?taskId=)。 */
export const DASHBOARD_AGENT_ROUTE = "/page/strategy/dashboard-agent";
@@ -66,6 +67,20 @@ export type TaskButtonIdKind = "task" | "action";
/** ``url`` 外链打开方式:``blank``=新窗口(默认)/ ``self``=当前窗口(嵌入态即当前 iframe)。 */
export type TaskButtonOpenMode = "blank" | "self";
/**
* ``url`` 外链追加的**登录参数**(与「应用管理」一致):在拼好的跳转地址后再依次
* 追加 ``?{key}={value}``,``value`` 运行时从 ``userInfo`` 按 ``source`` 解析
* (``other`` 时取 ``customValue`` 字面值)。复用 [[light-app-center]] 的取值来源。
*/
export interface TaskButtonLoginParam {
/** 拼到 URL 的查询参数名。 */
key: string;
/** 取值来源:``userInfo`` 字段名(如 ``accessToken`` / ``yUserId``)或 ``other``(自填)。 */
source: string;
/** ``source==="other"`` 时的自定义字面值。 */
customValue: string;
}
/** 一个可配置跳转按钮。 */
export interface TaskButton {
id: string;
@@ -85,10 +100,25 @@ export interface TaskButton {
idKind: TaskButtonIdKind;
/** ``url`` 外链打开方式:``blank``=新窗口(默认)/ ``self``=当前窗口。仅 ``url`` 类型有意义。 */
openMode: TaskButtonOpenMode;
/** ``url`` 外链追加的登录参数列表(仅 ``url`` 类型有意义;为空则不追加,行为同旧版)。 */
loginParams: TaskButtonLoginParam[];
enabled: boolean;
sortOrder: number;
}
/** 运行时解析单个登录参数的值(``other`` 取字面值,否则从 ``userInfo`` 取 ``source`` 字段)。 */
function resolveLoginParamValue(
p: TaskButtonLoginParam,
userInfo: Record<string, unknown>,
): string {
if (p.source === "other") return p.customValue ?? "";
if (p.source) {
const value = userInfo[p.source];
return value == null ? "" : String(value);
}
return "";
}
/** 内部切换可选的目标业务(``business`` 类型按钮的下拉项)。 */
export const TASK_BUSINESS_TARGETS: { code: TaskBusiness; label: string }[] = [
{ code: "3qfx", label: "三情分析" },
@@ -120,6 +150,7 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] {
idParam: "id",
idKind: "task",
openMode: "blank",
loginParams: [],
enabled: true,
sortOrder,
});
@@ -134,6 +165,7 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] {
idParam: "id",
idKind: "task",
openMode: "blank",
loginParams: [],
enabled: true,
sortOrder,
});
@@ -149,6 +181,7 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] {
idParam: "id",
idKind: "task",
openMode: "blank",
loginParams: [],
enabled: true,
sortOrder: 0,
},
@@ -167,6 +200,7 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] {
idParam: "taskId",
idKind: "task",
openMode: "self",
loginParams: [],
enabled: true,
sortOrder: 1,
},
@@ -180,6 +214,7 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] {
idParam: "id",
idKind: "task",
openMode: "blank",
loginParams: [],
enabled: true,
sortOrder: 2,
},
@@ -193,6 +228,7 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] {
idParam: "id",
idKind: "task",
openMode: "blank",
loginParams: [],
enabled: true,
sortOrder: 3,
},
@@ -259,7 +295,17 @@ export async function runTaskButton(btn: TaskButton, ctx: RunTaskButtonCtx): Pro
// 行动分析(xdfx)可选追加行动id;否则用任务id。行动id 缺失时回退任务id。
const id = btn.idKind === "action" ? ctx.actionId || ctx.taskId : ctx.taskId;
const param = (btn.idParam || "id").trim() || "id";
const url = btn.appendTaskId && id ? appendQueryParam(base, param, id) : base;
let url = btn.appendTaskId && id ? appendQueryParam(base, param, id) : base;
// 追加登录参数(与应用管理一致):运行时从 userInfo 解析每项的值后拼到地址尾部。
const loginParams = btn.loginParams ?? [];
if (loginParams.length > 0) {
const userInfo = readUserInfo();
for (const lp of loginParams) {
const key = (lp.key || "").trim();
if (!key) continue;
url = appendQueryParam(url, key, resolveLoginParamValue(lp, userInfo));
}
}
// 应用内部路由(以单个 / 开头,如「上一步」→ 大屏绘制页):
// self → 走 react-router 同窗导航(不刷新、保留 SPA);blank → 拼成当前源的 hash 路由新标签打开。
if (base.startsWith("/") && !base.startsWith("//")) {
+90 -1
View File
@@ -11,7 +11,7 @@
import { useEffect, useMemo, useState } from "react";
import { Navigate } from "react-router-dom";
import { ArrowDown, ArrowUp, Download, MousePointerClick, Plus, Save, Trash2 } from "lucide-react";
import { ArrowDown, ArrowUp, Download, MousePointerClick, Plus, Save, Trash2, X } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -37,7 +37,9 @@ import {
defaultTaskButtons,
type TaskBusiness,
type TaskButton,
type TaskButtonLoginParam,
} from "@/core/auth/task-buttons";
import { LOGIN_SOURCE_OPTIONS } from "@/strategy-components/lib/light-app";
import { useTaskButtonActions, useTaskButtons } from "@/strategy-components/api/task-buttons";
let _seq = 1;
@@ -105,12 +107,28 @@ export default function TaskButtonsPage() {
idParam: "id",
idKind: "task",
openMode: "blank",
loginParams: [],
enabled: true,
sortOrder: maxSort + 1,
},
]);
};
// ── 登录参数(url 外链追加,与应用管理一致)增删改 ──
const updateLoginParams = (id: string, fn: (ps: TaskButtonLoginParam[]) => TaskButtonLoginParam[]) =>
setRows((rs) =>
rs.map((r) => (r.id === id ? { ...r, loginParams: fn(r.loginParams ?? []) } : r)),
);
const addLoginParam = (id: string) =>
updateLoginParams(id, (ps) => [...ps, { key: "", source: "accessToken", customValue: "" }]);
const patchLoginParam = (id: string, idx: number, patch: Partial<TaskButtonLoginParam>) =>
updateLoginParams(id, (ps) => ps.map((p, i) => (i === idx ? { ...p, ...patch } : p)));
const removeLoginParam = (id: string, idx: number) =>
updateLoginParams(id, (ps) => ps.filter((_, i) => i !== idx));
const moveRow = (id: string, dir: "up" | "down") => {
const list = [...tabRows];
const i = list.findIndex((r) => r.id === id);
@@ -335,6 +353,77 @@ export default function TaskButtonsPage() {
</Select>
)}
</div>
{/* 登录参数(仅外链):与「应用管理」一致,运行时从 userInfo 解析后拼到地址尾部 */}
{r.linkType === "url" && (
<div className="border-border/60 ml-16 flex flex-col gap-2 rounded-md border border-dashed bg-muted/30 p-2">
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs">
登录参数(追加到地址,值取自登录信息)
</span>
<Button
variant="outline"
size="sm"
className="h-7"
onClick={() => addLoginParam(r.id)}
>
<Plus className="h-3.5 w-3.5" /> 添加登录参数
</Button>
</div>
{(r.loginParams ?? []).length === 0 ? (
<span className="text-muted-foreground text-[11px]">
暂无登录参数,跳转时不追加额外参数。
</span>
) : (
(r.loginParams ?? []).map((p, pi) => (
<div key={pi} className="flex flex-wrap items-center gap-2">
<Input
value={p.key}
onChange={(e) => patchLoginParam(r.id, pi, { key: e.target.value })}
placeholder="参数名 key"
className="h-8 w-32"
title="拼到地址的查询参数名"
/>
<span className="text-muted-foreground text-xs">=</span>
<Select
value={p.source}
onValueChange={(v) => patchLoginParam(r.id, pi, { source: v })}
>
<SelectTrigger className="h-8 w-36" title="取值来源">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LOGIN_SOURCE_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
{p.source === "other" && (
<Input
value={p.customValue}
onChange={(e) =>
patchLoginParam(r.id, pi, { customValue: e.target.value })
}
placeholder="自定义值"
className="h-8 w-40"
/>
)}
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-destructive h-8 w-8"
onClick={() => removeLoginParam(r.id, pi)}
title="删除该登录参数"
>
<X className="h-4 w-4" />
</Button>
</div>
))
)}
</div>
)}
</div>
<div className="flex items-center gap-1">
@@ -177,13 +177,13 @@ function ToolStep({
const display = getStepDisplay(step.key, step.label, step.args);
const argNode = renderStepArgs(step.key, step.args, resolveAgentName);
const [, toolName] = step.key.split(":");
// read_file 也可点 —— 验证类「查看文件…」步骤常出现在最后一条,点击后在沙箱里
// 查看该文件内容(走虚拟预览或 outputs HTTP)。主聊天只让 write 类可点,这里增强。
// 只让「自己生成/展示的文件」可点(write_file / str_replace / present_files),
// 与主聊天页一致。read_file 不可点 —— 否则智能体调用技能时读取的 SOUL.md、
// 执行的 .py 脚本等也会被渲染成可点击文件名,点开却没有意义。
const isArtifactStep =
toolName === "write_file" ||
toolName === "str_replace" ||
toolName === "present_files" ||
toolName === "read_file";
toolName === "present_files";
return (
<ChainOfThoughtStep
@@ -963,16 +963,8 @@ export function useStep2Orchestration(
openSeatVirtualArtifact(seatThreadId, virtualUrl, false);
return;
}
if (toolName === "read_file") {
// 验证类「查看文件…」步骤:用 args.path 在沙箱预览该文件。
// openArtifactPreview 内部 findSeatArtifactSource 优先按 path 命中流式期间
// write_file 缓存的内容(虚拟预览);未命中再走 seat thread 的 outputs HTTP。
const path = step.args?.path;
if (typeof path === "string" && path) {
openArtifactPreview(seatThreadId, path);
}
return;
}
// read_file 不再可点(见 MessageStepsCard 的 isArtifactStep):智能体调用技能
// 时读取的 SOUL.md / 执行的 .py 脚本等不应作为可点击文件渲染。
if (toolName === "present_files") {
const filepaths = step.args?.filepaths;
if (Array.isArray(filepaths)) {
@@ -584,6 +584,11 @@ export function useStep3Summary(
// 不再要求用户说出「结构化输出/流程图」等关键词。
const flowActiveRef = useRef(false);
const lastAutoOpenedUrlRef = useRef<string | null>(null);
// 流程图抽取串行化:在途抽取的 aborter + 当前承接抽取/校验的气泡 id。新一轮抽取会先中止
// 上一轮、并复用同一张「调度中」气泡——保证任何时刻只有一张流程图卡片。修复:报告刚结束时
// run() 的「自动出图」与用户「跳过报告直接生成流程图」的手动出图竞态,各自建卡片 → 双「调度中」。
const flowAborterRef = useRef<AbortController | null>(null);
const activeFlowBubbleRef = useRef<number | null>(null);
// ── 对话气泡三段式(append → delta → finalize)+ 工具步骤(与第 2 步同款)──
const appendStreamingDialogue = useCallback(
@@ -646,6 +651,25 @@ export function useStep3Summary(
setDialogues((prev) => prev.map((d) => (d.id === id ? { ...d, sender } : d)));
}, []);
/**
* 复用一条旧气泡承接新一轮(改名 + 清空内容/步骤 + 重新转圈)。流程图抽取被新一轮抢占时
* 复用上一轮那条气泡,避免遗留一张卡死的「调度中」卡片。
*/
const resetDialogueForReuse = useCallback((id: number, sender: string) => {
setDialogues((prev) =>
prev.map((d) =>
d.id === id
? { ...d, sender, content: "", steps: [], streaming: true, confidence: "调度中", time: nowTime() }
: d,
),
);
}, []);
/** 移除一条气泡(流程图抽取抢占时清掉被取代的那张孤儿「调度中」卡片)。 */
const removeDialogue = useCallback((id: number) => {
setDialogues((prev) => prev.filter((d) => d.id !== id));
}, []);
const appendStepIfNew = useCallback(
(
bubbleId: number,
@@ -1149,19 +1173,36 @@ export function useStep3Summary(
opts?: { instruction?: string; currentGraph?: FlowGraphData | null; bubbleId?: number },
): Promise<void> => {
const isEdit = !!opts?.instruction && !!opts?.currentGraph;
// 串行化:先中止上一轮仍在途的流程图抽取(auto 自动出图与用户手动「重画/跳过报告生成流程图」
// 可能并发),让被抢占的旧流走 abort 分支静默退出,避免两条抽取流各自建卡片 → 双「调度中」。
if (flowAborterRef.current && flowAborterRef.current !== aborter) {
flowAborterRef.current.abort();
}
flowAborterRef.current = aborter;
setFlowPhase("extracting");
if (!isEdit) setFlowGraphState(null); // 编辑模式保留旧图直到新图就绪(防画布闪空)
// 两个气泡:抽取/修改 + 校验——把过程(思考 `<think>` + 文字 + flow-json 代码块)都留着展示,
// 由 Step2Message 渲染(思考折叠块 + markdown 代码块),不折叠成一句话。
// 传入 bubbleId(意图分类期间已建的「调度中」气泡)则复用并改名,避免空窗 + 不重复建气泡。
// 气泡选择:①调用方指定(意图分类期间已建的「调度中」气泡)则复用并改名;②上一轮抽取气泡仍在
// (被本轮抢占)则复用并重置(清掉旧内容、重新转圈),不另起新卡片;③否则新建。
const extractSender = isEdit ? "结构化流程图 · 修改" : "结构化流程图 · 抽取";
const prevFlowBubble = activeFlowBubbleRef.current;
let extractBubbleId: number;
if (opts?.bubbleId != null) {
extractBubbleId = opts.bubbleId;
relabelDialogue(extractBubbleId, extractSender);
} else if (prevFlowBubble != null) {
extractBubbleId = prevFlowBubble;
resetDialogueForReuse(extractBubbleId, extractSender);
} else {
extractBubbleId = appendStreamingDialogue({ sender: extractSender, avatarType: "blue" });
}
// 抢占场景:上一轮在途流程图气泡 ≠ 本轮要用的气泡(如 ask 用意图分类气泡承接,而 auto 流程图
// 另起过一张)→ 移除那张被取代的孤儿「调度中」卡片,保证只剩一张。
if (prevFlowBubble != null && prevFlowBubble !== extractBubbleId) {
removeDialogue(prevFlowBubble);
}
activeFlowBubbleRef.current = extractBubbleId;
let validateBubbleId: number | null = null;
try {
const graph = await streamFlowExtract({
@@ -1179,7 +1220,10 @@ export function useStep3Summary(
instruction: opts?.instruction,
signal: aborter.signal,
// 模型自动容错:后端切到下一个模型重抽 → 清空抽取气泡里失败尝试的半截内容 + 留可见提示。
// 各回调都先看本轮是否仍是「在途的那一轮」——被新一轮抢占(aborter 已 abort)后立即
// 变 no-op,避免旧流的迟到帧污染已被新一轮复用的同一张气泡。
onModelSwitch: (info) => {
if (aborter.signal.aborted) return;
setDialogues((prev) => prev.map((d) => (d.id === extractBubbleId ? { ...d, content: "" } : d)));
setDialogueConfidence(extractBubbleId, `模型切换:${info.failedModel}→${info.nextModel}`);
appendStepIfNew(
@@ -1188,18 +1232,27 @@ export function useStep3Summary(
`model_switch:${info.nextModel}`,
);
},
onExtractDelta: (t) => appendToDialogue(extractBubbleId, t),
onExtractDelta: (t) => {
if (aborter.signal.aborted) return;
appendToDialogue(extractBubbleId, t);
},
onValidating: () => {
if (aborter.signal.aborted) return;
setFlowPhase("validating");
// 抽取气泡收尾(保留已流出的内容),另起校验气泡。
// 抽取气泡收尾(保留已流出的内容),另起校验气泡(校验阶段它成为当前流程图气泡)。
finalizeDialogue(extractBubbleId, "", "抽取完成");
validateBubbleId = appendStreamingDialogue({ sender: "结构化流程图 · 校验", avatarType: "blue" });
activeFlowBubbleRef.current = validateBubbleId;
},
onValidateDelta: (t) => {
if (aborter.signal.aborted) return;
if (validateBubbleId != null) appendToDialogue(validateBubbleId, t);
},
});
if (aborter.signal.aborted) return;
// 本轮成功收尾 → 释放串行化句柄(下一轮抽取会新建气泡,而非复用本轮这条已完成的)。
if (flowAborterRef.current === aborter) flowAborterRef.current = null;
activeFlowBubbleRef.current = null;
setFlowGraphState(graph);
setFlowPhase("done");
flowActiveRef.current = true;
@@ -1220,6 +1273,9 @@ export function useStep3Summary(
}
} catch (err) {
if (aborter.signal.aborted) return;
// 本轮失败收尾 → 同样释放串行化句柄。
if (flowAborterRef.current === aborter) flowAborterRef.current = null;
activeFlowBubbleRef.current = null;
const msg = err instanceof Error ? err.message : String(err);
setFlowPhase("error");
finalizeDialogue(
@@ -1238,6 +1294,8 @@ export function useStep3Summary(
appendToDialogue,
finalizeDialogue,
relabelDialogue,
resetDialogueForReuse,
removeDialogue,
],
);
@@ -1283,6 +1341,9 @@ export function useStep3Summary(
setSeatStubMessages({});
setFlowGraphState(null);
setFlowPhase("idle");
// 清空对话同时复位流程图串行化句柄(旧气泡已随 setDialogues([]) 移除,不能再被复用)。
flowAborterRef.current = null;
activeFlowBubbleRef.current = null;
// 整体重跑(重试 / 重新生成)= 重新分析 → 复位入库状态,旧产物的入库询问/按钮先隐藏;
// 重跑出新流程图后 runFlowExtract 会重新询问是否入库。
resetIngestState();
@@ -1316,9 +1377,13 @@ export function useStep3Summary(
// 不再在总结线程里续问吐 flow-json(那会被 coordinator_prompt 的报告锚定带偏、不稳);
// 改走独立端点:流式抽取(实时显示) → 校验修复 → 用校验后结构化图绘制。
// 有报告或有席位交付即触发(无报告也能据席位交付出图)。
// `aborterRef.current === aborter` 防竞态:用户在报告刚结束时发「跳过报告直接生成流程图」,
// ask() 会先 abort() 本轮(aborterRef 被置空/换成新 aborter)→ 这里不再自动出图,
// 由 ask 的手动出图独占,避免 auto + manual 双卡片。
if (
autoFlow &&
!aborter.signal.aborted &&
aborterRef.current === aborter &&
(currentMdRef.current.trim() || seatDeliveries.length > 0)
) {
await runFlowExtract(aborter);
@@ -1795,6 +1860,9 @@ export function useStep3Summary(
(snap: DraftStep3SummarySnapshot) => {
abort();
runningRef.current = false;
// 恢复历史 → 复位流程图串行化句柄(避免指向已不存在的旧气泡)。
flowAborterRef.current = null;
activeFlowBubbleRef.current = null;
// 恢复流程图:快照 closing 里若存了 flow-json,解析成结构化图填进 flowGraph 状态,
// 让画布直接用它绘制(也供 Phase 3 手动编辑)。解析不出则回退到对话气泡解析。
const restoredGraph = snap.closing ? extractFlowGraph(snap.closing) : null;
@@ -150,6 +150,12 @@ const BUSINESS_CODE_TO_TASK_BUSINESS: Record<string, TaskBusiness> = {
"7BF": "xdfx",
};
/**
* 入库仅 rwfx-A(任务分析,businessCode `6BF`)这一深链会商模式才提供——该业务较特殊,
* 其它业务(3qfx-A / xdfx-A 等)即便深链进会商生成了流程图也不出现入库按钮 / 入库询问。
*/
const INGEST_BUSINESS_CODE = "6BF";
/** 从任务页带进来的预填(经 navigation state 传递)。见会商自动化串联(模块 C)。 */
interface RoundtablePrefill {
/** 任务名称(注入 Step 1 首条用户消息)。 */
@@ -379,7 +385,8 @@ export default function RoundtablePlanningPage() {
const ingestTaskIdForCheck = prefillRef.current?.taskId ?? null;
const ingestBusinessCode = prefillRef.current?.businessCode ?? null;
useEffect(() => {
if (!ingestTaskIdForCheck) {
// 入库仅 rwfx-A(businessCode 6BF)深链会商模式提供;其它业务一律不判定、不显示入库。
if (!ingestTaskIdForCheck || ingestBusinessCode !== INGEST_BUSINESS_CODE) {
setIngestAvailable(false);
setIngestSkill("");
return;
@@ -16,11 +16,18 @@ import type {
TaskButton,
TaskButtonIdKind,
TaskButtonLinkType,
TaskButtonLoginParam,
TaskButtonOpenMode,
} from "@/core/auth/task-buttons";
const BASE = () => `${getBackendBaseURL()}/api/task-buttons`;
interface ServerLoginParam {
key: string;
source: string;
custom_value: string;
}
interface ServerButton {
id: string;
business: string;
@@ -31,10 +38,19 @@ interface ServerButton {
id_param: string;
id_kind: string;
open_mode: string;
login_params: ServerLoginParam[];
enabled: boolean;
sort_order: number;
}
function toLoginParam(p: ServerLoginParam): TaskButtonLoginParam {
return {
key: p.key ?? "",
source: p.source || "accessToken",
customValue: p.custom_value ?? "",
};
}
function toButton(s: ServerButton): TaskButton {
return {
id: s.id,
@@ -46,6 +62,7 @@ function toButton(s: ServerButton): TaskButton {
idParam: s.id_param || "id",
idKind: (s.id_kind as TaskButtonIdKind) ?? "task",
openMode: (s.open_mode as TaskButtonOpenMode) ?? "blank",
loginParams: (s.login_params ?? []).map(toLoginParam),
enabled: s.enabled ?? true,
sortOrder: s.sort_order ?? 0,
};
@@ -62,6 +79,11 @@ function toBody(b: TaskButton): ServerButton {
id_param: b.idParam,
id_kind: b.idKind,
open_mode: b.openMode,
login_params: (b.loginParams ?? []).map((p) => ({
key: p.key,
source: p.source,
custom_value: p.customValue,
})),
enabled: b.enabled,
sort_order: b.sortOrder,
};