diff --git a/frontend-web/.env.production b/frontend-web/.env.production
index 97415742..d1fc2180 100644
--- a/frontend-web/.env.production
+++ b/frontend-web/.env.production
@@ -5,7 +5,7 @@
VITE_USE_REAL_DATA=true
# 任务详情(登录深链 cop-task-detail)数据源:默认真实接口;改成 true 走本地假数据(调试用)。
-VITE_MOCK_COP_TASK_DETAIL=false
+VITE_MOCK_COP_TASK_DETAIL=true
# Gateway / DeerFlow API
VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow
diff --git a/frontend-web/src/components/workspace/user-identity-badge.tsx b/frontend-web/src/components/workspace/user-identity-badge.tsx
new file mode 100644
index 00000000..5929040e
--- /dev/null
+++ b/frontend-web/src/components/workspace/user-identity-badge.tsx
@@ -0,0 +1,54 @@
+"use client";
+
+import { User } from "lucide-react";
+import { useMemo } from "react";
+
+import { getStoredAuth } from "@/core/auth";
+import { useMyPosition } from "@/core/positions";
+import { cn } from "@/lib/utils";
+import { readUserInfo } from "@/strategy-components/lib/light-app";
+
+/**
+ * 名称 + 岗位 用户标识。全量模式由 strategy-components/Header.tsx 顶栏右侧展示;
+ * 紧凑模式(不带全局顶栏)无处承载,故抽成共享组件,复用同一套数据来源
+ * (`readUserInfo().userName` 优先、回退 auth 邮箱前缀;岗位取 `useMyPosition`),
+ * 供页面级顶栏(如 ChatPage)右侧复用,保证两套布局一致。
+ *
+ * 配色按当前内容区前景色(foreground / muted-foreground),适配聊天页浅底顶栏,
+ * 而非 Header 的白字贴 banner 方案。
+ */
+export function UserIdentityBadge({ className }: { className?: string }) {
+ const auth = getStoredAuth();
+ const { data: myPosition } = useMyPosition();
+ const positionName = myPosition?.name?.trim() || "";
+
+ // 与 Header.tsx 一致:外部登录写入的真实用户名优先,缺省回退邮箱前缀。
+ const displayName = useMemo(() => {
+ const info = readUserInfo();
+ const name = typeof info.userName === "string" ? info.userName.trim() : "";
+ if (name) return name;
+ const email = auth?.email?.trim();
+ return email ? email.split("@")[0] : "用户";
+ }, [auth?.email]);
+
+ return (
+
+
+
+
+
+
+ {displayName}
+
+ {positionName && (
+
+ {positionName}
+
+ )}
+
+
+ );
+}
diff --git a/frontend-web/src/core/auth/task-deeplink-config.ts b/frontend-web/src/core/auth/task-deeplink-config.ts
new file mode 100644
index 00000000..f31a74a3
--- /dev/null
+++ b/frontend-web/src/core/auth/task-deeplink-config.ts
@@ -0,0 +1,104 @@
+/**
+ * 任务深链(``goPath=rwfx``)的运行时配置:从后端公开只读接口
+ * ``GET /api/public/config/task-deeplink`` 读取(地址、测试开关、测试假数据全在后端
+ * ``config.yaml`` 的 ``task_deeplink`` 段,改完即时生效,前端无需重建)。
+ *
+ * 两种用法:
+ * - 命令式(登录深链流程,非 React 环境):``fetchTaskDeeplinkConfig()`` 拉取并模块级缓存;
+ * ``getCachedTaskDeeplinkConfig()`` 同步读缓存(拼跳转地址用)。
+ * - React 组件:``useTaskDeeplinkConfig()``(见 [[task-purpose]] 的面板/列表)。
+ *
+ * 拉取失败 → 用内置默认值(``test=false`` + 真实 URL)兜底,功能不整体挂掉。
+ */
+
+import { apiFetch } from "@/core/api/fetch-client";
+import { getBackendBaseURL } from "@/core/config";
+
+export interface TaskDeeplinkConfig {
+ /** 目的树查询走假数据。 */
+ test: boolean;
+ /** 删除接口走假数据(该接口未开发完时打开)。 */
+ deleteTest: boolean;
+ /** 目的树查询完整 URL(调用追加 ?taskId=)。 */
+ purposeDetailUrl: string;
+ /** 删除完整 URL(调用追加 ?taskId=)。 */
+ deleteUrl: string;
+ /** B 系统树形完整编辑页 URL(跳转追加 ?id=)。 */
+ treemapEditUrl: string;
+ /** B 系统树形下一步页 URL(跳转追加 ?id=)。 */
+ treemapNextUrl: string;
+ /** 测试假数据:purposeDetail = 原始 {state,msg,data};delete = {message,success}。 */
+ mock: {
+ purposeDetail?: unknown;
+ delete?: { message?: string; success?: boolean };
+ };
+}
+
+/** 内置默认值(后端拿不到时兜底;真实 URL + 关闭测试)。 */
+export const DEFAULT_TASK_DEEPLINK_CONFIG: TaskDeeplinkConfig = {
+ test: false,
+ deleteTest: false,
+ purposeDetailUrl: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/getTaskPurposeDetail",
+ deleteUrl: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/delete",
+ treemapEditUrl: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-edit",
+ treemapNextUrl: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-next",
+ mock: {},
+};
+
+/** 后端 snake_case 响应 → 前端 camelCase;缺字段回退默认值。 */
+function normalize(body: unknown): TaskDeeplinkConfig {
+ const o = body && typeof body === "object" ? (body as Record) : {};
+ const d = DEFAULT_TASK_DEEPLINK_CONFIG;
+ const str = (v: unknown, fallback: string) =>
+ typeof v === "string" && v.trim() ? v : fallback;
+ const mock = o.mock && typeof o.mock === "object" ? (o.mock as Record) : {};
+ return {
+ test: o.test === true,
+ deleteTest: o.delete_test === true,
+ purposeDetailUrl: str(o.purpose_detail_url, d.purposeDetailUrl),
+ deleteUrl: str(o.delete_url, d.deleteUrl),
+ treemapEditUrl: str(o.treemap_edit_url, d.treemapEditUrl),
+ treemapNextUrl: str(o.treemap_next_url, d.treemapNextUrl),
+ mock: {
+ purposeDetail: mock.purpose_detail,
+ delete:
+ mock.delete && typeof mock.delete === "object"
+ ? (mock.delete as { message?: string; success?: boolean })
+ : undefined,
+ },
+ };
+}
+
+let cached: TaskDeeplinkConfig | null = null;
+let inflight: Promise | null = null;
+
+/** 拉取(带模块级缓存 + 去重);失败回退默认值。 */
+export async function fetchTaskDeeplinkConfig(): Promise {
+ if (cached) return cached;
+ if (inflight) return inflight;
+ inflight = (async () => {
+ try {
+ const res = await apiFetch(`${getBackendBaseURL()}/api/public/config/task-deeplink`);
+ if (!res.ok) throw new Error(`task-deeplink config ${res.status}`);
+ cached = normalize(await res.json());
+ } catch {
+ cached = { ...DEFAULT_TASK_DEEPLINK_CONFIG };
+ } finally {
+ inflight = null;
+ }
+ return cached;
+ })();
+ return inflight;
+}
+
+/** 同步读缓存(未拉取过则返回默认值)。 */
+export function getCachedTaskDeeplinkConfig(): TaskDeeplinkConfig {
+ return cached ?? DEFAULT_TASK_DEEPLINK_CONFIG;
+}
+
+/** 在完整 URL(可能含 `#` 片段)后追加查询参数,供跳转/调用拼 id/taskId。 */
+export function appendQueryParam(url: string, key: string, value: string): string {
+ if (!url) return "";
+ const sep = url.includes("?") ? "&" : "?";
+ return `${url}${sep}${key}=${encodeURIComponent(value)}`;
+}
diff --git a/frontend-web/src/core/auth/task-deeplink.ts b/frontend-web/src/core/auth/task-deeplink.ts
index 603e75c4..54937c92 100644
--- a/frontend-web/src/core/auth/task-deeplink.ts
+++ b/frontend-web/src/core/auth/task-deeplink.ts
@@ -16,8 +16,12 @@
*/
import { requestCrudService } from "@/service/request-axios-service";
+import { clearTaskContext, persistTaskContext } from "@/core/auth/task-purpose";
import { listBusinessMappings } from "@/strategy-components/api/business-mapping";
+/** rwfx 业务码:单智能体走「任务工作区 + 目的树闸门」(其它业务保持原问答逻辑)。 */
+const RWFX_BUSINESS_CODE = "6BF";
+
/** consumer 接口返回的任务详情(data 字段)。 */
export interface CopTaskDetail {
id: string | number | null;
@@ -132,12 +136,16 @@ function buildOpeningText(detail: CopTaskDetail): string {
export async function resolveTaskDeeplinkTarget(
goPath: string,
detail: CopTaskDetail,
+ taskId: string,
): Promise {
const parsed = parseGoPath(goPath);
if (!parsed) return null;
const taskBase = BUSINESS_TASK_BASE[parsed.businessCode];
if (!taskBase) return null;
+ // 清掉上一次深链可能残留的任务上下文(仅 rwfx 单智能体会重新写入)。
+ clearTaskContext();
+
if (parsed.roundtable) {
// 多智能体会商:嵌进对应业务的「任务管理」布局;businessCode 让会商页自动选用配置的业务链条。
return {
@@ -162,6 +170,15 @@ export async function resolveTaskDeeplinkTarget(
}
if (!agentId) return null;
+ if (parsed.businessCode === RWFX_BUSINESS_CODE) {
+ // rwfx:走「任务工作区」——暂存任务上下文(taskId + 开场信息),跳到 chats 索引,
+ // 由工作区按 taskId 决定开最新历史对话 / 新建(新建走目的树闸门)。不设 pendingAgentMessage:
+ // 开场信息在闸门通过后由目的树面板发出。
+ persistTaskContext({ taskId, agentId, openingText: buildOpeningText(detail) });
+ return { path: `${taskBase}/agent/${agentId}/chats` };
+ }
+
+ // 其它单智能体问答:保持原逻辑(pendingAgentMessage 自动发出 + chats/new)。
try {
sessionStorage.setItem(
"pendingAgentMessage",
@@ -170,6 +187,5 @@ export async function resolveTaskDeeplinkTarget(
} catch {
/* noop */
}
- // 单智能体问答:同样嵌进对应业务的「任务管理」布局。
return { path: `${taskBase}/agent/${agentId}/chats/new` };
}
diff --git a/frontend-web/src/core/auth/task-purpose.ts b/frontend-web/src/core/auth/task-purpose.ts
new file mode 100644
index 00000000..b1a0bc3a
--- /dev/null
+++ b/frontend-web/src/core/auth/task-purpose.ts
@@ -0,0 +1,225 @@
+/**
+ * 任务深链(``goPath=rwfx``)的「目的树闸门 + 任务↔对话绑定」支持。
+ *
+ * 角色区分:
+ * - **目的树数据**:B 系统里的策划结果,走 ``getTaskPurposeDetail`` / ``delete``(地址、测试
+ * 开关、测试假数据都来自 [[task-deeplink-config]] / 后端 config.yaml),与聊天对话无关。
+ * 新建对话(含首次进入)前用它做「已有数据→覆盖确认」闸门。
+ * - **对话(thread)**:聊天会话;通过 thread 的 ``metadata.taskId`` 标签把多个对话挂到同一个
+ * 任务下(1:N),列表用 useThreadsPage({ metadata:{ taskId } }) 过滤。详见 [[task-deeplink]]。
+ */
+
+import {
+ appendQueryParam,
+ fetchTaskDeeplinkConfig,
+ getCachedTaskDeeplinkConfig,
+} from "@/core/auth/task-deeplink-config";
+import { requestCrudService } from "@/service/request-axios-service";
+
+/** 归一化后的目的树节点(用于展示,逐层:目的→行为体→预期行为→驱动因素→叙事)。 */
+export interface PurposeTreeNode {
+ /** 节点类型中文标签。 */
+ label: string;
+ /** 节点主文本(目的/行为体名/预期/因素/叙事内容)。 */
+ text: string;
+ /** 推荐理由(若有)。 */
+ reason?: string;
+ children: PurposeTreeNode[];
+}
+
+/** 当前 rwfx 任务上下文(深链进入或「新建对话」时暂存,供闸门面板与绑定使用)。 */
+export interface TaskContext {
+ taskId: string;
+ agentId: string;
+ /** 自动发出的开场信息(任务名称/内容)。 */
+ openingText: string;
+}
+
+const TASK_CONTEXT_STORAGE_KEY = "task.rwfxContext";
+
+/* --------------------------- 目的树查询/删除 --------------------------- */
+
+/** 逐层 dto 字段映射:key=childrenList 里出现的 *Dto 字段名。 */
+const DTO_FIELD_MAP: Record = {
+ purposeDto: { label: "目的", textKey: "zzPurpose" },
+ actorDto: { label: "行为体", textKey: "actorName" },
+ expectedBehaviorDto: { label: "预期行为", textKey: "behavior" },
+ drivingFactorDto: { label: "驱动因素", textKey: "drivingFactor" },
+ narrationDto: { label: "叙事", textKey: "narration" },
+};
+
+// 取节点主文本时跳过的字段(id/时间/标记/理由等非展示文本),保证容错。
+const SKIP_TEXT_KEY = /(^id$|Id$|Time$|Flag$|Type$|Level$|recReason|createBy|updateBy|^mid$)/i;
+
+/** 从一个 dto 里取展示文本:优先 preferredKey,否则取第一个像「内容」的字符串字段。 */
+function pickDtoText(dto: Record, preferredKey?: string): string {
+ if (preferredKey && typeof dto[preferredKey] === "string" && (dto[preferredKey] as string).trim()) {
+ return dto[preferredKey] as string;
+ }
+ for (const [k, v] of Object.entries(dto)) {
+ if (typeof v === "string" && v.trim() && !SKIP_TEXT_KEY.test(k)) return v;
+ }
+ return "";
+}
+
+/**
+ * 把一层节点归一化为 {label,text,reason,children}。**容错**:
+ * - 优先匹配已知 dto(目的/行为体/...);未知 ``*Dto`` 字段也兜底(标签取字段名去 Dto);
+ * - 文本字段改名也能取到(pickDtoText 兜底);
+ * - 完全不认识的结构仍递归 childrenList,避免整棵树丢失。
+ */
+function normalizeNode(raw: unknown): PurposeTreeNode | null {
+ if (!raw || typeof raw !== "object") return null;
+ const obj = raw as Record;
+ const isObj = (k: string) => obj[k] && typeof obj[k] === "object" && !Array.isArray(obj[k]);
+ // 先找已知 dto,再兜底任意 `*Dto` 字段。
+ let dtoKey = Object.keys(DTO_FIELD_MAP).find(isObj);
+ if (!dtoKey) dtoKey = Object.keys(obj).find((k) => /Dto$/.test(k) && isObj(k));
+
+ const childrenRaw = Array.isArray(obj.childrenList) ? obj.childrenList : [];
+ const children = childrenRaw
+ .map(normalizeNode)
+ .filter((n): n is PurposeTreeNode => n !== null);
+
+ if (!dtoKey) {
+ // 不认识的结构:仅当有子节点时保留为占位节点,否则丢弃。
+ return children.length ? { label: "节点", text: "", children } : null;
+ }
+
+ const mapped = DTO_FIELD_MAP[dtoKey];
+ const dto = obj[dtoKey] as Record;
+ const label = mapped?.label ?? dtoKey.replace(/Dto$/, "");
+ const text = pickDtoText(dto, mapped?.textKey);
+ const reason =
+ typeof dto.recReason === "string" && (dto.recReason as string).trim()
+ ? (dto.recReason as string)
+ : undefined;
+ return { label, text, reason, children };
+}
+
+/** 表格行:根→叶一条路径上的各层单元格(层级数可变,做了容错)。 */
+export type PurposeFlatCell = { label: string; text: string; reason?: string };
+
+/** 把目的树拍平成「一行一条叶子路径」,供表格展示(层级缺失/变动都不崩)。 */
+export function flattenPurposeTree(nodes: PurposeTreeNode[]): PurposeFlatCell[][] {
+ const rows: PurposeFlatCell[][] = [];
+ const walk = (node: PurposeTreeNode, trail: PurposeFlatCell[]) => {
+ const next = [...trail, { label: node.label, text: node.text, reason: node.reason }];
+ if (!node.children || node.children.length === 0) rows.push(next);
+ else node.children.forEach((c) => walk(c, next));
+ };
+ nodes.forEach((n) => walk(n, []));
+ return rows;
+}
+
+/** 从接口/假数据的整包 body 里取出 data 数组并归一化成树。 */
+function bodyToTree(body: unknown): PurposeTreeNode[] {
+ const data =
+ body && typeof body === "object" ? (body as { data?: unknown }).data : null;
+ const arr = Array.isArray(data) ? data : [];
+ return arr.map(normalizeNode).filter((n): n is PurposeTreeNode => n !== null);
+}
+
+export interface TaskPurposeResult {
+ tree: PurposeTreeNode[];
+ /** data 数组里有有效目的节点 = 已有策划数据。 */
+ hasData: boolean;
+}
+
+/**
+ * 查询任务目的树。``test=true`` 时直接用后端配置里的假数据;否则打真实接口
+ * (地址来自配置,``Authorization: Bearer admin`` 由 axios 拦截器统一注入)。
+ * 缺地址 / 返回不合法 → ``{ tree: [], hasData: false }``。
+ */
+export async function fetchTaskPurposeDetail(
+ taskId: string,
+): Promise {
+ if (!taskId) return { tree: [], hasData: false };
+ const cfg = await fetchTaskDeeplinkConfig();
+ let body: unknown;
+ if (cfg.test) {
+ body = cfg.mock.purposeDetail;
+ } else if (cfg.purposeDetailUrl) {
+ body = await requestCrudService.get(cfg.purposeDetailUrl, { taskId });
+ } else {
+ return { tree: [], hasData: false };
+ }
+ const tree = bodyToTree(body);
+ return { tree, hasData: tree.length > 0 };
+}
+
+export interface DeleteTaskPurposeResult {
+ message: string;
+ success: boolean;
+}
+
+/**
+ * 删除任务目的树(覆盖前清空 / 手动清除任务记录都走这里)。``delete_test=true`` 时
+ * 用后端配置里的假数据(接口未开发完时用);否则打真实接口。
+ */
+export async function deleteTaskPurpose(
+ taskId: string,
+): Promise {
+ if (!taskId) return { message: "缺少 taskId", success: false };
+ const cfg = await fetchTaskDeeplinkConfig();
+ if (cfg.deleteTest) {
+ const m = cfg.mock.delete ?? {};
+ return { message: typeof m.message === "string" ? m.message : "", success: m.success === true };
+ }
+ if (!cfg.deleteUrl) return { message: "缺少删除接口地址", success: false };
+ const body = await requestCrudService.get(cfg.deleteUrl, { taskId });
+ const obj =
+ body && typeof body === "object" ? (body as unknown as Record) : {};
+ return {
+ message: typeof obj.message === "string" ? obj.message : "",
+ success: obj.success === true,
+ };
+}
+
+/* ----------------------------- 跳转地址 ----------------------------- */
+
+/** B 系统「树形完整编辑」页地址(问答后「编辑」按钮,新标签打开)。用同步缓存配置。 */
+export function buildTreemapEditUrl(taskId: string): string {
+ const url = getCachedTaskDeeplinkConfig().treemapEditUrl;
+ return url && taskId ? appendQueryParam(url, "id", taskId) : "";
+}
+
+/** B 系统「树形下一步」页地址(问答后「下一步」按钮,新标签打开)。 */
+export function buildTreemapNextUrl(taskId: string): string {
+ const url = getCachedTaskDeeplinkConfig().treemapNextUrl;
+ return url && taskId ? appendQueryParam(url, "id", taskId) : "";
+}
+
+/* --------------------------- 任务上下文暂存 --------------------------- */
+
+export function persistTaskContext(ctx: TaskContext): void {
+ try {
+ sessionStorage.setItem(TASK_CONTEXT_STORAGE_KEY, JSON.stringify(ctx));
+ } catch {
+ /* noop */
+ }
+}
+
+export function readTaskContext(): TaskContext | null {
+ try {
+ const raw = sessionStorage.getItem(TASK_CONTEXT_STORAGE_KEY);
+ if (!raw) return null;
+ const parsed = JSON.parse(raw) as Partial;
+ if (!parsed?.taskId || !parsed?.agentId) return null;
+ return {
+ taskId: String(parsed.taskId),
+ agentId: String(parsed.agentId),
+ openingText: typeof parsed.openingText === "string" ? parsed.openingText : "",
+ };
+ } catch {
+ return null;
+ }
+}
+
+export function clearTaskContext(): void {
+ try {
+ sessionStorage.removeItem(TASK_CONTEXT_STORAGE_KEY);
+ } catch {
+ /* noop */
+ }
+}
diff --git a/frontend-web/src/core/threads/hooks.ts b/frontend-web/src/core/threads/hooks.ts
index 687529cc..6d34117b 100644
--- a/frontend-web/src/core/threads/hooks.ts
+++ b/frontend-web/src/core/threads/hooks.ts
@@ -34,6 +34,9 @@ export type ThreadStreamOptions = {
context: LocalSettings["context"];
isMock?: boolean;
assistantId?: string;
+ /** 额外写到新建 thread 的 metadata(与 agent_id 合并)。
+ * 任务深链用 `{ taskId }` 把对话挂到任务下(1:N),列表按 metadata.taskId 过滤。 */
+ threadMetadata?: Record;
onStart?: (threadId: string) => void;
onFinish?: (state: AgentThreadState) => void;
onToolEnd?: (event: ToolEndEvent) => void;
@@ -118,6 +121,39 @@ function getRunMetadataStorage(): {
};
}
+/**
+ * 显式停止某会话「服务端正在跑」的 run。
+ *
+ * 注意:useStream / `thread.stop()` 只中断本地 SSE 监听,服务端 run 仍会继续跑。
+ * 任务流程要求「开启新对话时老对话要停止」,所以这里读出 SDK 存在 sessionStorage
+ * (``lg:stream:{threadId}``)里的 run id,调用 ``runs.cancel`` 真正取消后端 run,
+ * 再清掉本地 reconnect 记录。已结束 / 不存在的 run 取消失败时静默忽略。
+ */
+export async function stopThreadRun(
+ threadId: string | null | undefined,
+): Promise {
+ if (!threadId || typeof window === "undefined") return;
+ let runId: string | null = null;
+ try {
+ runId = normalizeStoredRunId(
+ window.sessionStorage.getItem(`lg:stream:${threadId}`),
+ );
+ } catch {
+ runId = null;
+ }
+ if (!runId) return;
+ try {
+ await getAPIClient().runs.cancel(threadId, runId);
+ } catch {
+ /* 已结束 / 不存在 / 网络错误等忽略 */
+ }
+ try {
+ window.sessionStorage.removeItem(`lg:stream:${threadId}`);
+ } catch {
+ /* noop */
+ }
+}
+
function getStreamErrorMessage(error: unknown): string {
if (typeof error === "string" && error.trim()) {
return error;
@@ -200,6 +236,7 @@ export function useThreadStream({
context,
isMock,
assistantId,
+ threadMetadata,
onStart,
onFinish,
onToolEnd,
@@ -277,11 +314,15 @@ export function useThreadStream({
handleStreamStart(meta.thread_id);
setOnStreamThreadId(meta.thread_id);
const metadataAgentId = context.agent_id ?? context.agent_name;
- if (metadataAgentId && !isMock) {
+ const extraMetadata =
+ threadMetadata && typeof threadMetadata === "object" ? threadMetadata : {};
+ const metadata: Record = {
+ ...(metadataAgentId ? { agent_id: metadataAgentId } : {}),
+ ...extraMetadata,
+ };
+ if (Object.keys(metadata).length > 0 && !isMock) {
void getAPIClient()
- .threads.update(meta.thread_id, {
- metadata: { agent_id: metadataAgentId },
- })
+ .threads.update(meta.thread_id, { metadata })
.catch(() => ({}));
}
},
diff --git a/frontend-web/src/core/threads/use-task-threads.ts b/frontend-web/src/core/threads/use-task-threads.ts
new file mode 100644
index 00000000..cad4b9f1
--- /dev/null
+++ b/frontend-web/src/core/threads/use-task-threads.ts
@@ -0,0 +1,29 @@
+/**
+ * 「一个任务挂多个对话」(1:N):按 thread 的 ``metadata.taskId`` 过滤出某任务下的对话列表。
+ *
+ * 新建对话时由 [[task-purpose]] 流程给 thread 打 ``metadata.taskId`` 标签
+ * (见 useThreadStream 的 ``threadMetadata``);这里复用服务端分页搜索按该标签过滤,
+ * 后端默认按 ``updated_at desc`` 排序,所以 ``threads[0]`` 即最新一条。
+ */
+
+import { useThreadsPage } from "@/core/threads/hooks";
+import type { AgentThread } from "@/core/threads/types";
+
+export function useTaskThreads(taskId: string | null | undefined) {
+ const enabled = !!taskId;
+ // 任务工作区只在有 taskId 时渲染;无 taskId 时传一个不可能命中的过滤,返回空列表。
+ const query = useThreadsPage({
+ page: 1,
+ pageSize: 100,
+ metadata: enabled ? { taskId } : { taskId: "__none__" },
+ });
+ const threads: AgentThread[] = enabled ? (query.data?.threads ?? []) : [];
+ return {
+ ...query,
+ threads,
+ /** 最新对话(按 updated_at desc,列表第一条)。 */
+ latestThreadId: threads[0]?.thread_id ?? null,
+ /** 是否已有历史对话(决定深链进入后默认开最新还是走新建闸门)。 */
+ hasThreads: threads.length > 0,
+ };
+}
diff --git a/frontend-web/src/pages/AgentChatPage.tsx b/frontend-web/src/pages/AgentChatPage.tsx
index fc485f41..2d7372cd 100644
--- a/frontend-web/src/pages/AgentChatPage.tsx
+++ b/frontend-web/src/pages/AgentChatPage.tsx
@@ -26,14 +26,17 @@ import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks";
import { useNotification } from "@/core/notification/hooks";
import { saveLastAgentId, useThreadSettings } from "@/core/settings";
-import { useThreadStream } from "@/core/threads/hooks";
+import { stopThreadRun, useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env";
+import { readTaskContext } from "@/core/auth/task-purpose";
+import { TaskPurposePanel } from "@/strategy-components/components/task-components/TaskPurposePanel";
import { desensitize } from "@/lib/desensitize";
export default function AgentChatPage({
embeddedAgentId,
chatBasePath,
+ taskGate,
}: {
/** 当作为「通用问答右侧嵌入」渲染时由父组件传入的 agent id(替代路由参数)。
* 传入即视为嵌入态:路由仍停留在 /chats/new,仅右侧问答区换成该智能体的对话。 */
@@ -43,6 +46,9 @@ export default function AgentChatPage({
* 传入如 `/page/strategy/task-management/plan-task-sentiment/agent/${id}/chats`,
* 使开新会话 / 首条消息后的 URL 改写都停留在任务管理上下文(侧边栏保持对应业务高亮)。 */
chatBasePath?: string;
+ /** rwfx 任务深链闸门:新建对话时顶部展示目的树面板(覆盖确认后才开始问答),
+ * 并给新建 thread 打 `metadata.taskId` 标签(任务↔对话 1:N)。见 task-purpose。 */
+ taskGate?: boolean;
} = {}) {
const routeBase = useRouteBase();
const { t } = useI18n();
@@ -57,6 +63,16 @@ export default function AgentChatPage({
// 对话路由前缀:调用方可覆盖(任务深链嵌入态),否则用当前 routeBase 下的独立智能体对话路径。
const agentChatsBase = chatBasePath ?? `${routeBase}/agents/${agent_id}/chats`;
+ // rwfx 任务闸门:读取登录深链/「新建对话」暂存的任务上下文(taskId + 开场信息)。
+ const taskContext = useMemo(
+ () => (taskGate ? readTaskContext() : null),
+ [taskGate],
+ );
+ const taskId =
+ taskGate && taskContext && taskContext.agentId === agent_id
+ ? taskContext.taskId
+ : "";
+
// If ChatPage stashed an opening message for this agent, skip the landing
// screen on first render so the user doesn't see a flash of the welcome UI
// before the auto-sent message takes over.
@@ -101,6 +117,8 @@ export default function AgentChatPage({
threadId: isNewThread ? undefined : threadId,
assistantId: agent_id,
context: { ...settings.context, agent_id },
+ // 任务闸门:新建 thread 打 metadata.taskId 标签,供任务对话列表(1:N)按任务过滤。
+ threadMetadata: taskId ? { taskId } : undefined,
onStart: (createdThreadId) => {
setThreadId(createdThreadId);
setIsNewThread(false);
@@ -143,6 +161,30 @@ export default function AgentChatPage({
[sendMessage, threadId, agent_id],
);
+ // 任务流程:切换 / 新建会话时,停掉「上一会话服务端在跑」的 run(老对话要停止)。
+ // 仅 taskGate(rwfx 任务工作区)启用——普通问答页保留后台续跑行为不变。
+ const prevTaskThreadRef = useRef(null);
+ useEffect(() => {
+ if (!taskGate) return;
+ const prev = prevTaskThreadRef.current;
+ if (prev && prev !== threadId) void stopThreadRun(prev);
+ prevTaskThreadRef.current = threadId ?? null;
+ }, [taskGate, threadId]);
+ useEffect(() => {
+ // 离开任务问答页(卸载)时也停掉当前会话的服务端 run。
+ return () => {
+ if (taskGate) void stopThreadRun(prevTaskThreadRef.current);
+ };
+ }, [taskGate]);
+
+ // 任务闸门通过(无目的数据 / 覆盖成功):发出开场信息开始问答;无开场信息则
+ // 仅切到对话态(隐藏面板),让用户手动输入。
+ const handleGateStart = useCallback(() => {
+ const text = taskContext?.openingText?.trim();
+ if (text) handleSubmit({ text, files: [] });
+ else setHasStartedConversation(true);
+ }, [handleSubmit, taskContext]);
+
// ChatPage stashes the opening message in sessionStorage when the user picks
// an agent on the landing screen, then redirects here. Replay it once so the
// user doesn't have to re-type the question on the agent chat page.
@@ -181,6 +223,8 @@ export default function AgentChatPage({
}, [thread]);
const isLandingState = isNewThread && !hasStartedConversation;
+ // 任务闸门面板:仅在新建对话且尚未开始时,于问答页顶部展示目的树 + 覆盖确认。
+ const showTaskGate = !!taskId && isNewThread && !hasStartedConversation;
const isDemoMode = env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true";
const status = thread.error
@@ -245,6 +289,10 @@ export default function AgentChatPage({
+ {showTaskGate && (
+
+ )}
+
{isLandingState ? (
/* Home: centered hero, suggested questions, and input.
Everything lives inside the scrollable main column. No fixed,
diff --git a/frontend-web/src/pages/ChatPage.tsx b/frontend-web/src/pages/ChatPage.tsx
index 81f21b66..b1c24df3 100644
--- a/frontend-web/src/pages/ChatPage.tsx
+++ b/frontend-web/src/pages/ChatPage.tsx
@@ -27,6 +27,7 @@ import { QuestionNavigator } from "@/components/workspace/question-navigator";
import { ThreadTitle } from "@/components/workspace/thread-title";
import { TodoList } from "@/components/workspace/todo-list";
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
+import { UserIdentityBadge } from "@/components/workspace/user-identity-badge";
import { Welcome } from "@/components/workspace/welcome";
import { CompactWelcome } from "@/components/workspace/compact-welcome";
import { readSidebarEnabled } from "@/core/page-layout/sidebar-enabled";
@@ -481,6 +482,17 @@ export default function ChatPage() {
+ {/* 不带全局顶栏(紧凑模式)时,名称+岗位回落到聊天页顶栏右侧,
+ 与全量模式 Header 右上角保持一致。全量模式已有顶栏则不重复展示。 */}
+ {!compactChrome && (
+ <>
+
+
+ >
+ )}
diff --git a/frontend-web/src/pages/IframeChatPage.tsx b/frontend-web/src/pages/IframeChatPage.tsx
index f9983498..21422426 100644
--- a/frontend-web/src/pages/IframeChatPage.tsx
+++ b/frontend-web/src/pages/IframeChatPage.tsx
@@ -3,7 +3,6 @@
import {
BookMarkedIcon,
BrainCircuitIcon,
- CheckIcon,
FileTextIcon,
GraduationCapIcon,
} from "lucide-react";
@@ -23,7 +22,9 @@ import {
} from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { TodoList } from "@/components/workspace/todo-list";
+import { Tooltip } from "@/components/workspace/tooltip";
import { Button } from "@/components/ui/button";
+import { TooltipProvider } from "@/components/ui/tooltip";
import { useAgentSelector } from "@/core/agents";
import { getAccessToken } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
@@ -369,7 +370,7 @@ export default function IframeChatPage() {
}, [handleInterrupt, handleSubmit]);
const isLandingState = isNewThread && !hasStartedConversation;
- const bottomBarHeightClass = "pb-28";
+ const bottomBarHeightClass = "pb-24";
const modeButtons = [
{
key: "writing",
@@ -377,8 +378,7 @@ export default function IframeChatPage() {
icon: FileTextIcon,
active: !!settings.context.writing_mode,
onClick: toggleWritingMode,
- activeClassName:
- "border-amber-300 bg-amber-50 text-amber-700 hover:bg-amber-100",
+ activeClassName: "text-amber-600 hover:text-amber-700",
},
{
key: "memory",
@@ -386,8 +386,7 @@ export default function IframeChatPage() {
icon: BrainCircuitIcon,
active: settings.context.memory_injection_enabled ?? true,
onClick: toggleMemoryInjection,
- activeClassName:
- "border-blue-300 bg-blue-50 text-blue-700 hover:bg-blue-100",
+ activeClassName: "text-blue-600 hover:text-blue-700",
},
{
key: "references",
@@ -396,8 +395,7 @@ export default function IframeChatPage() {
active: ragModeEnabled,
onClick: toggleRagMode,
disabled: ragModeLocked,
- activeClassName:
- "border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100",
+ activeClassName: "text-emerald-600 hover:text-emerald-700",
},
{
key: "pro",
@@ -405,8 +403,7 @@ export default function IframeChatPage() {
icon: GraduationCapIcon,
active: proModeEnabled,
onClick: toggleProMode,
- activeClassName:
- "border-violet-300 bg-violet-50 text-violet-700 hover:bg-violet-100",
+ activeClassName: "text-violet-600 hover:text-violet-700",
},
] as const;
@@ -496,29 +493,32 @@ export default function IframeChatPage() {
-
- {modeButtons.map((button) => {
- const Icon = button.icon;
- return (
-
- );
- })}
-
+
+
+ {modeButtons.map((button) => {
+ const Icon = button.icon;
+ return (
+
+
+
+ );
+ })}
+
+
diff --git a/frontend-web/src/pages/LoginPage.tsx b/frontend-web/src/pages/LoginPage.tsx
index 5b495ea0..a8b91c68 100644
--- a/frontend-web/src/pages/LoginPage.tsx
+++ b/frontend-web/src/pages/LoginPage.tsx
@@ -164,7 +164,7 @@ export default function LoginPage() {
if (cancelled) return;
if (detail) {
persistCopTaskDetail(detail);
- const target = await resolveTaskDeeplinkTarget(goPath, detail);
+ const target = await resolveTaskDeeplinkTarget(goPath, detail, taskId);
if (cancelled) return;
if (target) {
navigate(target.path, { replace: true, state: target.state });
diff --git a/frontend-web/src/pages/StrategyRoutes.tsx b/frontend-web/src/pages/StrategyRoutes.tsx
index 15fa53a3..27816fa0 100644
--- a/frontend-web/src/pages/StrategyRoutes.tsx
+++ b/frontend-web/src/pages/StrategyRoutes.tsx
@@ -11,6 +11,11 @@ import TaskPgFrom from "@/strategy-components/components/task-components/TaskPgF
import TaskTyFrom from "@/strategy-components/components/task-components/TaskTyFrom";
import TaskXkFrom from "@/strategy-components/components/task-components/TaskXkFrom";
import TaskAnalyzes from "@/strategy-components/components/task-components/TaskAnalyzes";
+import {
+ TaskFxChat,
+ TaskFxIndexResolver,
+ TaskFxWorkspace,
+} from "@/strategy-components/components/task-components/TaskFxWorkspace";
import { ActionAnalyzes } from "@/strategy-components/components/task-components/ActionAnalyzes";
import KnowledgeBaseResourceManagement from "@/strategy-components/components/resource-management/KnowledgeBaseResourceManagement";
import NetworkActorResourceManagement from "@/strategy-components/components/resource-management/NetworkActorResourceManagement";
@@ -164,12 +169,26 @@ export default function StrategyRoutes() {
}
/>
+ {/* 任务FX:任务工作区(左对话列表 1:N + 右问答;新建对话顶部目的树闸门)。 */}
+
}
- />
+ >
+
+ }
+ />
+
+ }
+ />
+
渲染选中的对话(AgentChatPage,新建对话顶部带目的树闸门面板)。
+ *
+ * - 进入 chats 索引:有历史 → 跳最新;无历史 → 跳 new(=新建,走闸门)。
+ * - 新建对话 = 直接跳入:navigate 到 chats/new;切换/新建时 AgentChatPage 会停掉老对话的服务端 run。
+ * - 清除任务记录:仅调 delete 接口(聊天对话保留)。
+ * - 编辑 / 下一步:window.open 配置里的 B 系统地址(?id=taskId)。
+ * 见 [[task-purpose]] / [[task-deeplink-config]]。
+ */
+
+import { useEffect, useMemo, useState } from "react";
+import { Outlet, useLocation, useNavigate, useParams } from "react-router-dom";
+import { ArrowRight, ExternalLink, Loader2, MessageSquare, PlusSquare, Trash2 } from "lucide-react";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ buildTreemapEditUrl,
+ buildTreemapNextUrl,
+ deleteTaskPurpose,
+ readTaskContext,
+} from "@/core/auth/task-purpose";
+import { fetchTaskDeeplinkConfig } from "@/core/auth/task-deeplink-config";
+import { useTaskThreads } from "@/core/threads/use-task-threads";
+import { titleOfThread } from "@/core/threads/utils";
+import { desensitize } from "@/lib/desensitize";
+import { cn } from "@/lib/utils";
+import AgentChatPage from "@/pages/AgentChatPage";
+import { ChatRuntime } from "@/pages/WorkspaceRoutes";
+
+function useChatsBase(taskBase: string): { taskId: string; chatsBase: string } {
+ const { agent_id } = useParams<{ agent_id: string }>();
+ const taskId = readTaskContext()?.taskId ?? "";
+ return { taskId, chatsBase: `${taskBase}/agent/${agent_id}/chats` };
+}
+
+/** 从路由路径里解析当前打开的会话 id(布局组件读不到子路由的 :thread_id,故从 path 取)。 */
+function parseThreadId(pathname: string, chatsBase: string): string | null {
+ if (!pathname.startsWith(chatsBase)) return null;
+ const seg = pathname.slice(chatsBase.length).replace(/^\/+/, "").split("/")[0];
+ return seg && seg !== "new" ? seg : null;
+}
+
+/** 左侧任务对话列表 +「新建对话」。 */
+function TaskConversationsList({ taskId, chatsBase }: { taskId: string; chatsBase: string }) {
+ const { pathname } = useLocation();
+ const navigate = useNavigate();
+ const { threads, isLoading } = useTaskThreads(taskId);
+ const currentThreadId = useMemo(() => parseThreadId(pathname, chatsBase), [pathname, chatsBase]);
+
+ return (
+
+
+
+
+
任务对话
+
+ {isLoading ? "同步中" : `${threads.length} 个会话`}
+
+
+
+
+
+
+
+ {isLoading ? (
+
+ 加载中…
+
+ ) : threads.length === 0 ? (
+
+ 暂无对话
+
+ ) : (
+ threads.map((thread) => (
+
+ ))
+ )}
+
+
+
+ );
+}
+
+/**
+ * 左侧任务栏底部操作区:编辑 / 下一步 / 清除任务记录。
+ * 跟随任务对话面板布局,不再覆盖右侧聊天输入框。
+ */
+function TaskActionsPanel({ taskId }: { taskId: string }) {
+ const [clearOpen, setClearOpen] = useState(false);
+ const [clearing, setClearing] = useState(false);
+
+ const openExternal = (url: string, label: string) => {
+ if (url) window.open(url, "_blank", "noopener,noreferrer");
+ else toast.error(`未配置${label}地址`);
+ };
+
+ const handleClear = async () => {
+ setClearing(true);
+ try {
+ const r = await deleteTaskPurpose(taskId);
+ if (r.success) toast.success("已清除任务记录");
+ else toast.error(r.message || "清除失败");
+ } catch {
+ toast.error("清除失败,请稍后再试");
+ } finally {
+ setClearing(false);
+ setClearOpen(false);
+ }
+ };
+
+ return (
+ <>
+
+
+
+ 任务操作
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+/** 任务工作区布局:左列表 + 右 Outlet。 */
+export function TaskFxWorkspace({ taskBase }: { taskBase: string }) {
+ const { taskId, chatsBase } = useChatsBase(taskBase);
+ // 预拉配置,保证「编辑/下一步」的同步 URL 构造能读到缓存。
+ useEffect(() => {
+ void fetchTaskDeeplinkConfig();
+ }, []);
+ return (
+
+ );
+}
+
+/** chats 索引:有历史对话 → 跳最新;否则 → 跳 new(走闸门)。 */
+export function TaskFxIndexResolver({ taskBase }: { taskBase: string }) {
+ const { taskId, chatsBase } = useChatsBase(taskBase);
+ const navigate = useNavigate();
+ const { isLoading, hasThreads, latestThreadId } = useTaskThreads(taskId);
+
+ useEffect(() => {
+ if (isLoading) return;
+ if (hasThreads && latestThreadId) {
+ navigate(`${chatsBase}/${latestThreadId}`, { replace: true });
+ } else {
+ navigate(`${chatsBase}/new`, { replace: true });
+ }
+ }, [isLoading, hasThreads, latestThreadId, chatsBase, navigate]);
+
+ return (
+
+
+
+ );
+}
+
+/** 右侧对话宿主:AgentChatPage(taskGate 开启 → 新建对话顶部展示目的树闸门面板 + 老对话停止)。 */
+export function TaskFxChat({ taskBase }: { taskBase: string }) {
+ const { agent_id } = useParams<{ agent_id: string }>();
+ return (
+
+
+
+ );
+}
diff --git a/frontend-web/src/strategy-components/components/task-components/TaskPurposePanel.tsx b/frontend-web/src/strategy-components/components/task-components/TaskPurposePanel.tsx
new file mode 100644
index 00000000..c0998568
--- /dev/null
+++ b/frontend-web/src/strategy-components/components/task-components/TaskPurposePanel.tsx
@@ -0,0 +1,208 @@
+/**
+ * 任务深链(rwfx)「新建对话」前的目的树闸门面板(嵌在问答页顶部)。
+ *
+ * 展示:目的树拍平成表格(一行一条「目的→行为体→预期→因素→叙事」路径),列数随数据自适应,
+ * 字段/层级变动都不崩(见 flattenPurposeTree / normalizeNode 的容错)。可点「弹框查看」放大。
+ * 流程:点「开始问答」:
+ * - 已有数据 → 弹「将被覆盖,确认?」→ 确认调 delete:success=true 才 onStart();
+ * success=false 提示后端 message(如「行动已经开始策划,不可覆盖」)并停留。
+ * - 无数据 → 直接 onStart()。
+ * onStart 由父组件(AgentChatPage 闸门态)实现:发出开场消息、开始问答。
+ */
+
+import { useEffect, useMemo, useState } from "react";
+import { Loader2, Maximize2 } from "lucide-react";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ deleteTaskPurpose,
+ fetchTaskPurposeDetail,
+ flattenPurposeTree,
+ type PurposeTreeNode,
+ type TaskPurposeResult,
+} from "@/core/auth/task-purpose";
+import { cn } from "@/lib/utils";
+
+/** 目的树表格:拍平成行,列数随数据自适应;层级缺失补空、字段变动不崩。 */
+function PurposeTable({ tree, className }: { tree: PurposeTreeNode[]; className?: string }) {
+ const rows = useMemo(() => flattenPurposeTree(tree), [tree]);
+ const headers = useMemo(() => {
+ const maxDepth = rows.reduce((m, r) => Math.max(m, r.length), 0);
+ return Array.from({ length: maxDepth }, (_, i) => {
+ const r = rows.find((row) => row[i]);
+ return r?.[i]?.label ?? `层级${i + 1}`;
+ });
+ }, [rows]);
+
+ if (rows.length === 0) {
+ return 暂无目的数据,可直接开始问答。
;
+ }
+
+ return (
+
+
+
+
+ {headers.map((h, i) => (
+ |
+ {h}
+ |
+ ))}
+
+
+
+ {rows.map((row, ri) => (
+
+ {headers.map((_, ci) => {
+ const cell = row[ci];
+ return (
+ |
+ {cell ? (
+ <>
+ {cell.text || "(空)"}
+ {cell.reason ? (
+ 理由:{cell.reason}
+ ) : null}
+ >
+ ) : (
+ —
+ )}
+ |
+ );
+ })}
+
+ ))}
+
+
+
+ );
+}
+
+export function TaskPurposePanel({
+ taskId,
+ onStart,
+}: {
+ taskId: string;
+ /** 闸门通过(无数据 / 覆盖成功),开始问答。 */
+ onStart: () => void;
+}) {
+ const [loading, setLoading] = useState(true);
+ const [result, setResult] = useState(null);
+ const [confirmOpen, setConfirmOpen] = useState(false);
+ const [working, setWorking] = useState(false);
+ const [zoomOpen, setZoomOpen] = useState(false);
+
+ useEffect(() => {
+ let cancelled = false;
+ setLoading(true);
+ fetchTaskPurposeDetail(taskId)
+ .then((r) => {
+ if (!cancelled) setResult(r);
+ })
+ .catch(() => {
+ if (!cancelled) setResult({ tree: [], hasData: false });
+ })
+ .finally(() => {
+ if (!cancelled) setLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [taskId]);
+
+ const handleStart = () => {
+ if (result?.hasData) setConfirmOpen(true); // 有数据 → 先确认覆盖
+ else onStart(); // 无数据 → 直接开始
+ };
+
+ const handleConfirmOverwrite = async () => {
+ setWorking(true);
+ try {
+ const r = await deleteTaskPurpose(taskId);
+ if (r.success) {
+ setConfirmOpen(false);
+ onStart();
+ } else {
+ toast.error(r.message || "无法覆盖,请稍后再试");
+ setConfirmOpen(false);
+ }
+ } catch {
+ toast.error("覆盖失败,请稍后再试");
+ setConfirmOpen(false);
+ } finally {
+ setWorking(false);
+ }
+ };
+
+ const hasTree = !!result && result.tree.length > 0;
+
+ return (
+
+
+
任务目的(任务 {taskId})
+
+ {hasTree && (
+
+ )}
+
+
+
+
+ {loading ? (
+
+ 正在查询任务目的…
+
+ ) : (
+
+ )}
+
+ {/* 弹框查看:放大显示完整表格 */}
+
+
+ {/* 覆盖确认 */}
+
+
+ );
+}
+
+export default TaskPurposePanel;
diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md
index e4ec951f..bd35b976 100644
--- a/offline-backend-20260512/backend/CLAUDE.md
+++ b/offline-backend-20260512/backend/CLAUDE.md
@@ -370,7 +370,7 @@ shown to users ARE the **recommended questions** (`recommended_questions` table)
types into the existing "我的" tables, idempotently:
- agents → `agent_favorites` rows with `origin='position'`
- **skills → `skill_favorites` rows with `origin='position'`** (table
- `SkillFavoriteRow`, migration `20260616_02`, auto-created by `create_all`)
+ `SkillFavoriteRow`, migration `20260616_04`, auto-created by `create_all`)
- scheduled tasks → `scheduled_task_subscriptions` rows with `origin='position'`
Skills behave **exactly like agents**: a position only *adds* its tagged skills
diff --git a/offline-backend-20260512/backend/app/gateway/app.py b/offline-backend-20260512/backend/app/gateway/app.py
index 34603cd1..f624389b 100644
--- a/offline-backend-20260512/backend/app/gateway/app.py
+++ b/offline-backend-20260512/backend/app/gateway/app.py
@@ -52,6 +52,7 @@ from app.gateway.routers import (
parallel_agent_tasks,
parallel_agents,
positions,
+ public_config,
public_embed,
public_leaderboard,
public_scheduled_tasks,
@@ -1376,6 +1377,7 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Public embed Q&A status polling (/api/public/embed/*)
app.include_router(public_embed.router)
+ app.include_router(public_config.router)
app.include_router(html_page_favorites.router)
# Product knowledge base API is mounted at /api/knowledge
diff --git a/offline-backend-20260512/backend/app/gateway/routers/public_config.py b/offline-backend-20260512/backend/app/gateway/routers/public_config.py
new file mode 100644
index 00000000..861a2d8a
--- /dev/null
+++ b/offline-backend-20260512/backend/app/gateway/routers/public_config.py
@@ -0,0 +1,28 @@
+"""公开只读配置接口(前端运行时读取 config.yaml 派生值)。
+
+目前只暴露 ``task_deeplink`` 段(任务深链 rwfx 的目的树闸门地址 + 测试开关 +
+测试假数据),供前端登录深链流程在运行时读取——无需把这些地址硬编码进前端,
+改 ``config.yaml`` 即时生效(Gateway mtime 热重载)。
+
+**无鉴权**:路由挂在 ``/api/public/`` 前缀下,已被
+``auth_middleware._PUBLIC_PATH_PREFIXES`` 白名单放行。段内只有地址与开关,不含密钥。
+"""
+
+from __future__ import annotations
+
+import logging
+
+from fastapi import APIRouter
+
+from deerflow.config import get_app_config
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/api/public/config", tags=["public-config"])
+
+
+@router.get("/task-deeplink")
+async def get_task_deeplink_config() -> dict:
+ """返回 ``config.yaml`` 的 ``task_deeplink`` 段(含 test/delete_test/各 URL/mock)。"""
+ config = get_app_config()
+ return config.task_deeplink.model_dump()
diff --git a/offline-backend-20260512/backend/config.yaml b/offline-backend-20260512/backend/config.yaml
index c23bb4ce..953730c2 100644
--- a/offline-backend-20260512/backend/config.yaml
+++ b/offline-backend-20260512/backend/config.yaml
@@ -418,3 +418,40 @@ log_system:
enabled: true
url: 'https://your-log-system.example.com'
label: '日志系统'
+
+task_deeplink:
+ # 线上接口未通时用假数据;两个开关互相独立。
+ test: true # 目的树查询走 mock.purpose_detail 假数据
+ delete_test: true # 删除接口走 mock.delete 假数据(该接口尚未开发完)
+ purpose_detail_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/getTaskPurposeDetail"
+ delete_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/delete"
+ treemap_edit_url: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-edit"
+ treemap_next_url: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-next"
+ mock:
+ # test=true 时原样返回给前端(结构同真实 getTaskPurposeDetail 返回)。
+ purpose_detail:
+ state: "200"
+ msg: "操作成功!"
+ data:
+ - purposeDto: { id: 5837, zzPurpose: "目的01", selectedFlag: 1, taskId: 3324, recDetailId: 4271 }
+ childrenList:
+ - actorDto: { id: 9101, taskId: 3324, purposeId: 5837, actorName: "行为体01", actorOrgan: "行为体01", recReason: "行为体01理由", selectedFlag: 1 }
+ childrenList:
+ - expectedBehaviorDto: { id: 6910, taskId: 3324, purposeId: 5837, behavior: "预期01", recReason: "预期01理由", selectedFlag: "1" }
+ childrenList:
+ - drivingFactorDto: { id: 7964, taskId: 3324, purposeId: 5837, drivingFactor: "预期01因素", selectedFlag: 1 }
+ childrenList:
+ - narrationDto: { id: 6500, purposeId: 5837, taskId: 3324, narration: "叙事01", recReason: "叙事01理由", selectedFlag: 1 }
+ childrenList: []
+ - actorDto: { id: 9102, taskId: 3324, purposeId: 5837, actorName: "行为体02", actorOrgan: "行为体02", recReason: "行为体02理由", selectedFlag: 1 }
+ childrenList:
+ - expectedBehaviorDto: { id: 6911, taskId: 3324, purposeId: 5837, behavior: "预期02", recReason: "预期02理由", selectedFlag: "1" }
+ childrenList:
+ - drivingFactorDto: { id: 7965, taskId: 3324, purposeId: 5837, drivingFactor: "预期02因素", selectedFlag: 1 }
+ childrenList:
+ - narrationDto: { id: 6501, purposeId: 5837, taskId: 3324, narration: "叙事02", recReason: "叙事02理由", selectedFlag: 1 }
+ childrenList: []
+ # delete_test=true 时原样返回给前端。改 success 测「成功覆盖 / 不可覆盖」两条分支。
+ delete:
+ message: "行动已经开始策划,不可覆盖"
+ success: true
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/app_config.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/app_config.py
index cb277ee0..b86b549e 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/config/app_config.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/app_config.py
@@ -15,10 +15,10 @@ from deerflow.config.checkpointer_config import CheckpointerConfig, load_checkpo
from deerflow.config.database_config import DatabaseConfig
from deerflow.config.extensions_config import ExtensionsConfig
from deerflow.config.gateway_config import GatewayConfig
-from deerflow.config.guardrails_config import GuardrailsConfig, load_guardrails_config_from_dict
-from deerflow.config.knowledge_config import KnowledgeConfig
-from deerflow.config.knowledge_ingest_config import KnowledgeIngestConfig
-from deerflow.config.log_system_config import LogSystemConfig
+from deerflow.config.guardrails_config import GuardrailsConfig, load_guardrails_config_from_dict
+from deerflow.config.knowledge_config import KnowledgeConfig
+from deerflow.config.knowledge_ingest_config import KnowledgeIngestConfig
+from deerflow.config.log_system_config import LogSystemConfig
from deerflow.config.login_config import AuthLoginConfig
from deerflow.config.memory_config import MemoryConfig, load_memory_config_from_dict
from deerflow.config.model_config import ModelConfig
@@ -33,6 +33,7 @@ from deerflow.config.skills_config import SkillsConfig
from deerflow.config.stream_bridge_config import StreamBridgeConfig, load_stream_bridge_config_from_dict
from deerflow.config.subagents_config import SubagentsAppConfig, load_subagents_config_from_dict
from deerflow.config.summarization_config import SummarizationConfig, load_summarization_config_from_dict
+from deerflow.config.task_deeplink_config import TaskDeeplinkConfig
from deerflow.config.title_config import TitleConfig, load_title_config_from_dict
from deerflow.config.token_usage_config import TokenUsageConfig
from deerflow.config.tool_config import ToolConfig, ToolGroupConfig
@@ -136,9 +137,10 @@ class AppConfig(BaseModel):
run_events: RunEventsConfig = Field(default_factory=RunEventsConfig, description="Run event storage configuration")
checkpointer: CheckpointerConfig | None = Field(default=None, description="Checkpointer configuration")
stream_bridge: StreamBridgeConfig | None = Field(default=None, description="Stream bridge configuration")
- browser_context: BrowserContextConfig = Field(default_factory=BrowserContextConfig, description="Optional browser page capture integration")
- knowledge: KnowledgeConfig = Field(default_factory=KnowledgeConfig, description="Product knowledge base (obsidian-wiki compatible vault) configuration")
- knowledge_ingest: KnowledgeIngestConfig = Field(default_factory=KnowledgeIngestConfig, description="External knowledge-ingest endpoint configuration")
+ browser_context: BrowserContextConfig = Field(default_factory=BrowserContextConfig, description="Optional browser page capture integration")
+ knowledge: KnowledgeConfig = Field(default_factory=KnowledgeConfig, description="Product knowledge base (obsidian-wiki compatible vault) configuration")
+ knowledge_ingest: KnowledgeIngestConfig = Field(default_factory=KnowledgeIngestConfig, description="External knowledge-ingest endpoint configuration")
+ task_deeplink: TaskDeeplinkConfig = Field(default_factory=TaskDeeplinkConfig, description="任务深链(rwfx)目的树闸门与跳转地址配置(前端经 /api/public/config/task-deeplink 读取)")
@classmethod
def resolve_config_path(cls, config_path: str | None = None) -> Path:
@@ -387,26 +389,26 @@ def _get_config_mtime(config_path: Path) -> float | None:
return None
-def _load_and_cache_app_config(config_path: str | None = None) -> AppConfig:
- """Load config from disk and refresh cache metadata."""
- global _app_config, _app_config_path, _app_config_mtime, _app_config_skill_evolution_overrides_mtime, _app_config_is_custom
-
- resolved_path = AppConfig.resolve_config_path(config_path)
- _app_config = AppConfig.from_file(str(resolved_path))
- _app_config_path = resolved_path
- _app_config_mtime = _get_config_mtime(resolved_path)
- _app_config_skill_evolution_overrides_mtime = _get_skill_evolution_overrides_mtime()
- _app_config_is_custom = False
- summarization = _app_config.summarization
- logger.info(
- "Loaded AppConfig from %s; summarization enabled=%s trigger=%s keep=%s trim_tokens_to_summarize=%s",
- resolved_path,
- summarization.enabled,
- summarization.trigger,
- summarization.keep,
- summarization.trim_tokens_to_summarize,
- )
- return _app_config
+def _load_and_cache_app_config(config_path: str | None = None) -> AppConfig:
+ """Load config from disk and refresh cache metadata."""
+ global _app_config, _app_config_path, _app_config_mtime, _app_config_skill_evolution_overrides_mtime, _app_config_is_custom
+
+ resolved_path = AppConfig.resolve_config_path(config_path)
+ _app_config = AppConfig.from_file(str(resolved_path))
+ _app_config_path = resolved_path
+ _app_config_mtime = _get_config_mtime(resolved_path)
+ _app_config_skill_evolution_overrides_mtime = _get_skill_evolution_overrides_mtime()
+ _app_config_is_custom = False
+ summarization = _app_config.summarization
+ logger.info(
+ "Loaded AppConfig from %s; summarization enabled=%s trigger=%s keep=%s trim_tokens_to_summarize=%s",
+ resolved_path,
+ summarization.enabled,
+ summarization.trigger,
+ summarization.keep,
+ summarization.trim_tokens_to_summarize,
+ )
+ return _app_config
def get_app_config() -> AppConfig:
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/task_deeplink_config.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/task_deeplink_config.py
new file mode 100644
index 00000000..5ebc2f13
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/task_deeplink_config.py
@@ -0,0 +1,51 @@
+"""任务深链(``goPath=rwfx``)的「目的树闸门 + 跳转」配置。
+
+前端(登录深链流程)通过公开只读接口 ``GET /api/public/config/task-deeplink``
+读取这一段:所有外部地址都是**完整 URL**(调用时仅追加 ``?taskId=`` / ``?id=``),
+随时改 ``config.yaml`` 即可(Gateway 有 mtime 热重载,无需重新构建前端)。
+
+测试开关(线上接口未通时用假数据,互相独立):
+ - ``test`` → 目的树查询(``purpose_detail_url``)走 ``mock.purpose_detail`` 假数据。
+ - ``delete_test`` → 删除接口(``delete_url``)走 ``mock.delete`` 假数据(该接口尚未开发完时打开)。
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from pydantic import BaseModel, ConfigDict, Field
+
+
+class TaskDeeplinkConfig(BaseModel):
+ """rwfx 任务深链的目的树查询 / 删除接口与跳转页地址。"""
+
+ model_config = ConfigDict(extra="allow")
+
+ test: bool = Field(
+ default=False,
+ description="为真时前端目的树查询走 mock.purpose_detail 假数据(不打真实接口)",
+ )
+ delete_test: bool = Field(
+ default=False,
+ description="为真时前端删除接口走 mock.delete 假数据(删除接口未开发完时打开)",
+ )
+ purpose_detail_url: str = Field(
+ default="",
+ description="目的树查询完整 URL,调用时追加 ?taskId=",
+ )
+ delete_url: str = Field(
+ default="",
+ description="删除完整 URL,调用时追加 ?taskId=",
+ )
+ treemap_edit_url: str = Field(
+ default="",
+ description="B 系统树形完整编辑页 URL,跳转时追加 ?id=",
+ )
+ treemap_next_url: str = Field(
+ default="",
+ description="B 系统树形下一步页 URL,跳转时追加 ?id=",
+ )
+ mock: dict[str, Any] = Field(
+ default_factory=dict,
+ description="test/delete_test 为真时返回的假数据:{purpose_detail: {...}, delete: {message, success}}",
+ )
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260611_01_roundtable_draft_task_id.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260611_02_roundtable_draft_task_id.py
similarity index 77%
rename from offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260611_01_roundtable_draft_task_id.py
rename to offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260611_02_roundtable_draft_task_id.py
index 076a329b..c80317d5 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260611_01_roundtable_draft_task_id.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260611_02_roundtable_draft_task_id.py
@@ -7,8 +7,14 @@ draft is keyed/shared by ``task_id`` and is no longer partitioned by ``user_id``
``create_all`` adds the column for brand-new DBs; this revision patches DBs that
already have the table.
-Revision ID: 20260611_01
-Revises: 20260609_02
+Note: this migration originally shared the revision id ``20260611_01`` with
+``20260611_01_agents_skills_square_id`` (both branched off ``20260609_02``),
+which corrupted the alembic DAG ("present more than once" + phantom heads). It
+was renumbered to ``20260611_02`` and re-chained after that revision so the
+history is linear and single-headed.
+
+Revision ID: 20260611_02
+Revises: 20260611_01
Create Date: 2026-06-11
"""
@@ -19,8 +25,8 @@ from alembic import op
from sqlalchemy import inspect
-revision = "20260611_01"
-down_revision = "20260609_02"
+revision = "20260611_02"
+down_revision = "20260611_01"
branch_labels = None
depends_on = None
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260612_01_roundtable_jobs_orchestration_plan.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260612_01_roundtable_jobs_orchestration_plan.py
index f7a2cf90..a9b700fd 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260612_01_roundtable_jobs_orchestration_plan.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260612_01_roundtable_jobs_orchestration_plan.py
@@ -6,7 +6,7 @@
LONGTEXT(计划 JSON 可超 64 KB)。
Revision ID: 20260612_01
-Revises: 20260611_01
+Revises: 20260611_02
Create Date: 2026-06-12
"""
@@ -19,7 +19,7 @@ from sqlalchemy.dialects.mysql import LONGTEXT
revision = "20260612_01"
-down_revision = "20260611_01"
+down_revision = "20260611_02"
branch_labels = None
depends_on = None
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260616_03_skill_always_on.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260616_03_skill_always_on.py
index 68d458bd..57c0b0df 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260616_03_skill_always_on.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260616_03_skill_always_on.py
@@ -1,7 +1,7 @@
"""Add always_on (常驻/免压缩) column to skills table.
Revision ID: 20260616_03
-Revises: 20260616_02
+Revises: 20260616_04
Create Date: 2026-06-16
"""
@@ -13,7 +13,7 @@ from sqlalchemy import inspect
revision = "20260616_03"
-down_revision = "20260616_02"
+down_revision = "20260616_04"
branch_labels = None
depends_on = None
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260616_02_skill_favorites.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260616_04_skill_favorites.py
similarity index 81%
rename from offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260616_02_skill_favorites.py
rename to offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260616_04_skill_favorites.py
index 0a223a3b..e531a31e 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260616_02_skill_favorites.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260616_04_skill_favorites.py
@@ -10,8 +10,14 @@ startup (new tables are always created, even on existing DBs); this migration
keeps the alembic history consistent per the repo convention (see
backend/CLAUDE.md → Schema Migrations).
-Revision ID: 20260616_02
-Revises: 20260616_01
+Note: this migration originally shared the revision id ``20260616_02`` with
+``20260616_02_knowledge_folders_templates`` (both branched off ``20260616_01``),
+which corrupted the alembic DAG ("present more than once" + phantom heads). It
+was renumbered to ``20260616_04`` and re-chained after that revision so the
+history is linear and single-headed.
+
+Revision ID: 20260616_04
+Revises: 20260616_02
Create Date: 2026-06-16
"""
@@ -22,8 +28,8 @@ from alembic import op
from sqlalchemy import inspect
-revision = "20260616_02"
-down_revision = "20260616_01"
+revision = "20260616_04"
+down_revision = "20260616_02"
branch_labels = None
depends_on = None
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260617_01_merge_chain_seat_and_skill_always_on_heads.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260617_01_merge_chain_seat_and_skill_always_on_heads.py
new file mode 100644
index 00000000..5eb84731
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260617_01_merge_chain_seat_and_skill_always_on_heads.py
@@ -0,0 +1,30 @@
+"""Merge the chain-seat-mode and skill-always-on migration heads.
+
+Two lineages both descend from ``20260612_01`` (roundtable_jobs orchestration
+plan): ``20260615_01`` (chain seat mode) branched directly off it, while the
+``20260616_*`` line (positions → knowledge folders/templates → skill_favorites →
+skill always_on) is the other branch. That left the graph with two heads
+(``20260615_01`` and ``20260616_03``) so ``alembic upgrade head`` failed with
+"Multiple head revisions are present". This no-op merge revision joins both
+lineages so ``upgrade head`` resolves to a single head again.
+
+Revision ID: 20260617_01
+Revises: 20260615_01, 20260616_03
+Create Date: 2026-06-17
+"""
+
+from __future__ import annotations
+
+
+revision = "20260617_01"
+down_revision = ("20260615_01", "20260616_03")
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ pass
+
+
+def downgrade() -> None:
+ pass
diff --git a/offline-backend-20260512/config.yaml b/offline-backend-20260512/config.yaml
index 0bc2744e..94cb54a0 100644
--- a/offline-backend-20260512/config.yaml
+++ b/offline-backend-20260512/config.yaml
@@ -324,3 +324,44 @@ summarization:
type: tokens
value: 40000
trim_tokens_to_summarize: 8000
+
+# ───────────────────────────────────────────────────────────────────────────
+# 任务深链(goPath=rwfx):目的树闸门 + 问答后跳转。前端经公开只读接口
+# GET /api/public/config/task-deeplink 读取。地址都是完整 URL(调用时仅追加
+# ?taskId= / ?id=),随时改这里即可(Gateway mtime 热重载,前端无需重建)。
+task_deeplink:
+ # 线上接口未通时用假数据;两个开关互相独立。
+ test: true # 目的树查询走 mock.purpose_detail 假数据
+ delete_test: true # 删除接口走 mock.delete 假数据(该接口尚未开发完)
+ purpose_detail_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/getTaskPurposeDetail"
+ delete_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/delete"
+ treemap_edit_url: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-edit"
+ treemap_next_url: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-next"
+ mock:
+ # test=true 时原样返回给前端(结构同真实 getTaskPurposeDetail 返回)。
+ purpose_detail:
+ state: "200"
+ msg: "操作成功!"
+ data:
+ - purposeDto: { id: 5837, zzPurpose: "目的01", selectedFlag: 1, taskId: 3324, recDetailId: 4271 }
+ childrenList:
+ - actorDto: { id: 9101, taskId: 3324, purposeId: 5837, actorName: "行为体01", actorOrgan: "行为体01", recReason: "行为体01理由", selectedFlag: 1 }
+ childrenList:
+ - expectedBehaviorDto: { id: 6910, taskId: 3324, purposeId: 5837, behavior: "预期01", recReason: "预期01理由", selectedFlag: "1" }
+ childrenList:
+ - drivingFactorDto: { id: 7964, taskId: 3324, purposeId: 5837, drivingFactor: "预期01因素", selectedFlag: 1 }
+ childrenList:
+ - narrationDto: { id: 6500, purposeId: 5837, taskId: 3324, narration: "叙事01", recReason: "叙事01理由", selectedFlag: 1 }
+ childrenList: []
+ - actorDto: { id: 9102, taskId: 3324, purposeId: 5837, actorName: "行为体02", actorOrgan: "行为体02", recReason: "行为体02理由", selectedFlag: 1 }
+ childrenList:
+ - expectedBehaviorDto: { id: 6911, taskId: 3324, purposeId: 5837, behavior: "预期02", recReason: "预期02理由", selectedFlag: "1" }
+ childrenList:
+ - drivingFactorDto: { id: 7965, taskId: 3324, purposeId: 5837, drivingFactor: "预期02因素", selectedFlag: 1 }
+ childrenList:
+ - narrationDto: { id: 6501, purposeId: 5837, taskId: 3324, narration: "叙事02", recReason: "叙事02理由", selectedFlag: 1 }
+ childrenList: []
+ # delete_test=true 时原样返回给前端。改 success 测「成功覆盖 / 不可覆盖」两条分支。
+ delete:
+ message: "行动已经开始策划,不可覆盖"
+ success: true